From eb2c38115040b1fe38a139973b0af506424c51af Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Fri, 25 Sep 2026 04:40:57 +0000 Subject: [PATCH 1/2] Continue an existing Issue branch instead of starting over Issue branch selection now reads the Issue branches on origin (one git ls-remote) and their PRs in any state (one gh pr list). If the highest-numbered one is on origin with no PR or an open PR, the run checks it out and gives the agent the continuation prompt, in its update-PR form when a PR is open. An open PR's base becomes the Base branch, with a note on stderr when it differs from the checkout. A local Issue branch that doesn't match its origin copy stops the run before anything is created. Numbered branches after a merged or closed PR are left to #7; for now that case is an error. Co-Authored-By: Claude Opus 5.5 --- src/branch.rs | 115 ++++++++++++++++ src/github.rs | 63 +++++++-- src/main.rs | 1 + src/prompt.rs | 32 +++++ src/run.rs | 65 +++++++-- src/worktree.rs | 45 +++++-- tests/continuation.rs | 307 ++++++++++++++++++++++++++++++++++++++++++ tests/fakes/gh | 45 ++++++- tests/fresh_run.rs | 20 +++ tests/support/mod.rs | 55 ++++++++ 10 files changed, 716 insertions(+), 32 deletions(-) create mode 100644 src/branch.rs create mode 100644 tests/continuation.rs diff --git a/src/branch.rs b/src/branch.rs new file mode 100644 index 000000000..dede73b7f --- /dev/null +++ b/src/branch.rs @@ -0,0 +1,115 @@ +//! Issue branch selection: start a fresh Issue branch, or continue the one +//! already on origin (ADR-0002). + +use anyhow::{Result, bail}; + +use crate::git::Git; +use crate::github::{self, PullRequest}; +use crate::issue::IssueUrl; + +pub enum Selection { + /// No Issue branch has been used yet: start `branch` from the Base branch. + Fresh { branch: String }, + /// `branch` is on origin at `origin_sha`, with no PR or the open `pr`. + Continuation { + branch: String, + origin_sha: String, + pr: Option, + }, +} + +impl Selection { + pub fn branch(&self) -> &str { + match self { + Selection::Fresh { branch } | Selection::Continuation { branch, .. } => branch, + } + } + + /// Where `branch` is on origin, if it is there. + pub fn origin_sha(&self) -> Option<&str> { + match self { + Selection::Fresh { .. } => None, + Selection::Continuation { origin_sha, .. } => Some(origin_sha), + } + } +} + +/// Pick the Issue branch for `issue` from the Issue branches on origin (one +/// `git ls-remote`) and their PRs in any state (one `gh pr list`). +pub fn select(launch: &Git, issue: &IssueUrl) -> Result { + let first = format!("issue-{}", issue.number); + let remote = launch.run(&[ + "ls-remote", + "--heads", + "origin", + &format!("refs/heads/{first}"), + &format!("refs/heads/{first}-branch-*"), + ])?; + let on_origin: Vec<(u64, String, String)> = remote + .lines() + .filter_map(|line| { + let (sha, name) = line.split_once('\t')?; + let branch = name.strip_prefix("refs/heads/")?; + let number = branch_number(issue, branch)?; + Some((number, branch.to_string(), sha.to_string())) + }) + .collect(); + let prs: Vec = github::pull_requests_from(issue, &first)? + .into_iter() + .filter(|pr| branch_number(issue, &pr.head).is_some()) + .collect(); + + let highest = on_origin + .iter() + .map(|(number, ..)| *number) + .chain(prs.iter().filter_map(|pr| branch_number(issue, &pr.head))) + .max(); + let Some(highest) = highest else { + return Ok(Selection::Fresh { branch: first }); + }; + let branch = branch_name(issue, highest); + // A branch can have had several PRs; the newest one decides. + let pr = prs + .into_iter() + .filter(|pr| pr.head == branch) + .max_by_key(|pr| pr.number); + let origin_sha = on_origin + .into_iter() + .find_map(|(number, _, sha)| (number == highest).then_some(sha)); + match (origin_sha, pr) { + (Some(origin_sha), None) => Ok(Selection::Continuation { + branch, + origin_sha, + pr: None, + }), + (Some(origin_sha), Some(pr)) if pr.state == "OPEN" => Ok(Selection::Continuation { + branch, + origin_sha, + pr: Some(pr), + }), + (_, pr) => bail!( + "{branch} can't be continued ({}), and numbered Issue branches are not supported yet", + match pr { + Some(pr) => format!("its PR {} is {}", pr.url, pr.state.to_lowercase()), + None => "it is gone from origin".to_string(), + } + ), + } +} + +/// `issue-` is branch 1, `issue--branch-` is branch k for k ≥ 2. +fn branch_number(issue: &IssueUrl, branch: &str) -> Option { + let rest = branch.strip_prefix(&format!("issue-{}", issue.number))?; + if rest.is_empty() { + return Some(1); + } + let k: u64 = rest.strip_prefix("-branch-")?.parse().ok()?; + (k >= 2 && rest == format!("-branch-{k}")).then_some(k) +} + +fn branch_name(issue: &IssueUrl, number: u64) -> String { + match number { + 1 => format!("issue-{}", issue.number), + k => format!("issue-{}-branch-{k}", issue.number), + } +} diff --git a/src/github.rs b/src/github.rs index 9090a31e9..e25a3f3b3 100644 --- a/src/github.rs +++ b/src/github.rs @@ -7,17 +7,40 @@ use serde_json::Value; use crate::issue::IssueUrl; +const PR_FIELDS: &str = "number,url,state,headRefName,baseRefName"; + pub struct PullRequest { + pub number: u64, pub url: String, + /// `OPEN`, `CLOSED` or `MERGED`. pub state: String, + pub head: String, pub base: String, } +impl PullRequest { + fn from_json(json: &Value) -> Result { + let field = |name: &str| -> Result { + json[name] + .as_str() + .map(String::from) + .with_context(|| format!("gh output has no {name}")) + }; + Ok(PullRequest { + number: json["number"].as_u64().context("gh output has no number")?, + url: field("url")?, + state: field("state")?, + head: field("headRefName")?, + base: field("baseRefName")?, + }) + } +} + /// The pull request whose head is `branch`, if `gh` finds one. pub fn pull_request_for(issue: &IssueUrl, branch: &str) -> Result> { let output = Command::new("gh") .args(["pr", "view", branch, "--repo", &issue.repo_slug()]) - .args(["--json", "url,state,baseRefName"]) + .args(["--json", PR_FIELDS]) .output() .context("could not run gh")?; if !output.status.success() { @@ -29,15 +52,31 @@ pub fn pull_request_for(issue: &IssueUrl, branch: &str) -> Result Result { - json[name] - .as_str() - .map(String::from) - .with_context(|| format!("gh pr view output has no {name}")) - }; - Ok(Some(PullRequest { - url: field("url")?, - state: field("state")?, - base: field("baseRefName")?, - })) + PullRequest::from_json(&json).map(Some) +} + +/// Every pull request in this repository, in any state, whose head branch +/// starts with `prefix`. PRs from forks are left out: their heads are not +/// this repository's branches. +pub fn pull_requests_from(issue: &IssueUrl, prefix: &str) -> Result> { + let output = Command::new("gh") + .args(["pr", "list", "--repo", &issue.repo_slug(), "--state", "all"]) + .args(["--search", &format!("head:{prefix}"), "--limit", "1000"]) + .args(["--json", &format!("{PR_FIELDS},isCrossRepository")]) + .output() + .context("could not run gh")?; + if !output.status.success() { + bail!( + "gh pr list failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); + } + let json: Value = + serde_json::from_slice(&output.stdout).context("gh pr list returned invalid JSON")?; + json.as_array() + .context("gh pr list did not return a list")? + .iter() + .filter(|pr| pr["isCrossRepository"] != Value::Bool(true)) + .map(PullRequest::from_json) + .collect() } diff --git a/src/main.rs b/src/main.rs index 317c77a8c..f7ce27bbf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,4 @@ +mod branch; mod git; mod github; mod issue; diff --git a/src/prompt.rs b/src/prompt.rs index 679ff2e6b..11f7323c0 100644 --- a/src/prompt.rs +++ b/src/prompt.rs @@ -15,3 +15,35 @@ pub fn fresh(issue: &IssueUrl, base: &str, branch: &str) -> String { number = issue.number, ) } + +/// The continuation prompt, for a run that picks up an existing Issue branch. +/// With `pr_url`, the agent updates that PR; otherwise it creates one. +pub fn continuation(issue: &IssueUrl, base: &str, branch: &str, pr_url: Option<&str>) -> String { + let pr = match pr_url { + Some(url) => format!( + "Update PR {url} using /thirdshift:pr, rewriting its body to cover the whole branch, marked ready for review." + ), + None => format!( + "Create a pull request against {base} using /thirdshift:pr, marked ready for review." + ), + }; + format!( + "/thirdshift:implement {url}\n\ + \n\ + You are continuing work on branch {branch}, which already has commits (see git log {base}..HEAD). Build on them; don't start over.\n\ + \n\ + The base branch is {base}. Review with /thirdshift:code-review using {base} as the fixed point.\n\ + \n\ + Address the Standards and Spec findings you agree with.\n\ + \n\ + Push branch {branch}.\n\ + \n\ + {pr}\n\ + \n\ + In the PR body, add an \"Unaddressed findings\" section listing each skipped finding under Standards or Spec, with at least a one-line reason.\n\ + \n\ + Include \"Closes #{number}\" in the PR body.\n", + url = issue.url, + number = issue.number, + ) +} diff --git a/src/run.rs b/src/run.rs index 247219b50..f6dcb9c90 100644 --- a/src/run.rs +++ b/src/run.rs @@ -2,6 +2,7 @@ use anyhow::{Context, Result, bail}; +use crate::branch::{self, Selection}; use crate::git::Git; use crate::github; use crate::issue::IssueUrl; @@ -21,18 +22,41 @@ pub fn run(issue_url: &str) -> Result { if !issue.matches_origin(&origin) { bail!("origin mismatch: {issue_url} is not in the repository at origin {origin}"); } - let base = launch.run(&["symbolic-ref", "--short", "HEAD"])?; - let branch = format!("issue-{}", issue.number); + let checked_out = launch.run(&["symbolic-ref", "--short", "HEAD"])?; + let selection = branch::select(&launch, &issue)?; + let branch = selection.branch().to_string(); + let base = match &selection { + Selection::Continuation { pr: Some(pr), .. } => { + if pr.base != checked_out { + eprintln!( + "thirdshift: continuing {} and its PR {}, so the Base branch is {}, not the checked-out {checked_out}", + branch, pr.url, pr.base + ); + } + pr.base.clone() + } + _ => checked_out, + }; + check_local_issue_branch(&launch, &branch, selection.origin_sha())?; - let worktree = Worktree::create_fresh(&launch, &issue.repo, &branch, &base)?; + let (worktree, prompt) = match &selection { + Selection::Fresh { .. } => ( + Worktree::create_fresh(&launch, &issue.repo, &branch, &base)?, + prompt::fresh(&issue, &base, &branch), + ), + Selection::Continuation { pr, .. } => ( + Worktree::continue_existing(&launch, &issue.repo, &branch, &base)?, + prompt::continuation( + &issue, + &base, + &branch, + pr.as_ref().map(|pr| pr.url.as_str()), + ), + ), + }; let plugin = Plugin::write()?; let log = session::log_path(&issue, ×tamp, "implement")?; - session::run( - worktree.path(), - plugin.path(), - &prompt::fresh(&issue, &base, &branch), - &log, - )?; + session::run(worktree.path(), plugin.path(), &prompt, &log)?; worktree.git().run(&["push", "origin", &branch])?; let pr = github::pull_request_for(&issue, &branch)?.context("no PR found")?; @@ -44,3 +68,26 @@ pub fn run(issue_url: &str) -> Result { } Ok(pr.url) } + +/// A local Issue branch in the launch repository must match its origin copy +/// (`origin_sha`, or no origin copy at all): the run replaces it and deletes it +/// at cleanup, so anything else would destroy local-only commits. +fn check_local_issue_branch(launch: &Git, branch: &str, origin_sha: Option<&str>) -> Result<()> { + let Ok(local_sha) = launch.run(&[ + "rev-parse", + "--verify", + "--quiet", + &format!("refs/heads/{branch}"), + ]) else { + return Ok(()); + }; + match origin_sha { + Some(origin_sha) if origin_sha == local_sha => Ok(()), + Some(_) => bail!( + "the local branch {branch} differs from origin/{branch}; push, reset or delete it first" + ), + None => { + bail!("the local branch {branch} is not on origin; push, rename or delete it first") + } + } +} diff --git a/src/worktree.rs b/src/worktree.rs index fa0d23fec..b8b7fd0dc 100644 --- a/src/worktree.rs +++ b/src/worktree.rs @@ -17,6 +17,38 @@ impl Worktree { /// Create `branch` fresh from `origin/` in a new worktree next to the /// launch repository's root, named `-`. pub fn create_fresh(launch: &Git, repo: &str, branch: &str, base: &str) -> Result { + launch.run(&["fetch", "origin", base])?; + Self::add( + launch, + repo, + branch, + &["-b", branch], + &format!("origin/{base}"), + ) + } + + /// Check out the existing `branch` from origin in a new worktree next to + /// the launch repository's root, named `-`. `origin/` + /// is fetched too, for the review fixed point. A local `branch`, if any, + /// must already match its origin copy: it is reset to it. + pub fn continue_existing(launch: &Git, repo: &str, branch: &str, base: &str) -> Result { + launch.run(&["fetch", "origin", base, branch])?; + Self::add( + launch, + repo, + branch, + &["-B", branch], + &format!("origin/{branch}"), + ) + } + + fn add( + launch: &Git, + repo: &str, + branch: &str, + branch_args: &[&str], + start: &str, + ) -> Result { let root = PathBuf::from(launch.run(&["rev-parse", "--show-toplevel"])?); let path = root .parent() @@ -24,15 +56,10 @@ impl Worktree { .join(format!("{repo}-{branch}")); let path_arg = path.to_str().context("worktree path is not UTF-8")?; - launch.run(&["fetch", "origin", base])?; - launch.run(&[ - "worktree", - "add", - "-b", - branch, - path_arg, - &format!("origin/{base}"), - ])?; + let mut args = vec!["worktree", "add"]; + args.extend_from_slice(branch_args); + args.extend([path_arg, start]); + launch.run(&args)?; Ok(Worktree { launch: Git::new(root), branch: branch.to_string(), diff --git a/tests/continuation.rs b/tests/continuation.rs new file mode 100644 index 000000000..2a1dafd10 --- /dev/null +++ b/tests/continuation.rs @@ -0,0 +1,307 @@ +//! Continuation (ADR-0002): an Issue branch already on origin, with no PR or an +//! open one, is picked up where it stopped instead of started over. + +mod support; + +use support::Scenario; + +/// The agent commits on top of whatever the branch already has and leaves +/// the PR alone: it already exists, or the test doesn't need one. +const AGENT_COMMITS: &str = r#" +echo "more" > more.txt +git add more.txt +git commit -q -m "Continue the work" +"#; + +/// The agent commits and opens a PR for `issue-7` against `main`. +const AGENT_COMMITS_AND_OPENS_PR: &str = r#" +echo "more" > more.txt +git add more.txt +git commit -q -m "Continue the work" +gh pr create --base main --head issue-7 --title "Add feature" --body "Closes #7" +"#; + +#[test] +fn an_issue_branch_with_no_pr_is_continued() { + let scenario = Scenario::new(); + scenario.origin_has_branch("issue-7", "main", &["Earlier work"]); + scenario.agent_does(AGENT_COMMITS_AND_OPENS_PR); + + let result = scenario.run(&[&scenario.issue_url(7)]); + + assert_eq!(result.code, Some(0), "stderr: {}", result.stderr); + assert_eq!(result.stdout, "https://github.com/acme/widgets/pull/1\n"); + assert_eq!(scenario.claude_calls()[0]["branch"], "issue-7"); + assert_eq!( + scenario.origin_log("issue-7"), + Some(vec![ + "Continue the work".to_string(), + "Earlier work".to_string(), + "Initial commit".to_string(), + ]) + ); +} + +#[test] +fn without_a_pr_the_continuation_prompt_asks_for_one() { + let scenario = Scenario::new(); + scenario.origin_has_branch("issue-7", "main", &["Earlier work"]); + scenario.agent_does(AGENT_COMMITS_AND_OPENS_PR); + + scenario.run(&[&scenario.issue_url(7)]); + + assert_eq!( + scenario.claude_calls()[0]["prompt"], + "/thirdshift:implement https://github.com/acme/widgets/issues/7\n\ + \n\ + You are continuing work on branch issue-7, which already has commits (see git log main..HEAD). Build on them; don't start over.\n\ + \n\ + The base branch is main. Review with /thirdshift:code-review using main as the fixed point.\n\ + \n\ + Address the Standards and Spec findings you agree with.\n\ + \n\ + Push branch issue-7.\n\ + \n\ + Create a pull request against main using /thirdshift:pr, marked ready for review.\n\ + \n\ + In the PR body, add an \"Unaddressed findings\" section listing each skipped finding under Standards or Spec, with at least a one-line reason.\n\ + \n\ + Include \"Closes #7\" in the PR body.\n" + ); +} + +#[test] +fn an_issue_branch_with_an_open_pr_is_continued_and_that_pr_is_the_result() { + let scenario = Scenario::new(); + scenario.origin_has_branch("issue-7", "main", &["Earlier work"]); + let pr = scenario.github_has_pr("issue-7", "main", "OPEN"); + scenario.agent_does(AGENT_COMMITS); + + let result = scenario.run(&[&scenario.issue_url(7)]); + + assert_eq!(result.code, Some(0), "stderr: {}", result.stderr); + assert_eq!(result.stdout, format!("{pr}\n")); + assert_eq!(scenario.gh_state()["prs"].as_array().unwrap().len(), 1); + assert_eq!( + scenario.origin_log("issue-7"), + Some(vec![ + "Continue the work".to_string(), + "Earlier work".to_string(), + "Initial commit".to_string(), + ]) + ); +} + +#[test] +fn with_an_open_pr_the_continuation_prompt_asks_to_update_it() { + let scenario = Scenario::new(); + scenario.origin_has_branch("issue-7", "main", &["Earlier work"]); + scenario.github_has_pr("issue-7", "main", "OPEN"); + scenario.agent_does(AGENT_COMMITS); + + scenario.run(&[&scenario.issue_url(7)]); + + let prompt = scenario.claude_calls()[0]["prompt"] + .as_str() + .unwrap() + .to_string(); + assert!( + prompt.contains( + "\n\nPush branch issue-7.\n\ + \n\ + Update PR https://github.com/acme/widgets/pull/1 using /thirdshift:pr, rewriting its body to cover the whole branch, marked ready for review.\n\ + \n" + ), + "prompt: {prompt}" + ); + assert!( + !prompt.contains("Create a pull request"), + "prompt: {prompt}" + ); +} + +#[test] +fn the_open_prs_base_is_the_base_branch_whatever_is_checked_out() { + let scenario = Scenario::new(); + scenario.origin_has_branch("develop", "main", &["Develop work"]); + scenario.launch_checks_out("develop"); + scenario.origin_has_branch("issue-7", "main", &["Earlier work"]); + let pr = scenario.github_has_pr("issue-7", "main", "OPEN"); + scenario.agent_does(AGENT_COMMITS); + + let result = scenario.run(&[&scenario.issue_url(7)]); + + assert_eq!(result.code, Some(0), "stderr: {}", result.stderr); + assert_eq!(result.stdout, format!("{pr}\n")); + let prompt = scenario.claude_calls()[0]["prompt"] + .as_str() + .unwrap() + .to_string(); + assert!( + prompt.contains("(see git log main..HEAD)") + && prompt.contains("The base branch is main. Review with /thirdshift:code-review using main as the fixed point.") + && !prompt.contains("develop"), + "prompt: {prompt}" + ); + assert!( + result + .stderr + .contains("the Base branch is main, not the checked-out develop"), + "stderr: {}", + result.stderr + ); +} + +#[test] +fn without_an_open_pr_the_checked_out_branch_stays_the_base_branch() { + let scenario = Scenario::new(); + scenario.origin_has_branch("develop", "main", &["Develop work"]); + scenario.launch_checks_out("develop"); + scenario.origin_has_branch("issue-7", "develop", &["Earlier work"]); + scenario.agent_does( + r#" +git commit -q --allow-empty -m "Continue the work" +gh pr create --base develop --head issue-7 --title "Add feature" --body "Closes #7" +"#, + ); + + let result = scenario.run(&[&scenario.issue_url(7)]); + + assert_eq!(result.code, Some(0), "stderr: {}", result.stderr); + let prompt = scenario.claude_calls()[0]["prompt"] + .as_str() + .unwrap() + .to_string(); + assert!( + prompt.contains("Create a pull request against develop"), + "prompt: {prompt}" + ); + assert!( + !result.stderr.contains("Base branch"), + "stderr: {}", + result.stderr + ); +} + +/// Nothing was created: no session, worktree, temp directory or log, and the +/// local Issue branch is still there. +fn assert_nothing_created(scenario: &Scenario, local_branch: &str) { + assert!(scenario.claude_calls().is_empty(), "claude was run"); + assert_eq!(scenario.entries("work"), vec!["widgets"]); + assert_eq!(scenario.entries("tmp"), Vec::::new()); + assert!( + !scenario.path("home/.thirdshift").exists(), + "a log was created" + ); + assert_ne!(scenario.launch_git(&["branch", "--list", local_branch]), ""); +} + +#[test] +fn a_local_issue_branch_that_differs_from_origin_stops_the_run() { + let scenario = Scenario::new(); + scenario.origin_has_branch("issue-7", "main", &["Earlier work"]); + scenario.launch_checks_out("issue-7"); + scenario.launch_git(&["commit", "-q", "--allow-empty", "-m", "Local only"]); + scenario.launch_checks_out("main"); + scenario.agent_does(AGENT_COMMITS_AND_OPENS_PR); + + let result = scenario.run(&[&scenario.issue_url(7)]); + + assert_ne!(result.code, Some(0)); + assert_eq!(result.stdout, ""); + assert!( + result + .stderr + .contains("issue-7 differs from origin/issue-7"), + "stderr: {}", + result.stderr + ); + assert_nothing_created(&scenario, "issue-7"); + assert_eq!( + scenario.launch_git(&["log", "-1", "--format=%s", "issue-7"]), + "Local only\n" + ); + assert_eq!(scenario.origin_log("issue-7").map(|log| log.len()), Some(2)); +} + +#[test] +fn a_local_issue_branch_missing_from_origin_stops_the_run() { + let scenario = Scenario::new(); + scenario.launch_git(&["branch", "issue-7"]); + scenario.agent_does(AGENT_COMMITS_AND_OPENS_PR); + + let result = scenario.run(&[&scenario.issue_url(7)]); + + assert_ne!(result.code, Some(0)); + assert!( + result.stderr.contains("issue-7 is not on origin"), + "stderr: {}", + result.stderr + ); + assert_nothing_created(&scenario, "issue-7"); + assert_eq!(scenario.origin_log("issue-7"), None); +} + +#[test] +fn a_local_issue_branch_matching_origin_is_continued() { + let scenario = Scenario::new(); + scenario.origin_has_branch("issue-7", "main", &["Earlier work"]); + scenario.launch_checks_out("issue-7"); + scenario.launch_checks_out("main"); + scenario.agent_does(AGENT_COMMITS_AND_OPENS_PR); + + let result = scenario.run(&[&scenario.issue_url(7)]); + + assert_eq!(result.code, Some(0), "stderr: {}", result.stderr); + assert_eq!(scenario.origin_log("issue-7").map(|log| log.len()), Some(3)); +} + +#[test] +fn a_failed_runs_branch_is_continued_from_its_failure_commit() { + let scenario = Scenario::new(); + scenario.origin_has_branch( + "issue-7", + "main", + &["Earlier work", "thirdshift: failed run (claude exited 1)"], + ); + scenario.agent_does(AGENT_COMMITS_AND_OPENS_PR); + + let result = scenario.run(&[&scenario.issue_url(7)]); + + assert_eq!(result.code, Some(0), "stderr: {}", result.stderr); + assert_eq!( + scenario.origin_log("issue-7"), + Some(vec![ + "Continue the work".to_string(), + "thirdshift: failed run (claude exited 1)".to_string(), + "Earlier work".to_string(), + "Initial commit".to_string(), + ]) + ); +} + +#[test] +fn selection_asks_github_once_for_the_pr_history() { + let scenario = Scenario::new(); + scenario.origin_has_branch("issue-7", "main", &["Earlier work"]); + scenario.github_has_pr("issue-7", "main", "OPEN"); + scenario.agent_does(AGENT_COMMITS); + + scenario.run(&[&scenario.issue_url(7)]); + + // Everything before the post-session `pr view` is selection. The agent + // here makes no gh calls of its own. + let subcommands: Vec = scenario + .gh_calls() + .iter() + .map(|argv| argv[..2].join(" ")) + .collect(); + assert_eq!(subcommands, vec!["pr list", "pr view"]); + assert!( + scenario.gh_calls()[0] + .windows(2) + .any(|w| w == ["--state", "all"]), + "gh calls: {:?}", + scenario.gh_calls() + ); +} diff --git a/tests/fakes/gh b/tests/fakes/gh index fc6ab11f4..cfaeb5834 100644 --- a/tests/fakes/gh +++ b/tests/fakes/gh @@ -6,6 +6,8 @@ State lives in the JSON file named by $FAKE_GH_STATE: {"repo": "owner/repo", "prs": [{"number", "url", "head", "base", "state", "isDraft", "title", "body"}]} +Every call's argv is appended to the JSON list in $FAKE_GH_RECORD. + Only the subcommands thirdshift and the fake agents use are supported. Anything else exits 2 so an unexpected call fails loudly. """ @@ -84,19 +86,54 @@ def pr_view(state, positional, flags): if not matches: die(f'no pull requests found for branch "{head}"') pr = matches[-1] - fields = { + wanted = flags["json"].split(",") + print(json.dumps({key: pr_fields(pr)[key] for key in wanted})) + + +def record(args): + path = os.environ["FAKE_GH_RECORD"] + calls = [] + if os.path.exists(path): + with open(path) as f: + calls = json.load(f) + calls.append(args) + with open(path, "w") as f: + json.dump(calls, f, indent=2) + + +def pr_fields(pr): + return { "url": pr["url"], "state": pr["state"], + "headRefName": pr["head"], "baseRefName": pr["base"], "isDraft": pr["isDraft"], "number": pr["number"], + "isCrossRepository": pr.get("isCrossRepository", False), } + + +def pr_list(state, flags): + """Newest first. `--search head:` matches head branches starting + with , as GitHub's search does.""" + wanted_state = flags.get("state", "open").upper() + search = flags.get("search", "") + prefix = search[len("head:"):] if search.startswith("head:") else "" + if search and not prefix: + die(f"fake gh: unsupported search {search}", 2) + prs = [ + pr + for pr in reversed(state["prs"]) + if pr["head"].startswith(prefix) + and (wanted_state == "ALL" or pr["state"] == wanted_state) + ][: int(flags.get("limit", 30))] wanted = flags["json"].split(",") - print(json.dumps({key: fields[key] for key in wanted})) + print(json.dumps([{key: pr_fields(pr)[key] for key in wanted} for pr in prs])) def main(): args = sys.argv[1:] + record(args) state = load() if args[:2] == ["pr", "create"]: positional, flags = parse(args[2:]) @@ -106,6 +143,10 @@ def main(): positional, flags = parse(args[2:]) check_repo(state, flags) pr_view(state, positional, flags) + elif args[:2] == ["pr", "list"]: + positional, flags = parse(args[2:]) + check_repo(state, flags) + pr_list(state, flags) else: die(f"fake gh: unsupported command: {args}", 2) diff --git a/tests/fresh_run.rs b/tests/fresh_run.rs index 5fbbf0536..f3a6c8f56 100644 --- a/tests/fresh_run.rs +++ b/tests/fresh_run.rs @@ -183,3 +183,23 @@ fn leaves_no_worktree_local_issue_branch_or_temp_directory_behind() { assert_eq!(scenario.launch_git(&["branch", "--list", "issue-7"]), ""); assert_eq!(scenario.entries("tmp"), Vec::::new()); } + +#[test] +fn another_issues_branch_and_pr_do_not_count_as_this_issues() { + let scenario = Scenario::new(); + scenario.origin_has_branch("issue-70", "main", &["Other work"]); + scenario.github_has_pr("issue-70", "main", "MERGED"); + scenario.agent_does(AGENT_COMMITS_AND_OPENS_PR); + + let result = scenario.run(&[&scenario.issue_url(7)]); + + assert_eq!(result.code, Some(0), "stderr: {}", result.stderr); + assert_eq!(scenario.claude_calls()[0]["branch"], "issue-7"); + assert_eq!( + scenario.origin_log("issue-7"), + Some(vec![ + "Add feature".to_string(), + "Initial commit".to_string() + ]) + ); +} diff --git a/tests/support/mod.rs b/tests/support/mod.rs index 605dec894..d6c77f007 100644 --- a/tests/support/mod.rs +++ b/tests/support/mod.rs @@ -12,6 +12,7 @@ //! gh-state.json fake GitHub state //! claude-script.sh what the fake agent does this test //! claude-calls.json what the fake agent was asked to do +//! gh-calls.json every gh command run, by thirdshift or the fake agent //! ``` #![allow(dead_code)] @@ -116,6 +117,7 @@ impl Scenario { .env("FAKE_GH_STATE", self.path("gh-state.json")) .env("FAKE_CLAUDE_SCRIPT", self.path("claude-script.sh")) .env("FAKE_CLAUDE_RECORD", self.path("claude-calls.json")) + .env("FAKE_GH_RECORD", self.path("gh-calls.json")) .output() .unwrap(); RunResult { @@ -145,6 +147,53 @@ impl Scenario { } } + /// The argv of every `gh` call, in order. + pub fn gh_calls(&self) -> Vec> { + match fs::read_to_string(self.path("gh-calls.json")) { + Ok(text) => serde_json::from_str(&text).unwrap(), + Err(_) => Vec::new(), + } + } + + /// Push `branch` to origin: `from` plus one commit per subject in + /// `commits`, oldest first. + pub fn origin_has_branch(&self, branch: &str, from: &str, commits: &[&str]) { + let seed = self.path("seed"); + git(&self.path(""), &["clone", "-q", &self.github_url(), "seed"]); + git( + &seed, + &["checkout", "-q", "-b", branch, &format!("origin/{from}")], + ); + for (i, subject) in commits.iter().enumerate() { + fs::write(seed.join(format!("{branch}-{i}.txt")), subject).unwrap(); + git(&seed, &["add", "."]); + git(&seed, &["commit", "-q", "-m", subject]); + } + git(&seed, &["push", "-q", "origin", branch]); + fs::remove_dir_all(&seed).unwrap(); + } + + /// Add a PR from `head` into `base` in `state` (`OPEN`, `CLOSED` or + /// `MERGED`) to the fake GitHub and return its URL. + pub fn github_has_pr(&self, head: &str, base: &str, state: &str) -> String { + let mut gh = self.gh_state(); + let prs = gh["prs"].as_array_mut().unwrap(); + let number = prs.len() + 1; + let url = format!("https://github.com/{OWNER}/{REPO}/pull/{number}"); + prs.push(json!({ + "number": number, + "url": url, + "head": head, + "base": base, + "state": state, + "isDraft": false, + "title": format!("Work on {head}"), + "body": "", + })); + self.write_gh_state(&gh); + url + } + /// Commit subjects on `branch` in the origin repo, newest first, or `None` /// if the branch doesn't exist there. pub fn origin_log(&self, branch: &str) -> Option> { @@ -164,6 +213,12 @@ impl Scenario { }) } + /// Fetch origin in the launch clone and check out `branch` there. + pub fn launch_checks_out(&self, branch: &str) { + self.launch_git(&["fetch", "-q", "origin"]); + self.launch_git(&["checkout", "-q", branch]); + } + /// Output of a git command in the launch clone. pub fn launch_git(&self, args: &[&str]) -> String { git(&self.launch_dir(), args) From 52d373562b7b95c6bef928926be1e2d97f7bd817 Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Fri, 25 Sep 2026 04:43:38 +0000 Subject: [PATCH 2/2] Let Issue branch selection own the local-branch check and the Base branch From review: selection now refuses a local Issue branch that differs from origin, and works out the Base branch itself, so a detached HEAD only matters when the Base branch has to come from the checkout. PR state is an enum, gh JSON calls share one helper, and branch names come from one function. Co-Authored-By: Claude Opus 5.5 --- src/branch.rs | 113 +++++++++++++++++++++++++++--------------- src/github.rs | 100 ++++++++++++++++++++++++++----------- src/run.rs | 47 +++--------------- src/worktree.rs | 4 +- tests/continuation.rs | 34 ++++++++----- tests/support/mod.rs | 8 +++ 6 files changed, 183 insertions(+), 123 deletions(-) diff --git a/src/branch.rs b/src/branch.rs index dede73b7f..9b8c38869 100644 --- a/src/branch.rs +++ b/src/branch.rs @@ -1,19 +1,18 @@ //! Issue branch selection: start a fresh Issue branch, or continue the one //! already on origin (ADR-0002). -use anyhow::{Result, bail}; +use anyhow::{Context, Result, bail}; use crate::git::Git; -use crate::github::{self, PullRequest}; +use crate::github::{self, PrState, PullRequest}; use crate::issue::IssueUrl; pub enum Selection { /// No Issue branch has been used yet: start `branch` from the Base branch. Fresh { branch: String }, - /// `branch` is on origin at `origin_sha`, with no PR or the open `pr`. + /// `branch` is on origin, with no PR or the open `pr`. Continuation { branch: String, - origin_sha: String, pr: Option, }, } @@ -25,86 +24,122 @@ impl Selection { } } - /// Where `branch` is on origin, if it is there. - pub fn origin_sha(&self) -> Option<&str> { - match self { - Selection::Fresh { .. } => None, - Selection::Continuation { origin_sha, .. } => Some(origin_sha), + /// The Base branch: the open PR's base in a Continuation that has one, + /// otherwise `checked_out`, the branch checked out in the launch + /// directory (`None` on a detached HEAD). Says so on stderr when the PR's + /// base replaces a different checked-out branch. + pub fn base_branch(&self, checked_out: Option<&str>) -> Result { + if let Selection::Continuation { + branch, + pr: Some(pr), + } = self + { + if let Some(checked_out) = checked_out.filter(|&c| c != pr.base) { + eprintln!( + "thirdshift: continuing {branch} and its PR {}, so the Base branch is {}, not the checked-out {checked_out}", + pr.url, pr.base + ); + } + return Ok(pr.base.clone()); } + checked_out + .map(String::from) + .context("HEAD is detached, so there is no Base branch") } } /// Pick the Issue branch for `issue` from the Issue branches on origin (one -/// `git ls-remote`) and their PRs in any state (one `gh pr list`). +/// `git ls-remote`) and their PRs in any state (one `gh pr list`). Fails if a +/// local copy of the chosen branch in the launch repository differs from +/// origin's: the Run replaces it and deletes it at cleanup. pub fn select(launch: &Git, issue: &IssueUrl) -> Result { - let first = format!("issue-{}", issue.number); + let first_branch = branch_name(issue, 1); let remote = launch.run(&[ "ls-remote", "--heads", "origin", - &format!("refs/heads/{first}"), - &format!("refs/heads/{first}-branch-*"), + &format!("refs/heads/{first_branch}"), + &format!("refs/heads/{first_branch}-branch-*"), ])?; - let on_origin: Vec<(u64, String, String)> = remote + let on_origin: Vec<(u64, String)> = remote .lines() .filter_map(|line| { let (sha, name) = line.split_once('\t')?; - let branch = name.strip_prefix("refs/heads/")?; - let number = branch_number(issue, branch)?; - Some((number, branch.to_string(), sha.to_string())) + let number = branch_number(issue, name.strip_prefix("refs/heads/")?)?; + Some((number, sha.to_string())) }) .collect(); - let prs: Vec = github::pull_requests_from(issue, &first)? - .into_iter() - .filter(|pr| branch_number(issue, &pr.head).is_some()) - .collect(); + let prs: Vec<(u64, PullRequest)> = + github::pull_requests_with_head_prefix(issue, &first_branch)? + .into_iter() + .filter_map(|pr| Some((branch_number(issue, &pr.head)?, pr))) + .collect(); let highest = on_origin .iter() - .map(|(number, ..)| *number) - .chain(prs.iter().filter_map(|pr| branch_number(issue, &pr.head))) + .map(|(number, _)| *number) + .chain(prs.iter().map(|(number, _)| *number)) .max(); let Some(highest) = highest else { - return Ok(Selection::Fresh { branch: first }); + check_local_branch(launch, &first_branch, None)?; + return Ok(Selection::Fresh { + branch: first_branch, + }); }; let branch = branch_name(issue, highest); // A branch can have had several PRs; the newest one decides. let pr = prs .into_iter() - .filter(|pr| pr.head == branch) + .filter_map(|(number, pr)| (number == highest).then_some(pr)) .max_by_key(|pr| pr.number); let origin_sha = on_origin .into_iter() - .find_map(|(number, _, sha)| (number == highest).then_some(sha)); + .find_map(|(number, sha)| (number == highest).then_some(sha)); match (origin_sha, pr) { - (Some(origin_sha), None) => Ok(Selection::Continuation { - branch, - origin_sha, - pr: None, - }), - (Some(origin_sha), Some(pr)) if pr.state == "OPEN" => Ok(Selection::Continuation { - branch, - origin_sha, - pr: Some(pr), - }), + (Some(origin_sha), pr) if pr.as_ref().is_none_or(|pr| pr.state == PrState::Open) => { + check_local_branch(launch, &branch, Some(&origin_sha))?; + Ok(Selection::Continuation { branch, pr }) + } (_, pr) => bail!( "{branch} can't be continued ({}), and numbered Issue branches are not supported yet", match pr { - Some(pr) => format!("its PR {} is {}", pr.url, pr.state.to_lowercase()), + Some(pr) => format!("its PR {} is {}", pr.url, pr.state), None => "it is gone from origin".to_string(), } ), } } +/// A local `branch` in the launch repository must be at `origin_sha`, or not +/// exist when origin has no copy, so no local-only commits are destroyed. +fn check_local_branch(launch: &Git, branch: &str, origin_sha: Option<&str>) -> Result<()> { + let Ok(local_sha) = launch.run(&[ + "rev-parse", + "--verify", + "--quiet", + &format!("refs/heads/{branch}"), + ]) else { + return Ok(()); + }; + match origin_sha { + Some(origin_sha) if origin_sha == local_sha => Ok(()), + Some(_) => bail!( + "the local branch {branch} differs from origin/{branch}; push, reset or delete it first" + ), + None => { + bail!("the local branch {branch} is not on origin; push, rename or delete it first") + } + } +} + /// `issue-` is branch 1, `issue--branch-` is branch k for k ≥ 2. fn branch_number(issue: &IssueUrl, branch: &str) -> Option { - let rest = branch.strip_prefix(&format!("issue-{}", issue.number))?; + let rest = branch.strip_prefix(&branch_name(issue, 1))?; if rest.is_empty() { return Some(1); } let k: u64 = rest.strip_prefix("-branch-")?.parse().ok()?; - (k >= 2 && rest == format!("-branch-{k}")).then_some(k) + (k >= 2 && branch == branch_name(issue, k)).then_some(k) } fn branch_name(issue: &IssueUrl, number: u64) -> String { diff --git a/src/github.rs b/src/github.rs index e25a3f3b3..0dbebb1a4 100644 --- a/src/github.rs +++ b/src/github.rs @@ -1,5 +1,6 @@ //! Asking GitHub, through `gh`, about pull requests. +use std::fmt; use std::process::Command; use anyhow::{Context, Result, bail}; @@ -9,11 +10,27 @@ use crate::issue::IssueUrl; const PR_FIELDS: &str = "number,url,state,headRefName,baseRefName"; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PrState { + Open, + Closed, + Merged, +} + +impl fmt::Display for PrState { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + PrState::Open => "open", + PrState::Closed => "closed", + PrState::Merged => "merged", + }) + } +} + pub struct PullRequest { pub number: u64, pub url: String, - /// `OPEN`, `CLOSED` or `MERGED`. - pub state: String, + pub state: PrState, pub head: String, pub base: String, } @@ -26,10 +43,16 @@ impl PullRequest { .map(String::from) .with_context(|| format!("gh output has no {name}")) }; + let state = match field("state")?.as_str() { + "OPEN" => PrState::Open, + "CLOSED" => PrState::Closed, + "MERGED" => PrState::Merged, + other => bail!("gh output has an unknown PR state {other}"), + }; Ok(PullRequest { number: json["number"].as_u64().context("gh output has no number")?, url: field("url")?, - state: field("state")?, + state, head: field("headRefName")?, base: field("baseRefName")?, }) @@ -38,45 +61,62 @@ impl PullRequest { /// The pull request whose head is `branch`, if `gh` finds one. pub fn pull_request_for(issue: &IssueUrl, branch: &str) -> Result> { - let output = Command::new("gh") - .args(["pr", "view", branch, "--repo", &issue.repo_slug()]) - .args(["--json", PR_FIELDS]) - .output() - .context("could not run gh")?; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - if stderr.contains("no pull requests found") { - return Ok(None); - } - bail!("gh pr view {branch} failed: {}", stderr.trim()); - } - let json: Value = - serde_json::from_slice(&output.stdout).context("gh pr view returned invalid JSON")?; + let json = match gh_json(&[ + "pr", + "view", + branch, + "--repo", + &issue.repo_slug(), + "--json", + PR_FIELDS, + ]) { + Ok(json) => json, + Err(error) if format!("{error:#}").contains("no pull requests found") => return Ok(None), + Err(error) => return Err(error), + }; PullRequest::from_json(&json).map(Some) } /// Every pull request in this repository, in any state, whose head branch /// starts with `prefix`. PRs from forks are left out: their heads are not /// this repository's branches. -pub fn pull_requests_from(issue: &IssueUrl, prefix: &str) -> Result> { +pub fn pull_requests_with_head_prefix(issue: &IssueUrl, prefix: &str) -> Result> { + let json = gh_json(&[ + "pr", + "list", + "--repo", + &issue.repo_slug(), + "--state", + "all", + "--search", + &format!("head:{prefix}"), + "--limit", + "1000", + "--json", + &format!("{PR_FIELDS},isCrossRepository"), + ])?; + json.as_array() + .context("gh pr list did not return a list")? + .iter() + .filter(|pr| pr["isCrossRepository"] != Value::Bool(true)) + .map(PullRequest::from_json) + .collect() +} + +/// Run `gh ` and parse its stdout as JSON, failing with gh's stderr if +/// it exits non-zero. +fn gh_json(args: &[&str]) -> Result { + let command = format!("gh {}", args[..2].join(" ")); let output = Command::new("gh") - .args(["pr", "list", "--repo", &issue.repo_slug(), "--state", "all"]) - .args(["--search", &format!("head:{prefix}"), "--limit", "1000"]) - .args(["--json", &format!("{PR_FIELDS},isCrossRepository")]) + .args(args) .output() .context("could not run gh")?; if !output.status.success() { bail!( - "gh pr list failed: {}", + "{command} failed: {}", String::from_utf8_lossy(&output.stderr).trim() ); } - let json: Value = - serde_json::from_slice(&output.stdout).context("gh pr list returned invalid JSON")?; - json.as_array() - .context("gh pr list did not return a list")? - .iter() - .filter(|pr| pr["isCrossRepository"] != Value::Bool(true)) - .map(PullRequest::from_json) - .collect() + serde_json::from_slice(&output.stdout) + .with_context(|| format!("{command} returned invalid JSON")) } diff --git a/src/run.rs b/src/run.rs index f6dcb9c90..e6986ca8d 100644 --- a/src/run.rs +++ b/src/run.rs @@ -4,7 +4,7 @@ use anyhow::{Context, Result, bail}; use crate::branch::{self, Selection}; use crate::git::Git; -use crate::github; +use crate::github::{self, PrState}; use crate::issue::IssueUrl; use crate::plugin::Plugin; use crate::prompt; @@ -22,22 +22,12 @@ pub fn run(issue_url: &str) -> Result { if !issue.matches_origin(&origin) { bail!("origin mismatch: {issue_url} is not in the repository at origin {origin}"); } - let checked_out = launch.run(&["symbolic-ref", "--short", "HEAD"])?; + let checked_out = launch + .run(&["symbolic-ref", "--quiet", "--short", "HEAD"]) + .ok(); let selection = branch::select(&launch, &issue)?; let branch = selection.branch().to_string(); - let base = match &selection { - Selection::Continuation { pr: Some(pr), .. } => { - if pr.base != checked_out { - eprintln!( - "thirdshift: continuing {} and its PR {}, so the Base branch is {}, not the checked-out {checked_out}", - branch, pr.url, pr.base - ); - } - pr.base.clone() - } - _ => checked_out, - }; - check_local_issue_branch(&launch, &branch, selection.origin_sha())?; + let base = selection.base_branch(checked_out.as_deref())?; let (worktree, prompt) = match &selection { Selection::Fresh { .. } => ( @@ -60,34 +50,11 @@ pub fn run(issue_url: &str) -> Result { worktree.git().run(&["push", "origin", &branch])?; let pr = github::pull_request_for(&issue, &branch)?.context("no PR found")?; - if pr.state != "OPEN" { - bail!("PR {} is {}, not open", pr.url, pr.state.to_lowercase()); + if pr.state != PrState::Open { + bail!("PR {} is {}, not open", pr.url, pr.state); } if pr.base != base { bail!("PR targets {}, not {base}", pr.base); } Ok(pr.url) } - -/// A local Issue branch in the launch repository must match its origin copy -/// (`origin_sha`, or no origin copy at all): the run replaces it and deletes it -/// at cleanup, so anything else would destroy local-only commits. -fn check_local_issue_branch(launch: &Git, branch: &str, origin_sha: Option<&str>) -> Result<()> { - let Ok(local_sha) = launch.run(&[ - "rev-parse", - "--verify", - "--quiet", - &format!("refs/heads/{branch}"), - ]) else { - return Ok(()); - }; - match origin_sha { - Some(origin_sha) if origin_sha == local_sha => Ok(()), - Some(_) => bail!( - "the local branch {branch} differs from origin/{branch}; push, reset or delete it first" - ), - None => { - bail!("the local branch {branch} is not on origin; push, rename or delete it first") - } - } -} diff --git a/src/worktree.rs b/src/worktree.rs index b8b7fd0dc..d1e22c6ac 100644 --- a/src/worktree.rs +++ b/src/worktree.rs @@ -29,8 +29,8 @@ impl Worktree { /// Check out the existing `branch` from origin in a new worktree next to /// the launch repository's root, named `-`. `origin/` - /// is fetched too, for the review fixed point. A local `branch`, if any, - /// must already match its origin copy: it is reset to it. + /// is fetched too, for the review fixed point. A local `branch`, if any, is + /// reset to origin's: `branch::select` has checked they already match. pub fn continue_existing(launch: &Git, repo: &str, branch: &str, base: &str) -> Result { launch.run(&["fetch", "origin", base, branch])?; Self::add( diff --git a/tests/continuation.rs b/tests/continuation.rs index 2a1dafd10..133c25c9d 100644 --- a/tests/continuation.rs +++ b/tests/continuation.rs @@ -101,10 +101,7 @@ fn with_an_open_pr_the_continuation_prompt_asks_to_update_it() { scenario.run(&[&scenario.issue_url(7)]); - let prompt = scenario.claude_calls()[0]["prompt"] - .as_str() - .unwrap() - .to_string(); + let prompt = scenario.first_prompt(); assert!( prompt.contains( "\n\nPush branch issue-7.\n\ @@ -133,10 +130,7 @@ fn the_open_prs_base_is_the_base_branch_whatever_is_checked_out() { assert_eq!(result.code, Some(0), "stderr: {}", result.stderr); assert_eq!(result.stdout, format!("{pr}\n")); - let prompt = scenario.claude_calls()[0]["prompt"] - .as_str() - .unwrap() - .to_string(); + let prompt = scenario.first_prompt(); assert!( prompt.contains("(see git log main..HEAD)") && prompt.contains("The base branch is main. Review with /thirdshift:code-review using main as the fixed point.") @@ -168,10 +162,7 @@ gh pr create --base develop --head issue-7 --title "Add feature" --body "Closes let result = scenario.run(&[&scenario.issue_url(7)]); assert_eq!(result.code, Some(0), "stderr: {}", result.stderr); - let prompt = scenario.claude_calls()[0]["prompt"] - .as_str() - .unwrap() - .to_string(); + let prompt = scenario.first_prompt(); assert!( prompt.contains("Create a pull request against develop"), "prompt: {prompt}" @@ -305,3 +296,22 @@ fn selection_asks_github_once_for_the_pr_history() { scenario.gh_calls() ); } + +#[test] +fn an_open_pr_supplies_the_base_branch_even_on_a_detached_head() { + let scenario = Scenario::new(); + scenario.launch_git(&["checkout", "-q", "--detach"]); + scenario.origin_has_branch("issue-7", "main", &["Earlier work"]); + let pr = scenario.github_has_pr("issue-7", "main", "OPEN"); + scenario.agent_does(AGENT_COMMITS); + + let result = scenario.run(&[&scenario.issue_url(7)]); + + assert_eq!(result.code, Some(0), "stderr: {}", result.stderr); + assert_eq!(result.stdout, format!("{pr}\n")); + assert!( + scenario.first_prompt().contains("The base branch is main."), + "prompt: {}", + scenario.first_prompt() + ); +} diff --git a/tests/support/mod.rs b/tests/support/mod.rs index d6c77f007..c276c9900 100644 --- a/tests/support/mod.rs +++ b/tests/support/mod.rs @@ -147,6 +147,14 @@ impl Scenario { } } + /// The prompt of the first `claude` call. + pub fn first_prompt(&self) -> String { + self.claude_calls()[0]["prompt"] + .as_str() + .expect("claude got no prompt") + .to_string() + } + /// The argv of every `gh` call, in order. pub fn gh_calls(&self) -> Vec> { match fs::read_to_string(self.path("gh-calls.json")) {