Merge branch 'claudedo/295d5d409c194ba28dbb95ab13630832'

This commit is contained in:
mika kuns
2026-08-10 15:04:46 +02:00
9 changed files with 272 additions and 15 deletions
@@ -349,6 +349,53 @@ public class TaskMergeServiceTests : IDisposable
Assert.Equal("merged", result.Status);
}
[Fact]
public async Task MergeAsync_BranchAddsPathUntrackedInTarget_RefusesAndLeavesLocalContentIntact()
{
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));
// The task branch creates a brand new file at a path it has never seen tracked.
Directory.CreateDirectory(Path.Combine(wtCtx.WorktreePath, "docs"));
File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "docs", "PLUGIN.md"), "agent-written replacement\n");
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
// The same path already exists, untracked, in the target working directory — never
// committed, not ignored — carrying content unrelated to the task branch.
Directory.CreateDirectory(Path.Combine(repo.RepoDir, "docs"));
var localPath = Path.Combine(repo.RepoDir, "docs", "PLUGIN.md");
const string localContent = "local plugin docs — must survive\n";
File.WriteAllText(localPath, localContent);
var (svc, proxy) = BuildService(db);
var currentBranch = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
var headBefore = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
var result = await svc.MergeAsync(task.Id, currentBranch, removeWorktree: false,
commitMessage: "Merge task", ct: CancellationToken.None);
Assert.Equal(TaskMergeService.StatusUntrackedCollision, result.Status);
Assert.Contains("docs/PLUGIN.md", result.ConflictFiles);
Assert.Contains("docs/PLUGIN.md", result.ErrorMessage ?? "");
Assert.Contains($"{System.Text.Encoding.UTF8.GetByteCount(localContent)} bytes", result.ErrorMessage ?? "");
// Nothing landed: no merge was attempted, the local file is untouched, worktree unaffected.
Assert.Equal(localContent, File.ReadAllText(localPath));
Assert.Equal(headBefore, GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim());
Assert.False(await new GitService().IsMidMergeAsync(repo.RepoDir));
Assert.DoesNotContain(proxy.Calls, c => c.Method == "WorktreeUpdated");
using var ctx = db.CreateContext();
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id);
Assert.Equal(WorktreeState.Active, wt!.State);
}
[Fact]
public async Task MergeAsync_TargetBranchDifferentFromHead_ChecksOutBeforeMerging()
{
@@ -547,6 +594,66 @@ public class TaskMergeServiceTests : IDisposable
Assert.True(string.IsNullOrWhiteSpace(GitRepoFixture.RunGit(repo.RepoDir, "status", "--porcelain")));
}
[Fact]
public async Task ContinueMergeAsync_PathBecomesUntrackedDuringResolution_RefusesBeforeAddAll()
{
// git's own preflight already refuses a merge whose incoming branch collides with a path
// that is untracked in the target *at merge-start* (verified against real git while
// investigating this guard). The only path left uncovered is one that becomes untracked
// again *during* conflict resolution, after the initial merge already staged it cleanly —
// this reproduces exactly that narrow window, right before `git add -A` would sweep it
// back in unnoticed.
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");
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/t-untracked", wtPath, repo.BaseCommit);
File.WriteAllText(Path.Combine(wtPath, "README.md"), "# branch change\n");
File.WriteAllText(Path.Combine(wtPath, "extra.md"), "agent content\n");
GitRepoFixture.RunGit(wtPath, "add", "-A");
GitRepoFixture.RunGit(wtPath, "commit", "-m", "branch change + add extra.md");
var (_, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
await SeedWorktree(db, task.Id, wtPath, "claudedo/t-untracked", 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);
// extra.md landed clean already (git stages any non-conflicting file the moment the
// merge starts, even while README.md is left conflicted) -- confirm that, then unstage
// it and drop in different, untracked local content, reproducing "became untracked
// again mid-resolution".
Assert.Equal("A", GitRepoFixture.RunGit(repo.RepoDir, "diff", "--name-status", "--cached", "--", "extra.md").Trim()[..1]);
GitRepoFixture.RunGit(repo.RepoDir, "rm", "--cached", "extra.md");
const string localContent = "important local content that must survive\n";
File.WriteAllText(Path.Combine(repo.RepoDir, "extra.md"), localContent);
// Resolve the real (unrelated) conflict.
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# resolved\n");
var result = await svc.ContinueMergeAsync(task.Id, CancellationToken.None);
Assert.Equal(TaskMergeService.StatusUntrackedCollision, result.Status);
Assert.Contains("extra.md", result.ConflictFiles);
Assert.Equal(localContent, File.ReadAllText(Path.Combine(repo.RepoDir, "extra.md")));
// Nothing committed; still mid-merge so abort/continue can still resolve it.
Assert.True(await new GitService().IsMidMergeAsync(repo.RepoDir));
using var ctx = db.CreateContext();
var wt = ctx.Worktrees.Single(w => w.TaskId == task.Id);
Assert.Equal(WorktreeState.Active, wt.State);
}
[Fact]
public async Task AbortMergeAsync_AfterConflict_RestoresCleanStateAndLeavesWorktreeActive()
{
@@ -631,6 +738,33 @@ public class TaskMergeServiceTests : IDisposable
Assert.Contains("README.md", preview.ConflictFiles);
}
[Fact]
public async Task PreviewAsync_BranchAddsPathUntrackedInTarget_ReturnsUntrackedCollision()
{
// merge-tree (the real preview mechanism) never touches the working directory, so
// without this check the preview would say "clean" for a merge that can't actually land.
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var db = NewDb();
var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);
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, "PLUGIN.md"), "agent content\n");
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
File.WriteAllText(Path.Combine(repo.RepoDir, "PLUGIN.md"), "local content\n");
var (svc, _) = BuildService(db);
var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
var preview = await svc.PreviewAsync(task.Id, target, CancellationToken.None);
Assert.Equal(TaskMergeService.PreviewUntrackedCollision, preview.Status);
Assert.Contains("PLUGIN.md", preview.ConflictFiles);
}
[Fact]
public async Task PreviewAsync_NoActiveWorktree_ReturnsUnavailable()
{