diff --git a/src/ClaudeDo.Data/Git/GitService.cs b/src/ClaudeDo.Data/Git/GitService.cs index e7f55d7..d736b71 100644 --- a/src/ClaudeDo.Data/Git/GitService.cs +++ b/src/ClaudeDo.Data/Git/GitService.cs @@ -183,6 +183,14 @@ public sealed class GitService return File.Exists(Path.Combine(gitDir, "MERGE_HEAD")); } + public async Task<(int ExitCode, string Stderr)> MergeNoFfAsync( + string repoDir, string sourceBranch, string message, CancellationToken ct = default) + { + var (exitCode, _, stderr) = await RunGitAsync(repoDir, + ["merge", "--no-ff", "-m", message, sourceBranch], ct); + return (exitCode, stderr); + } + public async Task MergeFfOnlyAsync(string repoDir, string branchName, CancellationToken ct = default) { var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["merge", "--ff-only", branchName], ct); diff --git a/tests/ClaudeDo.Worker.Tests/Runner/GitServiceMergeTests.cs b/tests/ClaudeDo.Worker.Tests/Runner/GitServiceMergeTests.cs index 0b04efb..bce82ed 100644 --- a/tests/ClaudeDo.Worker.Tests/Runner/GitServiceMergeTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Runner/GitServiceMergeTests.cs @@ -72,4 +72,61 @@ public class GitServiceMergeTests : IDisposable var git = new GitService(); Assert.True(await git.IsMidMergeAsync(repo.RepoDir)); } + + [Fact] + public async Task MergeNoFfAsync_DivergedNonConflicting_ReturnsZero_AndCreatesMergeCommit() + { + if (!GitRepoFixture.IsGitAvailable()) return; + var repo = NewRepo(); + + // Create a feature branch with one new file. + GitRepoFixture.RunGit(repo.RepoDir, "checkout", "-b", "feature/merge"); + File.WriteAllText(Path.Combine(repo.RepoDir, "feature.txt"), "hello\n"); + GitRepoFixture.RunGit(repo.RepoDir, "add", "-A"); + GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "feat: add feature.txt"); + + // Back to default and add a non-overlapping file so history diverges. + string defaultBranch; + try { defaultBranch = GitRepoFixture.RunGit(repo.RepoDir, "symbolic-ref", "--short", "refs/remotes/origin/HEAD").Trim().Replace("origin/", ""); } + catch { defaultBranch = "main"; } + if (string.IsNullOrEmpty(defaultBranch)) defaultBranch = "main"; + try { GitRepoFixture.RunGit(repo.RepoDir, "checkout", defaultBranch); } + catch { GitRepoFixture.RunGit(repo.RepoDir, "checkout", "master"); defaultBranch = "master"; } + File.WriteAllText(Path.Combine(repo.RepoDir, "other.txt"), "other\n"); + GitRepoFixture.RunGit(repo.RepoDir, "add", "-A"); + GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "chore: add other.txt"); + + var git = new GitService(); + var (exitCode, stderr) = await git.MergeNoFfAsync(repo.RepoDir, "feature/merge", "Merge feature/merge"); + + Assert.Equal(0, exitCode); + // Confirm merge commit exists (two parents on HEAD). + var parents = GitRepoFixture.RunGit(repo.RepoDir, "rev-list", "--parents", "-n", "1", "HEAD").Trim(); + Assert.True(parents.Split(' ').Length >= 3, $"Expected merge commit (3 tokens), got: '{parents}'"); + } + + [Fact] + public async Task MergeNoFfAsync_Conflict_ReturnsNonZero() + { + if (!GitRepoFixture.IsGitAvailable()) return; + var repo = NewRepo(); + + // Both branches modify README.md — guaranteed conflict. + GitRepoFixture.RunGit(repo.RepoDir, "checkout", "-b", "feature/conflict"); + File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# feature side\n"); + GitRepoFixture.RunGit(repo.RepoDir, "add", "-A"); + GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "feat: feature edit"); + + string defaultBranch = "main"; + try { GitRepoFixture.RunGit(repo.RepoDir, "checkout", "main"); } + catch { GitRepoFixture.RunGit(repo.RepoDir, "checkout", "master"); defaultBranch = "master"; } + File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# main side\n"); + GitRepoFixture.RunGit(repo.RepoDir, "add", "-A"); + GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "chore: main edit"); + + var git = new GitService(); + var (exitCode, _) = await git.MergeNoFfAsync(repo.RepoDir, "feature/conflict", "merge"); + + Assert.NotEqual(0, exitCode); + } }