//! `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 ` 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 { 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 { 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()); } }