返回 DeepSeek-Reasonix
worktree_merge_test.go
根目录 / desktop / worktree_merge_test.go
1 package main
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "os"
8 "path/filepath"
9 "strings"
10 "testing"
11
12 "reasonix/internal/control"
13 "reasonix/internal/worktree"
14 )
15
16 func TestWorktreeCleanupResultJSONKeepsArraysAndOptionalRecoveryReceipt(t *testing.T) {
17 payload, err := json.Marshal(worktree.CleanupResult{
18 RecoveryRetained: true, RecoveryRoot: "/recovery", RecoveryWorktreeRegistered: true,
19 BranchRetained: true, Blockers: []worktree.MergeBlocker{},
20 })
21 if err != nil {
22 t.Fatal(err)
23 }
24 wire := string(payload)
25 for _, want := range []string{`"blockers":[]`, `"recoveryRetained":true`, `"recoveryRoot":"/recovery"`, `"recoveryWorktreeRegistered":true`, `"branchRetained":true`} {
26 if !strings.Contains(wire, want) {
27 t.Fatalf("cleanup result JSON %s does not contain %s", wire, want)
28 }
29 }
30 }
31
32 func TestAppInspectAndMergeWorktreeBackUsesRequestIdentity(t *testing.T) {
33 isolateDesktopUserDirs(t)
34 sourceRoot := t.TempDir()
35 worktreeRoot := t.TempDir()
36 origInspect, origMerge := inspectWorktreeMerge, mergeWorktreeBack
37 t.Cleanup(func() { inspectWorktreeMerge, mergeWorktreeBack = origInspect, origMerge })
38
39 inspectWorktreeMerge = func(_ context.Context, root, _ string) (worktree.MergeInspection, error) {
40 return worktree.MergeInspection{
41 Available: true, CanMerge: true, WorktreeRoot: worktreeRoot, SourceRoot: sourceRoot,
42 WorktreeBranch: "reasonix/delivery-test", TargetBranch: "main", WorktreeHead: "worktree-head",
43 WorktreeStateToken: "state-token", TargetHead: "target-head", AheadCount: 2, FilesChanged: 1, ChangedFiles: []string{"feature.go"},
44 ConflictFiles: []string{}, Blockers: []worktree.MergeBlocker{}, CleanupBlockers: []worktree.MergeBlocker{},
45 }, nil
46 }
47 var merged worktree.MergeRequest
48 mergeWorktreeBack = func(_ context.Context, _ string, request worktree.MergeRequest) (worktree.MergeResult, error) {
49 merged = request
50 return worktree.MergeResult{
51 Merged: true, SourceRoot: sourceRoot, TargetBranch: "main", MergedCommit: "merged-head",
52 WorktreeRoot: worktreeRoot, WorktreeBranch: "reasonix/delivery-test", WorktreeHead: "worktree-head",
53 }, nil
54 }
55
56 app := NewApp()
57 app.tabs["worktree-tab"] = &WorkspaceTab{
58 ID: "worktree-tab", Scope: "project", WorkspaceRoot: worktreeRoot, Ready: true,
59 Ctrl: &backgroundRuntimeController{},
60 }
61 app.tabOrder = []string{"worktree-tab"}
62
63 inspection, err := app.InspectWorktreeMerge("worktree-tab")
64 if err != nil || !inspection.CanMerge || inspection.AheadCount != 2 {
65 t.Fatalf("InspectWorktreeMerge = %+v, %v", inspection, err)
66 }
67 result, err := app.MergeWorktreeBack(MergeWorktreeBackRequest{
68 TabID: "worktree-tab", ExpectedTargetBranch: "main", ExpectedTargetHead: "target-head",
69 ExpectedWorktreeHead: "worktree-head", ExpectedWorktreeStateToken: "state-token", AutoCommitDirty: true,
70 })
71 if err != nil || !result.Merged {
72 t.Fatalf("MergeWorktreeBack = %+v, %v", result, err)
73 }
74 if merged.WorkspaceRoot != worktreeRoot || !merged.AutoCommitDirty || merged.ExpectedTargetHead != "target-head" || merged.ExpectedWorktreeStateToken != "state-token" {
75 t.Fatalf("backend merge request = %+v", merged)
76 }
77 }
78
79 func TestCleanupReservationSerializesRuntimeAdmission(t *testing.T) {
80 isolateDesktopUserDirs(t)
81 root := t.TempDir()
82 app := NewApp()
83 releaseAdmission, err := app.beginWorkspaceRuntimeAdmission(root)
84 if err != nil {
85 t.Fatal(err)
86 }
87 result := make(chan error, 1)
88 go func() {
89 release, reserveErr := app.reserveWorktreeCleanup(root)
90 if release != nil {
91 release()
92 }
93 result <- reserveErr
94 }()
95 app.mu.Lock()
96 app.tabs["late"] = &WorkspaceTab{ID: "late", Scope: "project", WorkspaceRoot: root}
97 app.mu.Unlock()
98 releaseAdmission()
99 if err := <-result; err == nil {
100 t.Fatal("cleanup reservation ignored the runtime published by an admitted owner")
101 }
102 app.mu.Lock()
103 delete(app.tabs, "late")
104 app.mu.Unlock()
105 release, err := app.reserveWorktreeCleanup(root)
106 if err != nil {
107 t.Fatalf("reserve after runtime removal: %v", err)
108 }
109 if _, err := app.beginWorkspaceRuntimeAdmission(root); err == nil {
110 t.Fatal("runtime admission entered a reserved cleanup workspace")
111 }
112 release()
113 }
114
115 func TestCloseMergedWorktreeTabRechecksSourceAndSupportsIdempotence(t *testing.T) {
116 isolateDesktopUserDirs(t)
117 sourceRoot := t.TempDir()
118 worktreeRoot := t.TempDir()
119 app := NewApp()
120 source := &WorkspaceTab{ID: "source", Scope: "project", WorkspaceRoot: sourceRoot}
121 worktreeTab := &WorkspaceTab{ID: "worktree", Scope: "project", WorkspaceRoot: worktreeRoot}
122 app.tabs[source.ID] = source
123 app.tabs[worktreeTab.ID] = worktreeTab
124 app.tabOrder = []string{source.ID, worktreeTab.ID}
125 app.activeTabID = worktreeTab.ID
126 if err := app.RegisterNavigationIntent("merge-close-1"); err != nil {
127 t.Fatal(err)
128 }
129 request := CloseMergedWorktreeTabRequest{
130 TabID: worktreeTab.ID, WorktreeRoot: worktreeRoot, SourceTabID: source.ID, SourceRoot: sourceRoot,
131 NavigationIntentToken: "merge-close-1",
132 }
133 if result, err := app.CloseMergedWorktreeTab(request); err == nil || result.Closed {
134 t.Fatalf("close with worktree reselected = %+v, %v", result, err)
135 }
136 app.activeTabID = source.ID
137 missingToken := request
138 missingToken.NavigationIntentToken = ""
139 if result, err := app.CloseMergedWorktreeTab(missingToken); err == nil || result.Closed {
140 t.Fatalf("close without navigation token = %+v, %v", result, err)
141 }
142 result, err := app.CloseMergedWorktreeTab(request)
143 if err != nil || !result.Closed || result.Idempotent {
144 t.Fatalf("exact close = %+v, %v", result, err)
145 }
146 result, err = app.CloseMergedWorktreeTab(request)
147 if err != nil || !result.Closed || !result.Idempotent {
148 t.Fatalf("idempotent close = %+v, %v", result, err)
149 }
150 app.mu.Lock()
151 app.detachedSessions["detached"] = &WorkspaceTab{ID: "detached", Scope: "project", WorkspaceRoot: worktreeRoot}
152 app.mu.Unlock()
153 if result, err := app.CloseMergedWorktreeTab(request); err == nil || result.Closed {
154 t.Fatalf("detached close = %+v, %v", result, err)
155 }
156 }
157
158 func TestCloseMergedWorktreeTabRejectsNewNavigationDuringSnapshot(t *testing.T) {
159 isolateDesktopUserDirs(t)
160 sourceRoot := t.TempDir()
161 worktreeRoot := t.TempDir()
162 app := NewApp()
163 source := &WorkspaceTab{ID: "source", Scope: "project", WorkspaceRoot: sourceRoot}
164 worktreeTab := &WorkspaceTab{ID: "worktree", Scope: "project", WorkspaceRoot: worktreeRoot}
165 app.tabs[source.ID] = source
166 app.tabs[worktreeTab.ID] = worktreeTab
167 app.tabOrder = []string{source.ID, worktreeTab.ID}
168 app.activeTabID = source.ID
169 if err := app.RegisterNavigationIntent("merge-close-old"); err != nil {
170 t.Fatal(err)
171 }
172 app.navigationIntent.beforeCloseFinalHook = func() {
173 if err := app.RegisterNavigationIntent("newer-user-navigation"); err != nil {
174 t.Fatal(err)
175 }
176 }
177 request := CloseMergedWorktreeTabRequest{
178 TabID: worktreeTab.ID, WorktreeRoot: worktreeRoot, SourceTabID: source.ID, SourceRoot: sourceRoot,
179 NavigationIntentToken: "merge-close-old",
180 }
181 result, err := app.CloseMergedWorktreeTab(request)
182 if err == nil || result.Closed {
183 t.Fatalf("stale navigation close = %+v, %v", result, err)
184 }
185 app.mu.RLock()
186 kept := app.tabs[worktreeTab.ID]
187 app.mu.RUnlock()
188 if kept != worktreeTab {
189 t.Fatal("stale navigation removed the worktree tab")
190 }
191 }
192
193 func TestRuntimeReferenceCanonicalizesSymlinkAndSubdirectory(t *testing.T) {
194 isolateDesktopUserDirs(t)
195 worktreeRoot := t.TempDir()
196 nested := filepath.Join(worktreeRoot, "nested")
197 if err := os.MkdirAll(nested, 0o755); err != nil {
198 t.Fatal(err)
199 }
200 alias := filepath.Join(t.TempDir(), "alias")
201 if err := os.Symlink(worktreeRoot, alias); err != nil {
202 t.Skipf("symlink unavailable: %v", err)
203 }
204 app := NewApp()
205 app.tabs["alias"] = &WorkspaceTab{ID: "alias", Scope: "project", WorkspaceRoot: filepath.Join(alias, "nested")}
206 if !app.worktreeRuntimeReferenced(worktreeRoot) {
207 t.Fatal("symlinked subdirectory runtime did not block cleanup")
208 }
209 }
210
211 func TestAppMergeWorktreeBackBlocksActiveAndChangedTab(t *testing.T) {
212 isolateDesktopUserDirs(t)
213 root := t.TempDir()
214 app := NewApp()
215 ctrl := &backgroundRuntimeController{status: control.RuntimeStatus{Running: true}}
216 tab := &WorkspaceTab{ID: "worktree-tab", Scope: "project", WorkspaceRoot: root, Ready: true, Ctrl: ctrl}
217 app.tabs[tab.ID] = tab
218 app.tabOrder = []string{tab.ID}
219 if result, err := app.MergeWorktreeBack(MergeWorktreeBackRequest{TabID: tab.ID}); err == nil || result.Merged {
220 t.Fatalf("active merge = %+v, %v", result, err)
221 }
222 ctrl.status = control.RuntimeStatus{}
223 app.mu.Lock()
224 tab.Ready = false
225 app.mu.Unlock()
226 if result, err := app.MergeWorktreeBack(MergeWorktreeBackRequest{TabID: tab.ID}); err == nil || result.Merged {
227 t.Fatalf("building merge = %+v, %v", result, err)
228 }
229 }
230
231 func TestAppFinalizeWorktreeMergeRequiresNoRuntimeReference(t *testing.T) {
232 isolateDesktopUserDirs(t)
233 sourceRoot := t.TempDir()
234 worktreeRoot := t.TempDir()
235 origFinalize := finalizeWorktreeMerge
236 t.Cleanup(func() { finalizeWorktreeMerge = origFinalize })
237 called := false
238 finalizeWorktreeMerge = func(_ context.Context, _ string, _ worktree.CleanupRequest) (worktree.CleanupResult, error) {
239 called = true
240 return worktree.CleanupResult{
241 RecoveryRetained: true, RecoveryRoot: filepath.Join(filepath.Dir(worktreeRoot), ".reasonix-cleanup", "recovery-test"),
242 RecoveryWorktreeRegistered: true, BranchRetained: true, Blockers: []worktree.MergeBlocker{},
243 }, nil
244 }
245 app := NewApp()
246 tab := &WorkspaceTab{ID: "visible", Scope: "project", WorkspaceRoot: filepath.Join(worktreeRoot, "subdir")}
247 app.tabs[tab.ID] = tab
248 request := worktree.CleanupRequest{WorktreeRoot: worktreeRoot, SourceRoot: sourceRoot}
249 result, err := app.FinalizeWorktreeMerge(request)
250 if err == nil || result.Completed || called {
251 t.Fatalf("referenced cleanup = %+v, %v, called=%v", result, err, called)
252 }
253 app.mu.Lock()
254 delete(app.tabs, tab.ID)
255 app.mu.Unlock()
256 result, err = app.FinalizeWorktreeMerge(request)
257 if err != nil || !result.RecoveryRetained || result.Completed || !called {
258 t.Fatalf("unreferenced cleanup = %+v, %v, called=%v", result, err, called)
259 }
260 }
261
262 func TestAppFinalizeRetainedWorktreeRemovesOnlyFormerProjectRegistration(t *testing.T) {
263 isolateDesktopUserDirs(t)
264 sourceRoot := t.TempDir()
265 allocationRoot := t.TempDir()
266 worktreeRoot := filepath.Join(allocationRoot, "repository")
267 recoveryRoot := filepath.Join(allocationRoot, ".reasonix-cleanup", "recovery-test")
268 if err := os.MkdirAll(worktreeRoot, 0o755); err != nil {
269 t.Fatal(err)
270 }
271 if err := addProject(sourceRoot, "Source"); err != nil {
272 t.Fatal(err)
273 }
274 if err := addProject(worktreeRoot, "Managed worktree"); err != nil {
275 t.Fatal(err)
276 }
277 saveWorkspace(worktreeRoot)
278 rememberWorkspace(worktreeRoot)
279
280 origFinalize := finalizeWorktreeMerge
281 t.Cleanup(func() { finalizeWorktreeMerge = origFinalize })
282 finalizeWorktreeMerge = func(_ context.Context, _ string, _ worktree.CleanupRequest) (worktree.CleanupResult, error) {
283 return worktree.CleanupResult{
284 RecoveryRetained: true, RecoveryRoot: recoveryRoot, RecoveryWorktreeRegistered: true,
285 BranchRetained: true,
286 Blockers: []worktree.MergeBlocker{{Code: "late_content_preserved", Message: "late content was preserved", Paths: []string{"."}}},
287 Error: "cleanup_state_changed: late content was preserved",
288 }, errors.New("cleanup_state_changed: late content was preserved")
289 }
290 app := NewApp()
291 app.catalogRegisteredProjectRoots.Store(projectRootKey(normalizeProjectRoot(worktreeRoot)), struct{}{})
292 result, err := app.FinalizeWorktreeMerge(worktree.CleanupRequest{WorktreeRoot: worktreeRoot, SourceRoot: sourceRoot})
293 if err != nil || !result.RecoveryRetained {
294 t.Fatalf("FinalizeWorktreeMerge = %+v, %v", result, err)
295 }
296 projects := loadProjectsFile().Projects
297 if projectIndexByRoot(projects, sourceRoot) < 0 || projectIndexByRoot(projects, worktreeRoot) >= 0 || projectIndexByRoot(projects, recoveryRoot) >= 0 {
298 t.Fatalf("projects after retained finalize = %+v", projects)
299 }
300 if !sameProjectRoot(loadWorkspace(), sourceRoot) {
301 t.Fatalf("active workspace = %q, want source %q", loadWorkspace(), sourceRoot)
302 }
303 if _, ok := app.catalogRegisteredProjectRoots.Load(projectRootKey(normalizeProjectRoot(worktreeRoot))); ok {
304 t.Fatal("former worktree catalog registration was retained")
305 }
306 }
307
308 func TestAppFinalizeRetainedWorktreeRetriesProjectRegistryFailure(t *testing.T) {
309 isolateDesktopUserDirs(t)
310 sourceRoot := t.TempDir()
311 worktreeRoot := t.TempDir()
312 if err := addProject(worktreeRoot, "Managed worktree"); err != nil {
313 t.Fatal(err)
314 }
315 origFinalize, origRemove := finalizeWorktreeMerge, removeWorktreeProject
316 t.Cleanup(func() { finalizeWorktreeMerge, removeWorktreeProject = origFinalize, origRemove })
317 finalizeWorktreeMerge = func(_ context.Context, _ string, _ worktree.CleanupRequest) (worktree.CleanupResult, error) {
318 return worktree.CleanupResult{
319 RecoveryRetained: true, RecoveryRoot: filepath.Join(filepath.Dir(worktreeRoot), "recovery"),
320 RecoveryWorktreeRegistered: true, BranchRetained: true, Blockers: []worktree.MergeBlocker{},
321 }, nil
322 }
323 fail := true
324 removeWorktreeProject = func(root string) error {
325 if fail {
326 return errors.New("registry busy")
327 }
328 return origRemove(root)
329 }
330 app := NewApp()
331 request := worktree.CleanupRequest{WorktreeRoot: worktreeRoot, SourceRoot: sourceRoot}
332 result, err := app.FinalizeWorktreeMerge(request)
333 if err != nil || !result.RecoveryRetained || result.Error == "" || projectIndexByRoot(loadProjectsFile().Projects, worktreeRoot) < 0 {
334 t.Fatalf("failed registry cleanup = %+v, %v", result, err)
335 }
336 fail = false
337 result, err = app.FinalizeWorktreeMerge(request)
338 if err != nil || !result.RecoveryRetained || projectIndexByRoot(loadProjectsFile().Projects, worktreeRoot) >= 0 {
339 t.Fatalf("retried registry cleanup = %+v, %v", result, err)
340 }
341 }
342
343 func TestPathWithinWorktreeRejectsPrefixSibling(t *testing.T) {
344 root := filepath.Join(t.TempDir(), "worktree")
345 if err := os.MkdirAll(filepath.Join(root, "nested"), 0o755); err != nil {
346 t.Fatal(err)
347 }
348 if !pathWithinWorktree(filepath.Join(root, "nested"), root) || pathWithinWorktree(root+"-backup", root) {
349 t.Fatal("worktree path boundary was not enforced")
350 }
351 }
352
352 lines GO