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:
committed by
mika kuns
co-authored by
Claude Sonnet 5
parent
1325ab9438
commit
684b0115e5
+138
-3
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user