chore(claude-do): merge Unit-Branch Teil 1: Branch-Naming + Basis-Branch pro Liste

ClaudeDo-Task: e202e601-64c0-4ecb-8dc9-2fb0da01a84a
This commit is contained in:
mika kuns
2026-10-01 11:03:02 +02:00
34 changed files with 1798 additions and 67 deletions
+1 -1
View File
@@ -16,7 +16,7 @@ Shared data layer: models, repositories, SQLite infrastructure, and git operatio
- `Color` (`color`, nullable) — dot colour: a swatch key (`Swatch3`), a literal `#RRGGBB`, or null = the hash-derived identity colour. Same value format as `ListFolderEntity.Color`; resolved UI-side by `ColorBrushConverter`.
- `FolderId` (`folder_id`, nullable FK → `list_folders.id`, `ON DELETE SET NULL`) — the sidebar folder the list sits in; null = top level. Written only by the UI (`ListsIslandViewModel` drag/context menu), never by MCP.
- **ListFolderEntity** — Id, Name, Icon, SortOrder, IsCollapsed, ParentFolderId, CreatedAt → `list_folders`. A pure sidebar grouping: no tasks, no config, no working dir. `ParentFolderId` (self-FK, `ON DELETE SET NULL`) lets folders nest arbitrarily deep — drag-and-drop only, no UI to pick a parent otherwise. Deliberately its own table rather than a flag on `lists`, so every caller that enumerates lists (MCP `list_task_lists`, "move to list", the queue picker, repo import) needs no folder filtering. Folder CRUD lives on `ListRepository`.
- **ListConfigEntity** — ListId (PK, 1:1), Model, SystemPrompt, AgentPath, MaxTurns, SessionSkills, VerifyCommand, PermissionMode, TicketProjectId (all nullable). `VerifyCommand` is an optional post-merge gate; null/blank = no gate → [review-merge](../../docs/explore-notes/review-merge.md). `PermissionMode` is null = inherit the global default; resolution order task → list → global lives in `EffectiveRunConfigResolver`. `TicketProjectId` (`ticket_project_id`) links the list to a project in the external ticket system; null = not linked → `ClaudeDo.Worker/CLAUDE.md` Tickets section.
- **ListConfigEntity** — ListId (PK, 1:1), Model, SystemPrompt, AgentPath, MaxTurns, SessionSkills, VerifyCommand, PermissionMode, TicketProjectId, BaseBranch (all nullable), PowerUseAllowed. `VerifyCommand` is an optional post-merge gate; null/blank = no gate → [review-merge](../../docs/explore-notes/review-merge.md). `PermissionMode` is null = inherit the global default; resolution order task → list → global lives in `EffectiveRunConfigResolver`. `TicketProjectId` (`ticket_project_id`) links the list to a project in the external ticket system; null = not linked → `ClaudeDo.Worker/CLAUDE.md` Tickets section. `BaseBranch` (`base_branch`) = base of new worktrees + default merge target; tri-state in `UpdateListConfigDto` (null = keep, "" = clear). ⚠️ Same verbatim-copy trap as `ticket_project_id`: `SetConfigAsync` overwrites every column, so every writer must carry it forward.
- ⚠️ `ListRepository.SetConfigAsync` copies the entity **verbatim**, so every writer must carry the fields it doesn't own (`SessionSkills`, `PermissionMode`, `TicketProjectId`) or they silently reset. `UpdateListConfigDto.TicketProjectId` is tri-state (`null` = keep stored) for exactly that reason — only the list-settings modal sends an explicit value. This bit `PermissionMode` for real: its update-branch copy was missing from `SetConfigAsync` until `f0a3a186`, so a list's permission-mode override only ever stuck on the list's *first* save, never on a later one.
- **WorktreeEntity** — TaskId (PK, 1:1), Path, BranchName, BaseCommit, HeadCommit, DiffStat, MergeCommit (nullable — SHA of the merge commit this branch produced; the only thing making `revert_merge` possible without searching `git log`), State (`Active|Merged|Discarded|Kept`)
- **TaskRunEntity** — per-run record: session_id, turns, result, structured output, exit code, log path, nullable `Model` (what the run actually executed with), and `TokensIn`/`TokensOut`/`CacheReadTokens`/`CacheWriteTokens`. ⚠️ Token fields come from the **session transcript**, not the stream-json event, as a per-run delta → [usage-monitoring](../../docs/explore-notes/usage-monitoring.md).
@@ -21,5 +21,6 @@ public class ListConfigEntityConfiguration : IEntityTypeConfiguration<ListConfig
builder.Property(c => c.PermissionMode).HasColumnName("permission_mode");
builder.Property(c => c.TicketProjectId).HasColumnName("ticket_project_id");
builder.Property(c => c.PowerUseAllowed).HasColumnName("power_use_allowed");
builder.Property(c => c.BaseBranch).HasColumnName("base_branch");
}
}
+7
View File
@@ -414,6 +414,13 @@ public sealed class GitService
.ToList();
}
/// <summary>URL des Remotes <paramref name="remote"/>, oder null wenn es keins gibt.</summary>
public async Task<string?> GetRemoteUrlAsync(string repoDir, string remote = "origin", CancellationToken ct = default)
{
var (exitCode, stdout, _) = await RunGitAsync(repoDir, ["remote", "get-url", remote], ct);
return exitCode == 0 && !string.IsNullOrWhiteSpace(stdout) ? stdout.Trim() : null;
}
public async Task<bool> IsMidMergeAsync(string repoDir, CancellationToken ct = default)
{
var (exitCode, stdout, _) = await RunGitAsync(repoDir, ["rev-parse", "--git-dir"], ct);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class AddListBaseBranch : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "base_branch",
table: "list_config",
type: "TEXT",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "base_branch",
table: "list_config");
}
}
}
@@ -268,6 +268,10 @@ namespace ClaudeDo.Data.Migrations
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("BaseBranch")
.HasColumnType("TEXT")
.HasColumnName("base_branch");
b.Property<int?>("MaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("max_turns");
@@ -23,6 +23,12 @@ public sealed class ListConfigEntity
// false, also opt-in. ⚠️ Gleiche Falle wie TicketProjectId: SetConfigAsync kopiert verbatim.
public bool PowerUseAllowed { get; set; }
/// <summary>
/// Basis neuer Worktrees und Default-Merge-/PR-Ziel (z.B. <c>dev</c>). Null = ausgecheckter Branch
/// (privat) bzw. Default-Branch des Remotes (Firma). ⚠️ SetConfigAsync kopiert verbatim.
/// </summary>
public string? BaseBranch { get; set; }
// Navigation property
public ListEntity List { get; set; } = null!;
}
@@ -175,6 +175,7 @@ public sealed class ListRepository
existing.PermissionMode = config.PermissionMode;
existing.TicketProjectId = config.TicketProjectId;
existing.PowerUseAllowed = config.PowerUseAllowed;
existing.BaseBranch = config.BaseBranch;
}
await _context.SaveChangesAsync(ct);
}
+3 -2
View File
@@ -108,11 +108,12 @@ public record UpdateListDto(string Id, string Name, string? WorkingDir, string D
// verbatim. Its scale: null = keep stored, <= 0 = clear the link, > 0 = set it.
// PowerUseAllowed is nullable for the same reason: null = keep stored, false = revoke the opt-in.
// A non-nullable bool here would let any caller that omits it silently revoke PowerUse for the list.
public record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null, string? VerifyCommand = null, string? PermissionMode = null, int? TicketProjectId = null, bool? PowerUseAllowed = null);
// BaseBranch: null = keep stored, "" = clear, value = set.
public record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null, string? VerifyCommand = null, string? PermissionMode = null, int? TicketProjectId = null, bool? PowerUseAllowed = null, string? BaseBranch = null);
public record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null, string? PermissionMode = null);
public record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null, string? VerifyCommand = null, string? PermissionMode = null, int? TicketProjectId = null, bool PowerUseAllowed = false);
public record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null, string? VerifyCommand = null, string? PermissionMode = null, int? TicketProjectId = null, bool PowerUseAllowed = false, string? BaseBranch = null);
public record SeedResultDto(int Copied, int Skipped);
@@ -472,6 +472,9 @@
"colorCustom": "Eigene",
"workingDirectory": "Arbeitsverzeichnis",
"workingDirectoryPlaceholder": "(keines)",
"baseBranch": "Basis-Branch",
"baseBranchPlaceholder": "(ausgecheckter Branch)",
"baseBranchHint": "Neue Task-Branches starten hier, und freigegebene Arbeit wird hierhin gemergt, z.B. dev. Leer = der gerade ausgecheckte Branch.",
"browse": "Durchsuchen...",
"defaultCommitType": "Standard-Commit-Typ",
"manualList": "Manuelle Liste (Erinnerungen)",
@@ -472,6 +472,9 @@
"colorCustom": "Custom",
"workingDirectory": "Working directory",
"workingDirectoryPlaceholder": "(none)",
"baseBranch": "Base branch",
"baseBranchPlaceholder": "(checked-out branch)",
"baseBranchHint": "New task branches start here and approved work merges back here, e.g. dev. Leave empty to use whatever branch is checked out.",
"browse": "Browse...",
"defaultCommitType": "Default commit type",
"manualList": "Manual list (reminders)",
@@ -208,13 +208,13 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
catch { }
}
// verifyCommand, ticketProjectId and powerUseAllowed are List-only fields owned by
// verifyCommand, ticketProjectId, powerUseAllowed and baseBranch are List-only fields owned by
// ListSettingsModalViewModel (not this editor, which is also reused for Task scope); the caller
// passes them through so the single UpdateListConfig call carries the full desired row instead
// of clobbering it. powerUseAllowed stays null-by-default for the same reason ticketProjectId
// does: a caller that doesn't own it must not revoke the list's opt-in by omission.
public async System.Threading.Tasks.Task SaveAsync(
string? verifyCommand = null, int? ticketProjectId = null, bool? powerUseAllowed = null)
string? verifyCommand = null, int? ticketProjectId = null, bool? powerUseAllowed = null, string? baseBranch = null)
{
if (TargetId is null) return;
var model = string.IsNullOrWhiteSpace(Model) ? null : Model;
@@ -227,7 +227,7 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
if (_scope == AgentConfigScope.Task)
await _worker.UpdateTaskAgentSettingsAsync(new UpdateTaskAgentSettingsDto(TargetId, model, sp, ap, turns, skills, permission));
else
await _worker.UpdateListConfigAsync(new UpdateListConfigDto(TargetId, model, sp, ap, turns, skills, verifyCommand, permission, ticketProjectId, powerUseAllowed));
await _worker.UpdateListConfigAsync(new UpdateListConfigDto(TargetId, model, sp, ap, turns, skills, verifyCommand, permission, ticketProjectId, powerUseAllowed, baseBranch));
}
private List<string>? SelectedSessionSkillNames()
@@ -667,7 +667,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
WorktreeBaseCommit = entity.Worktree?.BaseCommit ?? entity.HandlerBaseCommit;
WorktreeHeadCommit = entity.Worktree?.HeadCommit ?? entity.HandlerHeadCommit;
WorktreeStateLabel = entity.Worktree?.State.ToString();
BranchLine = entity.Worktree is { } w ? $"{w.BranchName} ← main" : null;
BranchLine = entity.Worktree?.BranchName;
var (add, del) = ParseDiffStat(entity.Worktree?.DiffStat);
DiffAdditions = add;
DiffDeletions = del;
@@ -930,7 +930,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
WorktreeBaseCommit = entity.Worktree?.BaseCommit ?? entity.HandlerBaseCommit;
WorktreeHeadCommit = entity.Worktree?.HeadCommit ?? entity.HandlerHeadCommit;
WorktreeStateLabel = entity.Worktree?.State.ToString();
BranchLine = entity.Worktree is { } w ? $"{w.BranchName} ← main" : null;
BranchLine = entity.Worktree?.BranchName;
if (Task is { } row && entity.Worktree?.DiffStat is { } stat)
row.DiffStat = stat;
var (add, del) = ParseDiffStat(entity.Worktree?.DiffStat);
@@ -1220,7 +1220,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
if (Task == null) return;
if (Dialogs == null) return;
var branchName = $"claudedo/{Task.Id.Replace("-", "")}";
var branchName = BranchLine ?? "the task branch";
var ok = await Dialogs.ConfirmAsync(
$"Reset and retry?\nThis discards branch {branchName} (and uncommitted changes), then queues the task to run from the beginning.");
if (!ok) return;
@@ -1382,7 +1382,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
private async System.Threading.Tasks.Task ResetReviewAsync()
{
if (Task is null || !_worker.IsConnected || Dialogs is null) return;
var branchName = $"claudedo/{Task.Id.Replace("-", "")}";
var branchName = BranchLine ?? "the task branch";
var ok = await Dialogs.ConfirmAsync(
$"Reset working tree?\nThis discards branch {branchName} (and all changes) and returns the task to Idle.");
if (!ok) return;
@@ -59,6 +59,8 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
// harvests new work, longer handoff chain) when this AND the global switch in Settings →
// Execution are both on. Off by default — the global switch alone must never change a list's behaviour.
[ObservableProperty] private bool _powerUseAllowed;
// Basis neuer Worktrees und Default-Merge-Ziel; leer = ausgecheckter Branch.
[ObservableProperty] private string _baseBranch = "";
// Dropdown hidden entirely when no ticket-system base URL is configured (Settings → Files tab).
// Icon key into the UI icon library (IconKeyConverter -> "Icon.<key>" StreamGeometry).
[ObservableProperty] private string _icon = DefaultIcon;
@@ -308,6 +310,7 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
cfg = Agent.LastLoadedListConfig;
VerifyCommand = cfg?.VerifyCommand ?? "";
PowerUseAllowed = cfg?.PowerUseAllowed ?? false;
BaseBranch = cfg?.BaseBranch ?? "";
}
catch (Exception ex)
{
@@ -411,7 +414,9 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
await Agent.SaveAsync(
string.IsNullOrWhiteSpace(VerifyCommand) ? null : VerifyCommand,
ticketProjectId,
PowerUseAllowed);
PowerUseAllowed,
// Always explicit too: "" clears the stored base branch.
BaseBranch.Trim());
Saved = true;
CloseAction?.Invoke();
@@ -153,6 +153,14 @@
Foreground="{DynamicResource StatusErrorBrush}" FontSize="12" TextWrapping="Wrap"/>
</StackPanel>
<StackPanel Spacing="4" IsVisible="{Binding !IsFolder}">
<TextBlock Classes="field-label" Text="{loc:Tr modals.listSettings.baseBranch}"/>
<TextBox Text="{Binding BaseBranch, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
PlaceholderText="{loc:Tr modals.listSettings.baseBranchPlaceholder}"/>
<TextBlock Text="{loc:Tr modals.listSettings.baseBranchHint}"
Opacity="0.6" FontSize="12" TextWrapping="Wrap"/>
</StackPanel>
<StackPanel Spacing="4" IsVisible="{Binding !IsFolder}">
<TextBlock Classes="field-label" Text="{loc:Tr modals.listSettings.defaultCommitType}"/>
<ComboBox ItemsSource="{Binding CommitTypeOptions}"
+9 -5
View File
@@ -55,7 +55,7 @@ subfolder within their area; the namespace stays the area namespace.
- **RunCancellationRegistry** — taskId → running-run CTS. Lets `TaskStateService.CancelAsync` kill a cancelled task's process without a DI cycle.
- **OverrideSlotService** — owns `RunNow` / `ContinueTask`; goes through `TaskStateService.StartRunningAsync` (caller-driven, serialized by slot lock).
- **StaleTaskRecovery** — startup-only; 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 multi-turn, planning internals, or app-settings writes). Auth via optional `X-ClaudeDo-Key`. **Task-ID resolution:** every tool parameter that accepts a task id (`taskId`, `parentId`, `taskIds`, etc.) is wired through `TaskIdResolver`, which accepts both `#123` (short number-based handle) and bare GUIDs. A bare all-digits string is never a GUID and is unambiguous. Task worktree branches and folders are named `{number}-{title-slug}` (`claudedo/{number}-{title-slug}`; stored in the `worktrees` row, never re-derived) — the `Number` is otherwise a display alias, never identity. Every task-returning tool stamps the task's `Number` in its DTO payload and the MCP tool descriptions carry a shared clause instructing the agent to **refer to tasks as `#<number>` when reporting to the user** (otherwise the number sits unused in the payload). **Hard convention (test-enforced):** every optional/filter parameter needs a C# default value — the MCP schema only marks a parameter optional when one exists; `ExternalMcpToolSchemaTests` guards this. **Registration trap:** the external server is a second `WebApplication` with its **own** DI container — a new tool class needs `externalBuilder.Services.AddScoped<T>()`, a `Share<Dep>()` for every main-container singleton it injects, *and* `.WithTools<T>()`; miss one and the tool fails only when called (no test builds that container). **Other conventions (not test-enforced):** no tool returns bare `Task`/a nullable payload directly (write tools return a confirmation record like `RunTaskNowResult`; read tools use an explicit `Found`/`Available` flag); tool descriptions follow rules documented in `External/McpToolDocs.cs`. Full tool inventory + per-tool behaviour → [external-mcp](../../docs/explore-notes/external-mcp.md).
- **External/*** — always-on MCP tools for general Claude sessions, scoped to *starting* and *observing* sessions (no multi-turn, planning internals, or app-settings writes). Auth via optional `X-ClaudeDo-Key`. **Task-ID resolution:** every tool parameter that accepts a task id (`taskId`, `parentId`, `taskIds`, etc.) is wired through `TaskIdResolver`, which accepts both `#123` (short number-based handle) and bare GUIDs. A bare all-digits string is never a GUID and is unambiguous. Task worktree folders are named `<nummer>-<slug>`, branches `<typ>/<nummer>-<slug>` (stored in the `worktrees` row, never re-derived; rules under WorktreeManager below) — the task `Number` is otherwise a display alias, never identity. Every task-returning tool stamps the task's `Number` in its DTO payload and the MCP tool descriptions carry a shared clause instructing the agent to **refer to tasks as `#<number>` when reporting to the user** (otherwise the number sits unused in the payload). **Hard convention (test-enforced):** every optional/filter parameter needs a C# default value — the MCP schema only marks a parameter optional when one exists; `ExternalMcpToolSchemaTests` guards this. **Registration trap:** the external server is a second `WebApplication` with its **own** DI container — a new tool class needs `externalBuilder.Services.AddScoped<T>()`, a `Share<Dep>()` for every main-container singleton it injects, *and* `.WithTools<T>()`; miss one and the tool fails only when called (no test builds that container). **Other conventions (not test-enforced):** no tool returns bare `Task`/a nullable payload directly (write tools return a confirmation record like `RunTaskNowResult`; read tools use an explicit `Found`/`Available` flag); tool descriptions follow rules documented in `External/McpToolDocs.cs`. Full tool inventory + per-tool behaviour → [external-mcp](../../docs/explore-notes/external-mcp.md).
## Status Model
@@ -130,7 +130,7 @@ Full flow, invariants, and model/effort/max-turns resolution (including the low-
- **ClaudeProcess** — spawns `claude -p --output-format stream-json --verbose --permission-mode auto` (or whatever app settings specify). Prompt via stdin, NDJSON from stdout. CancellationToken kills the process tree. Resolves `_cfg.ClaudeBin` via `ExecutableResolver` (`ClaudeDo.Data`, shared with `ClaudeCliPreflight` and the Installer's checks) before spawning — a `.cmd`/`.bat` shim (e.g. an npm-installed `claude`) is launched through `cmd.exe /c` since `UseShellExecute = false` can't exec a shim directly; a resolved `.exe` starts exactly as before. Throws if nothing resolves. *(Not yet on `main` — see `Environment Checks` in `ClaudeDo.Installer/CLAUDE.md`.)*
- **ClaudeArgsBuilder** — `--model`, `--effort`, `--max-turns`, `--append-system-prompt`, `--agents`, `--json-schema`, `--resume`
- **StreamAnalyzer** — parses NDJSON; extracts session_id, token counts, turn counts, result text, structured output. Replaced MessageParser.
- **WorktreeManager** — worktrees on `claudedo/{number}-{title-slug}` branches, same leaf as the folder (`NameFor`; the unique task number rules out collisions, slug capped at 40 chars); commits with semantic messages, updates DB with head commit + diff stats
- **WorktreeManager** — worktrees on `<typ>/<nummer>-<slug>` branches, same leaf as the folder (`NameFor`, slug capped at 40 chars, umlauts transliterated). Typ from `BranchTypeFor` (`feat`→`feature`, `fix/refactor/docs/test/chore` as-is, anything else `chore`). Nummer = ticket id (own `TicketRef` or the parent's), else the task number — in a company repo (`CompanyRepo`, origin matches `company_remote_pattern`) without a ticket no number at all. A taken folder/branch name gets an 8-char task-id suffix, so the self-heal never force-deletes a foreign branch. Base = parent head for children, else `list_config.base_branch`, else the checked-out HEAD. Commits with semantic messages, updates DB with head commit + diff stats
- **CommitMessageBuilder** — `{commitType}(slug): title\n\ndescription\n\nClaudeDo-Task: taskId`; `BuildMerge` is the merge-commit variant (`{commitType}(slug): merge title` + trailer). **Every merge caller passes a blank commit message on purpose** — `TaskMergeService` fills in `BuildMerge` from the task's commit type and its list's name, which is the only place that knows both. Don't reintroduce a caller-side literal.
- **TaskResetService** — discards a failed task's worktree, resets the row to Idle, preserves run history
- **AgentFileService** — manages `~/.claudeDo/agents/*.md`; list/refresh via SignalR
@@ -252,6 +252,7 @@ non-obvious, behaviour-changing. A fixed bug is git history, not a finding.
- `db_path`, `sandbox_root`, `log_root`
- `worktree_root_strategy` (`sibling` | `central`), `central_worktree_root`
- `queue_backstop_interval_ms` (30000) — also the gate/throttle recovery timer
- `company_remote_pattern` (`/_git/`) — Teilstring der origin-URL, an dem `CompanyRepo` ein Firmen-Repo erkennt; nur per Hand editierbar.
- `signalr_port` (47821), `claude_bin`, `ticket_api_base_url` — the two UI-writable fields
(`claude_bin`: Settings → Dateien → CLAUDE CLI, hub `GetClaudeBin`/`SetClaudeBin`;
`ticket_api_base_url`: Settings → Ticketsystem, hub `GetTicketSettings`/`SetTicketApiBaseUrl`).
@@ -269,8 +270,11 @@ Per-list config (`list_config` in DB) provides defaults for `model`, `system_pro
(resolution lives in `EffectiveRunConfigResolver`, so `TaskRunner` and
`get_effective_run_config` can't drift). `verify_command` is list-only — no task-level override —
and is written via `set_list_config`'s `verifyCommand` parameter (`ConfigMcpTools`) and the UI's
list config editor; `permission_mode` and `ticket_project_id` are UI-only (`set_list_config`
preserves but does not expose them).
list config editor; `permission_mode`, `ticket_project_id`, `base_branch` and `power_use_allowed`
are UI-only (`set_list_config` preserves but does not expose them; `get_list_config` returns
`baseBranch`). `base_branch` is the base of new worktrees and the default merge target —
`TaskMergeService.ResolveTargetAsync` resolves an empty target to it (else the checked-out branch),
centrally inside `MergeAsync` too.
## Tickets (Bandel ticket system)
@@ -320,6 +324,6 @@ distinguish the two singletons). The token never touches `worker.config.json` or
- The worker runs standalone — start it separately from the UI. Loopback only (127.0.0.1).
- `--permission-mode auto` by default; legacy `bypassPermissions` settings map to `auto` at dispatch time. `acceptEdits`, `plan`, `default` pass through unchanged.
- Task worktree branches follow `claudedo/{number}-{title-slug}`; planning worktrees stay `claudedo/planning/{guid}` (their path is re-derived from the id on resume).
- Task worktree branches follow `<typ>/<nummer>-<slug>` (see WorktreeManager); planning worktrees stay `claudedo/planning/{guid}` (their path is re-derived from the id on resume). `UntrackedWorktreeRecovery` therefore detects task worktrees by path (`.claudedo-worktrees` or the central root, minus `planning/`), not by branch prefix.
- **Both MCP servers run `Stateless = true`** (`Program.cs`). No `Mcp-Session-Id`, so a worker restart can't 404 a session that outlives it, and a 2026-07-28 client isn't refused back to the initialize handshake. The trade: **no** sampling, elicitation, resource subscriptions, unsolicited notifications, or legacy SSE endpoint — don't reach for those. In-tool progress notifications ride the POST's own response stream and work in both modes. All three consumers (`TaskRunner`, `PlanningSessionManager`, the installer's `RegisterMcpStep`) already use `type: "http"`.
- **An MCP tool that can run longer than ~5s reports progress.** Staying silent lets the MCP client abort after 300s idle while the worker keeps working — the caller sees an abort even though the operation is still running. `Lifecycle/ProgressReporter` is the one implementation (elapsed-time reporting via `RunAsync`, per-item `i/n` via `ReportItem`) — thread an `IProgress<ProgressNotificationValue>? progress = null` parameter through instead of writing another polling loop.
@@ -70,6 +70,10 @@ public sealed class WorkerConfig
[JsonPropertyName("ticket_api_base_url")]
public string? TicketApiBaseUrl { get; set; }
/// <summary>Teilstring der <c>origin</c>-URL, an dem ein Firmen-Repo erkannt wird. Nur per Hand editierbar.</summary>
[JsonPropertyName("company_remote_pattern")]
public string CompanyRemotePattern { get; set; } = "/_git/";
public static string DefaultConfigPath =>
Path.Combine(Paths.AppDataRoot(), "worker.config.json");
+8 -5
View File
@@ -10,7 +10,7 @@ using ModelContextProtocol.Server;
namespace ClaudeDo.Worker.External;
public sealed record TaskConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns);
public sealed record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns, string? VerifyCommand);
public sealed record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns, string? VerifyCommand, string? BaseBranch = null);
public sealed record TaskConfigResult(bool Found, TaskConfigDto? Config);
public sealed record ListConfigResult(bool Found, ListConfigDto? Config);
public sealed record SetListConfigResult(bool Ok, string ListId, ListConfigDto? Config);
@@ -55,7 +55,7 @@ public sealed class ConfigMcpTools
var cfg = await _lists.GetConfigAsync(listId, cancellationToken);
return cfg is null
? new ListConfigResult(false, null)
: new ListConfigResult(true, new ListConfigDto(cfg.Model, cfg.SystemPrompt, cfg.AgentPath, cfg.MaxTurns, cfg.VerifyCommand));
: new ListConfigResult(true, new ListConfigDto(cfg.Model, cfg.SystemPrompt, cfg.AgentPath, cfg.MaxTurns, cfg.VerifyCommand, cfg.BaseBranch));
}
// clearFields (not a per-field sentinel like UpdateList's "" for workingDir) because maxTurns is an
@@ -81,7 +81,7 @@ public sealed class ConfigMcpTools
"in this list that don't override them (verify command is list-only; tasks cannot override it). Only " +
"the fields you pass are changed; omitted fields keep their current value. To clear a field instead, " +
"name it in clearFields; clearing all five deletes the list's config unless it also carries settings " +
"this tool doesn't expose (session skills, file-scope serialization, permission mode), which are " +
"this tool doesn't expose (session skills, permission mode, ticket link, base branch, PowerUse opt-in), which are " +
"always preserved.")]
public async Task<SetListConfigResult> SetListConfig(
string listId, string? model = null, string? systemPrompt = null, string? agentPath = null,
@@ -110,7 +110,8 @@ public sealed class ConfigMcpTools
// UI-only).
var hasUnrelatedSettings = existing is not null
&& (existing.SessionSkills is not null
|| existing.PermissionMode is not null || existing.TicketProjectId is not null);
|| existing.PermissionMode is not null || existing.TicketProjectId is not null
|| existing.BaseBranch is not null || existing.PowerUseAllowed);
ListConfigDto? config;
var allCleared = m is null && sp is null && ap is null && mt is null && vc is null;
@@ -131,8 +132,10 @@ public sealed class ConfigMcpTools
VerifyCommand = vc, SessionSkills = existing?.SessionSkills,
PermissionMode = existing?.PermissionMode,
TicketProjectId = existing?.TicketProjectId,
BaseBranch = existing?.BaseBranch,
PowerUseAllowed = existing?.PowerUseAllowed ?? false,
}, cancellationToken);
config = allCleared ? null : new ListConfigDto(m, sp, ap, mt, vc);
config = allCleared ? null : new ListConfigDto(m, sp, ap, mt, vc, existing?.BaseBranch);
}
await _broadcaster.ListUpdated(listId);
+1 -3
View File
@@ -865,9 +865,7 @@ public sealed class MergeMcpTools
throw new InvalidOperationException(
"Merge preview unavailable for this task (worktree inactive or repo is not a git repository).");
var target = string.IsNullOrWhiteSpace(targetBranch)
? await _git.GetCurrentBranchAsync(list.WorkingDir, ct)
: targetBranch;
var target = await _merge.ResolveTargetAsync(list, targetBranch, ct);
var behind = await WorktreeMcpTools.GitRevListCountAsync(list.WorkingDir, $"{wt.BranchName}..{target}", ct);
var changedFiles = Directory.Exists(wt.Path)
+16
View File
@@ -0,0 +1,16 @@
using ClaudeDo.Data.Git;
namespace ClaudeDo.Worker.Git;
/// <summary>Erkennt Firmen-Repos am <c>origin</c>-Remote; das Muster kommt aus <c>WorkerConfig.CompanyRemotePattern</c>.</summary>
public static class CompanyRepo
{
/// <summary>True wenn <paramref name="remoteUrl"/> das (nicht-leere) Muster enthält, ohne Groß-/Kleinschreibung.</summary>
public static bool Matches(string? remoteUrl, string? pattern) =>
!string.IsNullOrWhiteSpace(remoteUrl) && !string.IsNullOrWhiteSpace(pattern)
&& remoteUrl.Contains(pattern, StringComparison.OrdinalIgnoreCase);
/// <summary>Liest <c>origin</c> live (kein Cache — ein Remote kann sich jederzeit ändern).</summary>
public static async Task<bool> IsCompanyAsync(GitService git, string workingDir, string? pattern, CancellationToken ct) =>
Matches(await git.GetRemoteUrlAsync(workingDir, "origin", ct), pattern);
}
+6 -2
View File
@@ -601,9 +601,12 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
// Gleiches Tri-State-Muster: null = gespeicherten Wert behalten, false = Opt-in widerrufen.
var powerUseAllowed = dto.PowerUseAllowed ?? existing?.PowerUseAllowed ?? false;
// Tri-State wie oben: null = behalten, "" = löschen.
var baseBranch = dto.BaseBranch is null ? existing?.BaseBranch : dto.BaseBranch.NullIfBlank();
// powerUseAllowed muss mit in die Leer-Prüfung: sonst löscht ein Save, bei dem PowerUse das
// EINZIGE gesetzte Feld ist, die Config-Zeile und damit das Opt-in gleich wieder mit.
if (model is null && systemPrompt is null && agentPath is null && dto.MaxTurns is null && sessionSkills is null && verifyCommand is null && permissionMode is null && ticketProjectId is null && !powerUseAllowed)
if (model is null && systemPrompt is null && agentPath is null && dto.MaxTurns is null && sessionSkills is null && verifyCommand is null && permissionMode is null && ticketProjectId is null && !powerUseAllowed && baseBranch is null)
{
await repo.DeleteConfigAsync(dto.ListId);
}
@@ -621,6 +624,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
PermissionMode = permissionMode,
TicketProjectId = ticketProjectId,
PowerUseAllowed = powerUseAllowed,
BaseBranch = baseBranch,
});
}
@@ -644,7 +648,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
var repo = new ListRepository(ctx);
var config = await repo.GetConfigAsync(listId);
if (config is null) return null;
return new ListConfigDto(config.Model, config.SystemPrompt, config.AgentPath, config.MaxTurns, SkillsFromJson(config.SessionSkills), config.VerifyCommand, config.PermissionMode, config.TicketProjectId, config.PowerUseAllowed);
return new ListConfigDto(config.Model, config.SystemPrompt, config.AgentPath, config.MaxTurns, SkillsFromJson(config.SessionSkills), config.VerifyCommand, config.PermissionMode, config.TicketProjectId, config.PowerUseAllowed, config.BaseBranch);
}
public async Task<SetTaskStatusResultDto> SetTaskStatus(string taskId, string status)
@@ -411,6 +411,7 @@ public sealed class TaskMergeService
return new MergeResult(StatusDirtyTarget, Array.Empty<string>(),
"target working tree has uncommitted changes");
targetBranch = await ResolveTargetAsync(list, targetBranch, ct);
var currentBranch = await _git.GetCurrentBranchAsync(list.WorkingDir, ct);
if (!string.Equals(currentBranch, targetBranch, StringComparison.Ordinal))
{
@@ -874,12 +875,26 @@ public sealed class TaskMergeService
if (string.IsNullOrWhiteSpace(list.WorkingDir))
return new MergeTargets("", Array.Empty<string>(), defaultMessage);
var current = await _git.GetCurrentBranchAsync(list.WorkingDir, ct);
var current = await ResolveTargetAsync(list, null, ct);
var branches = await _git.ListLocalBranchesAsync(list.WorkingDir, ct);
var dirty = await _git.HasChangesAsync(list.WorkingDir, includeUntracked: false, ct);
return new MergeTargets(current, branches, defaultMessage, dirty);
}
/// <summary>Leeres Ziel → <c>list_config.base_branch</c>, sonst der ausgecheckte Branch des Main-Checkouts.</summary>
public async Task<string> ResolveTargetAsync(ListEntity list, string? targetBranch, CancellationToken ct)
{
if (!string.IsNullOrWhiteSpace(targetBranch)) return targetBranch;
using var ctx = _dbFactory.CreateDbContext();
var baseBranch = await ctx.ListConfigs.AsNoTracking()
.Where(c => c.ListId == list.Id)
.Select(c => c.BaseBranch)
.FirstOrDefaultAsync(ct);
return string.IsNullOrWhiteSpace(baseBranch)
? await _git.GetCurrentBranchAsync(list.WorkingDir!, ct)
: baseBranch;
}
/// The commit message a merge uses when the caller passes none. Built here rather than in the
/// UI/MCP callers because only this layer knows the task's commit type and its list's name.
private static string DefaultMergeMessage(TaskEntity task, ListEntity list) =>
@@ -907,9 +922,7 @@ public sealed class TaskMergeService
if (string.IsNullOrWhiteSpace(list.WorkingDir) || !await _git.IsGitRepoAsync(list.WorkingDir, ct))
return new MergePreviewResult(PreviewUnavailable, Array.Empty<string>(), 0);
var target = string.IsNullOrWhiteSpace(targetBranch)
? await _git.GetCurrentBranchAsync(list.WorkingDir, ct)
: targetBranch;
var target = await ResolveTargetAsync(list, targetBranch, ct);
// merge-tree (below) is a pure object-level check — it has no idea the working directory
// has an untracked file that would block (or, worse, silently be lost by) the real merge.
@@ -1013,9 +1026,7 @@ public sealed class TaskMergeService
return Blocked(
$"worktree was reverted and its branch '{wt.BranchName}' no longer exists — cannot re-approve; discard the task or start a fresh worktree instead.");
var reviveTarget = string.IsNullOrWhiteSpace(targetBranch)
? await _git.GetCurrentBranchAsync(list.WorkingDir, ct)
: targetBranch;
var reviveTarget = await ResolveTargetAsync(list, targetBranch, ct);
// A branch that was already merged once (reverted or not) is already an ancestor of
// the target tip -- `git merge` on it again is a silent "Already up to date." no-op,
@@ -1074,9 +1085,7 @@ public sealed class TaskMergeService
if (string.IsNullOrWhiteSpace(list.WorkingDir))
return Blocked("list has no working directory");
var target = string.IsNullOrWhiteSpace(targetBranch)
? await _git.GetCurrentBranchAsync(list.WorkingDir, ct)
: targetBranch;
var target = await ResolveTargetAsync(list, targetBranch, ct);
// MergeAsync transitions the task WaitingForReview -> Done on a successful merge.
// Remove the worktree on approve (matching the unit-merge path) so merged
@@ -1,36 +1,38 @@
using ClaudeDo.Data;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Config;
using ClaudeDo.Worker.Hub;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Worker.Lifecycle;
/// <summary>
/// Startup-only sweep: warns when a list's repo has git-registered <c>claudedo/*</c> worktrees
/// that the <c>worktrees</c> table doesn't know about (e.g. left behind by a manual cleanup
/// Startup-only sweep: warns when a list's repo has git-registered ClaudeDo task worktrees (under
/// <c>.claudedo-worktrees</c> or the central root, planning sessions excluded) that the <c>worktrees</c> table doesn't know about (e.g. left behind by a manual cleanup
/// outside the app). Detection only — nothing is removed or pruned.
/// </summary>
public sealed class UntrackedWorktreeRecovery : IHostedService
{
public const string Phase = "untracked-worktrees";
private const string BranchPrefix = "claudedo/";
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly GitService _git;
private readonly HubBroadcaster _broadcaster;
private readonly WorkerConfig _cfg;
private readonly ILogger<UntrackedWorktreeRecovery> _logger;
public UntrackedWorktreeRecovery(
IDbContextFactory<ClaudeDoDbContext> dbFactory,
GitService git,
HubBroadcaster broadcaster,
WorkerConfig cfg,
ILogger<UntrackedWorktreeRecovery> logger)
{
_dbFactory = dbFactory;
_git = git;
_broadcaster = broadcaster;
_cfg = cfg;
_logger = logger;
}
@@ -41,6 +43,10 @@ public sealed class UntrackedWorktreeRecovery : IHostedService
var dbPaths = (await new WorktreeRepository(ctx).GetAllAsync(cancellationToken))
.Select(w => w.Path)
.ToList();
var settings = await new AppSettingsRepository(ctx).GetAsync(cancellationToken);
var centralRoot = !string.IsNullOrWhiteSpace(settings.CentralWorktreeRoot)
? settings.CentralWorktreeRoot
: _cfg.CentralWorktreeRoot;
var foundAny = false;
foreach (var list in lists.Where(l => !string.IsNullOrWhiteSpace(l.WorkingDir)))
@@ -57,7 +63,7 @@ public sealed class UntrackedWorktreeRecovery : IHostedService
}
var claudedoPaths = registered
.Where(w => w.Branch is not null && w.Branch.StartsWith(BranchPrefix, StringComparison.Ordinal))
.Where(w => w.Branch is not null && IsTaskWorktreePath(w.Path, centralRoot))
.Select(w => w.Path);
var untracked = FindUntrackedPaths(claudedoPaths, dbPaths);
@@ -84,6 +90,26 @@ public sealed class UntrackedWorktreeRecovery : IHostedService
return registeredPaths.Where(p => !known.Contains(Normalize(p))).ToList();
}
/// <summary>True für einen Pfad unter <c>.claudedo-worktrees</c> oder dem Central-Root, außer Planning-Session-Worktrees.</summary>
public static bool IsTaskWorktreePath(string path, string centralRoot)
{
var sep = Path.DirectorySeparatorChar;
var full = Normalize(path);
string? rest = null;
var central = Normalize(centralRoot);
if (full.StartsWith(central + sep, StringComparison.OrdinalIgnoreCase))
rest = full[(central.Length + 1)..];
else
{
var marker = $"{sep}.claudedo-worktrees{sep}";
var idx = full.IndexOf(marker, StringComparison.OrdinalIgnoreCase);
if (idx >= 0) rest = full[(idx + marker.Length)..];
}
return rest is not null && !rest.StartsWith($"planning{sep}", StringComparison.OrdinalIgnoreCase);
}
private static string Normalize(string path) =>
Path.GetFullPath(path).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
}
@@ -133,15 +133,29 @@ public sealed class PlanningAggregator
var current = await _git.GetCurrentBranchAsync(repoDir, ct);
if (string.Equals(current, branch, StringComparison.Ordinal))
{
var branches = await _git.ListLocalBranchesAsync(repoDir, ct);
var target = branches.FirstOrDefault(b => b != branch && !b.StartsWith("claudedo/", StringComparison.Ordinal)) ?? "main";
await _git.CheckoutBranchAsync(repoDir, target, ct);
await _git.CheckoutBranchAsync(repoDir, await ResolveReturnBranchAsync(planning, repoDir, ct), ct);
}
try { await _git.BranchDeleteAsync(repoDir, branch, force: true, ct); }
catch { /* already gone — idempotent */ }
}
/// <summary>
/// Rücksprung vom Integration-Branch: <c>list_config.base_branch</c>, sonst <c>main</c> (bzw. <c>master</c>).
/// Nie aus der Branchliste raten — die enthält auch Task-Branches, die in Worktrees ausgecheckt sind.
/// </summary>
private async Task<string> ResolveReturnBranchAsync(TaskEntity planning, string repoDir, CancellationToken ct)
{
using var ctx = _dbFactory.CreateDbContext();
var baseBranch = await ctx.ListConfigs.AsNoTracking()
.Where(c => c.ListId == planning.ListId)
.Select(c => c.BaseBranch)
.FirstOrDefaultAsync(ct);
if (!string.IsNullOrWhiteSpace(baseBranch)) return baseBranch;
var branches = await _git.ListLocalBranchesAsync(repoDir, ct);
return !branches.Contains("main") && branches.Contains("master") ? "master" : "main";
}
private async Task<(TaskEntity planning, string repoDir, IReadOnlyList<TaskEntity> children)>
LoadPlanningContextAsync(string planningTaskId, CancellationToken ct)
{
@@ -48,7 +48,9 @@ public static class CommitMessageBuilder
public static string ToSlug(string name)
{
var lower = name.ToLowerInvariant();
// Umlaute auflösen statt strippen: "Löschen" -> "loeschen", nicht "lschen".
var lower = name.ToLowerInvariant()
.Replace("ä", "ae").Replace("ö", "oe").Replace("ü", "ue").Replace("ß", "ss");
// Replace whitespace runs with a single dash.
var dashed = Regex.Replace(lower, @"\s+", "-");
// Remove all non-alphanumeric-and-dash characters.
+50 -10
View File
@@ -5,6 +5,7 @@ using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Config;
using ClaudeDo.Worker.Git;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Tickets;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Worker.Runner;
@@ -38,12 +39,29 @@ public sealed class WorktreeManager
private Task BroadcastCreating(string taskId) =>
_broadcaster?.OperationProgress(taskId, PhaseCreatingWorktree, 0, 0) ?? Task.CompletedTask;
/// <summary>Branch leaf and folder name for a task worktree: <c>{number}-{title-slug}</c>, unique via the task number.</summary>
public static string NameFor(TaskEntity task)
/// <summary>
/// Branch leaf and folder name: <c>{nummer}-{slug}</c>. Nummer = Ticket-Id, sonst die Task-Nummer —
/// außer im Firmen-Repo ohne Ticket, dort sähe sie wie eine Ticket-Id aus und entfällt.
/// </summary>
public static string NameFor(TaskEntity task, int? ticketId, bool isCompanyRepo)
{
var titleSlug = CommitMessageBuilder.ToSlug(task.Title);
if (titleSlug.Length > 40) titleSlug = titleSlug[..40].TrimEnd('-');
return titleSlug.Length == 0 ? task.Number.ToString() : $"{task.Number}-{titleSlug}";
var number = ticketId?.ToString() ?? (isCompanyRepo ? null : task.Number.ToString());
if (titleSlug.Length == 0) return number ?? task.Number.ToString();
return number is null ? titleSlug : $"{number}-{titleSlug}";
}
/// <summary>Branch-Präfix nach work-conventions: <c>feat</c> → <c>feature</c>, Typen ohne eigenes Präfix → <c>chore</c>.</summary>
public static string BranchTypeFor(string? commitType)
{
var t = (commitType ?? "").Trim().ToLowerInvariant();
return t switch
{
"feat" => "feature",
"fix" or "refactor" or "docs" or "test" or "chore" => t,
_ => "chore",
};
}
public async Task<WorktreeContext> CreateAsync(TaskEntity task, ListEntity list, CancellationToken ct)
@@ -59,7 +77,9 @@ public sealed class WorktreeManager
_logger.LogInformation("Repository at {Dir} had no commits; created an empty bootstrap commit", workingDir);
var baseCommit = await ResolveBaseCommitAsync(task, workingDir, ct);
var name = NameFor(task);
var isCompany = await CompanyRepo.IsCompanyAsync(_git, workingDir, _cfg.CompanyRemotePattern, ct);
var name = NameFor(task, await ResolveTicketIdAsync(task, ct), isCompany);
var branchType = BranchTypeFor(task.CommitType);
var slug = CommitMessageBuilder.ToSlug(list.Name);
string strategy;
@@ -77,16 +97,16 @@ public sealed class WorktreeManager
? Path.Combine(centralRoot ?? _cfg.CentralWorktreeRoot, slug)
: Path.Combine(WorktreeRootResolver.ResolveSiblingRoot(workingDir), ".claudedo-worktrees", slug);
// Numbers restart after a DB reset: a taken name may be another task's unmerged branch,
// which the self-heal below would force-delete — the id suffix keeps it untouched.
// Numbers restart after a DB reset, and without the claudedo/ prefix a taken name may also be
// the user's own branch — the self-heal below would force-delete it, the id suffix keeps it untouched.
if (Directory.Exists(Path.Combine(parentDir, name))
|| (await _git.ListLocalBranchesAsync(workingDir, ct)).Contains($"claudedo/{name}"))
|| (await _git.ListLocalBranchesAsync(workingDir, ct)).Contains($"{branchType}/{name}"))
{
var id = task.Id.Replace("-", "");
name = $"{name}-{id[..Math.Min(8, id.Length)]}";
}
var branchName = $"claudedo/{name}";
var branchName = $"{branchType}/{name}";
var worktreePath = Path.GetFullPath(Path.Combine(parentDir, name));
WorktreeRootResolver.EnsureOutsideWorkingDir(worktreePath, workingDir);
@@ -223,9 +243,22 @@ public sealed class WorktreeManager
_logger.LogInformation("Discarded worktree for task {TaskId} (branch {Branch})", wt.TaskId, wt.BranchName);
}
/// <summary>Ticket-Id des Tasks, sonst die seines Parents (Children erben das Ticket ihrer Unit).</summary>
private async Task<int?> ResolveTicketIdAsync(TaskEntity task, CancellationToken ct)
{
var own = TicketStatusMap.ParseBandelTicketId(task.TicketRef);
if (own is not null || task.ParentTaskId is null) return own;
using var ctx = _dbFactory.CreateDbContext();
var parentRef = await ctx.Tasks.AsNoTracking()
.Where(t => t.Id == task.ParentTaskId)
.Select(t => t.TicketRef)
.FirstOrDefaultAsync(ct);
return TicketStatusMap.ParseBandelTicketId(parentRef);
}
// Improvement children (parent is a non-planning task with its own worktree) branch
// from the parent's recorded HEAD so they build on the parent's not-yet-merged work.
// Planning children and standalone tasks base off the list's current HEAD.
// Planning children and standalone tasks base off list_config.base_branch, else the current HEAD.
private async Task<string> ResolveBaseCommitAsync(TaskEntity task, string workingDir, CancellationToken ct)
{
if (task.ParentTaskId is not null)
@@ -241,6 +274,13 @@ public sealed class WorktreeManager
return parentHead;
}
}
return await _git.RevParseHeadAsync(workingDir, ct);
using var cfgCtx = _dbFactory.CreateDbContext();
var baseBranch = await cfgCtx.ListConfigs.AsNoTracking()
.Where(c => c.ListId == task.ListId)
.Select(c => c.BaseBranch)
.FirstOrDefaultAsync(ct);
return string.IsNullOrWhiteSpace(baseBranch)
? await _git.RevParseHeadAsync(workingDir, ct)
: await _git.RevParseAsync(workingDir, baseBranch, ct);
}
}
@@ -228,6 +228,23 @@ public class ListSettingsModalViewModelTests
Assert.Equal(7, worker.SavedListConfig!.TicketProjectId);
}
[Fact]
public async Task Save_sends_BaseBranch_explicitly()
{
var worker = new CountingWorker { Cfg = new ListConfigDto("opus", null, null, BaseBranch: "dev") };
var vm = new ListSettingsModalViewModel(worker, NewFactory());
await vm.LoadAsync("l1", "List One", "/repo", "feat");
Assert.Equal("dev", vm.BaseBranch);
await vm.SaveCommand.ExecuteAsync(null);
Assert.Equal("dev", worker.SavedListConfig!.BaseBranch);
// "" (not null) so clearing the field really clears the stored branch.
vm.BaseBranch = " ";
await vm.SaveCommand.ExecuteAsync(null);
Assert.Equal("", worker.SavedListConfig!.BaseBranch);
}
[Fact]
public async Task LoadAsync_CoreLoadThrows_BlocksSaveAndDoesNotOverwriteConfig()
{
@@ -0,0 +1,34 @@
using ClaudeDo.Data.Git;
using ClaudeDo.Worker.Git;
using ClaudeDo.Worker.Tests.Infrastructure;
namespace ClaudeDo.Worker.Tests.Git;
public sealed class CompanyRepoTests : IDisposable
{
private readonly GitRepoFixture _repo = new();
public void Dispose() => _repo.Dispose();
[Theory]
[InlineData("https://srv.local/Coll/Proj/_git/Repo", "/_git/", true)]
[InlineData("https://mika@srv.local/tfs/Coll/Proj/_git/Mein%20Repo.git", "/_git/", true)]
[InlineData("ssh://git@git.kuns.dev:22222/kuns/ClaudeDo.git", "/_git/", false)]
[InlineData(null, "/_git/", false)]
[InlineData("https://srv.local/Coll/Proj/_git/Repo", "", false)]
[InlineData("https://GITHUB.com/acme/repo.git", "github.com/acme", true)]
public void Matches(string? url, string pattern, bool expected)
=> Assert.Equal(expected, CompanyRepo.Matches(url, pattern));
[Fact]
public async Task IsCompanyAsync_reads_origin()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var git = new GitService();
Assert.False(await CompanyRepo.IsCompanyAsync(git, _repo.RepoDir, "/_git/", CancellationToken.None));
GitRepoFixture.RunGit(_repo.RepoDir, "remote", "add", "origin", "https://srv.local/Coll/Proj/_git/Repo");
Assert.True(await CompanyRepo.IsCompanyAsync(git, _repo.RepoDir, "/_git/", CancellationToken.None));
Assert.Equal("https://srv.local/Coll/Proj/_git/Repo", await git.GetRemoteUrlAsync(_repo.RepoDir));
}
}
@@ -0,0 +1,100 @@
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.External;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Tests.Infrastructure;
using Xunit;
namespace ClaudeDo.Worker.Tests.Hub;
/// BaseBranch und PowerUseAllowed sitzen in der SetConfigAsync-kopiert-verbatim-Falle: jeder Writer,
/// der sie nicht durchreicht, setzt sie still zurück.
public sealed class ListConfigBaseBranchTests : IDisposable
{
private readonly DbFixture _db = new();
public void Dispose() => _db.Dispose();
private WorkerHub CreateHub()
{
var factory = _db.CreateFactory();
var broadcaster = new HubBroadcaster(new CapturingHubContext());
var hub = new WorkerHub(
null!, null!, null!, null!, broadcaster, factory,
null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!,
null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.OnlineRefreshTokenStore(new ClaudeDo.Worker.DpapiTokenStore("unused.token")),
new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), null!);
hub.Clients = new FakeHubCallerClients(new RecordingClientProxy());
hub.Context = new FakeHubCallerContext();
return hub;
}
private async Task<string> SeedListAsync(string? baseBranch, bool powerUse = false)
{
var listId = Guid.NewGuid().ToString();
await using var ctx = _db.CreateContext();
var repo = new ListRepository(ctx);
await repo.AddAsync(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow });
await repo.SetConfigAsync(new ListConfigEntity { ListId = listId, BaseBranch = baseBranch, PowerUseAllowed = powerUse });
return listId;
}
private async Task<ListConfigEntity?> ReadConfigAsync(string listId)
{
await using var ctx = _db.CreateContext();
return await new ListRepository(ctx).GetConfigAsync(listId);
}
private ConfigMcpTools CreateTool(ClaudeDoDbContext ctx) =>
new(new ListRepository(ctx), new TaskRepository(ctx),
new HubBroadcaster(new CapturingHubContext()), _db.CreateFactory());
[Fact]
public async Task set_list_config_preserves_BaseBranch_and_PowerUseAllowed()
{
var listId = await SeedListAsync("dev", powerUse: true);
await using var ctx = _db.CreateContext();
await CreateTool(ctx).SetListConfig(listId, model: "opus", cancellationToken: CancellationToken.None);
var cfg = await ReadConfigAsync(listId);
Assert.Equal("dev", cfg!.BaseBranch);
Assert.True(cfg.PowerUseAllowed);
}
[Fact]
public async Task get_list_config_returns_BaseBranch()
{
var listId = await SeedListAsync("dev");
await using var ctx = _db.CreateContext();
var result = await CreateTool(ctx).GetListConfig(listId, CancellationToken.None);
Assert.Equal("dev", result.Config!.BaseBranch);
}
[Fact]
public async Task UpdateListConfig_null_keeps_BaseBranch()
{
var listId = await SeedListAsync("dev");
await CreateHub().UpdateListConfig(new UpdateListConfigDto(listId, "opus", null, null));
Assert.Equal("dev", (await ReadConfigAsync(listId))!.BaseBranch);
}
[Fact]
public async Task UpdateListConfig_empty_clears_BaseBranch()
{
var listId = await SeedListAsync("dev");
await CreateHub().UpdateListConfig(new UpdateListConfigDto(listId, "opus", null, null, BaseBranch: ""));
Assert.Null((await ReadConfigAsync(listId))!.BaseBranch);
}
[Fact]
public async Task UpdateListConfig_sets_BaseBranch_and_GetListConfig_returns_it()
{
var listId = await SeedListAsync(null);
var hub = CreateHub();
await hub.UpdateListConfig(new UpdateListConfigDto(listId, null, null, null, BaseBranch: "dev"));
Assert.Equal("dev", (await hub.GetListConfig(listId))!.BaseBranch);
}
}
@@ -54,6 +54,18 @@ public sealed class UntrackedWorktreeRecoveryTests : IDisposable
Assert.Equal(registered, untracked);
}
[Theory]
[InlineData(@"C:\src\.claudedo-worktrees\lager-app\412-fix", @"C:\Users\x\.claudeDo\worktrees", true)]
[InlineData(@"C:\src\.claudedo-worktrees\planning\abc123", @"C:\Users\x\.claudeDo\worktrees", false)]
[InlineData(@"C:\Users\x\.claudeDo\worktrees\lager-app\412-fix", @"C:\Users\x\.claudeDo\worktrees", true)]
[InlineData(@"C:\Users\x\.claudeDo\worktrees\planning\abc123", @"C:\Users\x\.claudeDo\worktrees", false)]
[InlineData(@"C:\src\my-own-worktree", @"C:\Users\x\.claudeDo\worktrees", false)]
public void IsTaskWorktreePath(string path, string centralRoot, bool expected)
{
if (!OperatingSystem.IsWindows()) return;
Assert.Equal(expected, UntrackedWorktreeRecovery.IsTaskWorktreePath(path, centralRoot));
}
[Fact]
public async Task StartAsync_NoLists_DoesNotThrow()
{
@@ -62,6 +74,7 @@ public sealed class UntrackedWorktreeRecoveryTests : IDisposable
_db.CreateFactory(),
new ClaudeDo.Data.Git.GitService(),
new HubBroadcaster(hubContext),
new ClaudeDo.Worker.Config.WorkerConfig(),
NullLogger<UntrackedWorktreeRecovery>.Instance);
await sut.StartAsync(CancellationToken.None);
@@ -214,6 +214,39 @@ public class PlanningAggregatorTests : IDisposable
Assert.DoesNotContain(branches, b => b == ok.Value.IntegrationBranch);
}
[Theory]
[InlineData(null, "main")]
[InlineData("dev", "dev")]
public async Task CleanupIntegrationBranchAsync_ReturnsToBaseBranchOrMain_NotATaskBranch(string? baseBranch, string expected)
{
var db = NewDb();
var repo = NewRepo();
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
GitRepoFixture.RunGit(repo.RepoDir, "branch", "dev");
var (parentId, _, _) = await SeedPlanningWithTwoChildrenAsync(db, repo);
// Sorts before main and is checked out in a worktree — the old guess picked it and the checkout threw.
var taskWt = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
_wtCleanups.Add((repo.RepoDir, taskWt));
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", "chore/1-task", taskWt, repo.BaseCommit);
if (baseBranch is not null)
{
using var ctx = db.CreateContext();
var listId = (await new TaskRepository(ctx).GetByIdAsync(parentId))!.ListId;
await new ListRepository(ctx).SetConfigAsync(new ListConfigEntity { ListId = listId, BaseBranch = baseBranch });
}
var git = new GitService();
var svc = new PlanningAggregator(db.CreateFactory(), git, NullLogger<PlanningAggregator>.Instance);
var built = Assert.IsType<CombinedDiffResult.Ok>(await svc.BuildIntegrationBranchAsync(parentId, "main", CancellationToken.None));
Assert.Equal(built.Value.IntegrationBranch, await git.GetCurrentBranchAsync(repo.RepoDir));
await svc.CleanupIntegrationBranchAsync(parentId, CancellationToken.None);
Assert.Equal(expected, await git.GetCurrentBranchAsync(repo.RepoDir));
}
private void SeedWorktreeWithFile(ClaudeDoDbContext ctx, GitRepoFixture repo, string taskId, string filename, string content)
{
var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
@@ -28,6 +28,12 @@ public class CommitMessageBuilderTests
Assert.Equal("abc", CommitMessageBuilder.ToSlug("--abc--"));
}
[Fact]
public void ToSlug_transliterates_umlauts()
{
Assert.Equal("aerger-ueber-groesse-strasse", CommitMessageBuilder.ToSlug("Ärger über Größe Straße"));
}
[Fact]
public void Title_TruncatedTo60()
{
@@ -66,7 +66,7 @@ public class WorktreeManagerTests : IDisposable
Assert.NotNull(ctx);
Assert.True(Directory.Exists(ctx.WorktreePath));
// StartsWith: a folder leaked into the shared %TEMP% root by another test forces the id suffix.
Assert.StartsWith($"claudedo/{task.Number}-test-task", ctx.BranchName);
Assert.StartsWith($"chore/{task.Number}-test-task", ctx.BranchName);
Assert.StartsWith($"{task.Number}-test-task", Path.GetFileName(ctx.WorktreePath));
Assert.Equal(repo.BaseCommit, ctx.BaseCommit);
@@ -80,14 +80,33 @@ public class WorktreeManagerTests : IDisposable
}
[Theory]
[InlineData("Fix: Löschen des Tasks!", "7-fix-lschen-des-tasks")]
[InlineData("äöü", "7")]
[InlineData("a very long title that goes well beyond the forty char cap", "7-a-very-long-title-that-goes-well-beyond")]
public void NameFor_NumberPlusCappedTitleSlug(string title, string expected)
[InlineData("Fix: Löschen des Tasks!", null, false, "7-fix-loeschen-des-tasks")]
[InlineData("äöü", null, false, "7-aeoeue")]
[InlineData("a very long title that goes well beyond the forty char cap", null, false, "7-a-very-long-title-that-goes-well-beyond")]
[InlineData("test task", 412, false, "412-test-task")]
[InlineData("test task", 412, true, "412-test-task")]
[InlineData("test task", null, true, "test-task")]
[InlineData("!!!", null, true, "7")]
public void NameFor_NumberRules(string title, int? ticketId, bool isCompany, string expected)
{
Assert.Equal(expected, WorktreeManager.NameFor(new TaskEntity { Id = "x", ListId = "y", Title = title, Number = 7, CreatedAt = DateTime.UtcNow }));
var task = new TaskEntity { Id = "x", ListId = "y", Title = title, Number = 7, CreatedAt = DateTime.UtcNow };
Assert.Equal(expected, WorktreeManager.NameFor(task, ticketId, isCompany));
}
[Theory]
[InlineData("feat", "feature")]
[InlineData("fix", "fix")]
[InlineData("refactor", "refactor")]
[InlineData("docs", "docs")]
[InlineData("test", "test")]
[InlineData("chore", "chore")]
[InlineData("ci", "chore")]
[InlineData("perf", "chore")]
[InlineData(" FEAT ", "feature")]
[InlineData("", "chore")]
public void BranchTypeFor_maps_commit_types(string commitType, string expected)
=> Assert.Equal(expected, WorktreeManager.BranchTypeFor(commitType));
[Fact]
public async Task CreateAsync_BroadcastsCreatingWorktreePhase()
{
@@ -128,14 +147,14 @@ public class WorktreeManagerTests : IDisposable
var (mgr, _) = await CreateManagerAsync(task, list);
// A leftover from an earlier DB whose task happened to get the same number and title.
var foreign = $"claudedo/{WorktreeManager.NameFor(task)}";
var foreign = $"chore/{WorktreeManager.NameFor(task, null, false)}";
GitRepoFixture.RunGit(repo.RepoDir, "branch", foreign, repo.BaseCommit);
var ctx = await mgr.CreateAsync(task, list, CancellationToken.None);
_worktreeCleanups.Add((repo.RepoDir, ctx.WorktreePath));
var suffixed = $"{WorktreeManager.NameFor(task)}-{task.Id.Replace("-", "")[..8]}";
Assert.Equal($"claudedo/{suffixed}", ctx.BranchName);
var suffixed = $"{WorktreeManager.NameFor(task, null, false)}-{task.Id.Replace("-", "")[..8]}";
Assert.Equal($"chore/{suffixed}", ctx.BranchName);
Assert.Equal(suffixed, Path.GetFileName(ctx.WorktreePath));
Assert.False(string.IsNullOrWhiteSpace(GitRepoFixture.RunGit(repo.RepoDir, "branch", "--list", foreign)));
}
@@ -157,7 +176,7 @@ public class WorktreeManagerTests : IDisposable
// Occupy the plain name (-> id-suffixed fallback) and the fallback too, so
// `git worktree add -b` hits the "already exists" self-heal path.
var branchName = $"claudedo/{WorktreeManager.NameFor(task)}";
var branchName = $"chore/{WorktreeManager.NameFor(task, null, false)}";
GitRepoFixture.RunGit(repo.RepoDir, "branch", branchName, repo.BaseCommit);
GitRepoFixture.RunGit(repo.RepoDir, "branch", $"{branchName}-{task.Id.Replace("-", "")[..8]}", repo.BaseCommit);
@@ -311,13 +330,13 @@ public class WorktreeManagerTests : IDisposable
await Assert.ThrowsAnyAsync<Exception>(
() => mgr.CreateAsync(task, list, CancellationToken.None));
var branchName = $"claudedo/{WorktreeManager.NameFor(task)}";
var branchName = $"chore/{WorktreeManager.NameFor(task, null, false)}";
var branchList = GitRepoFixture.RunGit(repo.RepoDir, "branch", "--list", branchName);
Assert.True(string.IsNullOrWhiteSpace(branchList),
$"orphaned branch {branchName} should be cleaned up, got: {branchList}");
var worktreeList = GitRepoFixture.RunGit(repo.RepoDir, "worktree", "list");
Assert.DoesNotContain(WorktreeManager.NameFor(task), worktreeList);
Assert.DoesNotContain(WorktreeManager.NameFor(task, null, false), worktreeList);
}
[Fact]
@@ -340,6 +359,96 @@ public class WorktreeManagerTests : IDisposable
Assert.True(Directory.Exists(ctx.WorktreePath));
}
[Fact]
public async Task CreateAsync_CompanyRepoWithTicket_UsesTicketIdAndFeaturePrefix()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = CreateRepo();
GitRepoFixture.RunGit(repo.RepoDir, "remote", "add", "origin", "https://srv.local/Coll/Proj/_git/Repo");
var (task, list) = MakeEntities(repo.RepoDir);
task.CommitType = "feat";
task.TicketRef = "bandel:412";
var (mgr, _) = await CreateManagerAsync(task, list);
var ctx = await mgr.CreateAsync(task, list, CancellationToken.None);
_worktreeCleanups.Add((repo.RepoDir, ctx.WorktreePath));
Assert.StartsWith("feature/412-test-task", ctx.BranchName);
}
[Fact]
public async Task CreateAsync_CompanyRepoWithoutTicket_OmitsNumber()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = CreateRepo();
GitRepoFixture.RunGit(repo.RepoDir, "remote", "add", "origin", "https://srv.local/Coll/Proj/_git/Repo");
var (task, list) = MakeEntities(repo.RepoDir);
var (mgr, _) = await CreateManagerAsync(task, list);
var ctx = await mgr.CreateAsync(task, list, CancellationToken.None);
_worktreeCleanups.Add((repo.RepoDir, ctx.WorktreePath));
Assert.StartsWith("chore/test-task", ctx.BranchName);
Assert.DoesNotContain($"/{task.Number}-", ctx.BranchName);
}
[Fact]
public async Task CreateAsync_ChildWithoutTicket_UsesParentTicket()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = CreateRepo();
var (child, list) = MakeEntities(repo.RepoDir);
var parent = new TaskEntity
{
Id = Guid.NewGuid().ToString(), ListId = list.Id, Title = "parent", CommitType = "feat",
TicketRef = "bandel:77", CreatedAt = DateTime.UtcNow,
};
child.ParentTaskId = parent.Id;
var db = new DbFixture();
_dbFixtures.Add(db);
using (var seedCtx = db.CreateContext())
{
await new ListRepository(seedCtx).AddAsync(list);
await new TaskRepository(seedCtx).AddAsync(parent);
await new TaskRepository(seedCtx).AddAsync(child);
}
var mgr = new WorktreeManager(new GitService(), db.CreateFactory(),
new WorkerConfig { WorktreeRootStrategy = "sibling" }, NullLogger<WorktreeManager>.Instance);
var ctx = await mgr.CreateAsync(child, list, CancellationToken.None);
_worktreeCleanups.Add((repo.RepoDir, ctx.WorktreePath));
Assert.StartsWith("chore/77-test-task", ctx.BranchName);
}
[Fact]
public async Task CreateAsync_BaseBranchSet_BasesOnItEvenWhenAnotherBranchIsCheckedOut()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = CreateRepo();
GitRepoFixture.RunGit(repo.RepoDir, "checkout", "-b", "dev");
File.WriteAllText(Path.Combine(repo.RepoDir, "dev.txt"), "dev");
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "dev work");
var devHead = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
GitRepoFixture.RunGit(repo.RepoDir, "checkout", "-b", "elsewhere", repo.BaseCommit);
var (task, list) = MakeEntities(repo.RepoDir);
var (mgr, db) = await CreateManagerAsync(task, list);
using (var ctx0 = db.CreateContext())
await new ListRepository(ctx0).SetConfigAsync(new ListConfigEntity { ListId = list.Id, BaseBranch = "dev" });
var ctx = await mgr.CreateAsync(task, list, CancellationToken.None);
_worktreeCleanups.Add((repo.RepoDir, ctx.WorktreePath));
Assert.Equal(devHead, ctx.BaseCommit);
}
private static (TaskEntity task, ListEntity list) MakeEntities(string workingDir)
{
var listId = Guid.NewGuid().ToString();
@@ -1092,6 +1092,74 @@ public class TaskMergeServiceTests : IDisposable
Assert.False(Directory.Exists(wtCtx.WorktreePath));
}
[Fact]
public async Task GetTargetsAsync_prefers_list_base_branch()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
GitRepoFixture.RunGit(repo.RepoDir, "branch", "dev");
var db = NewDb();
var (list, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
using (var ctx = db.CreateContext())
await new ListRepository(ctx).SetConfigAsync(new ListConfigEntity { ListId = list.Id, BaseBranch = "dev" });
var (svc, _) = BuildService(db);
var targets = await svc.GetTargetsAsync(task.Id, CancellationToken.None);
Assert.NotEqual("dev", await new GitService().GetCurrentBranchAsync(repo.RepoDir));
Assert.Equal("dev", targets.DefaultBranch);
}
[Fact]
public async Task ApproveAndMergeAsync_EmptyTarget_MergesIntoListBaseBranch()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
GitRepoFixture.RunGit(repo.RepoDir, "branch", "dev");
var original = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
var db = NewDb();
var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);
using (var ctx0 = db.CreateContext())
await new ListRepository(ctx0).SetConfigAsync(new ListConfigEntity { ListId = list.Id, BaseBranch = "dev" });
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 result = await svc.ApproveAndMergeAsync(task.Id, "", CancellationToken.None);
Assert.Equal(TaskMergeService.StatusMerged, result.Status);
Assert.Contains("added.txt", GitRepoFixture.RunGit(repo.RepoDir, "ls-tree", "--name-only", "dev"));
Assert.Equal(repo.BaseCommit, GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", original).Trim());
}
[Fact]
public async Task MergeAsync_EmptyTarget_MergesIntoCheckedOutBranch()
{
// PlanningMergeOrchestrator.DrainAsync passes an empty target straight through — that used
// to end in `git checkout ""` -> Blocked.
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var current = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
var db = NewDb();
var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);
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 result = await svc.MergeAsync(task.Id, "", false, "msg", CancellationToken.None);
Assert.Equal(TaskMergeService.StatusMerged, result.Status);
Assert.Contains("added.txt", GitRepoFixture.RunGit(repo.RepoDir, "ls-tree", "--name-only", current));
}
[Fact]
public async Task ApproveAndMergeAsync_Conflict_LeavesTaskWaitingForReview()
{