Something went wrong. Try again.
atproto git client
Something went wrong. Try again.
Rust
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372//! `git worktree`: a second working tree on one object database.//!//! Two callers, and they want the same three things — make one, put it//! somewhere sensible, take it away again://!//! - `pr diff --interdiff` builds a detached scratch worktree, applies two//! rounds in it and removes it on every path including the failures.//! - `pr checkout --worktree <path>` builds one on a new branch and *keeps*//! it, so a pull can be reviewed without the caller's own checkout moving.//!//! The reason a worktree is the right tool for both is the same: it shares//! the repo's object database, so it costs no clone and no fetch, and the//! commits written in it are visible from the original checkout the moment//! they exist. It also shares `.git/config`, which is why a checkout//! `repo clone` configured — the `[user]` identity that attributes commits to//! an ATProto account, and the `core.sshCommand` that picks the key — carries//! into a worktree with nothing written twice. (Only until somebody sets//! `extensions.worktreeConfig`; nothing in atgc does.)//!//! What is *not* shared is the working tree, the index and `HEAD`. That is//! the whole point of [`add`] for `pr checkout`: the caller's checkout is not//! read, not moved and not required to be clean.
use anyhow::{Result, bail};use std::path::{Path, PathBuf};
/// Create a worktree at `dir`, on a new `branch` or detached, starting at/// `start`.////// `root` is the checkout to run this from — any working tree of the repo/// will do, since worktrees are registered against the common git directory/// rather than against whichever tree asked for one.////// `--` before the path because both remaining arguments are strings this/// process did not choose: the path is the caller's word, and `start` is/// built from a remote name and a branch out of somebody else's pull request/// record. Past the marker git reads them as a path and a commit-ish or it/// errors, rather than as options.////// Callers are expected to have validated the path with [`plan`] first. Git/// checks it too, but only *after* creating the branch, so a refusal from git/// leaves a branch behind that nothing is using.pub(crate) fn add( root: &Path, dir: &Path, branch: Option<&str>, start: &str, quiet: bool,) -> Result<()> { let path = dir.to_string_lossy(); let mut args: Vec<&str> = vec!["worktree", "add"]; match branch { Some(name) => args.extend_from_slice(&["-b", name]), None => args.push("--detach"), } if quiet { args.push("--quiet"); } args.extend_from_slice(&["--", &path, start]); if !super::run::status_in(root, &args)? { bail!("could not create a worktree at {}", dir.display()); } Ok(())}
/// Remove a worktree and its directory, reporting whether git managed it.////// `--force` because the caller is throwing the tree away deliberately and a/// scratch worktree with a stopped `git am` in it is exactly the state git/// would otherwise decline to remove. Failure is returned rather than raised:/// this runs on drop paths where there is nothing useful to do about it.pub(crate) fn remove(root: &Path, dir: &Path) -> bool { super::run::status_in( root, &[ "worktree", "remove", "--force", "--", &dir.to_string_lossy(), ], ) .unwrap_or(false)}
/// Where a requested worktree path really is, once it is checked.////// Answers with an absolute path, which is what should then be handed to/// [`add`] and printed: a relative path in the output of a command whose/// whole purpose is to send you somewhere else is a path relative to a/// directory the reader is about to leave. Making it absolute also removes/// the last way the path could reach git looking like an option.////// Three refusals, in the order they are cheapest to check:////// 1. **An empty path.** Git would take it as the current directory./// 2. **A path that already holds something.** Git refuses this too, but/// only after it has created the branch, so checking here is what keeps a/// failed `pr checkout --worktree` from leaving a stray branch behind. An/// existing *empty* directory is fine and is accepted, as git accepts it./// 3. **A path inside a different repository's working tree.** Git allows/// this and it is a trap: the new worktree shows up as untracked content/// in the other repo, where a commit can swallow it and `git clean -fdx`/// can delete it out from under the repo that registered it — leaving a/// worktree that git still lists and cannot find. Inside *this* repo is/// allowed, because it is how this project itself works/// (`.claude/worktrees/`, which `.gitignore` covers).pub(crate) fn plan(root: &Path, requested: &str) -> Result<PathBuf> { let requested = requested.trim(); if requested.is_empty() { return Err(crate::exit::fail( crate::exit::Exit::Usage, "--worktree needs a path to create the worktree at", )); }
let path = match Path::new(requested).is_absolute() { true => PathBuf::from(requested), false => std::env::current_dir() .map(|cwd| cwd.join(requested)) .unwrap_or_else(|_| PathBuf::from(requested)), }; let path = settle(&path);
if let Ok(entries) = std::fs::read_dir(&path) { if entries.count() > 0 { bail!( "{} already exists and is not empty\n\ pick a path that does not exist yet; `pr checkout --worktree` creates it, \ and will not write a pull request into a directory that holds something else", path.display() ); } } else if path.exists() { bail!( "{} already exists and is not a directory\n\ `pr checkout --worktree` creates the directory it is given", path.display() ); }
// The nearest ancestor that exists is the one to ask about, since the // path itself usually does not yet. `plan` is the answer to "where would // this land", and it lands inside whatever that directory is inside. if let Some(existing) = path.ancestors().skip(1).find(|p| p.is_dir()) && let Some(other) = common_dir(existing) { if common_dir(root).is_none_or(|ours| ours != other) { bail!( "{} is inside another git repository ({})\n\ a worktree there is untracked content in that repo: a commit can swallow it \ and `git clean -fdx` can delete it while git still lists it here\n\ pick a path outside it", path.display(), other.display() ); } // Inside our own repo is allowed and is how this project works, but // only because `.gitignore` covers `.claude/worktrees`. Somewhere // that is not ignored, the same hazard applies to the caller's own // repo — so it is said rather than refused, since it is their tree // and one line in `.gitignore` fixes it. if !super::run::quiet_in( existing, &["check-ignore", "-q", "--", &path.to_string_lossy()], ) { crate::term::say::note!( Git, "{} is inside this repo and not ignored, so the worktree will show as \ untracked content; add it to .gitignore", path.display() ); } }
Ok(path)}
/// One spelling of one place: `.` and `..` folded out, and the part of the/// path that already exists resolved through any symlinks.////// Worth doing because this path is *printed*, twice, as somewhere to go —/// and `--worktree ../review-23` joined onto the current directory produces/// `/home/you/atgc/../review-23`, which is a correct path and an unreadable/// instruction. Symlinks are resolved only as far as the directory that is/// there, since the path itself is not yet: that is enough for the two things/// resolution is for here, comparing this location against a repository's and/// showing the reader the same string twice.fn settle(path: &Path) -> PathBuf { let mut lexical = PathBuf::new(); for component in path.components() { match component { std::path::Component::CurDir => {} // A leading `..` has nothing above it to cancel, so it is kept: // dropping it would change which directory is named. std::path::Component::ParentDir => { if !lexical.pop() { lexical.push(".."); } } other => lexical.push(other), } } let Some(existing) = lexical.ancestors().find(|p| p.exists()) else { return lexical; }; match ( std::fs::canonicalize(existing), lexical.strip_prefix(existing), ) { // The whole path already exists — an empty directory, the one such // path this accepts. Joining the empty remainder onto it would leave // a trailing separator, and this string is printed. (Ok(real), Ok(rest)) if rest.as_os_str().is_empty() => real, (Ok(real), Ok(rest)) => real.join(rest), _ => lexical, }}
/// The repository `dir` belongs to, named by its common git directory — the/// one thing every worktree of a repo agrees on, and so the only honest way/// to ask whether two paths are in the *same* repo rather than merely each in/// some repo. `None` when `dir` is in no repository at all, which is the/// ordinary case and not a failure.fn common_dir(dir: &Path) -> Option<PathBuf> { let found = super::run::git_in( dir, &["rev-parse", "--path-format=absolute", "--git-common-dir"], ) .ok()?; // Symlinked temp directories (/tmp -> /private/tmp, and this project's // own scratch paths) make two spellings of one directory, and a textual // comparison of them would refuse a path that is in fact our own repo. let path = PathBuf::from(found); Some(std::fs::canonicalize(&path).unwrap_or(path))}
#[cfg(test)]mod tests { use super::{add, plan, remove}; use crate::testutil::TempRepo; use std::path::Path;
/// The refusals, and the one shape that is allowed through: a path that /// does not exist yet, and an existing but empty directory — which is /// what a shell's tab-completion or an `mkdir -p` leaves behind, and /// which git itself accepts. #[test] fn plans_a_path_that_is_free_and_refuses_one_that_is_not() { let repo = TempRepo::new("worktree-plan"); repo.commit("a.txt", "one\n", "Add a"); let here = std::env::current_dir().expect("inside the temp repo"); let root = Path::new(".");
// Relative in, absolute out: the path is printed to somebody who is // about to `cd` somewhere else, so it cannot stay relative. let planned = plan(root, "review/pr-1").expect("a path that does not exist"); assert!(planned.is_absolute(), "got {}", planned.display()); assert!(planned.ends_with("review/pr-1"));
// And with no `..` left in the middle of it. `--worktree ../review` // is the ordinary way to ask for one, and the answer is printed as // somewhere to `cd` to. let sibling = plan(root, "../review-23").expect("a sibling of the checkout"); assert!( !sibling.to_string_lossy().contains(".."), "got {}", sibling.display() ); assert_eq!(sibling.parent(), here.parent()); assert!(sibling.ends_with("review-23"));
std::fs::create_dir_all(here.join("empty")).expect("an empty directory"); assert!(plan(root, "empty").is_ok(), "an empty directory is free");
std::fs::create_dir_all(here.join("full")).expect("a directory"); std::fs::write(here.join("full/x"), "x").expect("something in it"); let err = plan(root, "full").unwrap_err().to_string(); assert!(err.contains("not empty"), "got: {err}");
std::fs::write(here.join("afile"), "x").expect("a file"); let err = plan(root, "afile").unwrap_err().to_string(); assert!(err.contains("not a directory"), "got: {err}");
assert!( plan(root, " ").is_err(), "an empty path is the cwd to git" ); }
/// A worktree inside *another* repo is untracked content over there, so /// a commit or a `git clean -fdx` in that repo can take it away while /// this one still lists it. Inside our own repo is fine — it is how this /// project works (`.claude/worktrees/`). #[test] fn plan_refuses_a_path_inside_a_different_repo() { let repo = TempRepo::new("worktree-nested"); repo.commit("a.txt", "one\n", "Add a"); let here = std::env::current_dir().expect("inside the temp repo"); let root = Path::new(".");
assert!( plan(root, "inside/our/own/repo").is_ok(), "a path inside the repo asking for the worktree is allowed" );
let other = here.join("other"); std::fs::create_dir_all(&other).expect("a directory"); crate::clients::git::run::git_in(&other, &["init", "-q"]).expect("a second repo"); let err = plan(root, "other/wt").unwrap_err().to_string(); assert!(err.contains("another git repository"), "got: {err}"); }
/// The claim `pr checkout --worktree` rests on: making one does not read, /// move or dirty the checkout it was asked from — including when that /// checkout has uncommitted work in it, which is the case the dirty-tree /// refusal exists for and which this path is exempt from. #[test] fn adding_a_worktree_leaves_the_origin_checkout_alone() { let repo = TempRepo::new("worktree-add"); repo.commit("a.txt", "one\n", "Add a"); let here = std::env::current_dir().expect("inside the temp repo"); let root = here.as_path();
// Uncommitted work, of both kinds git reports. std::fs::write(here.join("a.txt"), "one\ntwo\n").expect("modify a tracked file"); std::fs::write(here.join("untracked.txt"), "x").expect("add an untracked file"); let before_head = repo.git(&["rev-parse", "HEAD"]); let before_branch = repo.git(&["rev-parse", "--abbrev-ref", "HEAD"]); let before_status = repo.git(&["status", "--porcelain"]);
// Beside the repo rather than inside it. A worktree *inside* the // caller's own repo is untracked content there — which is exactly // what the note in `plan` is about — and it would show in the // porcelain compared below, hiding the thing this test is asking. let outside = here.with_file_name(format!( "{}-wt", here.file_name() .expect("a temp repo name") .to_string_lossy() )); let dir = plan(root, &outside.to_string_lossy()).expect("a free path"); add(root, &dir, Some("pr/somebody/3ms-r1"), "HEAD", true).expect("a worktree");
assert!(dir.join("a.txt").exists(), "the worktree was populated"); assert_eq!( std::fs::read_to_string(dir.join("a.txt")).unwrap(), "one\n", "the worktree holds the committed content, not the caller's edit" ); assert_eq!( crate::clients::git::run::git_in(&dir, &["rev-parse", "--abbrev-ref", "HEAD"]).unwrap(), "pr/somebody/3ms-r1" );
assert_eq!(repo.git(&["rev-parse", "HEAD"]), before_head); assert_eq!( repo.git(&["rev-parse", "--abbrev-ref", "HEAD"]), before_branch ); assert_eq!(repo.git(&["status", "--porcelain"]), before_status); assert_eq!( std::fs::read_to_string(here.join("a.txt")).unwrap(), "one\ntwo\n", "the caller's uncommitted edit survived" );
assert!(remove(root, &dir), "a worktree is removable again"); assert!(!dir.exists()); }}