diff --git a/docs/open.md b/docs/open.md index 9d2ca7d2..2846ddca 100644 --- a/docs/open.md +++ b/docs/open.md @@ -52,7 +52,6 @@ Alle 9 Review-Tasks (5 Refactorings, 4 Bugfixes) sind umgesetzt und gemerged; De **Plausibel, noch nicht einzeln verifiziert (bei Gelegenheit prüfen):** - Ketten-Kaskade stoppt an einem `Idle`-Mittelglied (`OnChildFinishedAsync` prüft `CancelAsync`-Ergebnis nicht) → Rest bleibt `Queued+blocked`. -- `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. diff --git a/src/ClaudeDo.Data/Git/GitService.cs b/src/ClaudeDo.Data/Git/GitService.cs index aad3bf43..372b56dd 100644 --- a/src/ClaudeDo.Data/Git/GitService.cs +++ b/src/ClaudeDo.Data/Git/GitService.cs @@ -71,9 +71,22 @@ public sealed class GitService return stdout; } - public async Task HasChangesAsync(string worktreePath, CancellationToken ct = default) + public Task HasChangesAsync(string worktreePath, CancellationToken ct = default) => + HasChangesAsync(worktreePath, includeUntracked: true, ct); + + /// + /// Uncommitted-changes check. =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. + /// + public async Task 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); diff --git a/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs b/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs index a2c26284..45bf152a 100644 --- a/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs +++ b/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs @@ -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); diff --git a/src/ClaudeDo.Worker/Planning/PlanningMergeOrchestrator.cs b/src/ClaudeDo.Worker/Planning/PlanningMergeOrchestrator.cs index 0e2aec33..cde06f83 100644 --- a/src/ClaudeDo.Worker/Planning/PlanningMergeOrchestrator.cs +++ b/src/ClaudeDo.Worker/Planning/PlanningMergeOrchestrator.cs @@ -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(); diff --git a/tests/ClaudeDo.Worker.Tests/Planning/PlanningMergeOrchestratorTests.cs b/tests/ClaudeDo.Worker.Tests/Planning/PlanningMergeOrchestratorTests.cs index 02022587..84633f8f 100644 --- a/tests/ClaudeDo.Worker.Tests/Planning/PlanningMergeOrchestratorTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Planning/PlanningMergeOrchestratorTests.cs @@ -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() { diff --git a/tests/ClaudeDo.Worker.Tests/Runner/GitServiceMergeTests.cs b/tests/ClaudeDo.Worker.Tests/Runner/GitServiceMergeTests.cs index f10528f6..3886cb64 100644 --- a/tests/ClaudeDo.Worker.Tests/Runner/GitServiceMergeTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Runner/GitServiceMergeTests.cs @@ -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() { diff --git a/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs index 7e86a1ad..6702e85e 100644 --- a/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs @@ -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() {