From 10e561f336d65296f0f69b9be7f7d8080a32f129 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Wed, 5 Aug 2026 11:46:51 +0200 Subject: [PATCH] feat(worker): record merge commit SHA and add revert_merge tool Persists the merge commit SHA on WorktreeEntity for every successful single-task and unit merge, and adds a TaskMergeService.RevertMergeAsync + revert_merge MCP tool that undoes a merged task's merge via `git revert -m 1` (never reset/rewrite, since the target checkout is shared). Rejects cleanly when there's no recorded SHA, the repo is mid-merge/mid-revert, or the target has foreign uncommitted changes; a conflicting revert aborts immediately. Also exposes the new mergeCommit field via get_task_worktree. --- src/ClaudeDo.Data/CLAUDE.md | 8 +- .../WorktreeEntityConfiguration.cs | 1 + src/ClaudeDo.Data/Git/GitService.cs | 30 + ...5091910_AddWorktreeMergeCommit.Designer.cs | 832 ++++++++++++++++++ .../20260805091910_AddWorktreeMergeCommit.cs | 28 + .../ClaudeDoDbContextModelSnapshot.cs | 4 + src/ClaudeDo.Data/Models/WorktreeEntity.cs | 1 + .../Repositories/WorktreeRepository.cs | 9 + src/ClaudeDo.Worker/CLAUDE.md | 2 +- .../External/ExternalMcpService.cs | 40 +- .../Lifecycle/TaskMergeService.cs | 101 ++- .../External/ExternalMcpServiceTests.cs | 105 +++ .../Runner/GitServiceMergeTests.cs | 91 ++ .../Services/TaskMergeServiceTests.cs | 214 ++++- 14 files changed, 1452 insertions(+), 14 deletions(-) create mode 100644 src/ClaudeDo.Data/Migrations/20260805091910_AddWorktreeMergeCommit.Designer.cs create mode 100644 src/ClaudeDo.Data/Migrations/20260805091910_AddWorktreeMergeCommit.cs diff --git a/src/ClaudeDo.Data/CLAUDE.md b/src/ClaudeDo.Data/CLAUDE.md index 819740e1..106f9b91 100644 --- a/src/ClaudeDo.Data/CLAUDE.md +++ b/src/ClaudeDo.Data/CLAUDE.md @@ -7,7 +7,7 @@ Shared data layer: models, repositories, SQLite infrastructure, and git operatio - **TaskEntity** — Id, ListId, Title, Description, Status (`Idle|Queued|Running|WaitingForChildren|WaitingForReview|Done|Failed|Cancelled`), PlanningPhase (`None|Active|Finalized` — parent-only), BlockedByTaskId (nullable FK to predecessor in a chain), ScheduledFor, Result, ReviewFeedback (nullable; reviewer's rejection comment, consumed and cleared by the runner on the next re-run), LogPath, timestamps, CommitType, Model / SystemPrompt / AgentPath / MaxTurns (nullable overrides), IsStarred, IsMyDay, IsManual (reminder only the user can do — automation skips it), Notes, ParentTaskId, PlanningSessionId, PlanningSessionToken, PlanningFinalizedAt, CreatedBy, HandlerBaseCommit / HandlerHeadCommit (nullable; review range for a worktree-less "list handler" host task — Mission Control's "Let Claude handle it" — which commits straight into the list's working dir instead of a per-task worktree: `HandlerBaseCommit` is stamped to the list repo's HEAD when the host task is created, `HandlerHeadCommit` when it's submitted for review; the Worker's `SubmitTaskForReview`/`GetTaskDiff` and the Ui's `DetailsIslandViewModel`/`MergeSectionViewModel` fall back to this pair whenever `Worktree` is null). Legacy values `Manual`/`Planning`/`Planned`/`Draft`/`Waiting` were retired; existing rows backfill automatically via the `RetireLegacyTaskStatus` migration. - **ListEntity** — Id, Name, WorkingDir, DefaultCommitType, CreatedAt, IsManual (reminder list — tasks created here default to `IsManual`) - **ListConfigEntity** — ListId (PK, 1:1 with list), Model, SystemPrompt, AgentPath, MaxTurns (all nullable) -- **WorktreeEntity** — TaskId (PK, 1:1 with task), Path, BranchName, BaseCommit, HeadCommit, DiffStat, State (Active|Merged|Discarded|Kept) +- **WorktreeEntity** — TaskId (PK, 1:1 with task), Path, BranchName, BaseCommit, HeadCommit, DiffStat, MergeCommit (nullable; SHA of the merge commit this worktree's branch produced on the target branch, stamped by `TaskMergeService` the moment a merge/continue-merge succeeds — the only thing that makes `revert_merge` possible without heuristically searching `git log`; null for any worktree merged before this field existed), State (Active|Merged|Discarded|Kept) - **TaskRunEntity** — per-run record (session_id, tokens, turns, result, structured output, exit code, log path, nullable `Model` — the model the run actually executed with, column `model`) - **PrimeScheduleEntity** — Id, Days (`[Flags] PrimeDays` weekday bitmask, stored as `days_of_week` int), TimeOfDay, Enabled, LastRunAt, PromptOverride, CreatedAt. Recurs on the selected weekdays; no date range. - **DailyNoteEntity** — Id, Date (DateOnly), Text, SortOrder, CreatedAt → table `daily_notes` @@ -25,7 +25,7 @@ All repositories use EF Core LINQ queries via `ClaudeDoDbContext`. The atomic `Q - **TaskRepository** — CRUD, planning helpers (`CreateChildAsync`, `SetPlanningStartedAsync`, `DiscardPlanningAsync`, `UpdateChildAsync`), `UpdateAgentSettingsAsync` (model / system-prompt / agent-path overrides). Status-mutation primitives `MarkRunningAsync` / `MarkDoneAsync` / `MarkFailedAsync` / `FlipAllRunningToFailedAsync` are `internal` and called only by `TaskStateService` in the worker. `CreateChildAsync` produces children with `Status=Idle, PlanningPhase=None`; once their parent's `PlanningPhase` becomes `Finalized`, the chain coordinator queues them. - **ListRepository** — CRUD, `GetConfigAsync` / `SetConfigAsync` (upsert) / `DeleteConfigAsync` for `list_config` -- **WorktreeRepository** — CRUD, `UpdateHeadAsync`, `SetStateAsync` +- **WorktreeRepository** — CRUD, `UpdateHeadAsync`, `SetStateAsync`, `SetMergedAsync` (atomically sets State=Merged and stamps MergeCommit in one update — the only writer of MergeCommit) - **TaskRunRepository**, **SubtaskRepository**, **AppSettingsRepository** - **DailyNoteRepository** — `ListByDayAsync`, `ListBetweenAsync`, `AddAsync`, `UpdateAsync`, `DeleteAsync` - **WeekReportRepository** — `GetByRangeAsync`, `UpsertAsync` @@ -41,11 +41,11 @@ 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), `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), revert (`RevertMergeCommitAsync` — `git revert --no-edit -m 1 `, reverts a merge commit against its first parent; `RevertAbortAsync`; `IsMidRevertAsync` — `REVERT_HEAD` presence, mirrors `IsMidMergeAsync`'s `MERGE_HEAD`), `PreviewMergeAsync` (non-destructive mergeability check via `git merge-tree --write-tree`), `CountChangedFilesAsync`, rev-parse, is-git-repo. Revert never resets/rewrites — it always produces a new commit, because the working directory it operates on is shared with other concurrent sessions. ## Schema -Tables: `lists`, `tasks`, `worktrees`, `list_config`, `task_runs`, `subtasks`, `app_settings`, `prime_schedules`, `daily_notes`, `week_reports`, `task_attachments`. Managed by EF Core migrations in the `Migrations/` folder. The `tasks` table holds `status`, `planning_phase` (default `none`), and `blocked_by_task_id` (FK to `tasks.id`, `ON DELETE SET NULL`). Migration `WeeklyReport` added `daily_notes`, `week_reports`, and the two new `app_settings` columns. Migration `DailyPrepMaxTasks` added the `daily_prep_max_tasks` column to `app_settings` (no new tables). Migration `AddTaskAttachments` created the `task_attachments` table. Migration `AddModelPresetsAndManualFlag` added `app_settings.model_presets` plus the `is_manual` flags on `tasks` and `lists`. Migration `AddHandlerCommitRange` added the nullable `handler_base_commit`/`handler_head_commit` columns to `tasks`. Migration `AddUsageGateAndRunModel` added `app_settings.usage_gate_five_hour_pct`/`usage_gate_seven_day_pct` (defaults 80/90) and the nullable `task_runs.model` column. `TaskRepository.DeleteAsync` and `ListRepository.DeleteAsync` also delete the on-disk attachment dir(s) via an optional `AttachmentStore` ctor param (defaults to the production store). +Tables: `lists`, `tasks`, `worktrees`, `list_config`, `task_runs`, `subtasks`, `app_settings`, `prime_schedules`, `daily_notes`, `week_reports`, `task_attachments`. Managed by EF Core migrations in the `Migrations/` folder. The `tasks` table holds `status`, `planning_phase` (default `none`), and `blocked_by_task_id` (FK to `tasks.id`, `ON DELETE SET NULL`). Migration `WeeklyReport` added `daily_notes`, `week_reports`, and the two new `app_settings` columns. Migration `DailyPrepMaxTasks` added the `daily_prep_max_tasks` column to `app_settings` (no new tables). Migration `AddTaskAttachments` created the `task_attachments` table. Migration `AddModelPresetsAndManualFlag` added `app_settings.model_presets` plus the `is_manual` flags on `tasks` and `lists`. Migration `AddHandlerCommitRange` added the nullable `handler_base_commit`/`handler_head_commit` columns to `tasks`. Migration `AddUsageGateAndRunModel` added `app_settings.usage_gate_five_hour_pct`/`usage_gate_seven_day_pct` (defaults 80/90) and the nullable `task_runs.model` column. Migration `AddWorktreeMergeCommit` added the nullable `worktrees.merge_commit` column. `TaskRepository.DeleteAsync` and `ListRepository.DeleteAsync` also delete the on-disk attachment dir(s) via an optional `AttachmentStore` ctor param (defaults to the production store). ## Conventions diff --git a/src/ClaudeDo.Data/Configuration/WorktreeEntityConfiguration.cs b/src/ClaudeDo.Data/Configuration/WorktreeEntityConfiguration.cs index 2917f475..3b1b2194 100644 --- a/src/ClaudeDo.Data/Configuration/WorktreeEntityConfiguration.cs +++ b/src/ClaudeDo.Data/Configuration/WorktreeEntityConfiguration.cs @@ -35,6 +35,7 @@ public class WorktreeEntityConfiguration : IEntityTypeConfiguration w.BaseCommit).HasColumnName("base_commit").IsRequired(); builder.Property(w => w.HeadCommit).HasColumnName("head_commit"); builder.Property(w => w.DiffStat).HasColumnName("diff_stat"); + builder.Property(w => w.MergeCommit).HasColumnName("merge_commit"); builder.Property(w => w.State).HasColumnName("state").IsRequired() .HasDefaultValue(WorktreeState.Active) .HasConversion(StateConverter); diff --git a/src/ClaudeDo.Data/Git/GitService.cs b/src/ClaudeDo.Data/Git/GitService.cs index e4f28966..1e2585bc 100644 --- a/src/ClaudeDo.Data/Git/GitService.cs +++ b/src/ClaudeDo.Data/Git/GitService.cs @@ -280,6 +280,36 @@ public sealed class GitService throw new InvalidOperationException($"git merge --abort failed (exit {exitCode}): {stderr}"); } + public async Task IsMidRevertAsync(string repoDir, CancellationToken ct = default) + { + var (exitCode, stdout, _) = await RunGitAsync(repoDir, ["rev-parse", "--git-dir"], ct); + if (exitCode != 0) return false; + var gitDir = stdout.Trim(); + if (!Path.IsPathRooted(gitDir)) + gitDir = Path.Combine(repoDir, gitDir); + return File.Exists(Path.Combine(gitDir, "REVERT_HEAD")); + } + + /// + /// Reverts a single commit with `-m 1` (diff against its first parent) — the form needed to + /// revert a merge commit. On success this creates a new commit with the inverse changes; + /// the original commit and all history stay intact (no rewrite, no reset). + /// + public async Task<(int ExitCode, string Stderr)> RevertMergeCommitAsync( + string repoDir, string mergeCommitSha, CancellationToken ct = default) + { + var (exitCode, _, stderr) = await RunGitAsync(repoDir, + ["-c", "merge.conflictStyle=diff3", "revert", "--no-edit", "-m", "1", mergeCommitSha], ct); + return (exitCode, stderr); + } + + public async Task RevertAbortAsync(string repoDir, CancellationToken ct = default) + { + var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["revert", "--abort"], ct); + if (exitCode != 0) + throw new InvalidOperationException($"git revert --abort failed (exit {exitCode}): {stderr}"); + } + public async Task> ListConflictedFilesAsync(string repoDir, CancellationToken ct = default) { var (exitCode, stdout, stderr) = await RunGitAsync(repoDir, diff --git a/src/ClaudeDo.Data/Migrations/20260805091910_AddWorktreeMergeCommit.Designer.cs b/src/ClaudeDo.Data/Migrations/20260805091910_AddWorktreeMergeCommit.Designer.cs new file mode 100644 index 00000000..b04c77f6 --- /dev/null +++ b/src/ClaudeDo.Data/Migrations/20260805091910_AddWorktreeMergeCommit.Designer.cs @@ -0,0 +1,832 @@ +// +using System; +using ClaudeDo.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace ClaudeDo.Data.Migrations +{ + [DbContext(typeof(ClaudeDoDbContext))] + [Migration("20260805091910_AddWorktreeMergeCommit")] + partial class AddWorktreeMergeCommit + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.11"); + + modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("CentralWorktreeRoot") + .HasColumnType("TEXT") + .HasColumnName("central_worktree_root"); + + b.Property("DailyPrepMaxTasks") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(5) + .HasColumnName("daily_prep_max_tasks"); + + b.Property("DefaultClaudeInstructions") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("") + .HasColumnName("default_claude_instructions"); + + b.Property("DefaultMaxTurns") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(30) + .HasColumnName("default_max_turns"); + + b.Property("DefaultModel") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("sonnet") + .HasColumnName("default_model"); + + b.Property("DefaultPermissionMode") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("bypassPermissions") + .HasColumnName("default_permission_mode"); + + b.Property("MaxParallelExecutions") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(1) + .HasColumnName("max_parallel_executions"); + + b.Property("ModelPresets") + .HasColumnType("TEXT") + .HasColumnName("model_presets"); + + b.Property("RepoImportFolders") + .HasColumnType("TEXT") + .HasColumnName("repo_import_folders"); + + b.Property("ReportExcludedPaths") + .HasColumnType("TEXT") + .HasColumnName("report_excluded_paths"); + + b.Property("SessionSkills") + .HasColumnType("TEXT") + .HasColumnName("session_skills"); + + b.Property("StandupWeekday") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(3) + .HasColumnName("standup_weekday"); + + b.Property("UsageGateFiveHourPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(80) + .HasColumnName("usage_gate_five_hour_pct"); + + b.Property("UsageGateSevenDayPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(90) + .HasColumnName("usage_gate_seven_day_pct"); + + b.Property("WorktreeAutoCleanupDays") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(7) + .HasColumnName("worktree_auto_cleanup_days"); + + b.Property("WorktreeAutoCleanupEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("worktree_auto_cleanup_enabled"); + + b.Property("WorktreeStrategy") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("sibling") + .HasColumnName("worktree_strategy"); + + b.HasKey("Id"); + + b.ToTable("app_settings", (string)null); + + b.HasData( + new + { + Id = 1, + DailyPrepMaxTasks = 5, + DefaultClaudeInstructions = "", + DefaultMaxTurns = 100, + DefaultModel = "sonnet", + DefaultPermissionMode = "auto", + MaxParallelExecutions = 1, + StandupWeekday = 3, + UsageGateFiveHourPct = 80, + UsageGateSevenDayPct = 90, + WorktreeAutoCleanupDays = 7, + WorktreeAutoCleanupEnabled = false, + WorktreeStrategy = "sibling" + }); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.DailyNoteEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("Date") + .HasColumnType("TEXT") + .HasColumnName("note_date"); + + b.Property("SortOrder") + .HasColumnType("INTEGER") + .HasColumnName("sort_order"); + + b.Property("Text") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("text"); + + b.HasKey("Id"); + + b.HasIndex("Date"); + + b.ToTable("daily_notes", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b => + { + b.Property("ListId") + .HasColumnType("TEXT") + .HasColumnName("list_id"); + + b.Property("AgentPath") + .HasColumnType("TEXT") + .HasColumnName("agent_path"); + + b.Property("MaxTurns") + .HasColumnType("INTEGER") + .HasColumnName("max_turns"); + + b.Property("Model") + .HasColumnType("TEXT") + .HasColumnName("model"); + + b.Property("SessionSkills") + .HasColumnType("TEXT") + .HasColumnName("session_skills"); + + b.Property("SystemPrompt") + .HasColumnType("TEXT") + .HasColumnName("system_prompt"); + + b.HasKey("ListId"); + + b.ToTable("list_config", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("DefaultCommitType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("chore") + .HasColumnName("default_commit_type"); + + b.Property("IsManual") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_manual"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0) + .HasColumnName("sort_order"); + + b.Property("WorkingDir") + .HasColumnType("TEXT") + .HasColumnName("working_dir"); + + b.HasKey("Id"); + + b.HasIndex("SortOrder") + .HasDatabaseName("idx_lists_sort"); + + b.ToTable("lists", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("Days") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(31) + .HasColumnName("days_of_week"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true) + .HasColumnName("enabled"); + + b.Property("LastRunAt") + .HasColumnType("TEXT") + .HasColumnName("last_run_at"); + + b.Property("PromptOverride") + .HasColumnType("TEXT") + .HasColumnName("prompt_override"); + + b.Property("TimeOfDay") + .HasColumnType("TEXT") + .HasColumnName("time_of_day"); + + b.HasKey("Id"); + + b.ToTable("prime_schedules", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.SessionSkillEntity", b => + { + b.Property("Name") + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("AddedAt") + .HasColumnType("TEXT") + .HasColumnName("added_at"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("description"); + + b.Property("PinnedRef") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("pinned_ref"); + + b.Property("SourceUrl") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("source_url"); + + b.Property("Subpath") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("subpath"); + + b.HasKey("Name"); + + b.ToTable("session_skills", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Completed") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("completed"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("OrderNum") + .HasColumnType("INTEGER") + .HasColumnName("order_num"); + + b.Property("TaskId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.HasKey("Id"); + + b.HasIndex("TaskId") + .HasDatabaseName("idx_subtasks_task_id"); + + b.ToTable("subtasks", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ByteSize") + .HasColumnType("INTEGER") + .HasColumnName("byte_size"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("file_name"); + + b.Property("TaskId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.HasKey("Id"); + + b.HasIndex("TaskId") + .HasDatabaseName("idx_task_attachments_task_id"); + + b.ToTable("task_attachments", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("AgentPath") + .HasColumnType("TEXT") + .HasColumnName("agent_path"); + + b.Property("BlockedByTaskId") + .HasColumnType("TEXT") + .HasColumnName("blocked_by_task_id"); + + b.Property("CommitType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("chore") + .HasColumnName("commit_type"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("TEXT") + .HasColumnName("created_by"); + + b.Property("Description") + .HasColumnType("TEXT") + .HasColumnName("description"); + + b.Property("FinishedAt") + .HasColumnType("TEXT") + .HasColumnName("finished_at"); + + b.Property("HandlerBaseCommit") + .HasColumnType("TEXT") + .HasColumnName("handler_base_commit"); + + b.Property("HandlerHeadCommit") + .HasColumnType("TEXT") + .HasColumnName("handler_head_commit"); + + b.Property("IsManual") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_manual"); + + b.Property("IsMyDay") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_my_day"); + + b.Property("IsStarred") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_starred"); + + b.Property("ListId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("list_id"); + + b.Property("LogPath") + .HasColumnType("TEXT") + .HasColumnName("log_path"); + + b.Property("MaxTurns") + .HasColumnType("INTEGER") + .HasColumnName("max_turns"); + + b.Property("Model") + .HasColumnType("TEXT") + .HasColumnName("model"); + + b.Property("Notes") + .HasColumnType("TEXT") + .HasColumnName("notes"); + + b.Property("ParentTaskId") + .HasColumnType("TEXT") + .HasColumnName("parent_task_id"); + + b.Property("PlanningFinalizedAt") + .HasColumnType("TEXT") + .HasColumnName("planning_finalized_at"); + + b.Property("PlanningPhase") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("none") + .HasColumnName("planning_phase"); + + b.Property("PlanningSessionId") + .HasColumnType("TEXT") + .HasColumnName("planning_session_id"); + + b.Property("PlanningSessionToken") + .HasColumnType("TEXT") + .HasColumnName("planning_session_token"); + + b.Property("Result") + .HasColumnType("TEXT") + .HasColumnName("result"); + + b.Property("ReviewFeedback") + .HasColumnType("TEXT") + .HasColumnName("review_feedback"); + + b.Property("RoadblockCount") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0) + .HasColumnName("roadblock_count"); + + b.Property("ScheduledFor") + .HasColumnType("TEXT") + .HasColumnName("scheduled_for"); + + b.Property("SessionSkills") + .HasColumnType("TEXT") + .HasColumnName("session_skills"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0) + .HasColumnName("sort_order"); + + b.Property("StartedAt") + .HasColumnType("TEXT") + .HasColumnName("started_at"); + + b.Property("Status") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("status"); + + b.Property("SystemPrompt") + .HasColumnType("TEXT") + .HasColumnName("system_prompt"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.HasKey("Id"); + + b.HasIndex("BlockedByTaskId") + .HasDatabaseName("idx_tasks_blocked_by"); + + b.HasIndex("ListId") + .HasDatabaseName("idx_tasks_list_id"); + + b.HasIndex("ParentTaskId") + .HasDatabaseName("idx_tasks_parent_task_id"); + + b.HasIndex("Status") + .HasDatabaseName("idx_tasks_status"); + + b.HasIndex("ListId", "SortOrder") + .HasDatabaseName("idx_tasks_list_sort"); + + b.ToTable("tasks", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ErrorMarkdown") + .HasColumnType("TEXT") + .HasColumnName("error_markdown"); + + b.Property("ExitCode") + .HasColumnType("INTEGER") + .HasColumnName("exit_code"); + + b.Property("FinishedAt") + .HasColumnType("TEXT") + .HasColumnName("finished_at"); + + b.Property("IsRetry") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_retry"); + + b.Property("LogPath") + .HasColumnType("TEXT") + .HasColumnName("log_path"); + + b.Property("Model") + .HasColumnType("TEXT") + .HasColumnName("model"); + + b.Property("Prompt") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("prompt"); + + b.Property("ResultMarkdown") + .HasColumnType("TEXT") + .HasColumnName("result_markdown"); + + b.Property("RunNumber") + .HasColumnType("INTEGER") + .HasColumnName("run_number"); + + b.Property("SessionId") + .HasColumnType("TEXT") + .HasColumnName("session_id"); + + b.Property("StartedAt") + .HasColumnType("TEXT") + .HasColumnName("started_at"); + + b.Property("StructuredOutputJson") + .HasColumnType("TEXT") + .HasColumnName("structured_output"); + + b.Property("TaskId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.Property("TokensIn") + .HasColumnType("INTEGER") + .HasColumnName("tokens_in"); + + b.Property("TokensOut") + .HasColumnType("INTEGER") + .HasColumnName("tokens_out"); + + b.Property("TurnCount") + .HasColumnType("INTEGER") + .HasColumnName("turn_count"); + + b.HasKey("Id"); + + b.HasIndex("TaskId") + .HasDatabaseName("idx_task_runs_task_id"); + + b.ToTable("task_runs", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.WeekReportEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("EndDate") + .HasColumnType("TEXT") + .HasColumnName("end_date"); + + b.Property("GeneratedAt") + .HasColumnType("TEXT") + .HasColumnName("generated_at"); + + b.Property("Markdown") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("markdown"); + + b.Property("StartDate") + .HasColumnType("TEXT") + .HasColumnName("start_date"); + + b.HasKey("Id"); + + b.HasIndex("StartDate", "EndDate") + .IsUnique(); + + b.ToTable("week_reports", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b => + { + b.Property("TaskId") + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.Property("BaseCommit") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("base_commit"); + + b.Property("BranchName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("branch_name"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("DiffStat") + .HasColumnType("TEXT") + .HasColumnName("diff_stat"); + + b.Property("HeadCommit") + .HasColumnType("TEXT") + .HasColumnName("head_commit"); + + b.Property("MergeCommit") + .HasColumnType("TEXT") + .HasColumnName("merge_commit"); + + b.Property("Path") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("path"); + + b.Property("State") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("active") + .HasColumnName("state"); + + b.HasKey("TaskId"); + + b.ToTable("worktrees", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.ListEntity", "List") + .WithOne("Config") + .HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("List"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task") + .WithMany("Subtasks") + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Task"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task") + .WithMany() + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Task"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.TaskEntity", null) + .WithMany() + .HasForeignKey("BlockedByTaskId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ClaudeDo.Data.Models.ListEntity", "List") + .WithMany("Tasks") + .HasForeignKey("ListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent") + .WithMany("Children") + .HasForeignKey("ParentTaskId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("List"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task") + .WithMany("Runs") + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Task"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task") + .WithOne("Worktree") + .HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Task"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b => + { + b.Navigation("Config"); + + b.Navigation("Tasks"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b => + { + b.Navigation("Children"); + + b.Navigation("Runs"); + + b.Navigation("Subtasks"); + + b.Navigation("Worktree"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/ClaudeDo.Data/Migrations/20260805091910_AddWorktreeMergeCommit.cs b/src/ClaudeDo.Data/Migrations/20260805091910_AddWorktreeMergeCommit.cs new file mode 100644 index 00000000..c403c0e5 --- /dev/null +++ b/src/ClaudeDo.Data/Migrations/20260805091910_AddWorktreeMergeCommit.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ClaudeDo.Data.Migrations +{ + /// + public partial class AddWorktreeMergeCommit : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "merge_commit", + table: "worktrees", + type: "TEXT", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "merge_commit", + table: "worktrees"); + } + } +} diff --git a/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs b/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs index 7f209fe5..365035ca 100644 --- a/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs +++ b/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs @@ -707,6 +707,10 @@ namespace ClaudeDo.Data.Migrations .HasColumnType("TEXT") .HasColumnName("head_commit"); + b.Property("MergeCommit") + .HasColumnType("TEXT") + .HasColumnName("merge_commit"); + b.Property("Path") .IsRequired() .HasColumnType("TEXT") diff --git a/src/ClaudeDo.Data/Models/WorktreeEntity.cs b/src/ClaudeDo.Data/Models/WorktreeEntity.cs index 9ac26b9c..defcb4a9 100644 --- a/src/ClaudeDo.Data/Models/WorktreeEntity.cs +++ b/src/ClaudeDo.Data/Models/WorktreeEntity.cs @@ -16,6 +16,7 @@ public sealed class WorktreeEntity public required string BaseCommit { get; set; } public string? HeadCommit { get; set; } public string? DiffStat { get; set; } + public string? MergeCommit { get; set; } public WorktreeState State { get; set; } = WorktreeState.Active; public required DateTime CreatedAt { get; init; } diff --git a/src/ClaudeDo.Data/Repositories/WorktreeRepository.cs b/src/ClaudeDo.Data/Repositories/WorktreeRepository.cs index 6499aa5a..04576c04 100644 --- a/src/ClaudeDo.Data/Repositories/WorktreeRepository.cs +++ b/src/ClaudeDo.Data/Repositories/WorktreeRepository.cs @@ -36,6 +36,15 @@ public sealed class WorktreeRepository .ExecuteUpdateAsync(s => s.SetProperty(w => w.State, state), ct); } + public async Task SetMergedAsync(string taskId, string mergeCommit, CancellationToken ct = default) + { + await _context.Worktrees + .Where(w => w.TaskId == taskId) + .ExecuteUpdateAsync(s => s + .SetProperty(w => w.State, WorktreeState.Merged) + .SetProperty(w => w.MergeCommit, mergeCommit), ct); + } + public async Task DeleteAsync(string taskId, CancellationToken ct = default) { await _context.Worktrees.Where(w => w.TaskId == taskId).ExecuteDeleteAsync(ct); diff --git a/src/ClaudeDo.Worker/CLAUDE.md b/src/ClaudeDo.Worker/CLAUDE.md index 0136b5fe..549e91ec 100644 --- a/src/ClaudeDo.Worker/CLAUDE.md +++ b/src/ClaudeDo.Worker/CLAUDE.md @@ -34,7 +34,7 @@ Interfaces (e.g. `IQueueWaker`, `IPrimeClock`, `ITaskStateService`) live in an ` - **OverrideSlotService** — owns `RunNow` / `ContinueTask`; goes through `TaskStateService.StartRunningAsync` (caller-driven, serialized by slot lock). - **StaleTaskRecovery** — startup-only service; calls `TaskStateService.RecoverStaleRunningAsync` to flip orphaned `Running` rows to `Failed`. - **External/*** — always-on MCP tools for general Claude sessions, scoped to *starting* and *observing* sessions (no worktree/merge, multi-turn, planning, or app-settings writes). Auth via optional `X-ClaudeDo-Key` header. Registered explicitly in `Program.cs`'s external app via `.WithTools()`. Every optional/filter parameter across these tools must carry a C# default value (e.g. `string? status = null`) — the MCP schema only marks a parameter optional when it has one; nullability alone doesn't do it (`ExternalMcpToolSchemaTests` guards this by reflection). `ExternalMcpExceptionFilter.Wrap` is registered as a call-tool filter so `InvalidOperationException`/`ArgumentException` messages survive as `McpException` — otherwise the SDK's own catch-all replaces any non-`McpException` with a generic "An error occurred invoking 'X'." **No external tool returns bare `Task` or a nullable payload directly** — an MCP client can't tell an empty/omitted response apart from a dropped one. Write tools return a small confirmation record (`{ ok/deleted/removed/reset/started: true, , ... }`, e.g. `DeleteListResult`, `RunTaskNowResult`, `ResetFailedTaskResult`, `RemoveAttachmentResult`; `SetListConfigResult`/`SetTaskConfigResult` additionally echo the resulting config so the caller can see which fields were set vs. cleared to null); read tools that may have nothing to return use an explicit `Found`/`Available` flag alongside the nullable payload (`TaskConfigResult`, pre-existing `BatchGetTaskResult`, `TaskLogResult`) instead of returning null outright. Organized by concern: - - `ExternalMcpService` — task CRUD + execution: `ListTaskLists`, `ListTasks`, `GetTask`, `AddTask`, `AddSubtask`, `UpdateTask`, `UpdateTaskStatus` (`Idle` / `Queued` / `Cancelled` / `Done` — `Cancelled` goes through `TaskStateService.CancelAsync(..., allowFromIdle: true)`, the only caller that opts into cancelling from `Idle`; `PlanningChainCoordinator` relies on `Idle` staying a no-op there by default, since a child parked back to `Idle` mid-chain is a manual opt-out signal; `Done` goes through `TaskStateService.ForceSetStatusAsync` — same unconditional write the UI's "set status freely" affordance uses — but is refused here with an active-worktree task, since that would skip `review_task`'s merge), `GetTaskStatusValues`, `ReviewTask` (`approve` / `reject_rerun` / `reject_park` / `cancel` for a WaitingForReview task; approve is review+merge exactly like the hub's `ApproveReview` — unit merge for parents, worktree merge into optional `targetBranch` for childless tasks, conflicts reported in the `ReviewTaskResult`), `RunTaskNow`, `ContinueTask`, `CancelTask`, `DeleteTask`; worktree/git: `GetTaskWorktree`, `GetTaskDiff`, `MergeTask`, `ListWorktrees`, `CleanupTaskWorktree` + - `ExternalMcpService` — task CRUD + execution: `ListTaskLists`, `ListTasks`, `GetTask`, `AddTask`, `AddSubtask`, `UpdateTask`, `UpdateTaskStatus` (`Idle` / `Queued` / `Cancelled` / `Done` — `Cancelled` goes through `TaskStateService.CancelAsync(..., allowFromIdle: true)`, the only caller that opts into cancelling from `Idle`; `PlanningChainCoordinator` relies on `Idle` staying a no-op there by default, since a child parked back to `Idle` mid-chain is a manual opt-out signal; `Done` goes through `TaskStateService.ForceSetStatusAsync` — same unconditional write the UI's "set status freely" affordance uses — but is refused here with an active-worktree task, since that would skip `review_task`'s merge), `GetTaskStatusValues`, `ReviewTask` (`approve` / `reject_rerun` / `reject_park` / `cancel` for a WaitingForReview task; approve is review+merge exactly like the hub's `ApproveReview` — unit merge for parents, worktree merge into optional `targetBranch` for childless tasks, conflicts reported in the `ReviewTaskResult`), `RunTaskNow`, `ContinueTask`, `CancelTask`, `DeleteTask`; worktree/git: `GetTaskWorktree`, `GetTaskDiff`, `MergeTask`, `ContinueMerge`, `AbortMerge`, `RevertMerge` (undoes a previously merged task's merge commit on `targetBranch` via `git revert -m 1` — a new commit, never a reset/rewrite, since the target working directory is shared with other sessions; requires the task to be `Done` with a `Merged` worktree carrying a recorded `WorktreeEntity.MergeCommit` — a task merged before that field existed has none and is refused rather than guessed via `git log`; on success the task returns to `WaitingForReview` and the worktree moves to `Kept`, not `Active` (its directory/branch are typically already gone from the original merge's cleanup) and not `Merged`/`Discarded` (`WorktreeMaintenanceService` sweeps those); a conflicting revert is aborted immediately, no half-resolved state is ever left in the tree), `ListWorktrees`, `CleanupTaskWorktree` - `BatchMcpTools` — best-effort batch variants that loop the `ExternalMcpService` single-entity methods (sequential — the scoped DbContext is not thread-safe; merge/review stay single-task): `BatchGetTasks`, `BatchAddTasks`, `BatchUpdateTaskStatus`, `BatchCancelTasks`, `BatchDeleteTasks`, `BatchSetMyDay`, `BatchCleanupTaskWorktrees`. Every tool returns a per-item result array ({ id/index, ok, error?, … }) — a failing item never aborts the rest — and rejects batches over 100 items. - `ListMcpTools` — `CreateList`, `UpdateList`, `DeleteList` - `ConfigMcpTools` — `GetListConfig`, `SetListConfig`, `GetTaskConfig`, `SetTaskConfig` diff --git a/src/ClaudeDo.Worker/External/ExternalMcpService.cs b/src/ClaudeDo.Worker/External/ExternalMcpService.cs index 26237593..38afc95f 100644 --- a/src/ClaudeDo.Worker/External/ExternalMcpService.cs +++ b/src/ClaudeDo.Worker/External/ExternalMcpService.cs @@ -39,7 +39,7 @@ public sealed record TaskDto( public sealed record WorktreeInfoDto( string Path, string Branch, string HeadCommit, string BaseCommit, - int Ahead, int Behind, bool IsDirty); + int Ahead, int Behind, bool IsDirty, string? MergeCommit = null); public sealed record TaskDiffDto( string Content, IReadOnlyList Files, bool Truncated, int TotalBytes); @@ -59,6 +59,9 @@ public sealed record WorktreeListItemDto( public sealed record CleanupWorktreeResult( bool Removed, string WorktreePath, bool BranchDeleted); +public sealed record RevertMergeResultDto( + bool Reverted, string? RevertCommit, IReadOnlyList Conflicts, string? Message); + public sealed record DailyPrepCandidateDto( string Id, string ListId, string ListName, string Title, string? Description, bool IsStarred, DateTime? ScheduledFor, DateTime CreatedAt); @@ -486,7 +489,10 @@ public sealed class ExternalMcpService "Get git worktree details for a task: path, branch, headCommit (current HEAD SHA), " + "baseCommit (SHA where the branch was created), ahead (commits on branch since base), " + "behind (commits on main not yet on this branch; 0 if 'main' ref is unreachable), " + - "isDirty (has uncommitted changes in the worktree directory). " + + "isDirty (has uncommitted changes in the worktree directory), " + + "mergeCommit (SHA of the merge commit this worktree's branch produced on the target branch, " + + "if it has been merged and that succeeded after this field was introduced; null otherwise — " + + "required by revert_merge). " + "Throws if the task or its worktree does not exist.")] public async Task GetTaskWorktree(string taskId, CancellationToken cancellationToken) { @@ -500,7 +506,7 @@ public sealed class ExternalMcpService var ahead = await GitRevListCountAsync(wt.Path, $"{wt.BaseCommit}..HEAD", cancellationToken); var behind = await GitRevListCountAsync(wt.Path, "HEAD..main", cancellationToken); - return new WorktreeInfoDto(wt.Path, wt.BranchName, headCommit!, wt.BaseCommit, ahead, behind, isDirty); + return new WorktreeInfoDto(wt.Path, wt.BranchName, headCommit!, wt.BaseCommit, ahead, behind, isDirty, wt.MergeCommit); } [McpServerTool, Description( @@ -729,6 +735,34 @@ public sealed class ExternalMcpService return ToDto((await _tasks.GetByIdAsync(taskId, cancellationToken))!); } + [McpServerTool, Description( + "Revert a previously merged task's merge commit on targetBranch (default: main), via `git revert -m 1` — " + + "a new commit, never a reset/rewrite (the target working directory is shared with other sessions). " + + "Requires the task to be Done with a Merged worktree that has a recorded merge commit; tasks merged " + + "before this feature existed have no recorded commit and are refused rather than guessed via git log. " + + "On success: reverted=true, revertCommit is the new commit's SHA, and the task returns to " + + "WaitingForReview so it can be reconsidered. " + + "On a conflicting revert: reverted=false, the revert is aborted immediately (no half-resolved state " + + "left in the tree) and conflicts lists the files that would have conflicted. " + + "Throws if there is no recorded merge commit, the repo is mid-merge/mid-revert, or the target working " + + "tree has uncommitted changes from another session.")] + public async Task RevertMerge( + string taskId, string targetBranch = "main", CancellationToken cancellationToken = default) + { + var result = await _merge.RevertMergeAsync(taskId, targetBranch, cancellationToken); + + if (result.Status == TaskMergeService.StatusReverted) + { + await _broadcaster.TaskUpdated(taskId); + return new RevertMergeResultDto(true, result.RevertCommit, Array.Empty(), null); + } + + if (result.Status == TaskMergeService.StatusConflictAborted) + return new RevertMergeResultDto(false, null, result.ConflictFiles, result.ErrorMessage); + + throw new InvalidOperationException(result.ErrorMessage ?? $"Revert blocked: {result.Status}"); + } + [McpServerTool, Description( "List all ClaudeDo-tracked worktrees. " + "Each entry: taskId, path, branch, headCommit (empty if path missing on disk), " + diff --git a/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs b/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs index 2c1a8574..acbdf8af 100644 --- a/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs +++ b/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs @@ -32,6 +32,12 @@ public sealed record ConflictDocumentContent( bool IsBinary, IReadOnlyList Segments); +public sealed record RevertResult( + string Status, + string? RevertCommit, + IReadOnlyList ConflictFiles, + string? ErrorMessage); + public sealed class TaskMergeService { public const string StatusMerged = "merged"; @@ -39,6 +45,9 @@ public sealed class TaskMergeService public const string StatusBlocked = "blocked"; public const string StatusAborted = "aborted"; + public const string StatusReverted = "reverted"; + public const string StatusConflictAborted = "conflict_aborted"; + public const string PreviewClean = "clean"; public const string PreviewConflict = "conflict"; public const string PreviewUnavailable = "unavailable"; @@ -75,11 +84,11 @@ public sealed class TaskMergeService return (task, list, wt); } - private async Task MarkWorktreeMergedAsync(string taskId, CancellationToken ct) + private async Task MarkWorktreeMergedAsync(string taskId, string mergeCommitSha, CancellationToken ct) { using (var ctx = _dbFactory.CreateDbContext()) { - await new WorktreeRepository(ctx).SetStateAsync(taskId, WorktreeState.Merged, ct); + await new WorktreeRepository(ctx).SetMergedAsync(taskId, mergeCommitSha, ct); } await _broadcaster.WorktreeUpdated(taskId); } @@ -155,6 +164,8 @@ public sealed class TaskMergeService return new MergeResult(StatusConflict, files, null); } + var mergeSha = await _git.RevParseHeadAsync(list.WorkingDir, ct); + string? cleanupWarning = null; if (removeWorktree) { @@ -175,7 +186,7 @@ public sealed class TaskMergeService } } - await MarkWorktreeMergedAsync(taskId, ct); + await MarkWorktreeMergedAsync(taskId, mergeSha, ct); await ApproveIfWaitingForReviewAsync(task, ct); _logger.LogInformation( @@ -233,7 +244,8 @@ public sealed class TaskMergeService try { await _git.CommitAsync(list.WorkingDir, $"Merge branch '{wt.BranchName}'", ct); } catch (Exception ex) { return Blocked($"commit failed: {ex.Message}"); } - await MarkWorktreeMergedAsync(taskId, ct); + var mergeSha = await _git.RevParseHeadAsync(list.WorkingDir, ct); + await MarkWorktreeMergedAsync(taskId, mergeSha, ct); await ApproveIfWaitingForReviewAsync(task, ct); _logger.LogInformation("Continued merge of task {TaskId} branch {Branch}", taskId, wt.BranchName); @@ -257,6 +269,84 @@ public sealed class TaskMergeService return new MergeResult(StatusAborted, Array.Empty(), null); } + /// + /// Reverts the merge commit recorded for this task () + /// via `git revert -m 1`, a new commit that undoes the merge without rewriting history — the + /// target working directory is shared with other sessions, so a reset/rebase is never an option. + /// On success the task returns to WaitingForReview so it can be reconsidered, and the worktree + /// state moves to Kept: Merged/Discarded are swept by WorktreeMaintenanceService, and by the time + /// a merge can be reverted its worktree directory and branch are typically already gone (removed + /// during the original merge cleanup), so Active — which implies a live, resumable worktree — + /// would be misleading. A conflicting revert is aborted immediately (`git revert --abort`); no + /// partial/half-resolved state is ever left in the tree. + /// + public async Task RevertMergeAsync(string taskId, string targetBranch, CancellationToken ct) + { + var (task, list, wt) = await LoadMergeContextAsync(taskId, ct); + + if (task.Status != TaskStatus.Done) + return RevertBlocked("task is not Done; only a merged task's revert can be undone"); + if (wt is null) + return RevertBlocked("task has no worktree"); + if (wt.State != WorktreeState.Merged) + return RevertBlocked($"worktree state is {wt.State}, expected Merged"); + if (string.IsNullOrWhiteSpace(wt.MergeCommit)) + return RevertBlocked("no merge commit recorded for this task; cannot revert"); + if (string.IsNullOrWhiteSpace(list.WorkingDir)) + return RevertBlocked("list has no working directory"); + if (!await _git.IsGitRepoAsync(list.WorkingDir, ct)) + return RevertBlocked("working directory is not a git repository"); + if (await _git.IsMidMergeAsync(list.WorkingDir, ct)) + return RevertBlocked("target working directory is mid-merge"); + if (await _git.IsMidRevertAsync(list.WorkingDir, ct)) + return RevertBlocked("target working directory is mid-revert"); + if (await _git.HasChangesAsync(list.WorkingDir, includeUntracked: false, ct)) + return RevertBlocked("target working tree has uncommitted changes"); + + var currentBranch = await _git.GetCurrentBranchAsync(list.WorkingDir, ct); + if (!string.Equals(currentBranch, targetBranch, StringComparison.Ordinal)) + { + try { await _git.CheckoutBranchAsync(list.WorkingDir, targetBranch, ct); } + catch (Exception ex) { return RevertBlocked($"failed to switch target branch: {ex.Message}"); } + } + + var (exitCode, stderr) = await _git.RevertMergeCommitAsync(list.WorkingDir, wt.MergeCommit!, ct); + if (exitCode != 0) + { + List files; + try { files = await _git.ListConflictedFilesAsync(list.WorkingDir, ct); } + catch { files = new(); } + + try { await _git.RevertAbortAsync(list.WorkingDir, ct); } + catch (Exception ex) + { + _logger.LogError(ex, "git revert --abort failed after conflict — repo is mid-revert"); + return RevertBlocked($"revert conflict and abort failed: {ex.Message} — repo is mid-revert, resolve manually"); + } + + if (files.Count == 0) + return RevertBlocked($"revert failed: {stderr}"); + + return new RevertResult(StatusConflictAborted, null, files, "revert conflicted; aborted cleanly, no changes made"); + } + + var revertSha = await _git.RevParseHeadAsync(list.WorkingDir, ct); + + using (var ctx = _dbFactory.CreateDbContext()) + { + await new WorktreeRepository(ctx).SetStateAsync(taskId, WorktreeState.Kept, ct); + } + await _broadcaster.WorktreeUpdated(taskId); + await _state.ForceSetStatusAsync(taskId, TaskStatus.WaitingForReview, ct); + + _logger.LogInformation( + "Reverted merge of task {TaskId} (merge commit {MergeSha}) via revert commit {RevertSha}", + taskId, wt.MergeCommit, revertSha); + await _broadcaster.WorkerLog($"Reverted merge of \"{task.Title}\"", WorkerLogLevel.Warn, DateTime.UtcNow); + + return new RevertResult(StatusReverted, revertSha, Array.Empty(), null); + } + /// /// 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. @@ -376,4 +466,7 @@ public sealed class TaskMergeService private static MergeResult Blocked(string reason) => new(StatusBlocked, Array.Empty(), reason); + + private static RevertResult RevertBlocked(string reason) => + new(StatusBlocked, null, Array.Empty(), reason); } diff --git a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs index 6921844d..3e282573 100644 --- a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs @@ -624,6 +624,39 @@ public sealed class ExternalMcpServiceTests : IDisposable Assert.False(info.IsDirty); } + [Fact] + public async Task GetTaskWorktree_BeforeMerge_MergeCommitIsNull() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var (task, _, _) = await SeedWorktreeAsync(); + var sut = BuildSut(CreateQueue()); + + var info = await sut.GetTaskWorktree(task.Id, CancellationToken.None); + + Assert.Null(info.MergeCommit); + } + + [Fact] + public async Task GetTaskWorktree_AfterMerge_ExposesMergeCommit() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var (task, list, wt) = await SeedWorktreeAsync(TaskStatus.Done); + File.WriteAllText(Path.Combine(wt.WorktreePath, "added.txt"), "new\n"); + GitRepoFixture.RunGit(wt.WorktreePath, "add", "-A"); + GitRepoFixture.RunGit(wt.WorktreePath, "commit", "-m", "feat: add file"); + + var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None); + var sut = BuildSut(CreateQueue()); + await sut.MergeTask(task.Id, target, true, false, false, cancellationToken: CancellationToken.None); + + var info = await sut.GetTaskWorktree(task.Id, CancellationToken.None); + + var expected = GitRepoFixture.RunGit(list.WorkingDir!, "rev-parse", "HEAD").Trim(); + Assert.Equal(expected, info.MergeCommit); + } + // ── GetTaskDiff ──────────────────────────────────────────────────────────── [Fact] @@ -1136,6 +1169,78 @@ public sealed class ExternalMcpServiceTests : IDisposable Assert.Contains("mid-merge", ex.Message); } + // ── RevertMerge ──────────────────────────────────────────────────────────── + + [Fact] + public async Task RevertMerge_NotMerged_Throws() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var (task, _, _) = await SeedWorktreeAsync(TaskStatus.Done); + var sut = BuildSut(CreateQueue()); + + var ex = await Assert.ThrowsAsync( + () => sut.RevertMerge(task.Id, "main", CancellationToken.None)); + Assert.Contains("expected Merged", ex.Message); + } + + [Fact] + public async Task RevertMerge_AfterCleanMerge_RevertsAndReturnsTaskToWaitingForReview() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var (task, list, wt) = await SeedWorktreeAsync(TaskStatus.Done); + File.WriteAllText(Path.Combine(wt.WorktreePath, "added.txt"), "new\n"); + GitRepoFixture.RunGit(wt.WorktreePath, "add", "-A"); + GitRepoFixture.RunGit(wt.WorktreePath, "commit", "-m", "feat: add file"); + + var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None); + var sut = BuildSut(CreateQueue()); + await sut.MergeTask(task.Id, target, true, false, false, cancellationToken: CancellationToken.None); + Assert.True(File.Exists(Path.Combine(list.WorkingDir!, "added.txt"))); + + var result = await sut.RevertMerge(task.Id, target, CancellationToken.None); + + Assert.True(result.Reverted); + Assert.False(string.IsNullOrWhiteSpace(result.RevertCommit)); + Assert.Empty(result.Conflicts); + Assert.False(File.Exists(Path.Combine(list.WorkingDir!, "added.txt"))); + + var reloaded = await _tasks.GetByIdAsync(task.Id); + Assert.Equal(TaskStatus.WaitingForReview, reloaded!.Status); + } + + [Fact] + public async Task RevertMerge_ConflictingRevert_ReturnsRevertedFalseWithConflictsAndAbortsCleanly() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var (task, list, wt) = await SeedWorktreeAsync(TaskStatus.Done); + File.WriteAllText(Path.Combine(wt.WorktreePath, "README.md"), "# test repo\nfeature\n"); + GitRepoFixture.RunGit(wt.WorktreePath, "add", "-A"); + GitRepoFixture.RunGit(wt.WorktreePath, "commit", "-m", "feat: append feature line"); + + var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None); + var sut = BuildSut(CreateQueue()); + await sut.MergeTask(task.Id, target, true, false, false, cancellationToken: CancellationToken.None); + + // A later commit on main edits the exact line the merge introduced, so the revert conflicts. + File.WriteAllText(Path.Combine(list.WorkingDir!, "README.md"), "# test repo\npost-merge edit\n"); + GitRepoFixture.RunGit(list.WorkingDir!, "add", "-A"); + GitRepoFixture.RunGit(list.WorkingDir!, "commit", "-m", "chore: edit the same line"); + var headBefore = GitRepoFixture.RunGit(list.WorkingDir!, "rev-parse", "HEAD").Trim(); + + var result = await sut.RevertMerge(task.Id, target, CancellationToken.None); + + Assert.False(result.Reverted); + Assert.Contains("README.md", result.Conflicts); + Assert.False(await new GitService().IsMidRevertAsync(list.WorkingDir!)); + Assert.Equal(headBefore, GitRepoFixture.RunGit(list.WorkingDir!, "rev-parse", "HEAD").Trim()); + + var reloaded = await _tasks.GetByIdAsync(task.Id); + Assert.Equal(TaskStatus.Done, reloaded!.Status); + } + [Fact] public async Task ContinueMerge_ParentUnitMergeConflict_RoutesToOrchestratorAndCompletes() { diff --git a/tests/ClaudeDo.Worker.Tests/Runner/GitServiceMergeTests.cs b/tests/ClaudeDo.Worker.Tests/Runner/GitServiceMergeTests.cs index 3886cb64..f01c3cb7 100644 --- a/tests/ClaudeDo.Worker.Tests/Runner/GitServiceMergeTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Runner/GitServiceMergeTests.cs @@ -226,4 +226,95 @@ public class GitServiceMergeTests : IDisposable await git.MergeAbortAsync(repo.RepoDir); } + + [Fact] + public async Task IsMidRevertAsync_FreshRepo_ReturnsFalse() + { + if (!GitRepoFixture.IsGitAvailable()) return; + var repo = NewRepo(); + var git = new GitService(); + + Assert.False(await git.IsMidRevertAsync(repo.RepoDir)); + } + + [Fact] + public async Task IsMidRevertAsync_RevertHeadPresent_ReturnsTrue() + { + if (!GitRepoFixture.IsGitAvailable()) return; + var repo = NewRepo(); + var revertHead = Path.Combine(repo.RepoDir, ".git", "REVERT_HEAD"); + File.WriteAllText(revertHead, "0000000000000000000000000000000000000000\n"); + + var git = new GitService(); + Assert.True(await git.IsMidRevertAsync(repo.RepoDir)); + } + + [Fact] + public async Task RevertMergeCommitAsync_CleanMerge_ReturnsZero_AndUndoesMergedChange() + { + if (!GitRepoFixture.IsGitAvailable()) return; + var repo = NewRepo(); + + GitRepoFixture.RunGit(repo.RepoDir, "checkout", "-b", "feature/revert"); + File.WriteAllText(Path.Combine(repo.RepoDir, "feature.txt"), "hello\n"); + GitRepoFixture.RunGit(repo.RepoDir, "add", "-A"); + GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "feat: add feature.txt"); + + try { GitRepoFixture.RunGit(repo.RepoDir, "checkout", "main"); } + catch { GitRepoFixture.RunGit(repo.RepoDir, "checkout", "master"); } + + var git = new GitService(); + var (mergeExit, _) = await git.MergeNoFfAsync(repo.RepoDir, "feature/revert", "Merge feature/revert"); + Assert.Equal(0, mergeExit); + var mergeSha = (await git.RevParseHeadAsync(repo.RepoDir)).Trim(); + Assert.True(File.Exists(Path.Combine(repo.RepoDir, "feature.txt"))); + + var (revertExit, _) = await git.RevertMergeCommitAsync(repo.RepoDir, mergeSha); + + Assert.Equal(0, revertExit); + Assert.False(File.Exists(Path.Combine(repo.RepoDir, "feature.txt"))); + Assert.False(await git.IsMidRevertAsync(repo.RepoDir)); + // History stays intact — both the merge and the new revert commit are reachable. + var log = GitRepoFixture.RunGit(repo.RepoDir, "log", "--oneline"); + Assert.Contains(mergeSha[..7], log); + } + + [Fact] + public async Task RevertMergeCommitAsync_ConflictingSubsequentEdit_ReturnsNonZero_AndAbortCleansUp() + { + if (!GitRepoFixture.IsGitAvailable()) return; + var repo = NewRepo(); + + GitRepoFixture.RunGit(repo.RepoDir, "checkout", "-b", "feature/revert-conflict"); + File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# test repo\nfeature\n"); + GitRepoFixture.RunGit(repo.RepoDir, "add", "-A"); + GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "feat: append feature line"); + + try { GitRepoFixture.RunGit(repo.RepoDir, "checkout", "main"); } + catch { GitRepoFixture.RunGit(repo.RepoDir, "checkout", "master"); } + + var git = new GitService(); + var (mergeExit, _) = await git.MergeNoFfAsync(repo.RepoDir, "feature/revert-conflict", "Merge feature/revert-conflict"); + Assert.Equal(0, mergeExit); + var mergeSha = (await git.RevParseHeadAsync(repo.RepoDir)).Trim(); + + // A direct edit on main to the same line the merge introduced makes the revert conflict. + File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# test repo\npost-merge edit\n"); + GitRepoFixture.RunGit(repo.RepoDir, "add", "-A"); + GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "chore: edit the same line"); + var headBefore = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim(); + + var (revertExit, _) = await git.RevertMergeCommitAsync(repo.RepoDir, mergeSha); + + Assert.NotEqual(0, revertExit); + Assert.True(await git.IsMidRevertAsync(repo.RepoDir)); + var conflicted = await git.ListConflictedFilesAsync(repo.RepoDir); + Assert.Contains("README.md", conflicted); + + await git.RevertAbortAsync(repo.RepoDir); + + Assert.False(await git.IsMidRevertAsync(repo.RepoDir)); + Assert.Equal(headBefore, GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim()); + Assert.True(string.IsNullOrWhiteSpace(GitRepoFixture.RunGit(repo.RepoDir, "status", "--porcelain"))); + } } diff --git a/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs index 6702e85e..30e2e79b 100644 --- a/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs @@ -54,7 +54,8 @@ public class TaskMergeServiceTests : IDisposable } private static async Task SeedWorktree( - DbFixture db, string taskId, string path, string branchName, string baseCommit) + DbFixture db, string taskId, string path, string branchName, string baseCommit, + WorktreeState state = WorktreeState.Active, string? mergeCommit = null) { var wt = new WorktreeEntity { @@ -62,7 +63,8 @@ public class TaskMergeServiceTests : IDisposable Path = path, BranchName = branchName, BaseCommit = baseCommit, - State = WorktreeState.Active, + State = state, + MergeCommit = mergeCommit, CreatedAt = DateTime.UtcNow, }; using var ctx = db.CreateContext(); @@ -772,6 +774,214 @@ public class TaskMergeServiceTests : IDisposable Assert.Equal("# resolved by user\n", File.ReadAllText(Path.Combine(repo.RepoDir, "README.md"))); Assert.False(await new GitService().IsMidMergeAsync(repo.RepoDir)); } + + [Fact] + public async Task MergeAsync_Success_RecordsMergeCommitOnWorktree() + { + 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, "added.txt"), "new\n"); + await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None); + + var (svc, _) = BuildService(db); + var currentBranch = await new GitService().GetCurrentBranchAsync(repo.RepoDir); + + var result = await svc.MergeAsync(task.Id, currentBranch, removeWorktree: false, + commitMessage: "Merge task", ct: CancellationToken.None); + Assert.Equal("merged", result.Status); + + var headSha = (await new GitService().RevParseHeadAsync(repo.RepoDir)).Trim(); + + using var ctx = db.CreateContext(); + var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id); + Assert.Equal(headSha, wt!.MergeCommit); + } + + [Fact] + public async Task RevertMergeAsync_NoWorktree_ReturnsBlocked() + { + var db = NewDb(); + var (_, task) = await SeedListAndTask(db, workingDir: "/tmp", status: TaskStatus.Done); + var (svc, _) = BuildService(db); + + var result = await svc.RevertMergeAsync(task.Id, "main", CancellationToken.None); + + Assert.Equal(TaskMergeService.StatusBlocked, result.Status); + Assert.Contains("no worktree", result.ErrorMessage ?? ""); + } + + [Fact] + public async Task RevertMergeAsync_TaskNotDone_ReturnsBlocked() + { + var db = NewDb(); + var (_, task) = await SeedListAndTask(db, workingDir: "/tmp", status: TaskStatus.WaitingForReview); + await SeedWorktree(db, task.Id, "/tmp/wt", "claudedo/x", "deadbeef", + state: WorktreeState.Merged, mergeCommit: "cafebabe"); + var (svc, _) = BuildService(db); + + var result = await svc.RevertMergeAsync(task.Id, "main", CancellationToken.None); + + Assert.Equal(TaskMergeService.StatusBlocked, result.Status); + Assert.Contains("not Done", result.ErrorMessage ?? ""); + } + + [Fact] + public async Task RevertMergeAsync_NoMergeCommitRecorded_ReturnsBlocked() + { + // Simulates a worktree merged before this feature existed — Merged state, no SHA. + var db = NewDb(); + var (_, task) = await SeedListAndTask(db, workingDir: "/tmp", status: TaskStatus.Done); + await SeedWorktree(db, task.Id, "/tmp/wt", "claudedo/x", "deadbeef", + state: WorktreeState.Merged, mergeCommit: null); + var (svc, _) = BuildService(db); + + var result = await svc.RevertMergeAsync(task.Id, "main", CancellationToken.None); + + Assert.Equal(TaskMergeService.StatusBlocked, result.Status); + Assert.Contains("no merge commit recorded", result.ErrorMessage ?? ""); + } + + [Fact] + public async Task RevertMergeAsync_WorktreeNotMerged_ReturnsBlocked() + { + var db = NewDb(); + var (_, task) = await SeedListAndTask(db, workingDir: "/tmp", status: TaskStatus.Done); + await SeedWorktree(db, task.Id, "/tmp/wt", "claudedo/x", "deadbeef", + state: WorktreeState.Active, mergeCommit: null); + var (svc, _) = BuildService(db); + + var result = await svc.RevertMergeAsync(task.Id, "main", CancellationToken.None); + + Assert.Equal(TaskMergeService.StatusBlocked, result.Status); + Assert.Contains("expected Merged", result.ErrorMessage ?? ""); + } + + [Fact] + public async Task RevertMergeAsync_AfterCleanMerge_CreatesRevertCommitAndResetsState() + { + 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, "added.txt"), "new\n"); + await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None); + + var (svc, proxy) = BuildService(db); + var currentBranch = await new GitService().GetCurrentBranchAsync(repo.RepoDir); + + var mergeResult = await svc.MergeAsync(task.Id, currentBranch, removeWorktree: true, + commitMessage: "Merge task", ct: CancellationToken.None); + Assert.Equal("merged", mergeResult.Status); + Assert.True(File.Exists(Path.Combine(repo.RepoDir, "added.txt"))); + + var revertResult = await svc.RevertMergeAsync(task.Id, currentBranch, CancellationToken.None); + + Assert.Equal(TaskMergeService.StatusReverted, revertResult.Status); + Assert.False(string.IsNullOrWhiteSpace(revertResult.RevertCommit)); + Assert.Empty(revertResult.ConflictFiles); + Assert.False(File.Exists(Path.Combine(repo.RepoDir, "added.txt"))); + + using var ctx = db.CreateContext(); + var updatedTask = await new TaskRepository(ctx).GetByIdAsync(task.Id); + Assert.Equal(TaskStatus.WaitingForReview, updatedTask!.Status); + var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id); + Assert.Equal(WorktreeState.Kept, wt!.State); + + Assert.Contains(proxy.Calls, c => c.Method == "WorktreeUpdated" && c.Args[0] is string s && s == task.Id); + } + + [Fact] + public async Task RevertMergeAsync_UncommittedChangesInTarget_ReturnsBlocked() + { + 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, "added.txt"), "new\n"); + await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None); + + var (svc, _) = BuildService(db); + var currentBranch = await new GitService().GetCurrentBranchAsync(repo.RepoDir); + + var mergeResult = await svc.MergeAsync(task.Id, currentBranch, removeWorktree: true, + commitMessage: "Merge task", ct: CancellationToken.None); + Assert.Equal("merged", mergeResult.Status); + + // A concurrent session leaves an uncommitted, tracked-file change in the shared checkout. + File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "dirty from another session\n"); + var headBefore = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim(); + + var revertResult = await svc.RevertMergeAsync(task.Id, currentBranch, CancellationToken.None); + + Assert.Equal(TaskMergeService.StatusBlocked, revertResult.Status); + Assert.Contains("uncommitted", revertResult.ErrorMessage ?? ""); + Assert.Equal(headBefore, GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim()); + + using var ctx = db.CreateContext(); + var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id); + Assert.Equal(WorktreeState.Merged, wt!.State); + } + + [Fact] + public async Task RevertMergeAsync_ConflictingRevert_AbortsCleanlyAndReportsConflictFiles() + { + 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, "README.md"), "# test repo\nfeature\n"); + await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None); + + var (svc, _) = BuildService(db); + var currentBranch = await new GitService().GetCurrentBranchAsync(repo.RepoDir); + + var mergeResult = await svc.MergeAsync(task.Id, currentBranch, removeWorktree: true, + commitMessage: "Merge task", ct: CancellationToken.None); + Assert.Equal("merged", mergeResult.Status); + + // A later commit on the target edits the exact line the merge introduced, so the revert conflicts. + File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# test repo\npost-merge edit\n"); + GitRepoFixture.RunGit(repo.RepoDir, "add", "-A"); + GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "chore: edit the same line"); + var headBefore = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim(); + + var revertResult = await svc.RevertMergeAsync(task.Id, currentBranch, CancellationToken.None); + + Assert.Equal(TaskMergeService.StatusConflictAborted, revertResult.Status); + Assert.Contains("README.md", revertResult.ConflictFiles); + Assert.False(await new GitService().IsMidRevertAsync(repo.RepoDir)); + Assert.Equal(headBefore, GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim()); + Assert.True(string.IsNullOrWhiteSpace(GitRepoFixture.RunGit(repo.RepoDir, "status", "--porcelain"))); + + // Task/worktree state must not change on a conflicted, aborted revert. + using var ctx = db.CreateContext(); + var updatedTask = await new TaskRepository(ctx).GetByIdAsync(task.Id); + Assert.Equal(TaskStatus.Done, updatedTask!.Status); + var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id); + Assert.Equal(WorktreeState.Merged, wt!.State); + } } #region Test doubles