| 1 | package worktree |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "os" |
| 7 | "os/exec" |
| 8 | "path/filepath" |
| 9 | "strings" |
| 10 | "testing" |
| 11 | ) |
| 12 | |
| 13 | func TestInspectMergeUsesRecordedLinkedSource(t *testing.T) { |
| 14 | requireGit(t) |
| 15 | repo := initRepo(t) |
| 16 | linked := filepath.Join(t.TempDir(), "source-linked") |
| 17 | gitTest(t, repo, "worktree", "add", "-b", "feature/source", linked, "HEAD") |
| 18 | managed := t.TempDir() |
| 19 | created, err := Create(context.Background(), linked, managed) |
| 20 | if err != nil { |
| 21 | t.Fatal(err) |
| 22 | } |
| 23 | |
| 24 | inspection, err := InspectMerge(context.Background(), created.WorkspaceRoot, managed) |
| 25 | if err != nil { |
| 26 | t.Fatal(err) |
| 27 | } |
| 28 | if err := sameDirectory(inspection.SourceRoot, linked); err != nil || inspection.TargetBranch != "feature/source" { |
| 29 | t.Fatalf("inspection source identity = %+v", inspection) |
| 30 | } |
| 31 | if inspection.ChangedFiles == nil || inspection.ConflictFiles == nil || inspection.Blockers == nil || inspection.CleanupBlockers == nil { |
| 32 | t.Fatalf("inspection arrays must encode as []: %+v", inspection) |
| 33 | } |
| 34 | } |
| 35 | |
| 36 | func TestMergeBackRequiresExplicitDirtyCommitAndFinalizesSeparately(t *testing.T) { |
| 37 | requireGit(t) |
| 38 | repo := initRepo(t) |
| 39 | managed := t.TempDir() |
| 40 | created, err := Create(context.Background(), repo, managed) |
| 41 | if err != nil { |
| 42 | t.Fatal(err) |
| 43 | } |
| 44 | if err := os.WriteFile(filepath.Join(created.WorktreeRoot, "feature.go"), []byte("package feature\n"), 0o644); err != nil { |
| 45 | t.Fatal(err) |
| 46 | } |
| 47 | inspection := inspectMergeTest(t, created.WorkspaceRoot, managed) |
| 48 | request := requestFromInspection(inspection) |
| 49 | if inspection.CanMerge || !inspection.WorktreeDirty { |
| 50 | t.Fatalf("dirty inspection = %+v", inspection) |
| 51 | } |
| 52 | if result, err := MergeBack(context.Background(), managed, request); err == nil || result.Merged { |
| 53 | t.Fatalf("dirty merge without opt-in = %+v, %v", result, err) |
| 54 | } |
| 55 | request.AutoCommitDirty = true |
| 56 | result, err := MergeBack(context.Background(), managed, request) |
| 57 | if err != nil { |
| 58 | t.Fatalf("MergeBack: %v (%+v)", err, result) |
| 59 | } |
| 60 | if !result.Merged || result.MergedCommit == "" { |
| 61 | t.Fatalf("merge result = %+v", result) |
| 62 | } |
| 63 | if _, err := os.Stat(created.WorktreeRoot); err != nil { |
| 64 | t.Fatalf("merge removed worktree before finalize: %v", err) |
| 65 | } |
| 66 | if _, err := os.Stat(filepath.Join(repo, "feature.go")); err != nil { |
| 67 | t.Fatalf("merged file missing from source: %v", err) |
| 68 | } |
| 69 | |
| 70 | cleanup, err := FinalizeMerge(context.Background(), managed, cleanupFromMerge(result)) |
| 71 | if err != nil { |
| 72 | t.Fatalf("FinalizeMerge: %v (%+v)", err, cleanup) |
| 73 | } |
| 74 | if cleanup.Completed || cleanup.WorktreeRemoved || cleanup.BranchDeleted || !cleanup.RecoveryRetained || !cleanup.RecoveryWorktreeRegistered || !cleanup.BranchRetained { |
| 75 | t.Fatalf("cleanup result = %+v", cleanup) |
| 76 | } |
| 77 | if _, err := os.Stat(created.WorktreeRoot); !os.IsNotExist(err) { |
| 78 | t.Fatalf("former worktree path remains after finalize: %v", err) |
| 79 | } |
| 80 | if _, err := os.Stat(cleanup.RecoveryRoot); err != nil { |
| 81 | t.Fatalf("retained recovery worktree is missing: %v", err) |
| 82 | } |
| 83 | } |
| 84 | |
| 85 | func TestMergeBackRejectsTargetHeadDrift(t *testing.T) { |
| 86 | requireGit(t) |
| 87 | repo := initRepo(t) |
| 88 | managed := t.TempDir() |
| 89 | created, err := Create(context.Background(), repo, managed) |
| 90 | if err != nil { |
| 91 | t.Fatal(err) |
| 92 | } |
| 93 | gitCommitFile(t, created.WorktreeRoot, "feature.txt", "feature\n", "feature") |
| 94 | inspection := inspectMergeTest(t, created.WorkspaceRoot, managed) |
| 95 | gitCommitFile(t, repo, "source.txt", "source\n", "source drift") |
| 96 | |
| 97 | result, err := MergeBack(context.Background(), managed, requestFromInspection(inspection)) |
| 98 | if err == nil || result.Merged || !strings.Contains(result.Error, "identity changed") { |
| 99 | t.Fatalf("drift merge = %+v, %v", result, err) |
| 100 | } |
| 101 | if _, err := os.Stat(created.WorktreeRoot); err != nil { |
| 102 | t.Fatalf("drift removed recoverable worktree: %v", err) |
| 103 | } |
| 104 | } |
| 105 | |
| 106 | func TestMergeBackRejectsConfirmedWorktreeContentDrift(t *testing.T) { |
| 107 | requireGit(t) |
| 108 | repo := initRepo(t) |
| 109 | managed := t.TempDir() |
| 110 | created, err := Create(context.Background(), repo, managed) |
| 111 | if err != nil { |
| 112 | t.Fatal(err) |
| 113 | } |
| 114 | path := filepath.Join(created.WorktreeRoot, "feature.txt") |
| 115 | if err := os.WriteFile(path, []byte("confirmed\n"), 0o644); err != nil { |
| 116 | t.Fatal(err) |
| 117 | } |
| 118 | inspection := inspectMergeTest(t, created.WorkspaceRoot, managed) |
| 119 | request := requestFromInspection(inspection) |
| 120 | request.AutoCommitDirty = true |
| 121 | if err := os.WriteFile(path, []byte("changed later\n"), 0o644); err != nil { |
| 122 | t.Fatal(err) |
| 123 | } |
| 124 | result, err := MergeBack(context.Background(), managed, request) |
| 125 | if err == nil || result.Merged || !strings.Contains(result.Error, "identity changed") { |
| 126 | t.Fatalf("content drift = %+v, %v", result, err) |
| 127 | } |
| 128 | if got := gitTest(t, created.WorktreeRoot, "rev-parse", "HEAD"); got != inspection.WorktreeHead { |
| 129 | t.Fatalf("content drift created a commit: %s", got) |
| 130 | } |
| 131 | } |
| 132 | |
| 133 | func TestMergeBackPreservesStagingWhenContentChangesDuringAdd(t *testing.T) { |
| 134 | requireGit(t) |
| 135 | repo := initRepo(t) |
| 136 | managed := t.TempDir() |
| 137 | created, err := Create(context.Background(), repo, managed) |
| 138 | if err != nil { |
| 139 | t.Fatal(err) |
| 140 | } |
| 141 | path := filepath.Join(created.WorktreeRoot, "feature.txt") |
| 142 | if err := os.WriteFile(path, []byte("confirmed\n"), 0o644); err != nil { |
| 143 | t.Fatal(err) |
| 144 | } |
| 145 | inspection := inspectMergeTest(t, created.WorkspaceRoot, managed) |
| 146 | request := requestFromInspection(inspection) |
| 147 | request.AutoCommitDirty = true |
| 148 | mergeStepHook = func(step string) { |
| 149 | if step == "after_worktree_add" { |
| 150 | if err := os.WriteFile(path, []byte("changed during add\n"), 0o644); err != nil { |
| 151 | t.Fatal(err) |
| 152 | } |
| 153 | } |
| 154 | } |
| 155 | t.Cleanup(func() { mergeStepHook = nil }) |
| 156 | result, err := MergeBack(context.Background(), managed, request) |
| 157 | if err == nil || result.Merged || !strings.Contains(result.Error, "real index was preserved") { |
| 158 | t.Fatalf("add drift = %+v, %v", result, err) |
| 159 | } |
| 160 | status := gitTest(t, created.WorktreeRoot, "status", "--porcelain=v1") |
| 161 | if !strings.Contains(status, "?? feature.txt") { |
| 162 | t.Fatalf("real index or later worktree contents changed: %q", status) |
| 163 | } |
| 164 | } |
| 165 | |
| 166 | func TestMergeBackRejectsExtraCommitAfterAutoCommit(t *testing.T) { |
| 167 | requireGit(t) |
| 168 | repo := initRepo(t) |
| 169 | managed := t.TempDir() |
| 170 | created, err := Create(context.Background(), repo, managed) |
| 171 | if err != nil { |
| 172 | t.Fatal(err) |
| 173 | } |
| 174 | if err := os.WriteFile(filepath.Join(created.WorktreeRoot, "feature.txt"), []byte("feature\n"), 0o644); err != nil { |
| 175 | t.Fatal(err) |
| 176 | } |
| 177 | inspection := inspectMergeTest(t, created.WorkspaceRoot, managed) |
| 178 | request := requestFromInspection(inspection) |
| 179 | request.AutoCommitDirty = true |
| 180 | mergeStepHook = func(step string) { |
| 181 | if step == "after_worktree_commit" { |
| 182 | gitCommitFile(t, created.WorktreeRoot, "extra.txt", "extra\n", "external extra") |
| 183 | } |
| 184 | } |
| 185 | t.Cleanup(func() { mergeStepHook = nil }) |
| 186 | result, err := MergeBack(context.Background(), managed, request) |
| 187 | if err == nil || result.Merged || !strings.Contains(result.Error, "branch HEAD changed") { |
| 188 | t.Fatalf("extra auto-commit = %+v, %v", result, err) |
| 189 | } |
| 190 | } |
| 191 | |
| 192 | func TestMergeBackStopsWhenSourceBranchChangesBeforePrepare(t *testing.T) { |
| 193 | requireGit(t) |
| 194 | repo := initRepo(t) |
| 195 | managed := t.TempDir() |
| 196 | created, err := Create(context.Background(), repo, managed) |
| 197 | if err != nil { |
| 198 | t.Fatal(err) |
| 199 | } |
| 200 | gitCommitFile(t, created.WorktreeRoot, "feature.txt", "feature\n", "feature") |
| 201 | inspection := inspectMergeTest(t, created.WorkspaceRoot, managed) |
| 202 | mergeStepHook = func(step string) { |
| 203 | if step == "before_merge_prepare" { |
| 204 | gitTest(t, repo, "switch", "-c", "source-drift") |
| 205 | } |
| 206 | } |
| 207 | t.Cleanup(func() { mergeStepHook = nil }) |
| 208 | result, err := MergeBack(context.Background(), managed, requestFromInspection(inspection)) |
| 209 | if err == nil || result.Merged || result.RecoveryRequired || !strings.Contains(result.Error, "source changed before merge preparation") { |
| 210 | t.Fatalf("source branch drift = %+v, %v", result, err) |
| 211 | } |
| 212 | } |
| 213 | |
| 214 | func TestMergeBackReportsRecoveryRequiredWhenPreparedSourceDrifts(t *testing.T) { |
| 215 | requireGit(t) |
| 216 | repo := initRepo(t) |
| 217 | managed := t.TempDir() |
| 218 | created, err := Create(context.Background(), repo, managed) |
| 219 | if err != nil { |
| 220 | t.Fatal(err) |
| 221 | } |
| 222 | gitCommitFile(t, created.WorktreeRoot, "feature.txt", "feature\n", "feature") |
| 223 | inspection := inspectMergeTest(t, created.WorkspaceRoot, managed) |
| 224 | mergeStepHook = func(step string) { |
| 225 | if step == "after_merge_prepare" { |
| 226 | // Preserve the prepared index and worktree while clearing merge |
| 227 | // metadata so switching branches deterministically simulates source |
| 228 | // identity drift on every supported Git platform. |
| 229 | gitTest(t, repo, "merge", "--quit") |
| 230 | gitTest(t, repo, "switch", "-c", "prepared-drift") |
| 231 | } |
| 232 | } |
| 233 | t.Cleanup(func() { mergeStepHook = nil }) |
| 234 | result, err := MergeBack(context.Background(), managed, requestFromInspection(inspection)) |
| 235 | if err == nil || result.Merged || !result.RecoveryRequired { |
| 236 | t.Fatalf("prepared source drift = %+v, %v", result, err) |
| 237 | } |
| 238 | } |
| 239 | |
| 240 | func TestMergeBackRechecksConflictAfterDirtyCommit(t *testing.T) { |
| 241 | requireGit(t) |
| 242 | repo := initRepo(t) |
| 243 | managed := t.TempDir() |
| 244 | created, err := Create(context.Background(), repo, managed) |
| 245 | if err != nil { |
| 246 | t.Fatal(err) |
| 247 | } |
| 248 | if err := os.WriteFile(filepath.Join(created.WorktreeRoot, "README.md"), []byte("worktree\n"), 0o644); err != nil { |
| 249 | t.Fatal(err) |
| 250 | } |
| 251 | gitCommitFile(t, repo, "README.md", "source\n", "source change") |
| 252 | inspection := inspectMergeTest(t, created.WorktreeRoot, managed) |
| 253 | request := requestFromInspection(inspection) |
| 254 | request.AutoCommitDirty = true |
| 255 | sourceHead := gitTest(t, repo, "rev-parse", "HEAD") |
| 256 | |
| 257 | result, err := MergeBack(context.Background(), managed, request) |
| 258 | if err == nil || result.Merged || !strings.Contains(result.Error, "merge is blocked") { |
| 259 | t.Fatalf("post-commit conflict = %+v, %v", result, err) |
| 260 | } |
| 261 | if got := gitTest(t, repo, "rev-parse", "HEAD"); got != sourceHead { |
| 262 | t.Fatalf("source HEAD changed on preflight conflict: %s != %s", got, sourceHead) |
| 263 | } |
| 264 | postCommit := inspectMergeTest(t, created.WorktreeRoot, managed) |
| 265 | if postCommit.WorktreeDirty || !postCommit.HasConflicts { |
| 266 | t.Fatalf("post-commit inspection = %+v", postCommit) |
| 267 | } |
| 268 | } |
| 269 | |
| 270 | func TestInspectMergeRejectsLegacyAndForgedMetadata(t *testing.T) { |
| 271 | requireGit(t) |
| 272 | repo := initRepo(t) |
| 273 | managed := t.TempDir() |
| 274 | created, err := Create(context.Background(), repo, managed) |
| 275 | if err != nil { |
| 276 | t.Fatal(err) |
| 277 | } |
| 278 | path := metadataPath(created.WorktreeRoot) |
| 279 | if err := os.Remove(path); err != nil { |
| 280 | t.Fatal(err) |
| 281 | } |
| 282 | inspection, err := InspectMerge(context.Background(), created.WorkspaceRoot, managed) |
| 283 | if err == nil || inspection.Available || !strings.Contains(inspection.Reason, "predates") { |
| 284 | t.Fatalf("legacy inspection = %+v, %v", inspection, err) |
| 285 | } |
| 286 | |
| 287 | forged := mergeMetadata{ |
| 288 | Version: mergeMetadataVersion, SourceRoot: repo, TargetBranch: gitTest(t, repo, "branch", "--show-current"), |
| 289 | CreatedHead: created.Head, WorktreeRoot: repo, WorktreeBranch: created.Branch, |
| 290 | } |
| 291 | body, _ := json.Marshal(forged) |
| 292 | if err := os.WriteFile(path, body, 0o600); err != nil { |
| 293 | t.Fatal(err) |
| 294 | } |
| 295 | inspection, err = InspectMerge(context.Background(), created.WorkspaceRoot, managed) |
| 296 | if err == nil || inspection.Available || !strings.Contains(inspection.Reason, "root mismatch") { |
| 297 | t.Fatalf("forged inspection = %+v, %v", inspection, err) |
| 298 | } |
| 299 | } |
| 300 | |
| 301 | func TestInspectMergeBlocksSourceBranchAndDirtyState(t *testing.T) { |
| 302 | requireGit(t) |
| 303 | repo := initRepo(t) |
| 304 | managed := t.TempDir() |
| 305 | created, err := Create(context.Background(), repo, managed) |
| 306 | if err != nil { |
| 307 | t.Fatal(err) |
| 308 | } |
| 309 | if err := os.WriteFile(filepath.Join(repo, "untracked.txt"), []byte("keep\n"), 0o644); err != nil { |
| 310 | t.Fatal(err) |
| 311 | } |
| 312 | inspection := inspectMergeTest(t, created.WorkspaceRoot, managed) |
| 313 | if inspection.CanMerge || !hasBlocker(inspection.Blockers, "source_dirty") { |
| 314 | t.Fatalf("source dirty inspection = %+v", inspection) |
| 315 | } |
| 316 | if err := os.Remove(filepath.Join(repo, "untracked.txt")); err != nil { |
| 317 | t.Fatal(err) |
| 318 | } |
| 319 | gitTest(t, repo, "switch", "-c", "other-target") |
| 320 | inspection = inspectMergeTest(t, created.WorkspaceRoot, managed) |
| 321 | if inspection.CanMerge || !hasBlocker(inspection.Blockers, "target_branch_drift") { |
| 322 | t.Fatalf("source branch drift inspection = %+v", inspection) |
| 323 | } |
| 324 | } |
| 325 | |
| 326 | func TestFinalizeMergePreservesIgnoredContentAndSupportsRetry(t *testing.T) { |
| 327 | requireGit(t) |
| 328 | repo := initRepo(t) |
| 329 | managed := t.TempDir() |
| 330 | created, err := Create(context.Background(), repo, managed) |
| 331 | if err != nil { |
| 332 | t.Fatal(err) |
| 333 | } |
| 334 | gitCommitFile(t, created.WorktreeRoot, "feature.txt", "feature\n", "feature") |
| 335 | inspection := inspectMergeTest(t, created.WorkspaceRoot, managed) |
| 336 | result, err := MergeBack(context.Background(), managed, requestFromInspection(inspection)) |
| 337 | if err != nil { |
| 338 | t.Fatal(err) |
| 339 | } |
| 340 | gitDir := gitTest(t, created.WorktreeRoot, "rev-parse", "--git-common-dir") |
| 341 | if !filepath.IsAbs(gitDir) { |
| 342 | gitDir = filepath.Join(created.WorktreeRoot, gitDir) |
| 343 | } |
| 344 | if err := os.WriteFile(filepath.Join(gitDir, "info", "exclude"), []byte("cache.bin\n"), 0o644); err != nil { |
| 345 | t.Fatal(err) |
| 346 | } |
| 347 | if err := os.WriteFile(filepath.Join(created.WorktreeRoot, "cache.bin"), []byte("keep\n"), 0o644); err != nil { |
| 348 | t.Fatal(err) |
| 349 | } |
| 350 | cleanup, err := FinalizeMerge(context.Background(), managed, cleanupFromMerge(result)) |
| 351 | if err == nil || cleanup.Completed || !hasBlocker(cleanup.Blockers, "worktree_content") { |
| 352 | t.Fatalf("ignored cleanup = %+v, %v", cleanup, err) |
| 353 | } |
| 354 | if _, err := os.Stat(filepath.Join(created.WorktreeRoot, "cache.bin")); err != nil { |
| 355 | t.Fatalf("ignored file was not preserved: %v", err) |
| 356 | } |
| 357 | if err := os.Remove(filepath.Join(created.WorktreeRoot, "cache.bin")); err != nil { |
| 358 | t.Fatal(err) |
| 359 | } |
| 360 | cleanup, err = FinalizeMerge(context.Background(), managed, cleanupFromMerge(result)) |
| 361 | if err != nil || !cleanup.RecoveryRetained || !cleanup.RecoveryWorktreeRegistered || !cleanup.BranchRetained { |
| 362 | t.Fatalf("cleanup retry = %+v, %v", cleanup, err) |
| 363 | } |
| 364 | } |
| 365 | |
| 366 | func TestInspectMergeRejectsOutsideManagedPath(t *testing.T) { |
| 367 | requireGit(t) |
| 368 | repo := initRepo(t) |
| 369 | inspection, err := InspectMerge(context.Background(), repo, t.TempDir()) |
| 370 | if err == nil || inspection.Available { |
| 371 | t.Fatalf("outside managed inspection = %+v, %v", inspection, err) |
| 372 | } |
| 373 | } |
| 374 | |
| 375 | func inspectMergeTest(t *testing.T, workspaceRoot, managedRoot string) MergeInspection { |
| 376 | t.Helper() |
| 377 | inspection, err := InspectMerge(context.Background(), workspaceRoot, managedRoot) |
| 378 | if err != nil { |
| 379 | t.Fatalf("InspectMerge: %v (%+v)", err, inspection) |
| 380 | } |
| 381 | return inspection |
| 382 | } |
| 383 | |
| 384 | func requestFromInspection(inspection MergeInspection) MergeRequest { |
| 385 | return MergeRequest{ |
| 386 | WorkspaceRoot: inspection.WorktreeRoot, ExpectedTargetBranch: inspection.TargetBranch, |
| 387 | ExpectedTargetHead: inspection.TargetHead, ExpectedWorktreeHead: inspection.WorktreeHead, |
| 388 | ExpectedWorktreeStateToken: inspection.WorktreeStateToken, |
| 389 | } |
| 390 | } |
| 391 | |
| 392 | func cleanupFromMerge(result MergeResult) CleanupRequest { |
| 393 | return CleanupRequest{ |
| 394 | WorktreeRoot: result.WorktreeRoot, SourceRoot: result.SourceRoot, TargetBranch: result.TargetBranch, |
| 395 | MergedCommit: result.MergedCommit, WorktreeBranch: result.WorktreeBranch, WorktreeHead: result.WorktreeHead, |
| 396 | } |
| 397 | } |
| 398 | |
| 399 | func hasBlocker(blockers []MergeBlocker, code string) bool { |
| 400 | for _, item := range blockers { |
| 401 | if item.Code == code { |
| 402 | return true |
| 403 | } |
| 404 | } |
| 405 | return false |
| 406 | } |
| 407 | |
| 408 | func gitCommitFile(t *testing.T, repo, name, content, message string) { |
| 409 | t.Helper() |
| 410 | if err := os.WriteFile(filepath.Join(repo, name), []byte(content), 0o644); err != nil { |
| 411 | t.Fatal(err) |
| 412 | } |
| 413 | gitTest(t, repo, "add", name) |
| 414 | gitTest(t, repo, "commit", "-m", message) |
| 415 | } |
| 416 | |
| 417 | func gitTest(t *testing.T, repo string, args ...string) string { |
| 418 | t.Helper() |
| 419 | cmd := exec.Command("git", append([]string{"-C", repo}, args...)...) |
| 420 | cmd.Env = append(os.Environ(), |
| 421 | "GIT_CONFIG_NOSYSTEM=1", "GIT_AUTHOR_NAME=Reasonix Test", "GIT_AUTHOR_EMAIL=reasonix@example.invalid", |
| 422 | "GIT_COMMITTER_NAME=Reasonix Test", "GIT_COMMITTER_EMAIL=reasonix@example.invalid") |
| 423 | out, err := cmd.CombinedOutput() |
| 424 | if err != nil { |
| 425 | t.Fatalf("git %v: %v\n%s", args, err, out) |
| 426 | } |
| 427 | return strings.TrimSpace(string(out)) |
| 428 | } |
| 429 |