diff --git a/src/ClaudeDo.Data/Git/GitService.cs b/src/ClaudeDo.Data/Git/GitService.cs index 8cf5466..c524473 100644 --- a/src/ClaudeDo.Data/Git/GitService.cs +++ b/src/ClaudeDo.Data/Git/GitService.cs @@ -160,6 +160,13 @@ public sealed class GitService return stdout.Trim(); } + public async Task CheckoutBranchAsync(string repoDir, string branchName, CancellationToken ct = default) + { + var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["checkout", branchName], ct); + if (exitCode != 0) + throw new InvalidOperationException($"git checkout '{branchName}' failed (exit {exitCode}): {stderr}"); + } + public async Task> ListLocalBranchesAsync(string repoDir, CancellationToken ct = default) { var (exitCode, stdout, stderr) = await RunGitAsync(repoDir, diff --git a/src/ClaudeDo.Worker/Services/TaskMergeService.cs b/src/ClaudeDo.Worker/Services/TaskMergeService.cs index bdddac3..df54264 100644 --- a/src/ClaudeDo.Worker/Services/TaskMergeService.cs +++ b/src/ClaudeDo.Worker/Services/TaskMergeService.cs @@ -75,6 +75,13 @@ public sealed class TaskMergeService if (await _git.HasChangesAsync(list.WorkingDir, ct)) return Blocked("target working tree has uncommitted changes"); + var currentBranch = await _git.GetCurrentBranchAsync(list.WorkingDir, ct); + if (!string.Equals(currentBranch, targetBranch, StringComparison.Ordinal)) + { + try { await _git.CheckoutBranchAsync(list.WorkingDir, targetBranch, ct); } + catch (Exception ex) { return Blocked($"failed to switch target branch: {ex.Message}"); } + } + var (exitCode, stderr) = await _git.MergeNoFfAsync(list.WorkingDir, wt.BranchName, commitMessage, ct); if (exitCode != 0) { diff --git a/tests/ClaudeDo.Worker.Tests/Runner/GitServiceMergeTests.cs b/tests/ClaudeDo.Worker.Tests/Runner/GitServiceMergeTests.cs index d97031b..323b1f9 100644 --- a/tests/ClaudeDo.Worker.Tests/Runner/GitServiceMergeTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Runner/GitServiceMergeTests.cs @@ -155,6 +155,20 @@ public class GitServiceMergeTests : IDisposable Assert.False(await git.IsMidMergeAsync(repo.RepoDir)); } + [Fact] + public async Task CheckoutBranchAsync_ExistingBranch_SwitchesBranch() + { + if (!GitRepoFixture.IsGitAvailable()) return; + var repo = NewRepo(); + GitRepoFixture.RunGit(repo.RepoDir, "branch", "feature/checkout-test"); + + var git = new GitService(); + await git.CheckoutBranchAsync(repo.RepoDir, "feature/checkout-test"); + + var current = await git.GetCurrentBranchAsync(repo.RepoDir); + Assert.Equal("feature/checkout-test", current); + } + [Fact] public async Task ListConflictedFilesAsync_MidConflict_ReturnsConflictedFile() { diff --git a/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs index e690a2d..a61867c 100644 --- a/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs @@ -293,6 +293,64 @@ public class TaskMergeServiceTests : IDisposable Assert.Equal("blocked", result.Status); Assert.Contains("uncommitted", result.ErrorMessage ?? ""); } + + [Fact] + public async Task MergeAsync_TargetBranchDifferentFromHead_ChecksOutBeforeMerging() + { + if (!GitRepoFixture.IsGitAvailable()) return; + + var repo = NewRepo(); + var db = NewDb(); + var (list, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done); + + // Create a feature branch in the repo (from current HEAD). + GitRepoFixture.RunGit(repo.RepoDir, "branch", "feature/target"); + + var wtMgr = BuildWorktreeManager(db); + var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None); + _wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath)); + + File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "feat.txt"), "data\n"); + await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None); + + var (svc, _) = BuildService(db); + // Repo is currently on main/master; request merge into feature/target. + var result = await svc.MergeAsync(task.Id, "feature/target", removeWorktree: false, + commitMessage: "Merge into feature", ct: CancellationToken.None); + + Assert.Equal("merged", result.Status); + + // HEAD must now be feature/target. + var head = await new GitService().GetCurrentBranchAsync(repo.RepoDir); + Assert.Equal("feature/target", head); + + // The merged file must exist on feature/target. + Assert.True(File.Exists(Path.Combine(repo.RepoDir, "feat.txt"))); + } + + [Fact] + public async Task MergeAsync_TargetBranchDoesNotExist_ReturnsBlocked() + { + if (!GitRepoFixture.IsGitAvailable()) return; + + var repo = NewRepo(); + var db = NewDb(); + var (list, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done); + + var wtMgr = BuildWorktreeManager(db); + var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None); + _wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath)); + + File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "x.txt"), "x\n"); + await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None); + + var (svc, _) = BuildService(db); + var result = await svc.MergeAsync(task.Id, "nonexistent/branch", removeWorktree: false, + commitMessage: "Merge", ct: CancellationToken.None); + + Assert.Equal("blocked", result.Status); + Assert.Contains("switch target branch", result.ErrorMessage ?? ""); + } } #region Test doubles