fix(data): merge preflights ignore untracked files in target working tree

HasChangesAsync counted untracked files, so a stray file in the shared
target working dir (e.g. left by a concurrent session) blocked merge
preflights even though nothing tracked changed. Add an includeUntracked
overload defaulting to true, and pass includeUntracked: false only from
the two target-working-tree merge preflights (TaskMergeService.MergeAsync,
PlanningMergeOrchestrator.StartAsync). Auto-commit and the worktree
cleanup data-loss guard keep counting untracked files, since those
callers need to know about them.
This commit is contained in:
mika kuns
2026-07-23 18:14:46 +02:00
parent f18e03354a
commit 14e4c086e2
7 changed files with 102 additions and 8 deletions
@@ -348,7 +348,8 @@ public sealed class PlanningMergeOrchestratorTests : IDisposable
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
var (parentId, _, _) = await SeedPlanningWithTwoNonConflictingChildrenAsync(db, repo);
File.WriteAllText(Path.Combine(repo.RepoDir, "dirty.txt"), "unstaged\n");
// Modify a tracked file (real uncommitted change).
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "unstaged\n");
var (orch, _) = BuildOrchestrator(db);
@@ -357,6 +358,27 @@ public sealed class PlanningMergeOrchestratorTests : IDisposable
Assert.Contains("uncommitted", ex.Message);
}
[Fact]
public async Task StartAsync_UntrackedFileInRepo_DoesNotThrow()
{
var db = NewDb();
var repo = NewRepo();
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
var (parentId, subA, subB) = await SeedPlanningWithTwoNonConflictingChildrenAsync(db, repo);
// An untracked file (e.g. left behind by a concurrent session) must not block the merge.
File.WriteAllText(Path.Combine(repo.RepoDir, "untracked.txt"), "stray\n");
var (orch, _) = BuildOrchestrator(db);
await orch.StartAsync(parentId, "main", CancellationToken.None);
using var ctx = db.CreateContext();
Assert.Equal(TaskStatus.Done, ctx.Tasks.Single(t => t.Id == parentId).Status);
Assert.Equal(WorktreeState.Merged, ctx.Worktrees.Single(w => w.TaskId == subA).State);
Assert.Equal(WorktreeState.Merged, ctx.Worktrees.Single(w => w.TaskId == subB).State);
}
[Fact]
public async Task StartAsync_IdempotentRestart_SkipsAlreadyMergedWorktrees()
{
@@ -168,6 +168,39 @@ public class GitServiceMergeTests : IDisposable
Assert.Equal("feature/checkout-test", current);
}
[Fact]
public async Task HasChangesAsync_UntrackedFileOnly_DefaultReturnsTrue()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
File.WriteAllText(Path.Combine(repo.RepoDir, "untracked.txt"), "new\n");
var git = new GitService();
Assert.True(await git.HasChangesAsync(repo.RepoDir));
}
[Fact]
public async Task HasChangesAsync_UntrackedFileOnly_IncludeUntrackedFalse_ReturnsFalse()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
File.WriteAllText(Path.Combine(repo.RepoDir, "untracked.txt"), "new\n");
var git = new GitService();
Assert.False(await git.HasChangesAsync(repo.RepoDir, includeUntracked: false));
}
[Fact]
public async Task HasChangesAsync_ModifiedTrackedFile_IncludeUntrackedFalse_StillReturnsTrue()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "changed\n");
var git = new GitService();
Assert.True(await git.HasChangesAsync(repo.RepoDir, includeUntracked: false));
}
[Fact]
public async Task ListConflictedFilesAsync_MidConflict_ReturnsConflictedFile()
{
@@ -302,8 +302,8 @@ public class TaskMergeServiceTests : IDisposable
var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
_wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
// Dirty the target working dir.
File.WriteAllText(Path.Combine(repo.RepoDir, "dirt.txt"), "dirty\n");
// Modify a tracked file in the target working dir (real uncommitted change).
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "dirty\n");
var (svc, _) = BuildService(db);
var result = await svc.MergeAsync(task.Id, "main", false, "Merge", CancellationToken.None);
@@ -312,6 +312,33 @@ public class TaskMergeServiceTests : IDisposable
Assert.Contains("uncommitted", result.ErrorMessage ?? "");
}
[Fact]
public async Task MergeAsync_UntrackedFileInTargetWorkingTree_DoesNotBlock()
{
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, "feature.txt"), "x\n");
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
// An untracked file (e.g. left behind by a concurrent session) must not block the merge.
File.WriteAllText(Path.Combine(repo.RepoDir, "untracked.txt"), "stray\n");
var (svc, _) = BuildService(db);
var currentBranch = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
var result = await svc.MergeAsync(task.Id, currentBranch, removeWorktree: false,
commitMessage: "Merge", ct: CancellationToken.None);
Assert.Equal("merged", result.Status);
}
[Fact]
public async Task MergeAsync_TargetBranchDifferentFromHead_ChecksOutBeforeMerging()
{