Merge branch 'claudedo/295d5d409c194ba28dbb95ab13630832'
This commit is contained in:
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user