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.
This commit is contained in:
mika kuns
2026-08-05 11:46:51 +02:00
parent 6c5acd09b9
commit 10e561f336
14 changed files with 1452 additions and 14 deletions
+4 -4
View File
@@ -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. - **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`) - **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) - **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`) - **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. - **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` - **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. - **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` - **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** - **TaskRunRepository**, **SubtaskRepository**, **AppSettingsRepository**
- **DailyNoteRepository** — `ListByDayAsync`, `ListBetweenAsync`, `AddAsync`, `UpdateAsync`, `DeleteAsync` - **DailyNoteRepository** — `ListByDayAsync`, `ListBetweenAsync`, `AddAsync`, `UpdateAsync`, `DeleteAsync`
- **WeekReportRepository** — `GetByRangeAsync`, `UpsertAsync` - **WeekReportRepository** — `GetByRangeAsync`, `UpsertAsync`
@@ -41,11 +41,11 @@ All repositories use EF Core LINQ queries via `ClaudeDoDbContext`. The atomic `Q
## Git ## 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 <sha>`, 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 ## 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 ## Conventions
@@ -35,6 +35,7 @@ public class WorktreeEntityConfiguration : IEntityTypeConfiguration<WorktreeEnti
builder.Property(w => w.BaseCommit).HasColumnName("base_commit").IsRequired(); builder.Property(w => w.BaseCommit).HasColumnName("base_commit").IsRequired();
builder.Property(w => w.HeadCommit).HasColumnName("head_commit"); builder.Property(w => w.HeadCommit).HasColumnName("head_commit");
builder.Property(w => w.DiffStat).HasColumnName("diff_stat"); builder.Property(w => w.DiffStat).HasColumnName("diff_stat");
builder.Property(w => w.MergeCommit).HasColumnName("merge_commit");
builder.Property(w => w.State).HasColumnName("state").IsRequired() builder.Property(w => w.State).HasColumnName("state").IsRequired()
.HasDefaultValue(WorktreeState.Active) .HasDefaultValue(WorktreeState.Active)
.HasConversion(StateConverter); .HasConversion(StateConverter);
+30
View File
@@ -280,6 +280,36 @@ public sealed class GitService
throw new InvalidOperationException($"git merge --abort failed (exit {exitCode}): {stderr}"); throw new InvalidOperationException($"git merge --abort failed (exit {exitCode}): {stderr}");
} }
public async Task<bool> 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"));
}
/// <summary>
/// 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).
/// </summary>
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<List<string>> ListConflictedFilesAsync(string repoDir, CancellationToken ct = default) public async Task<List<string>> ListConflictedFilesAsync(string repoDir, CancellationToken ct = default)
{ {
var (exitCode, stdout, stderr) = await RunGitAsync(repoDir, var (exitCode, stdout, stderr) = await RunGitAsync(repoDir,
@@ -0,0 +1,832 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<int>("Id")
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<string>("CentralWorktreeRoot")
.HasColumnType("TEXT")
.HasColumnName("central_worktree_root");
b.Property<int>("DailyPrepMaxTasks")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(5)
.HasColumnName("daily_prep_max_tasks");
b.Property<string>("DefaultClaudeInstructions")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("")
.HasColumnName("default_claude_instructions");
b.Property<int>("DefaultMaxTurns")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(30)
.HasColumnName("default_max_turns");
b.Property<string>("DefaultModel")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sonnet")
.HasColumnName("default_model");
b.Property<string>("DefaultPermissionMode")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("bypassPermissions")
.HasColumnName("default_permission_mode");
b.Property<int>("MaxParallelExecutions")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(1)
.HasColumnName("max_parallel_executions");
b.Property<string>("ModelPresets")
.HasColumnType("TEXT")
.HasColumnName("model_presets");
b.Property<string>("RepoImportFolders")
.HasColumnType("TEXT")
.HasColumnName("repo_import_folders");
b.Property<string>("ReportExcludedPaths")
.HasColumnType("TEXT")
.HasColumnName("report_excluded_paths");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<int>("StandupWeekday")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(3)
.HasColumnName("standup_weekday");
b.Property<int>("UsageGateFiveHourPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(80)
.HasColumnName("usage_gate_five_hour_pct");
b.Property<int>("UsageGateSevenDayPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(90)
.HasColumnName("usage_gate_seven_day_pct");
b.Property<int>("WorktreeAutoCleanupDays")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(7)
.HasColumnName("worktree_auto_cleanup_days");
b.Property<bool>("WorktreeAutoCleanupEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("worktree_auto_cleanup_enabled");
b.Property<string>("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<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<DateOnly>("Date")
.HasColumnType("TEXT")
.HasColumnName("note_date");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER")
.HasColumnName("sort_order");
b.Property<string>("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<string>("ListId")
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<int?>("MaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("max_turns");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.HasKey("ListId");
b.ToTable("list_config", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DefaultCommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("default_commit_type");
b.Property<bool>("IsManual")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_manual");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<string>("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<Guid>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("Days")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(31)
.HasColumnName("days_of_week");
b.Property<bool>("Enabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true)
.HasColumnName("enabled");
b.Property<DateTimeOffset?>("LastRunAt")
.HasColumnType("TEXT")
.HasColumnName("last_run_at");
b.Property<string>("PromptOverride")
.HasColumnType("TEXT")
.HasColumnName("prompt_override");
b.Property<TimeSpan>("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<string>("Name")
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<DateTimeOffset>("AddedAt")
.HasColumnType("TEXT")
.HasColumnName("added_at");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<string>("PinnedRef")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("pinned_ref");
b.Property<string>("SourceUrl")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("source_url");
b.Property<string>("Subpath")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("subpath");
b.HasKey("Name");
b.ToTable("session_skills", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Completed")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("completed");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("OrderNum")
.HasColumnType("INTEGER")
.HasColumnName("order_num");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("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<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<long>("ByteSize")
.HasColumnType("INTEGER")
.HasColumnName("byte_size");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("FileName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("file_name");
b.Property<string>("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<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("BlockedByTaskId")
.HasColumnType("TEXT")
.HasColumnName("blocked_by_task_id");
b.Property<string>("CommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("commit_type");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("CreatedBy")
.HasColumnType("TEXT")
.HasColumnName("created_by");
b.Property<string>("Description")
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<string>("HandlerBaseCommit")
.HasColumnType("TEXT")
.HasColumnName("handler_base_commit");
b.Property<string>("HandlerHeadCommit")
.HasColumnType("TEXT")
.HasColumnName("handler_head_commit");
b.Property<bool>("IsManual")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_manual");
b.Property<bool>("IsMyDay")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_my_day");
b.Property<bool>("IsStarred")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_starred");
b.Property<string>("ListId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<int?>("MaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("max_turns");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Notes")
.HasColumnType("TEXT")
.HasColumnName("notes");
b.Property<string>("ParentTaskId")
.HasColumnType("TEXT")
.HasColumnName("parent_task_id");
b.Property<DateTime?>("PlanningFinalizedAt")
.HasColumnType("TEXT")
.HasColumnName("planning_finalized_at");
b.Property<string>("PlanningPhase")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("none")
.HasColumnName("planning_phase");
b.Property<string>("PlanningSessionId")
.HasColumnType("TEXT")
.HasColumnName("planning_session_id");
b.Property<string>("PlanningSessionToken")
.HasColumnType("TEXT")
.HasColumnName("planning_session_token");
b.Property<string>("Result")
.HasColumnType("TEXT")
.HasColumnName("result");
b.Property<string>("ReviewFeedback")
.HasColumnType("TEXT")
.HasColumnName("review_feedback");
b.Property<int>("RoadblockCount")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("roadblock_count");
b.Property<DateTime?>("ScheduledFor")
.HasColumnType("TEXT")
.HasColumnName("scheduled_for");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("status");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("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<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("ErrorMarkdown")
.HasColumnType("TEXT")
.HasColumnName("error_markdown");
b.Property<int?>("ExitCode")
.HasColumnType("INTEGER")
.HasColumnName("exit_code");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsRetry")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_retry");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Prompt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("prompt");
b.Property<string>("ResultMarkdown")
.HasColumnType("TEXT")
.HasColumnName("result_markdown");
b.Property<int>("RunNumber")
.HasColumnType("INTEGER")
.HasColumnName("run_number");
b.Property<string>("SessionId")
.HasColumnType("TEXT")
.HasColumnName("session_id");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("StructuredOutputJson")
.HasColumnType("TEXT")
.HasColumnName("structured_output");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<int?>("TokensIn")
.HasColumnType("INTEGER")
.HasColumnName("tokens_in");
b.Property<int?>("TokensOut")
.HasColumnType("INTEGER")
.HasColumnName("tokens_out");
b.Property<int?>("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<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateOnly>("EndDate")
.HasColumnType("TEXT")
.HasColumnName("end_date");
b.Property<DateTime>("GeneratedAt")
.HasColumnType("TEXT")
.HasColumnName("generated_at");
b.Property<string>("Markdown")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("markdown");
b.Property<DateOnly>("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<string>("TaskId")
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("BaseCommit")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("base_commit");
b.Property<string>("BranchName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("branch_name");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DiffStat")
.HasColumnType("TEXT")
.HasColumnName("diff_stat");
b.Property<string>("HeadCommit")
.HasColumnType("TEXT")
.HasColumnName("head_commit");
b.Property<string>("MergeCommit")
.HasColumnType("TEXT")
.HasColumnName("merge_commit");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("path");
b.Property<string>("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
}
}
}
@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class AddWorktreeMergeCommit : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "merge_commit",
table: "worktrees",
type: "TEXT",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "merge_commit",
table: "worktrees");
}
}
}
@@ -707,6 +707,10 @@ namespace ClaudeDo.Data.Migrations
.HasColumnType("TEXT") .HasColumnType("TEXT")
.HasColumnName("head_commit"); .HasColumnName("head_commit");
b.Property<string>("MergeCommit")
.HasColumnType("TEXT")
.HasColumnName("merge_commit");
b.Property<string>("Path") b.Property<string>("Path")
.IsRequired() .IsRequired()
.HasColumnType("TEXT") .HasColumnType("TEXT")
@@ -16,6 +16,7 @@ public sealed class WorktreeEntity
public required string BaseCommit { get; set; } public required string BaseCommit { get; set; }
public string? HeadCommit { get; set; } public string? HeadCommit { get; set; }
public string? DiffStat { get; set; } public string? DiffStat { get; set; }
public string? MergeCommit { get; set; }
public WorktreeState State { get; set; } = WorktreeState.Active; public WorktreeState State { get; set; } = WorktreeState.Active;
public required DateTime CreatedAt { get; init; } public required DateTime CreatedAt { get; init; }
@@ -36,6 +36,15 @@ public sealed class WorktreeRepository
.ExecuteUpdateAsync(s => s.SetProperty(w => w.State, state), ct); .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) public async Task DeleteAsync(string taskId, CancellationToken ct = default)
{ {
await _context.Worktrees.Where(w => w.TaskId == taskId).ExecuteDeleteAsync(ct); await _context.Worktrees.Where(w => w.TaskId == taskId).ExecuteDeleteAsync(ct);
+1 -1
View File
@@ -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). - **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`. - **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<T>()`. 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, <id>, ... }`, 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: - **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<T>()`. 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, <id>, ... }`, 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. - `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` - `ListMcpTools``CreateList`, `UpdateList`, `DeleteList`
- `ConfigMcpTools``GetListConfig`, `SetListConfig`, `GetTaskConfig`, `SetTaskConfig` - `ConfigMcpTools``GetListConfig`, `SetListConfig`, `GetTaskConfig`, `SetTaskConfig`
+37 -3
View File
@@ -39,7 +39,7 @@ public sealed record TaskDto(
public sealed record WorktreeInfoDto( public sealed record WorktreeInfoDto(
string Path, string Branch, string HeadCommit, string BaseCommit, 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( public sealed record TaskDiffDto(
string Content, IReadOnlyList<string> Files, bool Truncated, int TotalBytes); string Content, IReadOnlyList<string> Files, bool Truncated, int TotalBytes);
@@ -59,6 +59,9 @@ public sealed record WorktreeListItemDto(
public sealed record CleanupWorktreeResult( public sealed record CleanupWorktreeResult(
bool Removed, string WorktreePath, bool BranchDeleted); bool Removed, string WorktreePath, bool BranchDeleted);
public sealed record RevertMergeResultDto(
bool Reverted, string? RevertCommit, IReadOnlyList<string> Conflicts, string? Message);
public sealed record DailyPrepCandidateDto( public sealed record DailyPrepCandidateDto(
string Id, string ListId, string ListName, string Title, string? Description, string Id, string ListId, string ListName, string Title, string? Description,
bool IsStarred, DateTime? ScheduledFor, DateTime CreatedAt); 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), " + "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), " + "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), " + "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.")] "Throws if the task or its worktree does not exist.")]
public async Task<WorktreeInfoDto> GetTaskWorktree(string taskId, CancellationToken cancellationToken) public async Task<WorktreeInfoDto> GetTaskWorktree(string taskId, CancellationToken cancellationToken)
{ {
@@ -500,7 +506,7 @@ public sealed class ExternalMcpService
var ahead = await GitRevListCountAsync(wt.Path, $"{wt.BaseCommit}..HEAD", cancellationToken); var ahead = await GitRevListCountAsync(wt.Path, $"{wt.BaseCommit}..HEAD", cancellationToken);
var behind = await GitRevListCountAsync(wt.Path, "HEAD..main", 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( [McpServerTool, Description(
@@ -729,6 +735,34 @@ public sealed class ExternalMcpService
return ToDto((await _tasks.GetByIdAsync(taskId, cancellationToken))!); 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<RevertMergeResultDto> 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<string>(), 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( [McpServerTool, Description(
"List all ClaudeDo-tracked worktrees. " + "List all ClaudeDo-tracked worktrees. " +
"Each entry: taskId, path, branch, headCommit (empty if path missing on disk), " + "Each entry: taskId, path, branch, headCommit (empty if path missing on disk), " +
@@ -32,6 +32,12 @@ public sealed record ConflictDocumentContent(
bool IsBinary, bool IsBinary,
IReadOnlyList<MergeSegment> Segments); IReadOnlyList<MergeSegment> Segments);
public sealed record RevertResult(
string Status,
string? RevertCommit,
IReadOnlyList<string> ConflictFiles,
string? ErrorMessage);
public sealed class TaskMergeService public sealed class TaskMergeService
{ {
public const string StatusMerged = "merged"; public const string StatusMerged = "merged";
@@ -39,6 +45,9 @@ public sealed class TaskMergeService
public const string StatusBlocked = "blocked"; public const string StatusBlocked = "blocked";
public const string StatusAborted = "aborted"; public const string StatusAborted = "aborted";
public const string StatusReverted = "reverted";
public const string StatusConflictAborted = "conflict_aborted";
public const string PreviewClean = "clean"; public const string PreviewClean = "clean";
public const string PreviewConflict = "conflict"; public const string PreviewConflict = "conflict";
public const string PreviewUnavailable = "unavailable"; public const string PreviewUnavailable = "unavailable";
@@ -75,11 +84,11 @@ public sealed class TaskMergeService
return (task, list, wt); 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()) 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); await _broadcaster.WorktreeUpdated(taskId);
} }
@@ -155,6 +164,8 @@ public sealed class TaskMergeService
return new MergeResult(StatusConflict, files, null); return new MergeResult(StatusConflict, files, null);
} }
var mergeSha = await _git.RevParseHeadAsync(list.WorkingDir, ct);
string? cleanupWarning = null; string? cleanupWarning = null;
if (removeWorktree) if (removeWorktree)
{ {
@@ -175,7 +186,7 @@ public sealed class TaskMergeService
} }
} }
await MarkWorktreeMergedAsync(taskId, ct); await MarkWorktreeMergedAsync(taskId, mergeSha, ct);
await ApproveIfWaitingForReviewAsync(task, ct); await ApproveIfWaitingForReviewAsync(task, ct);
_logger.LogInformation( _logger.LogInformation(
@@ -233,7 +244,8 @@ public sealed class TaskMergeService
try { await _git.CommitAsync(list.WorkingDir, $"Merge branch '{wt.BranchName}'", ct); } try { await _git.CommitAsync(list.WorkingDir, $"Merge branch '{wt.BranchName}'", ct); }
catch (Exception ex) { return Blocked($"commit failed: {ex.Message}"); } 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); await ApproveIfWaitingForReviewAsync(task, ct);
_logger.LogInformation("Continued merge of task {TaskId} branch {Branch}", taskId, wt.BranchName); _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<string>(), null); return new MergeResult(StatusAborted, Array.Empty<string>(), null);
} }
/// <summary>
/// Reverts the merge commit recorded for this task (<see cref="WorktreeEntity.MergeCommit"/>)
/// 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.
/// </summary>
public async Task<RevertResult> 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<string> 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<string>(), null);
}
/// <summary> /// <summary>
/// Reads each conflicted working-tree file and parses its conflict markers into line-level /// 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. /// 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) => private static MergeResult Blocked(string reason) =>
new(StatusBlocked, Array.Empty<string>(), reason); new(StatusBlocked, Array.Empty<string>(), reason);
private static RevertResult RevertBlocked(string reason) =>
new(StatusBlocked, null, Array.Empty<string>(), reason);
} }
@@ -624,6 +624,39 @@ public sealed class ExternalMcpServiceTests : IDisposable
Assert.False(info.IsDirty); 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 ──────────────────────────────────────────────────────────── // ── GetTaskDiff ────────────────────────────────────────────────────────────
[Fact] [Fact]
@@ -1136,6 +1169,78 @@ public sealed class ExternalMcpServiceTests : IDisposable
Assert.Contains("mid-merge", ex.Message); 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<InvalidOperationException>(
() => 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] [Fact]
public async Task ContinueMerge_ParentUnitMergeConflict_RoutesToOrchestratorAndCompletes() public async Task ContinueMerge_ParentUnitMergeConflict_RoutesToOrchestratorAndCompletes()
{ {
@@ -226,4 +226,95 @@ public class GitServiceMergeTests : IDisposable
await git.MergeAbortAsync(repo.RepoDir); 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")));
}
} }
@@ -54,7 +54,8 @@ public class TaskMergeServiceTests : IDisposable
} }
private static async Task SeedWorktree( 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 var wt = new WorktreeEntity
{ {
@@ -62,7 +63,8 @@ public class TaskMergeServiceTests : IDisposable
Path = path, Path = path,
BranchName = branchName, BranchName = branchName,
BaseCommit = baseCommit, BaseCommit = baseCommit,
State = WorktreeState.Active, State = state,
MergeCommit = mergeCommit,
CreatedAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow,
}; };
using var ctx = db.CreateContext(); 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.Equal("# resolved by user\n", File.ReadAllText(Path.Combine(repo.RepoDir, "README.md")));
Assert.False(await new GitService().IsMidMergeAsync(repo.RepoDir)); 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 #region Test doubles