From 29c79e8b8c89e3a935b8d0b5c58f2f8724eb9596 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Mon, 10 Aug 2026 11:56:37 +0200 Subject: [PATCH] fix(worker): stage only resolved conflict paths in continue_merge git add -A ran against the shared target checkout, sweeping in whatever untracked or unrelated modified files a concurrent session left there and committing them into an unrelated merge. Stage exactly the paths that were reported conflicted and resolved instead. --- .../Lifecycle/TaskMergeService.cs | 6 ++- .../Services/TaskMergeServiceTests.cs | 54 +++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs b/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs index e98c1c05..da9a31cb 100644 --- a/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs +++ b/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs @@ -315,7 +315,11 @@ public sealed class TaskMergeService if (stillConflicted.Count > 0) return new MergeResult(StatusConflict, stillConflicted, "conflicts not fully resolved"); - await _git.AddAllAsync(list.WorkingDir, ct); + // Stage exactly the resolved conflict paths — never `git add -A`, which would sweep + // untracked/unrelated changes left by other sessions into this merge commit (the + // target working dir is shared). + foreach (var path in unresolved) + await _git.AddPathAsync(list.WorkingDir, path, ct); var remaining = await _git.ListConflictedFilesAsync(list.WorkingDir, ct); if (remaining.Count > 0) diff --git a/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs index 2abefead..64897885 100644 --- a/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs @@ -447,6 +447,60 @@ public class TaskMergeServiceTests : IDisposable Assert.Equal(WorktreeState.Merged, wt.State); } + [Fact] + public async Task ContinueMergeAsync_UnrelatedChangesInSharedCheckout_AreNotStagedOrCommitted() + { + if (!GitRepoFixture.IsGitAvailable()) return; + + var db = NewDb(); + var repo = NewRepo(); + GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main"); + + File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# main change\n"); + File.WriteAllText(Path.Combine(repo.RepoDir, "tracked.txt"), "tracked v1\n"); + GitRepoFixture.RunGit(repo.RepoDir, "add", "tracked.txt"); + GitRepoFixture.RunGit(repo.RepoDir, "commit", "-am", "main change"); + + var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}"); + _wtCleanups.Add((repo.RepoDir, wtPath)); + GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", "claudedo/t5", wtPath, repo.BaseCommit); + File.WriteAllText(Path.Combine(wtPath, "README.md"), "# branch change\n"); + GitRepoFixture.RunGit(wtPath, "commit", "-am", "branch change"); + + var (_, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done); + await SeedWorktree(db, task.Id, wtPath, "claudedo/t5", repo.BaseCommit); + + var (svc, _) = BuildService(db); + + var first = await svc.MergeAsync(task.Id, "main", false, "msg", + leaveConflictsInTree: true, CancellationToken.None); + Assert.Equal(TaskMergeService.StatusConflict, first.Status); + + // Simulate the user resolving the conflict. + File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# resolved\n"); + + // A concurrent session leaves stray changes in this shared checkout while the merge is + // paused — these must survive continue_merge untouched and never enter the merge commit. + File.WriteAllText(Path.Combine(repo.RepoDir, "untracked.txt"), "from another session\n"); + File.WriteAllText(Path.Combine(repo.RepoDir, "tracked.txt"), "tracked v2 (unrelated edit)\n"); + + var result = await svc.ContinueMergeAsync(task.Id, CancellationToken.None); + + Assert.Equal(TaskMergeService.StatusMerged, result.Status); + + Assert.True(File.Exists(Path.Combine(repo.RepoDir, "untracked.txt"))); + Assert.Equal("from another session\n", File.ReadAllText(Path.Combine(repo.RepoDir, "untracked.txt"))); + Assert.Equal("tracked v2 (unrelated edit)\n", File.ReadAllText(Path.Combine(repo.RepoDir, "tracked.txt"))); + + var status = GitRepoFixture.RunGit(repo.RepoDir, "status", "--porcelain"); + Assert.Contains("?? untracked.txt", status); + Assert.Contains(" M tracked.txt", status); + + var committedFiles = GitRepoFixture.RunGit(repo.RepoDir, "show", "--name-only", "--pretty=format:", "HEAD"); + Assert.DoesNotContain("untracked.txt", committedFiles); + Assert.DoesNotContain("tracked.txt", committedFiles); + } + [Fact] public async Task ContinueMergeAsync_UnresolvedConflictMarkersLeft_RefusesAndDoesNotCommit() {