From cd2c6f368b07dad26af801ae0008844d385ff8e3 Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Fri, 25 Sep 2026 03:50:18 +0000 Subject: [PATCH 1/4] Add the walking skeleton: a fresh run on the happy path thirdshift creates a sibling worktree on issue- from origin/, writes the embedded Factory skills as the thirdshift plugin, runs a headless auto-mode claude session with the fresh prompt and a per-session log, pushes the branch, checks the PR through gh, prints its URL and cleans up. Tests run the compiled binary against real git (bare origin via insteadOf) and fake gh and claude executables. Refs #3 Co-Authored-By: Claude Opus 5.5 --- .gitignore | 1 + Cargo.lock | 499 +++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 16 ++ src/git.rs | 40 ++++ src/github.rs | 43 ++++ src/issue.rs | 60 ++++++ src/main.rs | 27 +++ src/plugin.rs | 47 ++++ src/prompt.rs | 17 ++ src/run.rs | 46 ++++ src/session.rs | 49 +++++ src/worktree.rs | 63 ++++++ tests/fakes/claude | 60 ++++++ tests/fakes/gh | 113 ++++++++++ tests/fresh_run.rs | 185 ++++++++++++++++ tests/support/mod.rs | 224 +++++++++++++++++++ 16 files changed, 1490 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 src/git.rs create mode 100644 src/github.rs create mode 100644 src/issue.rs create mode 100644 src/main.rs create mode 100644 src/plugin.rs create mode 100644 src/prompt.rs create mode 100644 src/run.rs create mode 100644 src/session.rs create mode 100644 src/worktree.rs create mode 100644 tests/fakes/claude create mode 100644 tests/fakes/gh create mode 100644 tests/fresh_run.rs create mode 100644 tests/support/mod.rs 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..2947b2d1b --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,16 @@ +[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" + +[dev-dependencies] +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..2e3dd2be6 --- /dev/null +++ b/src/worktree.rs @@ -0,0 +1,63 @@ +//! 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(); + let removed = self + .launch + .run(&["worktree", "remove", "--force", &path]) + .and_then(|_| self.launch.run(&["branch", "-D", &self.branch])); + if let Err(error) = removed { + 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() +} From dab51e7b56dcc8578f4f1094eebffe95bf72f310 Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Fri, 25 Sep 2026 03:51:43 +0000 Subject: [PATCH 2/4] Clean up the local Issue branch even if worktree removal fails Also drop dev-dependencies that duplicate normal dependencies. Refs #3 Co-Authored-By: Claude Opus 5.5 --- Cargo.toml | 4 ---- src/worktree.rs | 15 +++++++++------ 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 2947b2d1b..0cf58e036 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,3 @@ chrono = { version = "0.4.45", default-features = false, features = ["clock"] } include_dir = "0.7.4" serde_json = "1.0.151" tempfile = "3.27.0" - -[dev-dependencies] -serde_json = "1.0.151" -tempfile = "3.27.0" diff --git a/src/worktree.rs b/src/worktree.rs index 2e3dd2be6..fa0d23fec 100644 --- a/src/worktree.rs +++ b/src/worktree.rs @@ -52,12 +52,15 @@ impl Worktree { impl Drop for Worktree { fn drop(&mut self) { let path = self.path().to_string_lossy().into_owned(); - let removed = self - .launch - .run(&["worktree", "remove", "--force", &path]) - .and_then(|_| self.launch.run(&["branch", "-D", &self.branch])); - if let Err(error) = removed { - eprintln!("thirdshift: cleanup incomplete: {error:#}"); + // 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:#}"); + } } } } From edc3a64234ce0240fb4f53a623e50b506a554ea9 Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Fri, 25 Sep 2026 04:03:42 +0000 Subject: [PATCH 3/4] Run fmt, clippy and the tests in GitHub Actions Refs #3 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..6c2ce1a96 --- /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@v4 + - 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 From 65da004425d42c3eeead7f8320251fb83efe8e03 Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Fri, 25 Sep 2026 04:05:50 +0000 Subject: [PATCH 4/4] Use actions/checkout@v7, which runs on Node.js 24 Refs #3 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6c2ce1a96..e27cadf4b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,7 +9,7 @@ jobs: test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@stable with: components: rustfmt, clippy