fix(git): merge preflights ignore untracked files (HasChangesAsync includeUntracked)
# Conflicts: # docs/open.md
This commit is contained in:
@@ -51,7 +51,6 @@ Alle 9 Review-Tasks (5 Refactorings, 4 Bugfixes) sind umgesetzt und gemerged; De
|
||||
|
||||
**Plausibel, noch nicht einzeln verifiziert (bei Gelegenheit prüfen):**
|
||||
|
||||
- `HasChangesAsync` zählt untracked Files → blockiert Merges unnötig (`--untracked-files=no`).
|
||||
- `UnifiedDiffParser`: Pfade mit Leerzeichen / git-gequotete Pfade aus `diff --git` falsch geparst.
|
||||
- Kleinkram: MergePreview-Race bei schnellem Target-Wechsel, CTS-Dispose-Leak in Debounce-Saves, `Environment.CurrentDirectory`-Fallback im Konflikt-Dialog, Doppel-Continue-Fenster im Orchestrator.
|
||||
|
||||
|
||||
@@ -71,9 +71,22 @@ public sealed class GitService
|
||||
return stdout;
|
||||
}
|
||||
|
||||
public async Task<bool> HasChangesAsync(string worktreePath, CancellationToken ct = default)
|
||||
public Task<bool> HasChangesAsync(string worktreePath, CancellationToken ct = default) =>
|
||||
HasChangesAsync(worktreePath, includeUntracked: true, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Uncommitted-changes check. <paramref name="includeUntracked"/>=false ignores untracked
|
||||
/// files — use this for merge preflights on a shared target working dir, where stray
|
||||
/// untracked files (e.g. from a concurrent session) shouldn't block a merge. Auto-commit
|
||||
/// and data-loss-guard callers keep the default (true): a new file a task created, or an
|
||||
/// untracked file about to be discarded, is a real uncommitted change.
|
||||
/// </summary>
|
||||
public async Task<bool> HasChangesAsync(string worktreePath, bool includeUntracked, CancellationToken ct = default)
|
||||
{
|
||||
var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath, ["status", "--porcelain"], ct);
|
||||
string[] args = includeUntracked
|
||||
? ["status", "--porcelain"]
|
||||
: ["status", "--porcelain", "--untracked-files=no"];
|
||||
var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath, args, ct);
|
||||
if (exitCode != 0)
|
||||
throw new InvalidOperationException($"git status --porcelain failed (exit {exitCode}): {stderr}");
|
||||
return !string.IsNullOrWhiteSpace(stdout);
|
||||
|
||||
@@ -115,7 +115,7 @@ public sealed class TaskMergeService
|
||||
return Blocked("working directory is not a git repository");
|
||||
if (await _git.IsMidMergeAsync(list.WorkingDir, ct))
|
||||
return Blocked("target working directory is mid-merge");
|
||||
if (await _git.HasChangesAsync(list.WorkingDir, ct))
|
||||
if (await _git.HasChangesAsync(list.WorkingDir, includeUntracked: false, ct))
|
||||
return Blocked("target working tree has uncommitted changes");
|
||||
|
||||
var currentBranch = await _git.GetCurrentBranchAsync(list.WorkingDir, ct);
|
||||
|
||||
@@ -87,7 +87,7 @@ public sealed class PlanningMergeOrchestrator
|
||||
if (await _git.IsMidMergeAsync(workingDir, ct))
|
||||
throw new InvalidOperationException(
|
||||
"repo is mid-merge; use AbortPlanningMerge to reset the repository, then Approve again");
|
||||
if (await _git.HasChangesAsync(workingDir, ct))
|
||||
if (await _git.HasChangesAsync(workingDir, includeUntracked: false, ct))
|
||||
throw new InvalidOperationException("working tree has uncommitted changes");
|
||||
|
||||
var idsToMerge = new List<string>();
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user