From a1a30bcc422bd3cd99fd788802ebf2bb144ed65d Mon Sep 17 00:00:00 2001 From: dave Date: Wed, 25 Mar 2026 13:30:48 +0000 Subject: [PATCH] storkit: merge 387_story_configurable_base_branch_name_in_project_toml --- .storkit/project.toml | 5 ++ .storkit/project.toml.example | 43 +++++++++++++++++ server/src/config.rs | 90 ++++++++++++++++++++++++++++++++++- server/src/worktree.rs | 22 ++++++++- 4 files changed, 157 insertions(+), 3 deletions(-) create mode 100644 .storkit/project.toml.example diff --git a/.storkit/project.toml b/.storkit/project.toml index becc6bc..fe28d44 100644 --- a/.storkit/project.toml +++ b/.storkit/project.toml @@ -13,6 +13,11 @@ max_coders = 3 # Set to 0 to disable retry limits. max_retries = 2 +# Base branch name for this project. Worktree creation, merges, and agent prompts +# use this value for {{base_branch}}. When not set, falls back to auto-detection +# (reads current HEAD branch). +base_branch = "master" + [[component]] name = "frontend" path = "frontend" diff --git a/.storkit/project.toml.example b/.storkit/project.toml.example new file mode 100644 index 0000000..50f3bc5 --- /dev/null +++ b/.storkit/project.toml.example @@ -0,0 +1,43 @@ +# Example project.toml — copy to .storkit/project.toml and customise. +# This file is checked in; project.toml itself is gitignored (it may contain +# instance-specific settings). + +# Project-wide default QA mode: "server", "agent", or "human". +# Per-story `qa` front matter overrides this setting. +default_qa = "server" + +# Default model for coder agents. Only agents with this model are auto-assigned. +# Opus coders are reserved for explicit per-story `agent:` front matter requests. +default_coder_model = "sonnet" + +# Maximum concurrent coder agents. Stories wait in 2_current/ when all slots are full. +max_coders = 3 + +# Maximum retries per story per pipeline stage before marking as blocked. +# Set to 0 to disable retry limits. +max_retries = 2 + +# Base branch name for this project. Worktree creation, merges, and agent prompts +# use this value for {{base_branch}}. When not set, falls back to auto-detection +# (reads current HEAD branch). +base_branch = "main" + +[[component]] +name = "server" +path = "." +setup = ["cargo build"] +teardown = [] + +[[agent]] +name = "coder-1" +role = "Full-stack engineer" +stage = "coder" +model = "sonnet" +max_turns = 50 +max_budget_usd = 5.00 +prompt = """ +You are working in a git worktree on story {{story_id}}. +Read CLAUDE.md first, then .storkit/README.md to understand the dev process. +Run: cd "{{worktree_path}}" && git difftool {{base_branch}}...HEAD +Commit all your work before your process exits. +""" diff --git a/server/src/config.rs b/server/src/config.rs index 6601239..ace5eec 100644 --- a/server/src/config.rs +++ b/server/src/config.rs @@ -30,6 +30,12 @@ pub struct ProjectConfig { /// Default: 2. Set to 0 to disable retry limits. #[serde(default = "default_max_retries")] pub max_retries: u32, + /// Optional base branch name (e.g. "main", "master", "develop"). + /// When set, overrides the auto-detection logic (`detect_base_branch`) for all + /// worktree creation, merge operations, and agent prompt `{{base_branch}}` substitution. + /// When not set, the system falls back to `detect_base_branch` (reads current HEAD). + #[serde(default)] + pub base_branch: Option, } /// Configuration for the filesystem watcher's sweep behaviour. @@ -164,6 +170,8 @@ struct LegacyProjectConfig { max_coders: Option, #[serde(default = "default_max_retries")] max_retries: u32, + #[serde(default)] + base_branch: Option, } impl Default for ProjectConfig { @@ -190,6 +198,7 @@ impl Default for ProjectConfig { default_coder_model: None, max_coders: None, max_retries: default_max_retries(), + base_branch: None, } } } @@ -235,6 +244,7 @@ impl ProjectConfig { default_coder_model: legacy.default_coder_model, max_coders: legacy.max_coders, max_retries: legacy.max_retries, + base_branch: legacy.base_branch, }; validate_agents(&config.agent)?; return Ok(config); @@ -259,6 +269,7 @@ impl ProjectConfig { default_coder_model: legacy.default_coder_model, max_coders: legacy.max_coders, max_retries: legacy.max_retries, + base_branch: legacy.base_branch, }; validate_agents(&config.agent)?; Ok(config) @@ -271,6 +282,7 @@ impl ProjectConfig { default_coder_model: legacy.default_coder_model, max_coders: legacy.max_coders, max_retries: legacy.max_retries, + base_branch: legacy.base_branch, }) } } @@ -312,7 +324,9 @@ impl ProjectConfig { .ok_or_else(|| "No agents configured".to_string())?, }; - let bb = base_branch.unwrap_or("master"); + let bb = base_branch + .or(self.base_branch.as_deref()) + .unwrap_or("master"); let aname = agent.name.as_str(); let render = |s: &str| { s.replace("{{worktree_path}}", worktree_path) @@ -858,6 +872,80 @@ runtime = "openai" assert!(err.contains("unknown runtime 'openai'")); } + // ── base_branch config ────────────────────────────────────────────────── + + #[test] + fn base_branch_defaults_to_none() { + let toml_str = r#" +[[agent]] +name = "coder" +"#; + let config = ProjectConfig::parse(toml_str).unwrap(); + assert_eq!(config.base_branch, None); + } + + #[test] + fn base_branch_parsed_when_set() { + let toml_str = r#" +base_branch = "main" + +[[agent]] +name = "coder" +"#; + let config = ProjectConfig::parse(toml_str).unwrap(); + assert_eq!(config.base_branch, Some("main".to_string())); + } + + #[test] + fn render_agent_args_uses_config_base_branch_when_caller_passes_none() { + let toml_str = r#" +base_branch = "develop" + +[[agent]] +name = "coder" +prompt = "git difftool {{base_branch}}...HEAD" +"#; + let config = ProjectConfig::parse(toml_str).unwrap(); + let (_, _, prompt) = config + .render_agent_args("/tmp/wt", "42_foo", None, None) + .unwrap(); + assert!( + prompt.contains("develop"), + "Expected 'develop' in prompt, got: {prompt}" + ); + } + + #[test] + fn render_agent_args_caller_base_branch_takes_precedence_over_config() { + let toml_str = r#" +base_branch = "develop" + +[[agent]] +name = "coder" +prompt = "git difftool {{base_branch}}...HEAD" +"#; + let config = ProjectConfig::parse(toml_str).unwrap(); + let (_, _, prompt) = config + .render_agent_args("/tmp/wt", "42_foo", None, Some("feature-x")) + .unwrap(); + assert!( + prompt.contains("feature-x"), + "Caller-supplied base_branch should win, got: {prompt}" + ); + } + + #[test] + fn project_toml_has_base_branch_master() { + let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + let project_root = manifest_dir.parent().unwrap(); + let config = ProjectConfig::load(project_root).unwrap(); + assert_eq!( + config.base_branch, + Some("master".to_string()), + "project.toml must have base_branch = \"master\"" + ); + } + #[test] fn project_toml_has_three_sonnet_coders() { let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); diff --git a/server/src/worktree.rs b/server/src/worktree.rs index 4348c0a..cea3d1a 100644 --- a/server/src/worktree.rs +++ b/server/src/worktree.rs @@ -69,7 +69,10 @@ pub async fn create_worktree( ) -> Result { let wt_path = worktree_path(project_root, story_id); let branch = branch_name(story_id); - let base_branch = detect_base_branch(project_root); + let base_branch = config + .base_branch + .clone() + .unwrap_or_else(|| detect_base_branch(project_root)); let root = project_root.to_path_buf(); // Already exists — reuse (ensure sparse checkout is configured) @@ -199,7 +202,10 @@ pub async fn remove_worktree_by_story_id( return Err(format!("Worktree not found for story: {story_id}")); } let branch = branch_name(story_id); - let base_branch = detect_base_branch(project_root); + let base_branch = config + .base_branch + .clone() + .unwrap_or_else(|| detect_base_branch(project_root)); let info = WorktreeInfo { path, branch, @@ -519,6 +525,7 @@ mod tests { default_coder_model: None, max_coders: None, max_retries: 2, + base_branch: None, }; // Should complete without panic run_setup_commands(tmp.path(), &config).await; @@ -540,6 +547,7 @@ mod tests { default_coder_model: None, max_coders: None, max_retries: 2, + base_branch: None, }; // Should complete without panic run_setup_commands(tmp.path(), &config).await; @@ -561,6 +569,7 @@ mod tests { default_coder_model: None, max_coders: None, max_retries: 2, + base_branch: None, }; // Setup command failures are non-fatal — should not panic or propagate run_setup_commands(tmp.path(), &config).await; @@ -582,6 +591,7 @@ mod tests { default_coder_model: None, max_coders: None, max_retries: 2, + base_branch: None, }; // Teardown failures are best-effort — should not propagate assert!(run_teardown_commands(tmp.path(), &config).await.is_ok()); @@ -602,6 +612,7 @@ mod tests { default_coder_model: None, max_coders: None, max_retries: 2, + base_branch: None, }; let info = create_worktree(&project_root, "42_fresh_test", &config, 3001) .await @@ -629,6 +640,7 @@ mod tests { default_coder_model: None, max_coders: None, max_retries: 2, + base_branch: None, }; // First creation let _info1 = create_worktree(&project_root, "43_reuse_test", &config, 3001) @@ -697,6 +709,7 @@ mod tests { default_coder_model: None, max_coders: None, max_retries: 2, + base_branch: None, }; let result = remove_worktree_by_story_id(tmp.path(), "99_nonexistent", &config).await; @@ -723,6 +736,7 @@ mod tests { default_coder_model: None, max_coders: None, max_retries: 2, + base_branch: None, }; create_worktree(&project_root, "88_remove_by_id", &config, 3001) .await @@ -796,6 +810,7 @@ mod tests { default_coder_model: None, max_coders: None, max_retries: 2, + base_branch: None, }; // Even though setup commands fail, create_worktree must succeed // so the agent can start and fix the problem itself. @@ -825,6 +840,7 @@ mod tests { default_coder_model: None, max_coders: None, max_retries: 2, + base_branch: None, }; // First creation — no setup commands, should succeed create_worktree(&project_root, "173_reuse_fail", &empty_config, 3001) @@ -844,6 +860,7 @@ mod tests { default_coder_model: None, max_coders: None, max_retries: 2, + base_branch: None, }; // Second call — worktree exists, setup commands fail, must still succeed let result = create_worktree(&project_root, "173_reuse_fail", &failing_config, 3002).await; @@ -869,6 +886,7 @@ mod tests { default_coder_model: None, max_coders: None, max_retries: 2, + base_branch: None, }; let info = create_worktree(&project_root, "77_remove_async", &config, 3001) .await