From b3e099ca015b34b83d807b3dea839dbb1d7995f6 Mon Sep 17 00:00:00 2001 From: Mika Kuns Date: Fri, 19 Jun 2026 14:06:13 +0200 Subject: [PATCH] refactor(merge): drop dead hunks conflict API GetConflictsAsync/GetMergeConflicts (+ MergeConflicts/ConflictFileContent/ConflictFileDto/ConflictHunkDto DTOs and the now-orphaned GitService.ShowStageAsync) were superseded by the segment-based GetMergeConflictDocuments path and had no production callers. Removes the IWorkerClient member, both test fakes, the lingering test, and updates the Worker/Ui/Data CLAUDE.md surface notes. --- src/ClaudeDo.Data/CLAUDE.md | 2 +- src/ClaudeDo.Data/Git/GitService.cs | 11 ------ src/ClaudeDo.Ui/CLAUDE.md | 2 +- .../Services/Interfaces/IWorkerClient.cs | 1 - src/ClaudeDo.Ui/Services/WorkerClient.cs | 6 ---- src/ClaudeDo.Worker/CLAUDE.md | 2 +- src/ClaudeDo.Worker/Hub/WorkerHub.cs | 14 -------- .../Lifecycle/TaskMergeService.cs | 28 --------------- tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs | 1 - .../Services/TaskMergeServiceTests.cs | 35 ------------------- .../UiVm/TasksIslandViewModelPlanningTests.cs | 1 - 11 files changed, 3 insertions(+), 100 deletions(-) diff --git a/src/ClaudeDo.Data/CLAUDE.md b/src/ClaudeDo.Data/CLAUDE.md index 5809147..9152fbb 100644 --- a/src/ClaudeDo.Data/CLAUDE.md +++ b/src/ClaudeDo.Data/CLAUDE.md @@ -35,7 +35,7 @@ All repositories use EF Core LINQ queries via `ClaudeDoDbContext`. The atomic `Q ## Git -- **GitService** — async wrapper around git CLI (ProcessStartInfo, no shell). Worktree ops (add — serialized to avoid a commondir race —, remove, prune, list paths for branch), branch ops (current, list local, checkout, delete), staging/commit (status porcelain, add-all, add-path, commit via stdin), diffs (working tree, branch vs base, commit range `base..head` — used to show a merged task's diff after the worktree is gone —, per-file, diff-stat, committed files, has-changes), merge (ff-only, no-ff, abort, mid-merge detection, conflicted files, show-stage for conflict hunks), `PreviewMergeAsync` (non-destructive mergeability check via `git merge-tree --write-tree`), `CountChangedFilesAsync`, rev-parse, is-git-repo +- **GitService** — async wrapper around git CLI (ProcessStartInfo, no shell). Worktree ops (add — serialized to avoid a commondir race —, remove, prune, list paths for branch), branch ops (current, list local, checkout, delete), staging/commit (status porcelain, add-all, add-path, commit via stdin), diffs (working tree, branch vs base, commit range `base..head` — used to show a merged task's diff after the worktree is gone —, per-file, diff-stat, committed files, has-changes), merge (ff-only, no-ff, abort, mid-merge detection, conflicted files), `PreviewMergeAsync` (non-destructive mergeability check via `git merge-tree --write-tree`), `CountChangedFilesAsync`, rev-parse, is-git-repo ## Schema diff --git a/src/ClaudeDo.Data/Git/GitService.cs b/src/ClaudeDo.Data/Git/GitService.cs index b07c77c..aad3bf4 100644 --- a/src/ClaudeDo.Data/Git/GitService.cs +++ b/src/ClaudeDo.Data/Git/GitService.cs @@ -280,17 +280,6 @@ public sealed class GitService .ToList(); } - /// - /// Reads a conflicted file's blob at a merge stage: 1=base, 2=ours, 3=theirs. - /// Returns null when the stage doesn't exist (e.g. add/add conflict has no base). - /// Output is NOT trimmed so file content round-trips exactly. - /// - public async Task ShowStageAsync(string repoDir, int stage, string path, CancellationToken ct = default) - { - var (exitCode, stdout, _) = await RunGitAsync(repoDir, ["show", $":{stage}:{path}"], ct, trimOutput: false); - return exitCode == 0 ? stdout : null; - } - public async Task AddPathAsync(string repoDir, string path, CancellationToken ct = default) { var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["add", "--", path], ct); diff --git a/src/ClaudeDo.Ui/CLAUDE.md b/src/ClaudeDo.Ui/CLAUDE.md index 55a51bc..0650031 100644 --- a/src/ClaudeDo.Ui/CLAUDE.md +++ b/src/ClaudeDo.Ui/CLAUDE.md @@ -45,7 +45,7 @@ Design/ — Tokens.axaml (design tokens; merged before styles) + IslandStyle ## Services -- **WorkerClient** / **IWorkerClient** — SignalR client connecting to `http://127.0.0.1:47821/hub`, auto-reconnect with exponential backoff. The surface tracks `WorkerHub` (see `src/ClaudeDo.Worker/CLAUDE.md` for the canonical method/event list); groups: task execution (RunNow/Cancel/Continue/Reset/SetTaskStatus), review (`ApproveReviewAsync(taskId, targetBranch) -> MergeResultDto`, reject-to-queue/idle, cancel review, `PreviewMergeAsync -> MergePreviewDto`), planning sessions (start/resume/discard/finalize, queue subtasks, pending draft count, interactive terminal, refine), planning aggregate/integration-branch diffs, unit-merge continue/abort, single-task conflict resolving (start/get-conflicts/write-resolution/continue/abort), worktrees (overview, set state, force remove, cleanup, reset all), agents, app settings, lists/config, weekly report, daily notes, daily prep (`RunDailyPrepNowAsync`, `ClearMyDayAsync`, `GetLastPrepLogAsync`), prime schedules. Events mirror `HubBroadcaster` (task/worktree/list/run updates, prep events, planning-merge events, refine events, worker log). Lifecycle (`StartAsync`/`StopAsync`) and a few admin methods live only on the concrete `WorkerClient`. +- **WorkerClient** / **IWorkerClient** — SignalR client connecting to `http://127.0.0.1:47821/hub`, auto-reconnect with exponential backoff. The surface tracks `WorkerHub` (see `src/ClaudeDo.Worker/CLAUDE.md` for the canonical method/event list); groups: task execution (RunNow/Cancel/Continue/Reset/SetTaskStatus), review (`ApproveReviewAsync(taskId, targetBranch) -> MergeResultDto`, reject-to-queue/idle, cancel review, `PreviewMergeAsync -> MergePreviewDto`), planning sessions (start/resume/discard/finalize, queue subtasks, pending draft count, interactive terminal, refine), planning aggregate/integration-branch diffs, unit-merge continue/abort, single-task conflict resolving (start/get-conflict-documents/write-resolution/continue/abort), worktrees (overview, set state, force remove, cleanup, reset all), agents, app settings, lists/config, weekly report, daily notes, daily prep (`RunDailyPrepNowAsync`, `ClearMyDayAsync`, `GetLastPrepLogAsync`), prime schedules. Events mirror `HubBroadcaster` (task/worktree/list/run updates, prep events, planning-merge events, refine events, worker log). Lifecycle (`StartAsync`/`StopAsync`) and a few admin methods live only on the concrete `WorkerClient`. - **INotesApi** / **WorkerNotesApi** — daily-note CRUD (`ListAsync(day)`, `AddAsync`, `UpdateAsync`, `DeleteAsync`); UI DTO `DailyNoteDto(Id, Date, Text, SortOrder)`. - **IPrimeScheduleApi** — prime-schedule CRUD (`ListAsync`, `UpsertAsync`, `DeleteAsync`). - **UpdateCheckService** — polls releases, exposes `LastCheckStatus`/`LatestVersion`/`CheckNowAsync` (feeds the shell's update banner). diff --git a/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs b/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs index 8e317d4..d357001 100644 --- a/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs @@ -54,7 +54,6 @@ public interface IWorkerClient : INotifyPropertyChanged // ── Conflict resolution (worker hub side implemented by Layer C) ── Task StartConflictMergeAsync(string taskId, string targetBranch); - Task GetMergeConflictsAsync(string taskId); Task GetMergeConflictDocumentsAsync(string taskId); Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent); Task ContinueConflictMergeAsync(string taskId); diff --git a/src/ClaudeDo.Ui/Services/WorkerClient.cs b/src/ClaudeDo.Ui/Services/WorkerClient.cs index 623d6a9..9d12f5c 100644 --- a/src/ClaudeDo.Ui/Services/WorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/WorkerClient.cs @@ -272,9 +272,6 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC public Task StartConflictMergeAsync(string taskId, string targetBranch) => _hub.InvokeAsync("StartConflictMerge", taskId, targetBranch); - public Task GetMergeConflictsAsync(string taskId) - => _hub.InvokeAsync("GetMergeConflicts", taskId); - public Task GetMergeConflictDocumentsAsync(string taskId) => _hub.InvokeAsync("GetMergeConflictDocuments", taskId); @@ -559,9 +556,6 @@ public sealed record WorktreeResetDto(int Removed, int TasksAffected, bool Block public record MergeResultDto(string Status, IReadOnlyList ConflictFiles, string? ErrorMessage); public record MergePreviewDto(string Status, IReadOnlyList ConflictFiles, int ChangedFileCount); public record MergeTargetsDto(string DefaultBranch, IReadOnlyList LocalBranches); -public record MergeConflictsDto(string TaskId, IReadOnlyList Files); -public record ConflictFileDto(string Path, IReadOnlyList Hunks); -public record ConflictHunkDto(string Ours, string Theirs, string? Base); public record MergeConflictDocumentsDto(string TaskId, IReadOnlyList Files); public record ConflictDocumentDto(string Path, bool IsBinary, IReadOnlyList Segments); public record MergeSegmentDto(bool IsConflict, string Text, string Ours, string? Base, string Theirs); diff --git a/src/ClaudeDo.Worker/CLAUDE.md b/src/ClaudeDo.Worker/CLAUDE.md index d7de8ad..730f1e8 100644 --- a/src/ClaudeDo.Worker/CLAUDE.md +++ b/src/ClaudeDo.Worker/CLAUDE.md @@ -150,7 +150,7 @@ Each CLI invocation is recorded in the `task_runs` table via `TaskRunRepository` - Execution: `Ping`, `GetActive`, `RunNow`, `CancelTask`, `WakeQueue`, `ContinueTask`, `ResetTask`, `SetTaskStatus`, `RefineTask` - Review/merge: `ApproveReview(taskId, targetBranch) -> MergeResultDto` (childless task: merges its worktree then Done, conflict stays WaitingForReview; task with children: drives `PlanningMergeOrchestrator` to merge the whole unit), `ContinuePlanningMerge` / `AbortPlanningMerge` (resolve a unit-merge conflict), `PreviewMerge(taskId, targetBranch) -> MergePreviewDto` (non-destructive mergeability check), `RejectReviewToQueue`, `RejectReviewToIdle`, `CancelReview`, `MergeTask`, `GetMergeTargets` -- Single-task conflict resolver (Layer C): `StartConflictMerge`, `GetMergeConflicts` (hunks), `WriteConflictResolution`, `ContinueConflictMerge`, `AbortConflictMerge` (service-level `TaskMergeService.ContinueMergeAsync`/`AbortMergeAsync` keep their names) +- Single-task conflict resolver (Layer C): `StartConflictMerge`, `GetMergeConflictDocuments` (segments), `WriteConflictResolution`, `ContinueConflictMerge`, `AbortConflictMerge` (service-level `TaskMergeService.ContinueMergeAsync`/`AbortMergeAsync` keep their names) - Planning sessions: `StartPlanningSession`, `ResumePlanningSession`, `DiscardPlanningSession`, `FinalizePlanningSession`, `QueuePlanningSubtasks`, `GetPendingDraftCount`, `OpenInteractiveTerminal`, `GetPlanningAggregate` (per-subtask diffs), `BuildPlanningIntegrationBranch` (combined diff) - Worktrees: `CleanupFinishedWorktrees`, `ResetAllWorktrees`, `GetWorktreesOverview`, `SetWorktreeState`, `ForceRemoveWorktree` - Agents/settings/lists: `GetAgents`, `RefreshAgents`, `RestoreDefaultAgents`, `GetAppSettings`, `UpdateAppSettings`, `UpdateList`, `UpdateListConfig`, `GetListConfig`, `UpdateTaskAgentSettings` diff --git a/src/ClaudeDo.Worker/Hub/WorkerHub.cs b/src/ClaudeDo.Worker/Hub/WorkerHub.cs index 9979357..f040fb8 100644 --- a/src/ClaudeDo.Worker/Hub/WorkerHub.cs +++ b/src/ClaudeDo.Worker/Hub/WorkerHub.cs @@ -58,9 +58,6 @@ public record ForceRemoveResultDto(bool Removed, string? Reason); public record MergeResultDto(string Status, IReadOnlyList ConflictFiles, string? ErrorMessage); public record MergePreviewDto(string Status, IReadOnlyList ConflictFiles, int ChangedFileCount); public record MergeTargetsDto(string DefaultBranch, IReadOnlyList LocalBranches); -public record MergeConflictsDto(string TaskId, IReadOnlyList Files); -public record ConflictFileDto(string Path, IReadOnlyList Hunks); -public record ConflictHunkDto(string Ours, string Theirs, string? Base); public record MergeConflictDocumentsDto(string TaskId, IReadOnlyList Files); public record ConflictDocumentDto(string Path, bool IsBinary, IReadOnlyList Segments); public record MergeSegmentDto(bool IsConflict, string Text, string Ours, string? Base, string Theirs); @@ -375,17 +372,6 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub return new MergeResultDto(r.Status, r.ConflictFiles, r.ErrorMessage); }); - public Task GetMergeConflicts(string taskId) - => HubGuard(async () => - { - var c = await _mergeService.GetConflictsAsync(taskId, CancellationToken.None); - return new MergeConflictsDto( - c.TaskId, - c.Files.Select(f => new ConflictFileDto( - f.Path, - new[] { new ConflictHunkDto(f.Ours, f.Theirs, f.Base) })).ToList()); - }); - public Task GetMergeConflictDocuments(string taskId) => HubGuard(async () => { diff --git a/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs b/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs index 6e3dfcf..2789589 100644 --- a/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs +++ b/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs @@ -23,16 +23,6 @@ public sealed record MergePreviewResult( IReadOnlyList ConflictFiles, int ChangedFileCount); -public sealed record MergeConflicts( - string TaskId, - IReadOnlyList Files); - -public sealed record ConflictFileContent( - string Path, - string Ours, - string Theirs, - string? Base); - public sealed record ConflictDocuments( string TaskId, IReadOnlyList Files); @@ -247,24 +237,6 @@ public sealed class TaskMergeService return new MergeResult(StatusAborted, Array.Empty(), null); } - public async Task GetConflictsAsync(string taskId, CancellationToken ct) - { - var (_, list, _) = await LoadMergeContextAsync(taskId, ct); - if (string.IsNullOrWhiteSpace(list.WorkingDir)) - throw new InvalidOperationException("list has no working directory"); - - var files = await _git.ListConflictedFilesAsync(list.WorkingDir, ct); - var result = new List(files.Count); - foreach (var path in files) - { - var ours = await _git.ShowStageAsync(list.WorkingDir, 2, path, ct) ?? ""; - var theirs = await _git.ShowStageAsync(list.WorkingDir, 3, path, ct) ?? ""; - var @base = await _git.ShowStageAsync(list.WorkingDir, 1, path, ct); - result.Add(new ConflictFileContent(path, ours, theirs, @base)); - } - return new MergeConflicts(taskId, result); - } - /// /// Reads each conflicted working-tree file and parses its conflict markers into line-level /// segments (with the diff3 merge base when present). Binary files are flagged and skipped. diff --git a/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs b/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs index 092e1f9..cc166de 100644 --- a/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs +++ b/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs @@ -65,7 +65,6 @@ public abstract class StubWorkerClient : IWorkerClient public virtual Task RejectReviewToIdleAsync(string taskId) => Task.CompletedTask; public virtual Task CancelReviewAsync(string taskId) => Task.CompletedTask; public virtual Task StartConflictMergeAsync(string taskId, string targetBranch) => Task.FromResult(new MergeResultDto("conflict", System.Array.Empty(), null)); - public virtual Task GetMergeConflictsAsync(string taskId) => Task.FromResult(new MergeConflictsDto(taskId, System.Array.Empty())); public virtual Task GetMergeConflictDocumentsAsync(string taskId) => Task.FromResult(new MergeConflictDocumentsDto(taskId, System.Array.Empty())); public virtual Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent) => Task.CompletedTask; public virtual Task ContinueConflictMergeAsync(string taskId) => Task.FromResult(new MergeResultDto("merged", System.Array.Empty(), null)); diff --git a/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs index 4c63d46..a4ef405 100644 --- a/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs @@ -668,41 +668,6 @@ public class TaskMergeServiceTests : IDisposable // Cleanup GitRepoFixture.RunGit(repo.RepoDir, "merge", "--abort"); } - [Fact] - public async Task GetConflictsAsync_AfterConflictMerge_ReturnsOursAndTheirs() - { - if (!GitRepoFixture.IsGitAvailable()) return; - - var db = NewDb(); - var repo = NewRepo(); - GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main"); - File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# main change\n"); - GitRepoFixture.RunGit(repo.RepoDir, "commit", "-am", "main change"); - - var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}"); - _wtCleanups.Add((repo.RepoDir, wtPath)); - GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", "claudedo/c1", wtPath, repo.BaseCommit); - File.WriteAllText(Path.Combine(wtPath, "README.md"), "# branch change\n"); - GitRepoFixture.RunGit(wtPath, "commit", "-am", "branch change"); - - var (_, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.WaitingForReview); - await SeedWorktree(db, task.Id, wtPath, "claudedo/c1", repo.BaseCommit); - - var (svc, _) = BuildService(db); - var start = await svc.MergeAsync(task.Id, "main", false, "msg", leaveConflictsInTree: true, CancellationToken.None); - Assert.Equal(TaskMergeService.StatusConflict, start.Status); - - var conflicts = await svc.GetConflictsAsync(task.Id, CancellationToken.None); - - Assert.Equal(task.Id, conflicts.TaskId); - var file = Assert.Single(conflicts.Files); - Assert.Equal("README.md", file.Path); - Assert.Contains("main change", file.Ours); - Assert.Contains("branch change", file.Theirs); - Assert.NotNull(file.Base); - - GitRepoFixture.RunGit(repo.RepoDir, "merge", "--abort"); - } [Fact] public async Task WriteResolutionAsync_ThenContinue_CompletesMerge() diff --git a/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs b/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs index 96bbbe3..0128ff4 100644 --- a/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs +++ b/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs @@ -52,7 +52,6 @@ sealed class FakeWorkerClient : IWorkerClient public Task PreviewMergeAsync(string taskId, string targetBranch) => Task.FromResult(null); public Task MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage) => Task.FromResult(new MergeResultDto("merged", System.Array.Empty(), null)); public Task StartConflictMergeAsync(string taskId, string targetBranch) => Task.FromResult(new MergeResultDto("conflict", System.Array.Empty(), null)); - public Task GetMergeConflictsAsync(string taskId) => Task.FromResult(new MergeConflictsDto(taskId, System.Array.Empty())); public Task GetMergeConflictDocumentsAsync(string taskId) => Task.FromResult(new MergeConflictDocumentsDto(taskId, System.Array.Empty())); public Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent) => Task.CompletedTask; public Task ContinueConflictMergeAsync(string taskId) => Task.FromResult(new MergeResultDto("merged", System.Array.Empty(), null));