diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..e27cadf4b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,21 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + - run: cargo fmt --check + - run: cargo clippy --all-targets -- -D warnings + # The tests run the binary against real git and fake gh and claude + # (Python scripts in tests/fakes), all present on ubuntu-latest. + - run: cargo test diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..ea8c4bf7f --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 000000000..96721ce9a --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,499 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "cc" +version = "1.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "windows-link", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b" + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "include_dir" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" +dependencies = [ + "include_dir_macros", +] + +[[package]] +name = "include_dir_macros" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thirdshift" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "include_dir", + "serde_json", + "tempfile", +] + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 000000000..0cf58e036 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "thirdshift" +version = "0.1.0" +edition = "2024" +description = "Turn a GitHub issue into a ready-for-review pull request, unattended" + +[dependencies] +anyhow = "1.0.104" +chrono = { version = "0.4.45", default-features = false, features = ["clock"] } +include_dir = "0.7.4" +serde_json = "1.0.151" +tempfile = "3.27.0" diff --git a/src/git.rs b/src/git.rs new file mode 100644 index 000000000..3603333a3 --- /dev/null +++ b/src/git.rs @@ -0,0 +1,40 @@ +//! Running `git` in a directory. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use anyhow::{Context, Result, bail}; + +/// `git` bound to a working directory. Output is captured, never passed +/// through, so stdout stays reserved for the PR URL. +pub struct Git { + dir: PathBuf, +} + +impl Git { + pub fn new(dir: impl Into) -> Self { + Git { dir: dir.into() } + } + + pub fn dir(&self) -> &Path { + &self.dir + } + + /// Run `git ` and return its trimmed stdout, failing with git's + /// stderr if it exits non-zero. + pub fn run(&self, args: &[&str]) -> Result { + let output = Command::new("git") + .args(args) + .current_dir(&self.dir) + .output() + .with_context(|| format!("could not run git {}", args.join(" ")))?; + if !output.status.success() { + bail!( + "git {} failed: {}", + args.join(" "), + String::from_utf8_lossy(&output.stderr).trim() + ); + } + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) + } +} diff --git a/src/github.rs b/src/github.rs new file mode 100644 index 000000000..9090a31e9 --- /dev/null +++ b/src/github.rs @@ -0,0 +1,43 @@ +//! Asking GitHub, through `gh`, about pull requests. + +use std::process::Command; + +use anyhow::{Context, Result, bail}; +use serde_json::Value; + +use crate::issue::IssueUrl; + +pub struct PullRequest { + pub url: String, + pub state: String, + pub base: String, +} + +/// 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"]) + .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 field = |name: &str| -> 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")?, + })) +} diff --git a/src/issue.rs b/src/issue.rs new file mode 100644 index 000000000..3c69b29eb --- /dev/null +++ b/src/issue.rs @@ -0,0 +1,60 @@ +//! The Issue URL and the Origin match. + +use anyhow::{Result, bail}; + +/// A parsed `https://github.com///issues/`. +#[derive(Debug, Clone)] +pub struct IssueUrl { + pub url: String, + pub owner: String, + pub repo: String, + pub number: u64, +} + +impl IssueUrl { + pub fn parse(url: &str) -> Result { + let parsed = url + .strip_prefix("https://github.com/") + .map(|path| path.trim_end_matches('/').split('/').collect::>()) + .and_then(|parts| match parts.as_slice() { + [owner, repo, "issues", number] if !owner.is_empty() && !repo.is_empty() => { + number.parse().ok().map(|number| IssueUrl { + url: url.to_string(), + owner: owner.to_string(), + repo: repo.to_string(), + number, + }) + } + _ => None, + }); + match parsed { + Some(issue) => Ok(issue), + None => bail!("not a GitHub issue URL: {url}"), + } + } + + /// `owner/repo`, as `gh --repo` takes it. + pub fn repo_slug(&self) -> String { + format!("{}/{}", self.owner, self.repo) + } + + /// The Origin match: does `origin_url` (the raw configured origin, HTTPS or + /// SSH, with or without `.git`) name this issue's repository? Case-insensitive. + pub fn matches_origin(&self, origin_url: &str) -> bool { + normalise_origin(origin_url).is_some_and(|origin| { + origin == format!("github.com/{}", self.repo_slug()).to_lowercase() + }) + } +} + +/// `github.com//`, lowercased, or `None` for a non-GitHub origin. +fn normalise_origin(origin_url: &str) -> Option { + let lower = origin_url.trim().to_lowercase(); + let path = lower + .strip_prefix("https://github.com/") + .or_else(|| lower.strip_prefix("git@github.com:")) + .or_else(|| lower.strip_prefix("ssh://git@github.com/"))?; + let path = path.trim_end_matches('/'); + let path = path.strip_suffix(".git").unwrap_or(path); + Some(format!("github.com/{path}")) +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 000000000..317c77a8c --- /dev/null +++ b/src/main.rs @@ -0,0 +1,27 @@ +mod git; +mod github; +mod issue; +mod plugin; +mod prompt; +mod run; +mod session; +mod worktree; + +use std::process::ExitCode; + +fn main() -> ExitCode { + let Some(issue_url) = std::env::args().nth(1) else { + eprintln!("usage: thirdshift "); + return ExitCode::from(2); + }; + match run::run(&issue_url) { + Ok(pr_url) => { + println!("{pr_url}"); + ExitCode::SUCCESS + } + Err(error) => { + eprintln!("thirdshift: {error:#}"); + ExitCode::FAILURE + } + } +} diff --git a/src/plugin.rs b/src/plugin.rs new file mode 100644 index 000000000..632b4e1c5 --- /dev/null +++ b/src/plugin.rs @@ -0,0 +1,47 @@ +//! The Factory skills, embedded at compile time and written out as the +//! `thirdshift` plugin (ADR-0001). + +use std::fs; +use std::path::Path; + +use anyhow::{Context, Result}; +use include_dir::{Dir, include_dir}; +use tempfile::TempDir; + +static SKILLS: Dir = include_dir!("$CARGO_MANIFEST_DIR/skills"); + +/// A temp directory holding the Factory skills and a generated plugin +/// manifest, removed when dropped. +pub struct Plugin { + dir: TempDir, +} + +impl Plugin { + pub fn write() -> Result { + let dir = tempfile::Builder::new() + .prefix("thirdshift-plugin-") + .tempdir()?; + let skills = dir.path().join("skills"); + fs::create_dir_all(&skills)?; + SKILLS + .extract(&skills) + .context("could not write the Factory skills")?; + + let manifest_dir = dir.path().join(".claude-plugin"); + fs::create_dir_all(&manifest_dir)?; + let manifest = serde_json::json!({ + "name": "thirdshift", + "version": env!("CARGO_PKG_VERSION"), + "description": "Factory skills for thirdshift's headless sessions", + }); + fs::write( + manifest_dir.join("plugin.json"), + serde_json::to_string_pretty(&manifest)?, + )?; + Ok(Plugin { dir }) + } + + pub fn path(&self) -> &Path { + self.dir.path() + } +} diff --git a/src/prompt.rs b/src/prompt.rs new file mode 100644 index 000000000..679ff2e6b --- /dev/null +++ b/src/prompt.rs @@ -0,0 +1,17 @@ +//! The prompts agent sessions are started with. + +use crate::issue::IssueUrl; + +/// The fresh prompt, for a run that starts a new Issue branch. +pub fn fresh(issue: &IssueUrl, base: &str, branch: &str) -> String { + format!( + "/thirdshift:implement {url}\n\ + The base branch is {base}. Review with /thirdshift:code-review using {base} as the fixed point.\n\ + Address the Standards and Spec findings you agree with.\n\ + Push branch {branch} and create a pull request against {base} using /thirdshift:pr, marked ready for review.\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\ + Include \"Closes #{number}\" in the PR body.\n", + url = issue.url, + number = issue.number, + ) +} diff --git a/src/run.rs b/src/run.rs new file mode 100644 index 000000000..247219b50 --- /dev/null +++ b/src/run.rs @@ -0,0 +1,46 @@ +//! One Run: from an Issue URL to a checked PR. + +use anyhow::{Context, Result, bail}; + +use crate::git::Git; +use crate::github; +use crate::issue::IssueUrl; +use crate::plugin::Plugin; +use crate::prompt; +use crate::session; +use crate::worktree::Worktree; + +/// Take `issue_url` to a PR and return the PR's URL. The worktree, the local +/// Issue branch and the plugin directory are gone when this returns. +pub fn run(issue_url: &str) -> Result { + let issue = IssueUrl::parse(issue_url)?; + let timestamp = chrono::Utc::now().format("%Y%m%dT%H%M%SZ").to_string(); + let launch = Git::new(std::env::current_dir().context("no current directory")?); + + let origin = launch.run(&["config", "remote.origin.url"])?; + 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 worktree = Worktree::create_fresh(&launch, &issue.repo, &branch, &base)?; + let plugin = Plugin::write()?; + let log = session::log_path(&issue, ×tamp, "implement")?; + session::run( + worktree.path(), + plugin.path(), + &prompt::fresh(&issue, &base, &branch), + &log, + )?; + 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.base != base { + bail!("PR targets {}, not {base}", pr.base); + } + Ok(pr.url) +} diff --git a/src/session.rs b/src/session.rs new file mode 100644 index 000000000..07f8d2f34 --- /dev/null +++ b/src/session.rs @@ -0,0 +1,49 @@ +//! Headless Claude Code sessions and their logs. + +use std::fs::{self, File}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +use anyhow::{Context, Result, bail}; + +use crate::issue::IssueUrl; + +/// Where a session's stream is logged: +/// `~/.thirdshift/logs/--issue---.jsonl`. +/// Every session in a Run shares the Run's `timestamp`. +pub fn log_path(issue: &IssueUrl, timestamp: &str, kind: &str) -> Result { + let home = std::env::var_os("HOME").context("HOME is not set")?; + Ok(PathBuf::from(home).join(".thirdshift/logs").join(format!( + "{}-{}-issue-{}-{timestamp}-{kind}.jsonl", + issue.owner, issue.repo, issue.number + ))) +} + +/// Run `claude` headless in auto mode in `worktree`, with the Factory skills +/// plugin at `plugin_dir` loaded, streaming its output to `log`. +pub fn run(worktree: &Path, plugin_dir: &Path, prompt: &str, log: &Path) -> Result<()> { + if let Some(dir) = log.parent() { + fs::create_dir_all(dir).with_context(|| format!("could not create {}", dir.display()))?; + } + let log_file = + File::create(log).with_context(|| format!("could not create {}", log.display()))?; + let status = Command::new("claude") + .args(["-p", "--permission-mode", "auto", "--plugin-dir"]) + .arg(plugin_dir) + .args(["--output-format", "stream-json", "--verbose"]) + .arg(prompt) + .current_dir(worktree) + .stdin(Stdio::null()) + .stdout(log_file) + .status() + .context("could not run claude")?; + if !status.success() { + bail!( + "claude exited {}", + status + .code() + .map_or("by signal".to_string(), |code| code.to_string()) + ); + } + Ok(()) +} diff --git a/src/worktree.rs b/src/worktree.rs new file mode 100644 index 000000000..fa0d23fec --- /dev/null +++ b/src/worktree.rs @@ -0,0 +1,66 @@ +//! The Run's worktree: a sibling of the launch repository, on the Issue +//! branch, removed together with the local Issue branch when dropped. + +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; + +use crate::git::Git; + +pub struct Worktree { + launch: Git, + branch: String, + git: Git, +} + +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 { + let root = PathBuf::from(launch.run(&["rev-parse", "--show-toplevel"])?); + let path = root + .parent() + .context("the repository root has no parent directory")? + .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}"), + ])?; + Ok(Worktree { + launch: Git::new(root), + branch: branch.to_string(), + git: Git::new(path), + }) + } + + pub fn path(&self) -> &Path { + self.git.dir() + } + + pub fn git(&self) -> &Git { + &self.git + } +} + +impl Drop for Worktree { + fn drop(&mut self) { + let path = self.path().to_string_lossy().into_owned(); + // Each step is attempted even if the one before it failed. + let steps: [&[&str]; 2] = [ + &["worktree", "remove", "--force", &path], + &["branch", "-D", &self.branch], + ]; + for step in steps { + if let Err(error) = self.launch.run(step) { + eprintln!("thirdshift: cleanup incomplete: {error:#}"); + } + } + } +} diff --git a/tests/fakes/claude b/tests/fakes/claude new file mode 100644 index 000000000..936fb2269 --- /dev/null +++ b/tests/fakes/claude @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +"""Fake `claude` for the thirdshift test harness. + +Each call appends a record to the JSON list in $FAKE_CLAUDE_RECORD: its argv, +prompt, working directory, the branch checked out there, and a snapshot of the +--plugin-dir contents (the directory is gone by the time a test looks). + +It then runs the per-test bash script $FAKE_CLAUDE_SCRIPT in the working +directory, emits a couple of stream-json lines, and exits with the script's +exit code. +""" + +import json +import os +import subprocess +import sys + + +def plugin_snapshot(plugin_dir): + files = {} + for dirpath, _, filenames in os.walk(plugin_dir): + for filename in filenames: + path = os.path.join(dirpath, filename) + with open(path, encoding="utf-8", errors="replace") as f: + files[os.path.relpath(path, plugin_dir)] = f.read() + return files + + +def main(): + argv = sys.argv[1:] + plugin_dir = argv[argv.index("--plugin-dir") + 1] if "--plugin-dir" in argv else None + branch = subprocess.run( + ["git", "branch", "--show-current"], capture_output=True, text=True + ).stdout.strip() + + record_path = os.environ["FAKE_CLAUDE_RECORD"] + records = [] + if os.path.exists(record_path): + with open(record_path) as f: + records = json.load(f) + records.append( + { + "argv": argv, + "prompt": argv[-1] if argv else None, + "cwd": os.getcwd(), + "branch": branch, + "plugin_dir": plugin_dir, + "plugin_files": plugin_snapshot(plugin_dir) if plugin_dir else {}, + } + ) + with open(record_path, "w") as f: + json.dump(records, f, indent=2) + + print(json.dumps({"type": "system", "subtype": "init", "cwd": os.getcwd()}), flush=True) + code = subprocess.run(["bash", "-e", os.environ["FAKE_CLAUDE_SCRIPT"]]).returncode + print(json.dumps({"type": "result", "subtype": "success", "is_error": code != 0}), flush=True) + sys.exit(code) + + +main() diff --git a/tests/fakes/gh b/tests/fakes/gh new file mode 100644 index 000000000..fc6ab11f4 --- /dev/null +++ b/tests/fakes/gh @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +"""Fake `gh` for the thirdshift test harness. + +State lives in the JSON file named by $FAKE_GH_STATE: + + {"repo": "owner/repo", + "prs": [{"number", "url", "head", "base", "state", "isDraft", "title", "body"}]} + +Only the subcommands thirdshift and the fake agents use are supported. Anything +else exits 2 so an unexpected call fails loudly. +""" + +import json +import os +import sys + + +def load(): + with open(os.environ["FAKE_GH_STATE"]) as f: + return json.load(f) + + +def save(state): + with open(os.environ["FAKE_GH_STATE"], "w") as f: + json.dump(state, f, indent=2) + + +BOOLEAN_FLAGS = {"draft"} + + +def parse(args): + """Split args into positionals and a dict of --flag values.""" + positional, flags = [], {} + i = 0 + while i < len(args): + arg = args[i] + if arg.startswith("-"): + name = arg.lstrip("-") + if name in BOOLEAN_FLAGS: + value = True + elif "=" in name: + name, value = name.split("=", 1) + else: + i += 1 + value = args[i] + flags[name] = value + else: + positional.append(arg) + i += 1 + return positional, flags + + +def check_repo(state, flags): + repo = flags.get("R") or flags.get("repo") + if repo is not None and repo.lower() != state["repo"].lower(): + die(f"fake gh: unknown repo {repo}") + + +def die(message, code=1): + print(message, file=sys.stderr) + sys.exit(code) + + +def pr_create(state, flags): + number = len(state["prs"]) + 1 + pr = { + "number": number, + "url": f"https://github.com/{state['repo']}/pull/{number}", + "head": flags["head"], + "base": flags["base"], + "state": "OPEN", + "isDraft": "draft" in flags, + "title": flags.get("title", ""), + "body": flags.get("body", ""), + } + state["prs"].append(pr) + save(state) + print(pr["url"]) + + +def pr_view(state, positional, flags): + head = positional[0] + matches = [pr for pr in state["prs"] if pr["head"] == head] + if not matches: + die(f'no pull requests found for branch "{head}"') + pr = matches[-1] + fields = { + "url": pr["url"], + "state": pr["state"], + "baseRefName": pr["base"], + "isDraft": pr["isDraft"], + "number": pr["number"], + } + wanted = flags["json"].split(",") + print(json.dumps({key: fields[key] for key in wanted})) + + +def main(): + args = sys.argv[1:] + state = load() + if args[:2] == ["pr", "create"]: + positional, flags = parse(args[2:]) + check_repo(state, flags) + pr_create(state, flags) + elif args[:2] == ["pr", "view"]: + positional, flags = parse(args[2:]) + check_repo(state, flags) + pr_view(state, positional, flags) + else: + die(f"fake gh: unsupported command: {args}", 2) + + +main() diff --git a/tests/fresh_run.rs b/tests/fresh_run.rs new file mode 100644 index 000000000..5fbbf0536 --- /dev/null +++ b/tests/fresh_run.rs @@ -0,0 +1,185 @@ +//! A fresh run on the happy path: no Issue branch exists yet, the agent +//! implements the issue and opens a PR, and thirdshift pushes, checks the PR, +//! prints its URL and cleans up. + +mod support; + +use support::Scenario; + +/// The agent commits its work and opens a PR, but leaves the pushing to +/// thirdshift. +const AGENT_COMMITS_AND_OPENS_PR: &str = r#" +echo "feature" > feature.txt +git add feature.txt +git commit -q -m "Add feature" +gh pr create --base main --head issue-7 --title "Add feature" --body "Closes #7" +"#; + +#[test] +fn prints_only_the_pr_url_and_exits_zero() { + let scenario = Scenario::new(); + 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"); +} + +#[test] +fn runs_claude_headless_in_auto_mode_in_a_sibling_worktree_on_the_issue_branch() { + let scenario = Scenario::new(); + scenario.agent_does(AGENT_COMMITS_AND_OPENS_PR); + + scenario.run(&[&scenario.issue_url(7)]); + + let calls = scenario.claude_calls(); + assert_eq!(calls.len(), 1); + let call = &calls[0]; + let argv: Vec<&str> = call["argv"] + .as_array() + .unwrap() + .iter() + .map(|a| a.as_str().unwrap()) + .collect(); + assert!(argv.contains(&"-p"), "argv: {argv:?}"); + assert!( + argv.windows(2).any(|w| w == ["--permission-mode", "auto"]), + "argv: {argv:?}" + ); + assert!( + argv.windows(2) + .any(|w| w == ["--output-format", "stream-json"]), + "argv: {argv:?}" + ); + assert!(argv.contains(&"--verbose"), "argv: {argv:?}"); + assert!(argv.contains(&"--plugin-dir"), "argv: {argv:?}"); + assert_eq!( + call["cwd"], + scenario.path("work/widgets-issue-7").to_str().unwrap() + ); + assert_eq!(call["branch"], "issue-7"); +} + +#[test] +fn gives_the_agent_the_fresh_prompt() { + let scenario = Scenario::new(); + 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\ + The base branch is main. Review with /thirdshift:code-review using main as the fixed point.\n\ + Address the Standards and Spec findings you agree with.\n\ + Push branch issue-7 and create a pull request against main using /thirdshift:pr, marked ready for review.\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\ + Include \"Closes #7\" in the PR body.\n" + ); +} + +#[test] +fn loads_every_factory_skill_as_the_thirdshift_plugin() { + let scenario = Scenario::new(); + scenario.agent_does(AGENT_COMMITS_AND_OPENS_PR); + + scenario.run(&[&scenario.issue_url(7)]); + + let files = &scenario.claude_calls()[0]["plugin_files"]; + let manifest: serde_json::Value = serde_json::from_str( + files[".claude-plugin/plugin.json"] + .as_str() + .expect("no plugin manifest"), + ) + .unwrap(); + assert_eq!(manifest["name"], "thirdshift"); + for skill in [ + "implement", + "code-review", + "pr", + "tdd", + "resolving-merge-conflicts", + ] { + let skill_md = files[format!("skills/{skill}/SKILL.md")].as_str(); + assert!( + skill_md.is_some_and(|text| text.contains(&format!("name: {skill}"))), + "skill {skill} missing from the plugin" + ); + } + assert!( + files["skills/tdd/tests.md"].is_string(), + "a skill's supporting files are missing" + ); +} + +#[test] +fn logs_the_session_stream_under_the_home_directory() { + let scenario = Scenario::new(); + scenario.agent_does(AGENT_COMMITS_AND_OPENS_PR); + + scenario.run(&[&scenario.issue_url(7)]); + + let logs = scenario.entries("home/.thirdshift/logs"); + assert_eq!(logs.len(), 1, "logs: {logs:?}"); + let name = &logs[0]; + assert!( + name.starts_with("acme-widgets-issue-7-") && name.ends_with("-implement.jsonl"), + "log: {name}" + ); + let log = std::fs::read_to_string(scenario.path("home/.thirdshift/logs").join(name)).unwrap(); + assert!( + log.contains(r#""type": "system""#) && log.contains(r#""type": "result""#), + "log: {log}" + ); +} + +#[test] +fn pushes_the_issue_branch_when_the_agent_did_not() { + let scenario = Scenario::new(); + scenario.agent_does(AGENT_COMMITS_AND_OPENS_PR); + + scenario.run(&[&scenario.issue_url(7)]); + + assert_eq!( + scenario.origin_log("issue-7"), + Some(vec![ + "Add feature".to_string(), + "Initial commit".to_string() + ]) + ); +} + +#[test] +fn succeeds_when_the_agent_pushed_the_branch_itself() { + let scenario = Scenario::new(); + scenario.agent_does(&format!( + "{AGENT_COMMITS_AND_OPENS_PR}\ngit push -q origin issue-7\n" + )); + + 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.origin_log("issue-7").map(|log| log.len()), Some(2)); +} + +#[test] +fn leaves_no_worktree_local_issue_branch_or_temp_directory_behind() { + let scenario = Scenario::new(); + 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.entries("work"), vec!["widgets"]); + assert_eq!( + scenario + .launch_git(&["worktree", "list", "--porcelain"]) + .matches("worktree ") + .count(), + 1 + ); + assert_eq!(scenario.launch_git(&["branch", "--list", "issue-7"]), ""); + assert_eq!(scenario.entries("tmp"), Vec::::new()); +} diff --git a/tests/support/mod.rs b/tests/support/mod.rs new file mode 100644 index 000000000..605dec894 --- /dev/null +++ b/tests/support/mod.rs @@ -0,0 +1,224 @@ +//! Test harness: runs the compiled `thirdshift` binary against real git and +//! fake `gh` and `claude` executables. +//! +//! Layout of a scenario's temp root: +//! +//! ```text +//! origin.git/ bare repo standing in for github.com// +//! home/ $HOME: .gitconfig with identity and the insteadOf rule +//! bin/ fake gh and claude, first on PATH +//! tmp/ $TMPDIR, so leftover temp directories are visible +//! work// the launch clone, origin https://github.com//.git +//! 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 +//! ``` + +#![allow(dead_code)] + +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +use serde_json::{Value, json}; +use tempfile::TempDir; + +pub const OWNER: &str = "acme"; +pub const REPO: &str = "widgets"; + +pub struct Scenario { + root: TempDir, +} + +pub struct RunResult { + pub stdout: String, + pub stderr: String, + pub code: Option, +} + +impl Scenario { + /// An origin with one commit on `main`, and a launch clone of it with + /// `main` checked out. + pub fn new() -> Self { + let root = TempDir::new().unwrap(); + let scenario = Scenario { root }; + for dir in ["home", "bin", "tmp", "work"] { + fs::create_dir_all(scenario.path(dir)).unwrap(); + } + scenario.write_gitconfig(); + scenario.install_fakes(); + scenario.write_gh_state(&json!({ "repo": format!("{OWNER}/{REPO}"), "prs": [] })); + scenario.agent_does("true"); + + git( + &scenario.path(""), + &["init", "--bare", "--initial-branch=main", "origin.git"], + ); + let seed = scenario.path("seed"); + git( + &scenario.path(""), + &["clone", &scenario.github_url(), "seed"], + ); + fs::write(seed.join("README.md"), "widgets\n").unwrap(); + git(&seed, &["add", "."]); + git(&seed, &["commit", "-m", "Initial commit"]); + git(&seed, &["push", "origin", "HEAD:main"]); + fs::remove_dir_all(&seed).unwrap(); + + git( + &scenario.path("work"), + &["clone", &scenario.github_url(), REPO], + ); + scenario + } + + pub fn path(&self, relative: &str) -> PathBuf { + self.root.path().join(relative) + } + + pub fn launch_dir(&self) -> PathBuf { + self.path("work").join(REPO) + } + + pub fn origin_dir(&self) -> PathBuf { + self.path("origin.git") + } + + pub fn github_url(&self) -> String { + format!("https://github.com/{OWNER}/{REPO}.git") + } + + pub fn issue_url(&self, number: u32) -> String { + format!("https://github.com/{OWNER}/{REPO}/issues/{number}") + } + + /// Script the fake agent: `script` is bash, run with `-e` in the session's + /// working directory. + pub fn agent_does(&self, script: &str) { + fs::write(self.path("claude-script.sh"), script).unwrap(); + } + + pub fn run(&self, args: &[&str]) -> RunResult { + let path = format!( + "{}:{}", + self.path("bin").display(), + std::env::var("PATH").unwrap() + ); + let output: Output = Command::new(env!("CARGO_BIN_EXE_thirdshift")) + .args(args) + .current_dir(self.launch_dir()) + .env_clear() + .env("PATH", path) + .env("HOME", self.path("home")) + .env("TMPDIR", self.path("tmp")) + .env("GIT_CONFIG_NOSYSTEM", "1") + .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")) + .output() + .unwrap(); + RunResult { + stdout: String::from_utf8(output.stdout).unwrap(), + stderr: String::from_utf8(output.stderr).unwrap(), + code: output.status.code(), + } + } + + pub fn gh_state(&self) -> Value { + serde_json::from_str(&fs::read_to_string(self.path("gh-state.json")).unwrap()).unwrap() + } + + pub fn write_gh_state(&self, state: &Value) { + fs::write( + self.path("gh-state.json"), + serde_json::to_string_pretty(state).unwrap(), + ) + .unwrap(); + } + + /// Every call the fake agent received, in order. + pub fn claude_calls(&self) -> Vec { + match fs::read_to_string(self.path("claude-calls.json")) { + Ok(text) => serde_json::from_str(&text).unwrap(), + Err(_) => Vec::new(), + } + } + + /// 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> { + let output = Command::new("git") + .args(["log", "--format=%s", &format!("refs/heads/{branch}")]) + .current_dir(self.origin_dir()) + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("HOME", self.path("home")) + .output() + .unwrap(); + output.status.success().then(|| { + String::from_utf8(output.stdout) + .unwrap() + .lines() + .map(String::from) + .collect() + }) + } + + /// Output of a git command in the launch clone. + pub fn launch_git(&self, args: &[&str]) -> String { + git(&self.launch_dir(), args) + } + + /// Names of the files and directories directly inside `relative`. + pub fn entries(&self, relative: &str) -> Vec { + let mut names: Vec = fs::read_dir(self.path(relative)) + .unwrap() + .map(|entry| entry.unwrap().file_name().into_string().unwrap()) + .collect(); + names.sort(); + names + } + + fn write_gitconfig(&self) { + let config = format!( + "[user]\n\tname = Test Runner\n\temail = runner@example.com\n\ + [init]\n\tdefaultBranch = main\n\ + [url \"{origin}\"]\n\tinsteadOf = {github}\n", + origin = self.origin_dir().display(), + github = self.github_url(), + ); + fs::write(self.path("home/.gitconfig"), config).unwrap(); + } + + fn install_fakes(&self) { + let fakes = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fakes"); + for name in ["gh", "claude"] { + let target = self.path("bin").join(name); + fs::copy(fakes.join(name), &target).unwrap(); + fs::set_permissions(&target, fs::Permissions::from_mode(0o755)).unwrap(); + } + } +} + +/// Run git in `dir` with the scenario's config and return stdout, panicking on +/// failure. `dir` must be inside a scenario root. +fn git(dir: &Path, args: &[&str]) -> String { + let home = dir + .ancestors() + .find(|ancestor| ancestor.join("home/.gitconfig").exists()) + .expect("git() called outside a scenario") + .join("home"); + let output = Command::new("git") + .args(args) + .current_dir(dir) + .env("HOME", home) + .env("GIT_CONFIG_NOSYSTEM", "1") + .output() + .unwrap(); + assert!( + output.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).unwrap() +}