返回 DeepSeek-Reasonix
worktree_merge_admission_test.go
根目录 / desktop / worktree_merge_admission_test.go
1 package main
2
3 import (
4 "context"
5 "os"
6 "path/filepath"
7 "strings"
8 "testing"
9
10 "reasonix/internal/control"
11 "reasonix/internal/worktree"
12 )
13
14 func TestBlankSessionCreationHonorsCleanupReservation(t *testing.T) {
15 isolateDesktopUserDirs(t)
16 worktreeRoot := t.TempDir()
17 app := NewApp()
18 release, err := app.reserveWorktreeCleanup(worktreeRoot)
19 if err != nil {
20 t.Fatal(err)
21 }
22 defer release()
23
24 if _, err := app.ensureBlankTab("project", worktreeRoot); err == nil || !strings.Contains(err.Error(), "cleanup is in progress") {
25 t.Fatalf("reserved blank session open = %v", err)
26 }
27 app.mu.RLock()
28 tabCount := len(app.tabs)
29 app.mu.RUnlock()
30 if tabCount != 0 {
31 t.Fatalf("reserved fallback published %d tabs", tabCount)
32 }
33 if _, err := os.Stat(desktopSessionDir(worktreeRoot)); !os.IsNotExist(err) {
34 t.Fatalf("reserved fallback created session state: %v", err)
35 }
36 }
37
38 func TestFinalizeReservationRejectsConcurrentBlankSessionPublication(t *testing.T) {
39 isolateDesktopUserDirs(t)
40 sourceRoot := t.TempDir()
41 worktreeRoot := t.TempDir()
42 originalFinalize := finalizeWorktreeMerge
43 t.Cleanup(func() { finalizeWorktreeMerge = originalFinalize })
44 entered := make(chan struct{})
45 releaseFinalize := make(chan struct{})
46 finalizeWorktreeMerge = func(ctx context.Context, _ string, _ worktree.CleanupRequest) (worktree.CleanupResult, error) {
47 close(entered)
48 select {
49 case <-releaseFinalize:
50 case <-ctx.Done():
51 return worktree.CleanupResult{}, ctx.Err()
52 }
53 return worktree.CleanupResult{Completed: true, WorktreeRemoved: true, BranchDeleted: true, Blockers: []worktree.MergeBlocker{}}, nil
54 }
55
56 app := NewApp()
57 result := startFinalizeAdmissionTest(t, app, worktree.CleanupRequest{SourceRoot: sourceRoot, WorktreeRoot: worktreeRoot})
58 waitForFinalizeAdmissionGate(t, entered, result)
59 if _, err := app.ensureBlankTab("project", worktreeRoot); err == nil || !strings.Contains(err.Error(), "cleanup is in progress") {
60 close(releaseFinalize)
61 t.Fatalf("concurrent blank session open = %v", err)
62 }
63 close(releaseFinalize)
64 if err := waitForFinalizeAdmissionResult(t, result); err != nil {
65 t.Fatalf("finalize = %v", err)
66 }
67 app.mu.RLock()
68 tabCount := len(app.tabs)
69 app.mu.RUnlock()
70 if tabCount != 0 {
71 t.Fatalf("concurrent fallback published %d tabs", tabCount)
72 }
73 }
74
75 func TestFinalizeReservationCoversRetainedProjectRegistryUpdate(t *testing.T) {
76 isolateDesktopUserDirs(t)
77 sourceRoot := t.TempDir()
78 allocationRoot := t.TempDir()
79 worktreeRoot := filepath.Join(allocationRoot, "repository")
80 recoveryRoot := filepath.Join(allocationRoot, ".reasonix-cleanup", "recovery-test")
81 if err := os.MkdirAll(worktreeRoot, 0o755); err != nil {
82 t.Fatal(err)
83 }
84 originalFinalize, originalRemove := finalizeWorktreeMerge, removeWorktreeProject
85 t.Cleanup(func() { finalizeWorktreeMerge, removeWorktreeProject = originalFinalize, originalRemove })
86 finalizeWorktreeMerge = func(_ context.Context, _ string, _ worktree.CleanupRequest) (worktree.CleanupResult, error) {
87 return worktree.CleanupResult{
88 RecoveryRetained: true, RecoveryRoot: recoveryRoot, RecoveryWorktreeRegistered: true,
89 BranchRetained: true, Blockers: []worktree.MergeBlocker{},
90 }, nil
91 }
92 registryEntered := make(chan struct{})
93 releaseRegistry := make(chan struct{})
94 app := NewApp()
95 removeWorktreeProject = func(string) error {
96 close(registryEntered)
97 select {
98 case <-releaseRegistry:
99 return nil
100 case <-app.bootContext().Done():
101 return app.bootContext().Err()
102 }
103 }
104 result := startFinalizeAdmissionTest(t, app, worktree.CleanupRequest{SourceRoot: sourceRoot, WorktreeRoot: worktreeRoot})
105 waitForFinalizeAdmissionGate(t, registryEntered, result)
106 if _, err := app.ensureBlankTab("project", recoveryRoot); err == nil || !strings.Contains(err.Error(), "cleanup is in progress") {
107 close(releaseRegistry)
108 t.Fatalf("recovery runtime entered during registry update: %v", err)
109 }
110 close(releaseRegistry)
111 if err := waitForFinalizeAdmissionResult(t, result); err != nil {
112 t.Fatalf("finalize = %v", err)
113 }
114 }
115
116 func TestCleanupReservationRejectsDeletedRootDescendants(t *testing.T) {
117 isolateDesktopUserDirs(t)
118 allocationsRoot := t.TempDir()
119 allocationRoot := filepath.Join(allocationsRoot, "repo-key", "allocation")
120 worktreeRoot := filepath.Join(allocationRoot, "repository")
121 if err := os.MkdirAll(worktreeRoot, 0o755); err != nil {
122 t.Fatal(err)
123 }
124 app := NewApp()
125 release, err := app.reserveWorktreeCleanup(worktreeRoot)
126 if err != nil {
127 t.Fatal(err)
128 }
129 defer release()
130 alias := filepath.Join(t.TempDir(), "allocation-alias")
131 aliasAvailable := os.Symlink(worktreeRoot, alias) == nil
132 if err := os.RemoveAll(worktreeRoot); err != nil {
133 t.Fatal(err)
134 }
135
136 nested := filepath.Join(worktreeRoot, "saved", "subproject")
137 if unexpectedRelease, err := app.beginWorkspaceRuntimeAdmission(nested); err == nil {
138 unexpectedRelease()
139 t.Fatal("deleted-root descendant entered a cleanup reservation")
140 }
141 if unexpectedRelease, err := app.reserveWorktreeCleanup(nested); err == nil {
142 unexpectedRelease()
143 t.Fatal("overlapping descendant cleanup reservation was accepted")
144 }
145 quarantined := filepath.Join(allocationRoot, ".reasonix-cleanup", "random", "subproject")
146 if unexpectedRelease, err := app.beginWorkspaceRuntimeAdmission(quarantined); err == nil {
147 unexpectedRelease()
148 t.Fatal("cleanup quarantine descendant entered a cleanup reservation")
149 }
150 allocationSibling := filepath.Join(allocationRoot, "late-project")
151 if unexpectedRelease, err := app.beginWorkspaceRuntimeAdmission(allocationSibling); err == nil {
152 unexpectedRelease()
153 t.Fatal("allocation sibling entered a cleanup reservation")
154 }
155 if aliasAvailable {
156 if unexpectedRelease, err := app.beginWorkspaceRuntimeAdmission(filepath.Join(alias, "saved", "subproject")); err == nil {
157 unexpectedRelease()
158 t.Fatal("dangling symlink descendant entered a cleanup reservation")
159 }
160 }
161 tab := &WorkspaceTab{ID: "late", Scope: "project", WorkspaceRoot: nested, Ready: true, Ctrl: &backgroundRuntimeController{}}
162 if err := app.workspaceRuntimeAdmissionErr(tab, tab.Ctrl); err == nil || !strings.Contains(err.Error(), "cleanup is in progress") {
163 t.Fatalf("deleted-root submit admission = %v", err)
164 }
165
166 adjacentAllocation := filepath.Join(filepath.Dir(allocationRoot), "allocation-backup", "repository")
167 if err := os.MkdirAll(adjacentAllocation, 0o755); err != nil {
168 t.Fatal(err)
169 }
170 releaseSibling, err := app.beginWorkspaceRuntimeAdmission(adjacentAllocation)
171 if err != nil {
172 t.Fatalf("adjacent allocation was rejected: %v", err)
173 }
174 releaseSibling()
175 }
176
177 func TestMergeRuntimeInspectionIncludesAllRuntimeBlockers(t *testing.T) {
178 isolateDesktopUserDirs(t)
179 sourceRoot := t.TempDir()
180 worktreeRoot := t.TempDir()
181 app := NewApp()
182 source := &WorkspaceTab{
183 ID: "source", Scope: "project", WorkspaceRoot: sourceRoot, Ready: true,
184 Ctrl: &backgroundRuntimeController{status: control.RuntimeStatus{Running: true}},
185 }
186 building := &WorkspaceTab{ID: "building", Scope: "project", WorkspaceRoot: filepath.Join(worktreeRoot, "nested")}
187 app.tabs[source.ID] = source
188 app.detachedSessions[building.ID] = building
189 app.terminals.mu.Lock()
190 app.terminals.sessions["terminal"] = &terminalSession{
191 view: TerminalSessionView{ID: "terminal", Running: true}, tabID: source.ID,
192 }
193 app.terminals.mu.Unlock()
194
195 blockers := app.inspectWorktreeMergeRuntimeBlockers(sourceRoot, worktreeRoot)
196 for _, code := range []string{"tab_building", "active_work", "active_terminal"} {
197 if !desktopHasMergeBlocker(blockers, code) {
198 t.Fatalf("missing %s blocker in %+v", code, blockers)
199 }
200 }
201 }
202
203 func TestMergeRuntimeReservationBlocksOwnersTurnsAndTerminals(t *testing.T) {
204 isolateDesktopUserDirs(t)
205 sourceRoot := t.TempDir()
206 worktreeRoot := t.TempDir()
207 app := NewApp()
208 source := &WorkspaceTab{ID: "source", Scope: "project", WorkspaceRoot: sourceRoot, Ready: true, Ctrl: &backgroundRuntimeController{}}
209 worktreeTab := &WorkspaceTab{ID: "worktree", Scope: "project", WorkspaceRoot: worktreeRoot, Ready: true, Ctrl: &backgroundRuntimeController{}}
210 app.tabs[source.ID] = source
211 app.tabs[worktreeTab.ID] = worktreeTab
212 app.tabOrder = []string{source.ID, worktreeTab.ID}
213 app.activeTabID = source.ID
214
215 release, err := app.reserveWorktreeMergeRuntime(sourceRoot, worktreeRoot)
216 if err != nil {
217 t.Fatal(err)
218 }
219 if unexpectedRelease, err := app.beginWorkspaceRuntimeAdmission(filepath.Join(sourceRoot, "nested")); err == nil {
220 unexpectedRelease()
221 t.Fatal("source descendant entered merge reservation")
222 }
223 if unexpectedRelease, err := app.beginWorkspaceRuntimeAdmission(worktreeRoot); err == nil {
224 unexpectedRelease()
225 t.Fatal("worktree root entered merge reservation")
226 }
227 if _, _, err := app.beginTabTurn(source.ID, false); err == nil || !strings.Contains(err.Error(), "merge-back is in progress") {
228 t.Fatalf("reserved turn admission = %v", err)
229 }
230 if _, err := app.CreateTerminalForTab(source.ID, "", ""); err == nil || !strings.Contains(err.Error(), "merge-back is in progress") {
231 t.Fatalf("reserved terminal create = %v", err)
232 }
233 if err := app.WriteTerminalForTab(source.ID, "missing", "status\n"); err == nil || !strings.Contains(err.Error(), "merge-back is in progress") {
234 t.Fatalf("reserved terminal write = %v", err)
235 }
236 if _, err := app.CreateTopic("project", sourceRoot, "blocked"); err == nil || !strings.Contains(err.Error(), "merge-back is in progress") {
237 t.Fatalf("reserved topic create = %v", err)
238 }
239 if unlock, ok := app.lockTabControllerPublication(app.currentExtensionGeneration(), "project", worktreeRoot); ok {
240 unlock()
241 t.Fatal("controller published into a merge reservation")
242 }
243 sibling := sourceRoot + "-backup"
244 if err := os.MkdirAll(sibling, 0o755); err != nil {
245 t.Fatal(err)
246 }
247 releaseSibling, err := app.beginWorkspaceRuntimeAdmission(sibling)
248 if err != nil {
249 t.Fatalf("prefix sibling was rejected: %v", err)
250 }
251 releaseSibling()
252 if unlock, ok := app.lockTabControllerPublication(app.currentExtensionGeneration(), "project", sibling); !ok {
253 t.Fatal("prefix sibling controller publication was rejected")
254 } else {
255 unlock()
256 }
257 release()
258
259 releaseAfter, err := app.beginWorkspaceRuntimeAdmission(sourceRoot)
260 if err != nil {
261 t.Fatalf("merge reservation leaked: %v", err)
262 }
263 releaseAfter()
264 }
265
266 func TestMergeWorktreeBackHoldsRuntimeReservationThroughGitMutation(t *testing.T) {
267 isolateDesktopUserDirs(t)
268 sourceRoot := t.TempDir()
269 worktreeRoot := t.TempDir()
270 origInspect, origMerge := inspectWorktreeMerge, mergeWorktreeBack
271 t.Cleanup(func() { inspectWorktreeMerge, mergeWorktreeBack = origInspect, origMerge })
272 inspectWorktreeMerge = func(_ context.Context, _, _ string) (worktree.MergeInspection, error) {
273 return worktree.MergeInspection{
274 Available: true, CanMerge: true, SourceRoot: sourceRoot, WorktreeRoot: worktreeRoot,
275 TargetBranch: "main", TargetHead: "target", WorktreeBranch: "reasonix/delivery-test",
276 WorktreeHead: "worktree", WorktreeStateToken: "token", ChangedFiles: []string{},
277 ConflictFiles: []string{}, Blockers: []worktree.MergeBlocker{}, CleanupBlockers: []worktree.MergeBlocker{},
278 }, nil
279 }
280
281 app := NewApp()
282 tab := &WorkspaceTab{ID: "worktree", Scope: "project", WorkspaceRoot: worktreeRoot, Ready: true, Ctrl: &backgroundRuntimeController{}}
283 app.tabs[tab.ID] = tab
284 app.tabOrder = []string{tab.ID}
285 mergeWorktreeBack = func(_ context.Context, _ string, _ worktree.MergeRequest) (worktree.MergeResult, error) {
286 for _, root := range []string{sourceRoot, worktreeRoot} {
287 if unexpectedRelease, err := app.beginWorkspaceRuntimeAdmission(root); err == nil {
288 unexpectedRelease()
289 t.Fatalf("runtime entered reserved merge root %s", filepath.Base(root))
290 }
291 }
292 return worktree.MergeResult{Merged: true, SourceRoot: sourceRoot, WorktreeRoot: worktreeRoot}, nil
293 }
294
295 result, err := app.MergeWorktreeBack(MergeWorktreeBackRequest{
296 TabID: tab.ID, ExpectedTargetBranch: "main", ExpectedTargetHead: "target",
297 ExpectedWorktreeHead: "worktree", ExpectedWorktreeStateToken: "token",
298 })
299 if err != nil || !result.Merged {
300 t.Fatalf("MergeWorktreeBack = %+v, %v", result, err)
301 }
302 release, err := app.beginWorkspaceRuntimeAdmission(worktreeRoot)
303 if err != nil {
304 t.Fatalf("reservation was not released after merge: %v", err)
305 }
306 release()
307 }
308
309 func desktopHasMergeBlocker(blockers []worktree.MergeBlocker, code string) bool {
310 for _, blocker := range blockers {
311 if blocker.Code == code {
312 return true
313 }
314 }
315 return false
316 }
317
317 lines GO