diff --git a/src/ClaudeDo.Data/CLAUDE.md b/src/ClaudeDo.Data/CLAUDE.md index 68cb34e3..5084e66a 100644 --- a/src/ClaudeDo.Data/CLAUDE.md +++ b/src/ClaudeDo.Data/CLAUDE.md @@ -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). diff --git a/src/ClaudeDo.Data/Configuration/ListConfigEntityConfiguration.cs b/src/ClaudeDo.Data/Configuration/ListConfigEntityConfiguration.cs index c873c5ac..292cdc97 100644 --- a/src/ClaudeDo.Data/Configuration/ListConfigEntityConfiguration.cs +++ b/src/ClaudeDo.Data/Configuration/ListConfigEntityConfiguration.cs @@ -21,5 +21,6 @@ public class ListConfigEntityConfiguration : IEntityTypeConfiguration 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"); } } diff --git a/src/ClaudeDo.Data/Git/GitService.cs b/src/ClaudeDo.Data/Git/GitService.cs index 789b26ca..bacd9855 100644 --- a/src/ClaudeDo.Data/Git/GitService.cs +++ b/src/ClaudeDo.Data/Git/GitService.cs @@ -414,6 +414,13 @@ public sealed class GitService .ToList(); } + /// URL des Remotes , oder null wenn es keins gibt. + public async Task 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 IsMidMergeAsync(string repoDir, CancellationToken ct = default) { var (exitCode, stdout, _) = await RunGitAsync(repoDir, ["rev-parse", "--git-dir"], ct); diff --git a/src/ClaudeDo.Data/Migrations/20261001083902_AddListBaseBranch.Designer.cs b/src/ClaudeDo.Data/Migrations/20261001083902_AddListBaseBranch.Designer.cs new file mode 100644 index 00000000..54c8a3e0 --- /dev/null +++ b/src/ClaudeDo.Data/Migrations/20261001083902_AddListBaseBranch.Designer.cs @@ -0,0 +1,1164 @@ +// +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("20261001083902_AddListBaseBranch")] + partial class AddListBaseBranch + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.11"); + + modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("AutoContinueOnUsageLimit") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("auto_continue_on_usage_limit"); + + b.Property("CentralWorktreeRoot") + .HasColumnType("TEXT") + .HasColumnName("central_worktree_root"); + + b.Property("DailyPrepMaxTasks") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(5) + .HasColumnName("daily_prep_max_tasks"); + + b.Property("DefaultClaudeInstructions") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("") + .HasColumnName("default_claude_instructions"); + + b.Property("DefaultMaxTurns") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(40) + .HasColumnName("default_max_turns"); + + b.Property("DefaultModel") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("sonnet") + .HasColumnName("default_model"); + + b.Property("DefaultPermissionMode") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("bypassPermissions") + .HasColumnName("default_permission_mode"); + + b.Property("ListHandlerMaxHandoffs") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(4) + .HasColumnName("list_handler_max_handoffs"); + + b.Property("MaxParallelExecutions") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(1) + .HasColumnName("max_parallel_executions"); + + b.Property("MaxTurnsCeiling") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(80) + .HasColumnName("max_turns_ceiling"); + + b.Property("ModelPresets") + .HasColumnType("TEXT") + .HasColumnName("model_presets"); + + b.Property("NextTaskNumber") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(1) + .HasColumnName("next_task_number"); + + b.Property("PowerUseEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("power_use_enabled"); + + b.Property("PowerUseHandoffPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(95) + .HasColumnName("power_use_handoff_pct"); + + b.Property("PowerUseWindowEnd") + .HasColumnType("TEXT") + .HasColumnName("power_use_window_end"); + + b.Property("PowerUseWindowStart") + .HasColumnType("TEXT") + .HasColumnName("power_use_window_start"); + + b.Property("RepoImportFolders") + .HasColumnType("TEXT") + .HasColumnName("repo_import_folders"); + + b.Property("ReportExcludedPaths") + .HasColumnType("TEXT") + .HasColumnName("report_excluded_paths"); + + b.Property("SessionSkills") + .HasColumnType("TEXT") + .HasColumnName("session_skills"); + + b.Property("StandupWeekday") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(3) + .HasColumnName("standup_weekday"); + + b.Property("TicketFallbackListId") + .HasColumnType("TEXT") + .HasColumnName("ticket_fallback_list_id"); + + b.Property("UsageGateFiveHourPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(80) + .HasColumnName("usage_gate_five_hour_pct"); + + b.Property("UsageGateSevenDayPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(90) + .HasColumnName("usage_gate_seven_day_pct"); + + b.Property("UsageThrottleFiveHourHardPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(65) + .HasColumnName("usage_throttle_five_hour_hard_pct"); + + b.Property("UsageThrottleFiveHourSoftPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(50) + .HasColumnName("usage_throttle_five_hour_soft_pct"); + + b.Property("UsageThrottleSevenDayHardPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(65) + .HasColumnName("usage_throttle_seven_day_hard_pct"); + + b.Property("UsageThrottleSevenDaySoftPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(50) + .HasColumnName("usage_throttle_seven_day_soft_pct"); + + b.Property("WorktreeAutoCleanupDays") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(7) + .HasColumnName("worktree_auto_cleanup_days"); + + b.Property("WorktreeAutoCleanupEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("worktree_auto_cleanup_enabled"); + + b.Property("WorktreeStrategy") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("sibling") + .HasColumnName("worktree_strategy"); + + b.HasKey("Id"); + + b.ToTable("app_settings", (string)null); + + b.HasData( + new + { + Id = 1, + AutoContinueOnUsageLimit = false, + DailyPrepMaxTasks = 5, + DefaultClaudeInstructions = "", + DefaultMaxTurns = 40, + DefaultModel = "sonnet", + DefaultPermissionMode = "auto", + ListHandlerMaxHandoffs = 4, + MaxParallelExecutions = 1, + MaxTurnsCeiling = 80, + NextTaskNumber = 1, + PowerUseEnabled = false, + PowerUseHandoffPct = 95, + StandupWeekday = 3, + UsageGateFiveHourPct = 80, + UsageGateSevenDayPct = 90, + UsageThrottleFiveHourHardPct = 65, + UsageThrottleFiveHourSoftPct = 50, + UsageThrottleSevenDayHardPct = 65, + UsageThrottleSevenDaySoftPct = 50, + WorktreeAutoCleanupDays = 7, + WorktreeAutoCleanupEnabled = false, + WorktreeStrategy = "sibling" + }); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.DailyNoteEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("Date") + .HasColumnType("TEXT") + .HasColumnName("note_date"); + + b.Property("FromAgent") + .HasColumnType("INTEGER") + .HasColumnName("from_agent"); + + b.Property("SortOrder") + .HasColumnType("INTEGER") + .HasColumnName("sort_order"); + + b.Property("Text") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("text"); + + b.HasKey("Id"); + + b.HasIndex("Date"); + + b.ToTable("daily_notes", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b => + { + b.Property("ListId") + .HasColumnType("TEXT") + .HasColumnName("list_id"); + + b.Property("AgentPath") + .HasColumnType("TEXT") + .HasColumnName("agent_path"); + + b.Property("BaseBranch") + .HasColumnType("TEXT") + .HasColumnName("base_branch"); + + b.Property("MaxTurns") + .HasColumnType("INTEGER") + .HasColumnName("max_turns"); + + b.Property("Model") + .HasColumnType("TEXT") + .HasColumnName("model"); + + b.Property("PermissionMode") + .HasColumnType("TEXT") + .HasColumnName("permission_mode"); + + b.Property("PowerUseAllowed") + .HasColumnType("INTEGER") + .HasColumnName("power_use_allowed"); + + b.Property("SessionSkills") + .HasColumnType("TEXT") + .HasColumnName("session_skills"); + + b.Property("SystemPrompt") + .HasColumnType("TEXT") + .HasColumnName("system_prompt"); + + b.Property("TicketProjectId") + .HasColumnType("INTEGER") + .HasColumnName("ticket_project_id"); + + b.Property("VerifyCommand") + .HasColumnType("TEXT") + .HasColumnName("verify_command"); + + b.HasKey("ListId"); + + b.ToTable("list_config", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("DefaultCommitType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("chore") + .HasColumnName("default_commit_type"); + + b.Property("FindingsTracked") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("findings_tracked"); + + b.Property("FolderId") + .HasColumnType("TEXT") + .HasColumnName("folder_id"); + + b.Property("Icon") + .HasColumnType("TEXT") + .HasColumnName("icon"); + + b.Property("IsManual") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_manual"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0) + .HasColumnName("sort_order"); + + b.Property("WorkingDir") + .HasColumnType("TEXT") + .HasColumnName("working_dir"); + + b.HasKey("Id"); + + b.HasIndex("FolderId") + .HasDatabaseName("idx_lists_folder"); + + b.HasIndex("SortOrder") + .HasDatabaseName("idx_lists_sort"); + + b.ToTable("lists", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.ListFolderEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Color") + .HasColumnType("TEXT") + .HasColumnName("color"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("Icon") + .HasColumnType("TEXT") + .HasColumnName("icon"); + + b.Property("IsCollapsed") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_collapsed"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("ParentFolderId") + .HasColumnType("TEXT") + .HasColumnName("parent_folder_id"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0) + .HasColumnName("sort_order"); + + b.HasKey("Id"); + + b.HasIndex("ParentFolderId") + .HasDatabaseName("idx_list_folders_parent"); + + b.HasIndex("SortOrder") + .HasDatabaseName("idx_list_folders_sort"); + + b.ToTable("list_folders", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.MissionControlSessionEntity", b => + { + b.Property("SessionId") + .HasColumnType("TEXT") + .HasColumnName("session_id"); + + b.Property("BriefPath") + .HasColumnType("TEXT") + .HasColumnName("brief_path"); + + b.Property("Cwd") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("cwd"); + + b.Property("PredecessorSessionId") + .HasColumnType("TEXT") + .HasColumnName("predecessor_session_id"); + + b.Property("StartedAt") + .HasColumnType("TEXT") + .HasColumnName("started_at"); + + b.Property("TaskId") + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.HasKey("SessionId"); + + b.HasIndex("StartedAt"); + + b.ToTable("mission_control_sessions", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("Days") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(31) + .HasColumnName("days_of_week"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true) + .HasColumnName("enabled"); + + b.Property("Kind") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("ping") + .HasColumnName("action_kind"); + + b.Property("LastRunAt") + .HasColumnType("TEXT") + .HasColumnName("last_run_at"); + + b.Property("PromptOverride") + .HasColumnType("TEXT") + .HasColumnName("prompt_override"); + + b.Property("TimeOfDay") + .HasColumnType("TEXT") + .HasColumnName("time_of_day"); + + b.HasKey("Id"); + + b.ToTable("prime_schedules", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.SessionSkillEntity", b => + { + b.Property("Name") + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("AddedAt") + .HasColumnType("TEXT") + .HasColumnName("added_at"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("description"); + + b.Property("PinnedRef") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("pinned_ref"); + + b.Property("SourceUrl") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("source_url"); + + b.Property("Subpath") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("subpath"); + + b.HasKey("Name"); + + b.ToTable("session_skills", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Completed") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("completed"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("OrderNum") + .HasColumnType("INTEGER") + .HasColumnName("order_num"); + + b.Property("TaskId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.HasKey("Id"); + + b.HasIndex("TaskId") + .HasDatabaseName("idx_subtasks_task_id"); + + b.ToTable("subtasks", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ByteSize") + .HasColumnType("INTEGER") + .HasColumnName("byte_size"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("file_name"); + + b.Property("TaskId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.HasKey("Id"); + + b.HasIndex("TaskId") + .HasDatabaseName("idx_task_attachments_task_id"); + + b.ToTable("task_attachments", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskDependencyEntity", b => + { + b.Property("TaskId") + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.Property("DependsOnTaskId") + .HasColumnType("TEXT") + .HasColumnName("depends_on_task_id"); + + b.HasKey("TaskId", "DependsOnTaskId"); + + b.HasIndex("DependsOnTaskId") + .HasDatabaseName("idx_task_dependencies_depends_on"); + + b.ToTable("task_dependencies", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("AgentPath") + .HasColumnType("TEXT") + .HasColumnName("agent_path"); + + b.Property("BlockedByTaskId") + .HasColumnType("TEXT") + .HasColumnName("blocked_by_task_id"); + + b.Property("CommitType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("chore") + .HasColumnName("commit_type"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("TEXT") + .HasColumnName("created_by"); + + b.Property("Description") + .HasColumnType("TEXT") + .HasColumnName("description"); + + b.Property("ExpectsNoDiff") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("expects_no_diff"); + + b.Property("FailureMaxTurns") + .HasColumnType("INTEGER") + .HasColumnName("failure_max_turns"); + + b.Property("FailureReason") + .HasColumnType("TEXT") + .HasColumnName("failure_reason"); + + b.Property("FailureTurnsUsed") + .HasColumnType("INTEGER") + .HasColumnName("failure_turns_used"); + + b.Property("FinishedAt") + .HasColumnType("TEXT") + .HasColumnName("finished_at"); + + b.Property("HandlerBaseCommit") + .HasColumnType("TEXT") + .HasColumnName("handler_base_commit"); + + b.Property("HandlerHeadCommit") + .HasColumnType("TEXT") + .HasColumnName("handler_head_commit"); + + b.Property("InteractiveSessionId") + .HasColumnType("TEXT") + .HasColumnName("interactive_session_id"); + + b.Property("IsManual") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_manual"); + + b.Property("IsMyDay") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_my_day"); + + b.Property("IsStarred") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_starred"); + + b.Property("ListId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("list_id"); + + b.Property("LogPath") + .HasColumnType("TEXT") + .HasColumnName("log_path"); + + b.Property("MaxTurns") + .HasColumnType("INTEGER") + .HasColumnName("max_turns"); + + b.Property("Model") + .HasColumnType("TEXT") + .HasColumnName("model"); + + b.Property("Notes") + .HasColumnType("TEXT") + .HasColumnName("notes"); + + b.Property("Number") + .HasColumnType("INTEGER") + .HasColumnName("number"); + + b.Property("ParentTaskId") + .HasColumnType("TEXT") + .HasColumnName("parent_task_id"); + + b.Property("PermissionMode") + .HasColumnType("TEXT") + .HasColumnName("permission_mode"); + + b.Property("PlanningFinalizedAt") + .HasColumnType("TEXT") + .HasColumnName("planning_finalized_at"); + + b.Property("PlanningPhase") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("none") + .HasColumnName("planning_phase"); + + b.Property("PlanningSessionId") + .HasColumnType("TEXT") + .HasColumnName("planning_session_id"); + + b.Property("PlanningSessionToken") + .HasColumnType("TEXT") + .HasColumnName("planning_session_token"); + + b.Property("Result") + .HasColumnType("TEXT") + .HasColumnName("result"); + + b.Property("ReviewFeedback") + .HasColumnType("TEXT") + .HasColumnName("review_feedback"); + + b.Property("RoadblockCount") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0) + .HasColumnName("roadblock_count"); + + b.Property("ScheduledFor") + .HasColumnType("TEXT") + .HasColumnName("scheduled_for"); + + b.Property("SessionSkills") + .HasColumnType("TEXT") + .HasColumnName("session_skills"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0) + .HasColumnName("sort_order"); + + b.Property("StartedAt") + .HasColumnType("TEXT") + .HasColumnName("started_at"); + + b.Property("Status") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("status"); + + b.Property("SystemPrompt") + .HasColumnType("TEXT") + .HasColumnName("system_prompt"); + + b.Property("TicketRef") + .HasColumnType("TEXT") + .HasColumnName("ticket_ref"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.Property("UsageLimitAutoContinuedAt") + .HasColumnType("TEXT") + .HasColumnName("usage_limit_auto_continued_at"); + + b.HasKey("Id"); + + b.HasIndex("BlockedByTaskId") + .HasDatabaseName("idx_tasks_blocked_by"); + + b.HasIndex("ListId") + .HasDatabaseName("idx_tasks_list_id"); + + b.HasIndex("Number") + .IsUnique() + .HasDatabaseName("idx_tasks_number"); + + b.HasIndex("ParentTaskId") + .HasDatabaseName("idx_tasks_parent_task_id"); + + b.HasIndex("Status") + .HasDatabaseName("idx_tasks_status"); + + b.HasIndex("ListId", "SortOrder") + .HasDatabaseName("idx_tasks_list_sort"); + + b.ToTable("tasks", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CacheReadTokens") + .HasColumnType("INTEGER") + .HasColumnName("cache_read_tokens"); + + b.Property("CacheWriteTokens") + .HasColumnType("INTEGER") + .HasColumnName("cache_write_tokens"); + + b.Property("ErrorMarkdown") + .HasColumnType("TEXT") + .HasColumnName("error_markdown"); + + b.Property("Errors") + .HasColumnType("TEXT") + .HasColumnName("errors"); + + b.Property("ExitCode") + .HasColumnType("INTEGER") + .HasColumnName("exit_code"); + + b.Property("FinishedAt") + .HasColumnType("TEXT") + .HasColumnName("finished_at"); + + b.Property("IsRetry") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_retry"); + + b.Property("LogPath") + .HasColumnType("TEXT") + .HasColumnName("log_path"); + + b.Property("Model") + .HasColumnType("TEXT") + .HasColumnName("model"); + + b.Property("Prompt") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("prompt"); + + b.Property("ResultMarkdown") + .HasColumnType("TEXT") + .HasColumnName("result_markdown"); + + b.Property("ResultSubtype") + .HasColumnType("TEXT") + .HasColumnName("result_subtype"); + + b.Property("RunNumber") + .HasColumnType("INTEGER") + .HasColumnName("run_number"); + + b.Property("SessionId") + .HasColumnType("TEXT") + .HasColumnName("session_id"); + + b.Property("StartedAt") + .HasColumnType("TEXT") + .HasColumnName("started_at"); + + b.Property("StructuredOutputJson") + .HasColumnType("TEXT") + .HasColumnName("structured_output"); + + b.Property("TaskId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.Property("TerminalReason") + .HasColumnType("TEXT") + .HasColumnName("terminal_reason"); + + b.Property("TokensIn") + .HasColumnType("INTEGER") + .HasColumnName("tokens_in"); + + b.Property("TokensOut") + .HasColumnType("INTEGER") + .HasColumnName("tokens_out"); + + b.Property("TurnCount") + .HasColumnType("INTEGER") + .HasColumnName("turn_count"); + + b.HasKey("Id"); + + b.HasIndex("TaskId") + .HasDatabaseName("idx_task_runs_task_id"); + + b.ToTable("task_runs", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.WeekReportEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("EndDate") + .HasColumnType("TEXT") + .HasColumnName("end_date"); + + b.Property("GeneratedAt") + .HasColumnType("TEXT") + .HasColumnName("generated_at"); + + b.Property("Markdown") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("markdown"); + + b.Property("StartDate") + .HasColumnType("TEXT") + .HasColumnName("start_date"); + + b.HasKey("Id"); + + b.HasIndex("StartDate", "EndDate") + .IsUnique(); + + b.ToTable("week_reports", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b => + { + b.Property("TaskId") + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.Property("BaseCommit") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("base_commit"); + + b.Property("BranchName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("branch_name"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("DiffStat") + .HasColumnType("TEXT") + .HasColumnName("diff_stat"); + + b.Property("HeadCommit") + .HasColumnType("TEXT") + .HasColumnName("head_commit"); + + b.Property("MergeCommit") + .HasColumnType("TEXT") + .HasColumnName("merge_commit"); + + b.Property("Path") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("path"); + + b.Property("State") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("active") + .HasColumnName("state"); + + b.HasKey("TaskId"); + + b.ToTable("worktrees", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.ListEntity", "List") + .WithOne("Config") + .HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("List"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.ListFolderEntity", null) + .WithMany() + .HasForeignKey("FolderId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.ListFolderEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.ListFolderEntity", null) + .WithMany() + .HasForeignKey("ParentFolderId") + .OnDelete(DeleteBehavior.SetNull); + }); + + 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.TaskDependencyEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.TaskEntity", null) + .WithMany() + .HasForeignKey("DependsOnTaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ClaudeDo.Data.Models.TaskEntity", null) + .WithMany("Dependencies") + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + 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("Dependencies"); + + b.Navigation("Runs"); + + b.Navigation("Subtasks"); + + b.Navigation("Worktree"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/ClaudeDo.Data/Migrations/20261001083902_AddListBaseBranch.cs b/src/ClaudeDo.Data/Migrations/20261001083902_AddListBaseBranch.cs new file mode 100644 index 00000000..5c0b883f --- /dev/null +++ b/src/ClaudeDo.Data/Migrations/20261001083902_AddListBaseBranch.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ClaudeDo.Data.Migrations +{ + /// + public partial class AddListBaseBranch : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "base_branch", + table: "list_config", + type: "TEXT", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "base_branch", + table: "list_config"); + } + } +} diff --git a/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs b/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs index f2d94c5a..b3b1353d 100644 --- a/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs +++ b/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs @@ -268,6 +268,10 @@ namespace ClaudeDo.Data.Migrations .HasColumnType("TEXT") .HasColumnName("agent_path"); + b.Property("BaseBranch") + .HasColumnType("TEXT") + .HasColumnName("base_branch"); + b.Property("MaxTurns") .HasColumnType("INTEGER") .HasColumnName("max_turns"); diff --git a/src/ClaudeDo.Data/Models/ListConfigEntity.cs b/src/ClaudeDo.Data/Models/ListConfigEntity.cs index 14545892..fba2ce67 100644 --- a/src/ClaudeDo.Data/Models/ListConfigEntity.cs +++ b/src/ClaudeDo.Data/Models/ListConfigEntity.cs @@ -23,6 +23,12 @@ public sealed class ListConfigEntity // false, also opt-in. ⚠️ Gleiche Falle wie TicketProjectId: SetConfigAsync kopiert verbatim. public bool PowerUseAllowed { get; set; } + /// + /// Basis neuer Worktrees und Default-Merge-/PR-Ziel (z.B. dev). Null = ausgecheckter Branch + /// (privat) bzw. Default-Branch des Remotes (Firma). ⚠️ SetConfigAsync kopiert verbatim. + /// + public string? BaseBranch { get; set; } + // Navigation property public ListEntity List { get; set; } = null!; } diff --git a/src/ClaudeDo.Data/Repositories/ListRepository.cs b/src/ClaudeDo.Data/Repositories/ListRepository.cs index 0a0a328d..ce66adec 100644 --- a/src/ClaudeDo.Data/Repositories/ListRepository.cs +++ b/src/ClaudeDo.Data/Repositories/ListRepository.cs @@ -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); } diff --git a/src/ClaudeDo.Data/Wire.cs b/src/ClaudeDo.Data/Wire.cs index 0b938bd9..fda7b5b5 100644 --- a/src/ClaudeDo.Data/Wire.cs +++ b/src/ClaudeDo.Data/Wire.cs @@ -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? 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? 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? SessionSkills = null, string? PermissionMode = null); -public record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List? 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? SessionSkills = null, string? VerifyCommand = null, string? PermissionMode = null, int? TicketProjectId = null, bool PowerUseAllowed = false, string? BaseBranch = null); public record SeedResultDto(int Copied, int Skipped); diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json index ecd70fd3..c7d20b2a 100644 --- a/src/ClaudeDo.Localization/locales/de.json +++ b/src/ClaudeDo.Localization/locales/de.json @@ -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)", diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json index e8041bd5..8753c160 100644 --- a/src/ClaudeDo.Localization/locales/en.json +++ b/src/ClaudeDo.Localization/locales/en.json @@ -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)", diff --git a/src/ClaudeDo.Ui/ViewModels/Agent/AgentConfigEditorViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Agent/AgentConfigEditorViewModel.cs index 952ee7c0..208e0ff4 100644 --- a/src/ClaudeDo.Ui/ViewModels/Agent/AgentConfigEditorViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Agent/AgentConfigEditorViewModel.cs @@ -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? SelectedSessionSkillNames() diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs index 4e363abc..23154686 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs @@ -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; diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/ListSettingsModalViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/ListSettingsModalViewModel.cs index 0eedf0b6..88437b05 100644 --- a/src/ClaudeDo.Ui/ViewModels/Modals/ListSettingsModalViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Modals/ListSettingsModalViewModel.cs @@ -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." 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(); diff --git a/src/ClaudeDo.Ui/Views/Modals/ListSettingsModalView.axaml b/src/ClaudeDo.Ui/Views/Modals/ListSettingsModalView.axaml index 40e80488..c37366b6 100644 --- a/src/ClaudeDo.Ui/Views/Modals/ListSettingsModalView.axaml +++ b/src/ClaudeDo.Ui/Views/Modals/ListSettingsModalView.axaml @@ -153,6 +153,14 @@ Foreground="{DynamicResource StatusErrorBrush}" FontSize="12" TextWrapping="Wrap"/> + + + + + + ` 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()`, a `Share()` for every main-container singleton it injects, *and* `.WithTools()`; 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 `-`, branches `/-` (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 `#` 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()`, a `Share()` for every main-container singleton it injects, *and* `.WithTools()`; 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 `/-` 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 `/-` (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? progress = null` parameter through instead of writing another polling loop. diff --git a/src/ClaudeDo.Worker/Config/WorkerConfig.cs b/src/ClaudeDo.Worker/Config/WorkerConfig.cs index 9d0cc234..3eee5907 100644 --- a/src/ClaudeDo.Worker/Config/WorkerConfig.cs +++ b/src/ClaudeDo.Worker/Config/WorkerConfig.cs @@ -70,6 +70,10 @@ public sealed class WorkerConfig [JsonPropertyName("ticket_api_base_url")] public string? TicketApiBaseUrl { get; set; } + /// Teilstring der origin-URL, an dem ein Firmen-Repo erkannt wird. Nur per Hand editierbar. + [JsonPropertyName("company_remote_pattern")] + public string CompanyRemotePattern { get; set; } = "/_git/"; + public static string DefaultConfigPath => Path.Combine(Paths.AppDataRoot(), "worker.config.json"); diff --git a/src/ClaudeDo.Worker/External/ConfigMcpTools.cs b/src/ClaudeDo.Worker/External/ConfigMcpTools.cs index 78108c13..3c17c5bb 100644 --- a/src/ClaudeDo.Worker/External/ConfigMcpTools.cs +++ b/src/ClaudeDo.Worker/External/ConfigMcpTools.cs @@ -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 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); diff --git a/src/ClaudeDo.Worker/External/MergeMcpTools.cs b/src/ClaudeDo.Worker/External/MergeMcpTools.cs index 95d977bd..56a48574 100644 --- a/src/ClaudeDo.Worker/External/MergeMcpTools.cs +++ b/src/ClaudeDo.Worker/External/MergeMcpTools.cs @@ -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) diff --git a/src/ClaudeDo.Worker/Git/CompanyRepo.cs b/src/ClaudeDo.Worker/Git/CompanyRepo.cs new file mode 100644 index 00000000..08e0624a --- /dev/null +++ b/src/ClaudeDo.Worker/Git/CompanyRepo.cs @@ -0,0 +1,16 @@ +using ClaudeDo.Data.Git; + +namespace ClaudeDo.Worker.Git; + +/// Erkennt Firmen-Repos am origin-Remote; das Muster kommt aus WorkerConfig.CompanyRemotePattern. +public static class CompanyRepo +{ + /// True wenn das (nicht-leere) Muster enthält, ohne Groß-/Kleinschreibung. + public static bool Matches(string? remoteUrl, string? pattern) => + !string.IsNullOrWhiteSpace(remoteUrl) && !string.IsNullOrWhiteSpace(pattern) + && remoteUrl.Contains(pattern, StringComparison.OrdinalIgnoreCase); + + /// Liest origin live (kein Cache — ein Remote kann sich jederzeit ändern). + public static async Task IsCompanyAsync(GitService git, string workingDir, string? pattern, CancellationToken ct) => + Matches(await git.GetRemoteUrlAsync(workingDir, "origin", ct), pattern); +} diff --git a/src/ClaudeDo.Worker/Hub/WorkerHub.cs b/src/ClaudeDo.Worker/Hub/WorkerHub.cs index 8ed9a4af..7a6374fb 100644 --- a/src/ClaudeDo.Worker/Hub/WorkerHub.cs +++ b/src/ClaudeDo.Worker/Hub/WorkerHub.cs @@ -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 SetTaskStatus(string taskId, string status) diff --git a/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs b/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs index 7adee6b5..af3ba7bb 100644 --- a/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs +++ b/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs @@ -411,6 +411,7 @@ public sealed class TaskMergeService return new MergeResult(StatusDirtyTarget, Array.Empty(), "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(), 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); } + /// Leeres Ziel → list_config.base_branch, sonst der ausgecheckte Branch des Main-Checkouts. + public async Task 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(), 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 diff --git a/src/ClaudeDo.Worker/Lifecycle/UntrackedWorktreeRecovery.cs b/src/ClaudeDo.Worker/Lifecycle/UntrackedWorktreeRecovery.cs index d97ae76e..399826ae 100644 --- a/src/ClaudeDo.Worker/Lifecycle/UntrackedWorktreeRecovery.cs +++ b/src/ClaudeDo.Worker/Lifecycle/UntrackedWorktreeRecovery.cs @@ -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; /// -/// Startup-only sweep: warns when a list's repo has git-registered claudedo/* worktrees -/// that the worktrees 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 +/// .claudedo-worktrees or the central root, planning sessions excluded) that the worktrees table doesn't know about (e.g. left behind by a manual cleanup /// outside the app). Detection only — nothing is removed or pruned. /// public sealed class UntrackedWorktreeRecovery : IHostedService { public const string Phase = "untracked-worktrees"; - private const string BranchPrefix = "claudedo/"; - private readonly IDbContextFactory _dbFactory; private readonly GitService _git; private readonly HubBroadcaster _broadcaster; + private readonly WorkerConfig _cfg; private readonly ILogger _logger; public UntrackedWorktreeRecovery( IDbContextFactory dbFactory, GitService git, HubBroadcaster broadcaster, + WorkerConfig cfg, ILogger 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(); } + /// True für einen Pfad unter .claudedo-worktrees oder dem Central-Root, außer Planning-Session-Worktrees. + 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); } diff --git a/src/ClaudeDo.Worker/Planning/PlanningAggregator.cs b/src/ClaudeDo.Worker/Planning/PlanningAggregator.cs index 73c90b72..b9ab7f42 100644 --- a/src/ClaudeDo.Worker/Planning/PlanningAggregator.cs +++ b/src/ClaudeDo.Worker/Planning/PlanningAggregator.cs @@ -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 */ } } + /// + /// Rücksprung vom Integration-Branch: list_config.base_branch, sonst main (bzw. master). + /// Nie aus der Branchliste raten — die enthält auch Task-Branches, die in Worktrees ausgecheckt sind. + /// + private async Task 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 children)> LoadPlanningContextAsync(string planningTaskId, CancellationToken ct) { diff --git a/src/ClaudeDo.Worker/Runner/CommitMessageBuilder.cs b/src/ClaudeDo.Worker/Runner/CommitMessageBuilder.cs index 9e33cd5a..ab2b7dba 100644 --- a/src/ClaudeDo.Worker/Runner/CommitMessageBuilder.cs +++ b/src/ClaudeDo.Worker/Runner/CommitMessageBuilder.cs @@ -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. diff --git a/src/ClaudeDo.Worker/Runner/WorktreeManager.cs b/src/ClaudeDo.Worker/Runner/WorktreeManager.cs index 9fe83a29..7b6540a2 100644 --- a/src/ClaudeDo.Worker/Runner/WorktreeManager.cs +++ b/src/ClaudeDo.Worker/Runner/WorktreeManager.cs @@ -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; - /// Branch leaf and folder name for a task worktree: {number}-{title-slug}, unique via the task number. - public static string NameFor(TaskEntity task) + /// + /// Branch leaf and folder name: {nummer}-{slug}. 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. + /// + 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}"; + } + + /// Branch-Präfix nach work-conventions: feat → feature, Typen ohne eigenes Präfix → chore. + 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 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); } + /// Ticket-Id des Tasks, sonst die seines Parents (Children erben das Ticket ihrer Unit). + private async Task 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 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); } } diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/ListSettingsModalViewModelTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/ListSettingsModalViewModelTests.cs index 69397e5a..bb36e18d 100644 --- a/tests/ClaudeDo.Ui.Tests/ViewModels/ListSettingsModalViewModelTests.cs +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/ListSettingsModalViewModelTests.cs @@ -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() { diff --git a/tests/ClaudeDo.Worker.Tests/Git/CompanyRepoTests.cs b/tests/ClaudeDo.Worker.Tests/Git/CompanyRepoTests.cs new file mode 100644 index 00000000..32b9a4d7 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Git/CompanyRepoTests.cs @@ -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)); + } +} diff --git a/tests/ClaudeDo.Worker.Tests/Hub/ListConfigBaseBranchTests.cs b/tests/ClaudeDo.Worker.Tests/Hub/ListConfigBaseBranchTests.cs new file mode 100644 index 00000000..ae940e62 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Hub/ListConfigBaseBranchTests.cs @@ -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 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 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); + } +} diff --git a/tests/ClaudeDo.Worker.Tests/Lifecycle/UntrackedWorktreeRecoveryTests.cs b/tests/ClaudeDo.Worker.Tests/Lifecycle/UntrackedWorktreeRecoveryTests.cs index a0d2561d..1aad8725 100644 --- a/tests/ClaudeDo.Worker.Tests/Lifecycle/UntrackedWorktreeRecoveryTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Lifecycle/UntrackedWorktreeRecoveryTests.cs @@ -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.Instance); await sut.StartAsync(CancellationToken.None); diff --git a/tests/ClaudeDo.Worker.Tests/Planning/PlanningAggregatorTests.cs b/tests/ClaudeDo.Worker.Tests/Planning/PlanningAggregatorTests.cs index a1ccaea8..299c1864 100644 --- a/tests/ClaudeDo.Worker.Tests/Planning/PlanningAggregatorTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Planning/PlanningAggregatorTests.cs @@ -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.Instance); + var built = Assert.IsType(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}"); diff --git a/tests/ClaudeDo.Worker.Tests/Runner/CommitMessageBuilderTests.cs b/tests/ClaudeDo.Worker.Tests/Runner/CommitMessageBuilderTests.cs index 0e4b6529..5984c9b3 100644 --- a/tests/ClaudeDo.Worker.Tests/Runner/CommitMessageBuilderTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Runner/CommitMessageBuilderTests.cs @@ -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() { diff --git a/tests/ClaudeDo.Worker.Tests/Runner/WorktreeManagerTests.cs b/tests/ClaudeDo.Worker.Tests/Runner/WorktreeManagerTests.cs index eec0c552..e3ffac27 100644 --- a/tests/ClaudeDo.Worker.Tests/Runner/WorktreeManagerTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Runner/WorktreeManagerTests.cs @@ -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( () => 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.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(); diff --git a/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs index 13cb7791..966e9842 100644 --- a/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs @@ -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() {