| 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 |