feat(worker): preview_merge_set flags removed symbols and locale-key overlaps

Overlap detection was file-name-only: 9/15 tasks touching en.json/de.json in
run #316 all got flagged even though only one pair actually collided on a
key, and a removed method with no textual file conflict (#297 vs #286) went
unflagged entirely.

- removedSymbols per task entry: a line-pattern scan of `-` diff lines for
  C# method/property/field/type/event declarations (skips .axaml/.json).
- symbolWarnings at the set level: cross-references those names against
  every other task's `+` lines, catching same-symbol collisions across
  files that overlaps/subsets (both purely file-based) can't see.
- overlaps narrows to key-path level for .json files (base commit vs. task
  branch head, both sides parsed and flattened) so two tasks adding
  different locale keys to the same file no longer collide; unparsable
  JSON on either side falls back to the old file-level check.
- GitService.ShowFileAsync: `git show ref:path`, used to read a task
  branch's json content at its base/head commit.

Out of scope per the task: no Roslyn/parser, no AXAML analysis, and the
merge-helper prompt in PromptFiles.cs doesn't mention the new fields yet.

Build: dotnet build src/ClaudeDo.App (pulls in Ui+Data) + src/ClaudeDo.Worker, both clean.
Tests: dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~MergeMcpTools|FullyQualifiedName~ExternalMcpToolSchema" -- 51 passed, 0 failed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Mika Kuns
2026-09-16 15:43:38 +02:00
committed by mika kuns
co-authored by Claude Sonnet 5
parent 1325ab9438
commit 684b0115e5
3 changed files with 442 additions and 35 deletions
+138 -3
View File
@@ -163,12 +163,13 @@ public sealed class MergeMcpToolsTests : IDisposable
}
private async Task<string> CreateActiveWorktreeAsync(
GitRepoFixture repo, string taskId, string fileName, string content)
GitRepoFixture repo, string taskId, string fileName, string content, string? baseCommit = null)
{
baseCommit ??= repo.BaseCommit;
var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
_worktreeCleanups.Add((repo.RepoDir, wtPath));
var branch = $"claudedo/{taskId[..8]}";
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", branch, wtPath, repo.BaseCommit);
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", branch, wtPath, baseCommit);
File.WriteAllText(Path.Combine(wtPath, fileName), content);
GitRepoFixture.RunGit(wtPath, "add", fileName);
GitRepoFixture.RunGit(wtPath, "commit", "-m", $"edit {fileName}");
@@ -177,7 +178,7 @@ public sealed class MergeMcpToolsTests : IDisposable
ctx.Worktrees.Add(new WorktreeEntity
{
TaskId = taskId, Path = wtPath, BranchName = branch,
BaseCommit = repo.BaseCommit,
BaseCommit = baseCommit,
HeadCommit = GitRepoFixture.RunGit(wtPath, "rev-parse", "HEAD").Trim(),
State = WorktreeState.Active, CreatedAt = DateTime.UtcNow,
});
@@ -1317,4 +1318,138 @@ public sealed class MergeMcpToolsTests : IDisposable
Assert.Equal(0, result.Tasks.Single().VerifyExitCode);
Assert.Equal("dotnet build", fakeVerify.CapturedCommand);
}
[Fact]
public async Task PreviewMergeSet_RemovedSymbolReferencedByAnotherTask_ReportsSymbolWarning()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = new GitRepoFixture();
_repos.Add(repo);
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
File.WriteAllText(Path.Combine(repo.RepoDir, "Foo.cs"),
"public class Foo\n{\n private void RestartWorkerStatus()\n {\n }\n}\n");
File.WriteAllText(Path.Combine(repo.RepoDir, "Bar.cs"), "public class Bar\n{\n}\n");
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "add Foo/Bar");
var baseCommit = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
var listId = Guid.NewGuid().ToString();
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
var removerTask = await SeedTaskAsync(listId, "removes", TaskStatus.WaitingForReview);
var referencerTask = await SeedTaskAsync(listId, "references", TaskStatus.WaitingForReview);
await CreateActiveWorktreeAsync(repo, removerTask.Id, "Foo.cs", "public class Foo\n{\n}\n", baseCommit);
await CreateActiveWorktreeAsync(repo, referencerTask.Id, "Bar.cs",
"public class Bar\n{\n private void Call()\n {\n RestartWorkerStatus();\n }\n}\n", baseCommit);
var sut = BuildSut(CreateQueue());
var result = await sut.PreviewMergeSet(
[removerTask.Id, referencerTask.Id], "main", cancellationToken: CancellationToken.None);
var removerEntry = result.Tasks.Single(t => t.TaskId == removerTask.Id);
Assert.Contains(removerEntry.RemovedSymbols!, s => s.Name == "RestartWorkerStatus" && s.File == "Foo.cs");
var warning = Assert.Single(result.SymbolWarnings);
Assert.Equal("RestartWorkerStatus", warning.Symbol);
Assert.Equal("Foo.cs", warning.RemovedInFile);
Assert.Equal(removerTask.Id, warning.RemovedByTaskId);
Assert.Equal(referencerTask.Id, warning.ReferencedByTaskId);
Assert.Equal("Bar.cs", warning.ReferencedInFile);
}
[Fact]
public async Task PreviewMergeSet_TwoTasksAddDifferentJsonKeys_ReportsNoOverlap()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = new GitRepoFixture();
_repos.Add(repo);
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
File.WriteAllText(Path.Combine(repo.RepoDir, "en.json"), "{}\n");
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "add en.json");
var baseCommit = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
var listId = Guid.NewGuid().ToString();
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
var taskA = await SeedTaskAsync(listId, "a", TaskStatus.WaitingForReview);
var taskB = await SeedTaskAsync(listId, "b", TaskStatus.WaitingForReview);
await CreateActiveWorktreeAsync(repo, taskA.Id, "en.json", "{\"a\": {\"one\": \"1\"}}\n", baseCommit);
await CreateActiveWorktreeAsync(repo, taskB.Id, "en.json", "{\"a\": {\"two\": \"2\"}}\n", baseCommit);
var sut = BuildSut(CreateQueue());
var result = await sut.PreviewMergeSet([taskA.Id, taskB.Id], "main", cancellationToken: CancellationToken.None);
Assert.Empty(result.Overlaps);
}
[Fact]
public async Task PreviewMergeSet_TwoTasksChangeSameJsonKey_ReportsOverlap()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = new GitRepoFixture();
_repos.Add(repo);
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
File.WriteAllText(Path.Combine(repo.RepoDir, "en.json"), "{}\n");
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "add en.json");
var baseCommit = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
var listId = Guid.NewGuid().ToString();
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
var taskA = await SeedTaskAsync(listId, "a", TaskStatus.WaitingForReview);
var taskB = await SeedTaskAsync(listId, "b", TaskStatus.WaitingForReview);
await CreateActiveWorktreeAsync(repo, taskA.Id, "en.json", "{\"a\": {\"one\": \"1\"}}\n", baseCommit);
await CreateActiveWorktreeAsync(repo, taskB.Id, "en.json", "{\"a\": {\"one\": \"2\"}}\n", baseCommit);
var sut = BuildSut(CreateQueue());
var result = await sut.PreviewMergeSet([taskA.Id, taskB.Id], "main", cancellationToken: CancellationToken.None);
var overlap = Assert.Single(result.Overlaps);
Assert.Equal("en.json", overlap.File);
Assert.Equal(
new[] { taskA.Id, taskB.Id }.OrderBy(x => x),
overlap.TaskIds.OrderBy(x => x));
}
[Fact]
public async Task PreviewMergeSet_UnparsableJsonOnOneSide_FallsBackToFileOverlap()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = new GitRepoFixture();
_repos.Add(repo);
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
File.WriteAllText(Path.Combine(repo.RepoDir, "en.json"), "{}\n");
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "add en.json");
var baseCommit = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
var listId = Guid.NewGuid().ToString();
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
var taskA = await SeedTaskAsync(listId, "a", TaskStatus.WaitingForReview);
var taskB = await SeedTaskAsync(listId, "b", TaskStatus.WaitingForReview);
// taskA leaves the file syntactically broken -- both sides must still be flagged since
// there's no way to tell whether the keys actually collide.
await CreateActiveWorktreeAsync(repo, taskA.Id, "en.json", "{ not valid json", baseCommit);
await CreateActiveWorktreeAsync(repo, taskB.Id, "en.json", "{\"a\": {\"one\": \"2\"}}\n", baseCommit);
var sut = BuildSut(CreateQueue());
var result = await sut.PreviewMergeSet([taskA.Id, taskB.Id], "main", cancellationToken: CancellationToken.None);
var overlap = Assert.Single(result.Overlaps);
Assert.Equal("en.json", overlap.File);
Assert.Equal(
new[] { taskA.Id, taskB.Id }.OrderBy(x => x),
overlap.TaskIds.OrderBy(x => x));
}
}