//! Running git. //! //! Every git subprocess atgc starts is built *and spawned* here, by [`Run`]. //! Nothing outside `clients/git/` constructs a `Command` of its own — that is //! what makes the non-interactive gate universal rather than per-caller, so a //! fetch run for a pull checkout cannot hang on a credential prompt that a //! fetch run for a push would have refused. It is also what makes //! [`crate::logging::git`] complete: a single `spawn` in this file is the //! only way a git process starts, so no call site can forget to record one //! and a new call site cannot be added without recording one. //! //! [`Run`] used to be a bare `Command` builder each runner finished for //! itself, which was fine until the log needed the argv, the directory, the //! stdio wiring and the exit status in one place. Collecting them costs the //! runners below nothing they were not already doing. //! //! Two shapes of runner, because callers genuinely want both: //! //! - [`git_in`] and [`git`] **capture** git's output and hand it back, with //! a failure that reads the same wherever it came from: the command, and //! git's own stderr. //! - [`status_in`], [`quiet_in`] and [`stdin_in`] **inherit** stdio, so git //! owns the colour, the pager and the progress. `pr diff` and //! `pr checkout` are built on these; letting git do its own rendering is //! the whole design of that command. //! //! The split is what the caller wants back — a string, a bool, or nothing — //! not where the code lives. Config reads and writes are //! [`super::config`]; the patch-building commands are [`super::patch`]. //! //! # The checkout that cannot answer //! //! Three functions here — [`remote_url`], [`current_branch`] and //! [`repo_root`] — are how nearly every command finds the repo it acts on, //! and they are where a command run in the wrong directory first fails. Their //! failures do *not* pass git's own words on, because git is answering a //! question atgc asked and not the one the user has: `fatal: not a git //! repository (or any parent up to mount point /)` names neither atgc, nor //! the directory, nor anything to do next. [`NoCheckout`] is the four ways //! this goes wrong, each with a message and all of them //! [`Usage`](crate::exit::Exit::Usage). use crate::logging::git::{self as gitlog, At, Stdio}; use anyhow::{Context, Result, bail}; use std::io::Write as _; use std::path::{Path, PathBuf}; use std::process::Command; pub(crate) fn git(args: &[&str]) -> Result { git_in(Path::new("."), args) } /// One git subprocess: everything about it that is settled before it starts. /// /// Every git process atgc runs is one of these. The builder exists because /// the five runners below want five different combinations of four things — /// a working directory, extra environment, bytes on stdin, and where stdout /// and stderr go — and because [`crate::logging::git`] wants all four /// described in one place rather than reconstructed from a half-built /// `Command`. pub(super) struct Run<'a> { args: &'a [&'a str], /// The `-C` directory. `None` runs git where this process is standing. dir: Option<&'a Path>, env: &'a [(&'a str, &'a str)], stdin: Option<&'a [u8]>, stdio: Stdio, /// The checkout whose `HEAD` is read before and after, when it differs /// from where the command runs. Only `clone` needs it: it runs in one /// directory and is about another, and probing the directory it ran in /// would record the *surrounding* repo's `HEAD` — a true fact about the /// wrong repository, which is worse than none. probe: Option<&'a Path>, /// False for the `HEAD` probe itself. Its own two lines would double the /// file to say nothing, and it is the one caller that is part of the log /// rather than something the log is about. logged: bool, } /// What a finished subprocess left behind. /// /// `stdout` and `stderr` are empty unless the run was [`Stdio::Captured`], /// which is the honest shape: an inherited stderr went to the terminal and /// there is nothing here to hand back. pub(super) struct Finished { pub status: std::process::ExitStatus, pub stdout: Vec, pub stderr: Vec, } impl<'a> Run<'a> { /// A captured run in the current directory, which is the common case. pub(super) fn new(args: &'a [&'a str]) -> Self { Run { args, dir: None, env: &[], stdin: None, stdio: Stdio::Captured, probe: None, logged: true, } } /// Run against a checkout that isn't the current directory — which `repo /// clone` needs, since the checkout it configures is one it just made, /// and `pr diff --interdiff` needs for the scratch worktree it builds. pub(super) fn at(mut self, dir: &'a Path) -> Self { self.dir = Some(dir); self } pub(super) fn env(mut self, env: &'a [(&'a str, &'a str)]) -> Self { self.env = env; self } pub(super) fn stdin(mut self, input: &'a [u8]) -> Self { self.stdin = Some(input); self } /// Leave stdio to the terminal, so git owns the colour, the pager and the /// progress. pub(super) fn inherited(mut self) -> Self { self.stdio = Stdio::Inherited; self } /// Send stdio to `/dev/null`, for the probes that run in a loop and whose /// failures are the expected outcome rather than news. pub(super) fn discarded(mut self) -> Self { self.stdio = Stdio::Discarded; self } /// Read `HEAD` in this checkout rather than in the one the command runs /// in. See [`Run::probe`]. fn probing(mut self, dir: &'a Path) -> Self { self.probe = Some(dir); self } fn unlogged(mut self) -> Self { self.logged = false; self } /// Start git, wait for it, and record both ends. /// /// In a non-interactive session git is told to fail rather than prompt /// (`GIT_TERMINAL_PROMPT=0`): a credential question asked of a pipe, a CI /// job or an agent harness has no answerer, and the command would sit /// until something killed it. git prompts via /dev/tty, not stdio, so /// capturing output is no protection by itself. pub(super) fn run(self) -> Result { // Asked once, before anything is spent on it: with the log switched // off this whole function is what it always was. let watched = self.logged && gitlog::LOG.is_open(); let moves = watched && gitlog::moves_a_ref(self.args); let probe = self.probe.or(self.dir).unwrap_or(Path::new(".")); let before = if moves { head_at(probe) } else { At::default() }; let mut command = Command::new("git"); let mut env: Vec<&str> = Vec::new(); if crate::term::noinput::active() { command.env("GIT_TERMINAL_PROMPT", "0"); env.push("GIT_TERMINAL_PROMPT"); } if let Some(dir) = self.dir { command.arg("-C").arg(dir); } command.args(self.args); for (key, value) in self.env { command.env(key, value); env.push(key); } match self.stdio { Stdio::Captured => { command .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()); } Stdio::Inherited => {} Stdio::Discarded => { command .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()); } } if self.stdin.is_some() { command.stdin(std::process::Stdio::piped()); } if watched { gitlog::emit(gitlog::spawned( self.args, self.dir, self.stdio, &env, self.stdin.map(<[u8]>::len), before.clone(), )); } let started = std::time::Instant::now(); let outcome = self.spawn_and_wait(command); let elapsed_ms = started.elapsed().as_millis().min(u64::MAX as u128) as u64; match outcome { Ok(finished) => { if watched { let after = if moves { head_at(probe) } else { At::default() }; gitlog::emit(gitlog::exited( self.args, &finished.status, before, after, &finished.stderr, elapsed_ms, )); } Ok(finished) } Err(err) => { if watched { gitlog::emit(gitlog::spawn_failed(self.args, &err.to_string())); } Err(anyhow::Error::new(err).context("failed to run git")) } } } /// The syscalls, separated from the bookkeeping so that every failure /// between `spawn` and `wait` is one `io::Error` the caller above records /// once. fn spawn_and_wait(&self, mut command: Command) -> std::io::Result { let mut child = command.spawn()?; if let Some(input) = self.stdin { if let Some(pipe) = child.stdin.as_mut() { // A `git apply --check` that has already made up its mind // closes the pipe before the patch is finished arriving, and // that is a verdict, not an error — the exit status below is // the one that matters. let _ = pipe.write_all(input); } // Before the wait, or a git reading to end-of-input never gets // one and neither of us moves again. drop(child.stdin.take()); } match self.stdio { Stdio::Captured => { let out = child.wait_with_output()?; Ok(Finished { status: out.status, stdout: out.stdout, stderr: out.stderr, }) } _ => Ok(Finished { status: child.wait()?, stdout: Vec::new(), stderr: Vec::new(), }), } } } /// Where `HEAD` stands in `dir`, for the log to record either side of a /// command that can move it. /// /// Unlogged, and the only caller of that: this is the log's own instrument, /// and a probe that recorded itself would put two lines in the file for every /// line it explains. fn head_at(dir: &Path) -> At { let out = Run::new(&["rev-parse", "HEAD", "--abbrev-ref", "HEAD"]) .at(dir) .unlogged() .run(); match out { Ok(out) if out.status.success() => At::read(&String::from_utf8_lossy(&out.stdout)), // No repository there yet, or an unborn HEAD. Both are states to // record as "nothing", not failures to report. _ => At::default(), } } /// The sha `HEAD` stands at. /// /// The one thing a push has to know that no record can tell it: where the /// branch is about to be, as against where the last round said it was. pub fn head() -> Result { Ok(git(&["rev-parse", "HEAD"])?.trim().to_string()) } /// git's own version line, for the diagnostics an `atgc report` carries. /// /// Here rather than in `cmd/report.rs` because that was the one place outside /// this folder still starting a git process of its own — harmless in itself, /// and exactly the crack through which the next one would have gone /// unrecorded. pub(crate) fn version() -> Option { let out = Run::new(&["--version"]).run().ok()?; if !out.status.success() { return None; } let line = String::from_utf8(out.stdout).ok()?; Some(line.trim().to_string()).filter(|line| !line.is_empty()) } /// Capture git's output and hand it back, with a failure that reads the same /// wherever it came from: the command, and git's own stderr. pub fn git_in(dir: &Path, args: &[&str]) -> Result { let out = Run::new(args).at(dir).run()?; if !out.status.success() { bail!( "git {} failed: {}", args.join(" "), String::from_utf8_lossy(&out.stderr).trim() ); } Ok(String::from_utf8(out.stdout)?.trim().to_string()) } /// Why the checkout atgc is standing in could not name a repo or a branch. /// /// These were one failure until now — whatever git's plumbing said, from /// whichever command happened to run first — and they send someone in four /// different directions. Which one it is cannot be read off git's stderr /// without matching on prose that git is free to reword, so each is decided /// by asking git a second, narrower question instead. #[derive(Debug, PartialEq, Eq)] pub(crate) enum NoCheckout { /// Neither this directory nor any parent is a git repository. NotARepo, /// A git repository, but with no remote of that name. `known` is what it /// does have — which is the whole answer when the remote is called /// `tangled` or `upstream` rather than `origin`. NoRemote { remote: String, known: Vec }, /// A git repository whose `HEAD` names no branch. Detached, /// The same, during a rebase git has stopped in the middle of. /// /// **A rebase in progress detaches `HEAD`**, so every command that reads /// the current branch reported "detached HEAD … `git checkout ` /// first" — which is true, useless, and would throw away the conflict /// resolution in the working tree. Somebody who has just hit a conflict /// is the likeliest reader of any of these messages, and it was the one /// message that did not mention the rebase. MidRebase, /// A git repository whose `HEAD` names a branch no commit has created /// yet. Distinct from [`Detached`](NoCheckout::Detached), and reported as /// it until now: `rev-parse --abbrev-ref HEAD` fails outright on an /// unborn ref rather than answering `HEAD`, so this case never reached /// the detached check and leaked git's *ambiguous argument 'HEAD'*. Unborn { branch: String }, } /// What to print for one of these, given the directory atgc was standing in. /// /// Pure, and split from the git calls above it for that reason. Naming the /// directory is the most valuable thing in these messages and the easiest to /// get wrong: a shell, an agent harness or a worktree session is routinely /// somewhere other than where its operator believes, and *which directory* /// is then the entire diagnosis. Every arm also names a way out, because an /// error that only says no leaves the reader to guess which of `cd`, `git /// remote add`, `--remote` and `atgc repo clone` was meant. pub(crate) fn no_checkout_message(trouble: &NoCheckout, dir: &str) -> String { match trouble { NoCheckout::NotARepo => format!( "not a git repository: {dir}: atgc reads its repo from the checkout it runs in; \ cd into one, or `atgc repo clone /`" ), NoCheckout::NoRemote { remote, known } if known.is_empty() => format!( "no remote named {remote} in {dir}, which has no remotes at all: add one with \ `git remote add {remote} git@tangled.org:/`, or work in a clone" ), NoCheckout::NoRemote { remote, known } => format!( "no remote named {remote} in {dir}: it has {}; name one with `--remote `", known.join(", ") ), NoCheckout::Detached => format!( "detached HEAD in {dir}: atgc reads the branch it works from out of the \ checkout, so `git checkout ` first" ), NoCheckout::MidRebase => format!( "a rebase is in progress in {dir}, which leaves HEAD on no branch\n\ finish it with `git rebase --continue` once the conflicts are resolved, or \ `git rebase --abort` to put the branch back; `atgc stack sync` then rebases \ and reconciles together" ), NoCheckout::Unborn { branch } => format!( "no commits yet in {dir}: HEAD names {branch}, which nothing has created; \ commit something first" ), } } /// Turn one into the failure a caller returns. /// /// [`Usage`](crate::exit::Exit::Usage) for all of them, sharing clap's `2` /// for clap's own reason: the command as typed does not mean anything from /// here, and running it again unchanged cannot start working. That is the /// same claim clap makes about an argument it could not parse, and the pair /// is exactly what [`crate::exit`] describes `2` as covering — the refusals /// clap cannot make itself, "an argument that only means something inside a /// checkout". A repo read out of the working directory is that argument, /// left implicit. fn no_checkout(trouble: NoCheckout) -> anyhow::Error { crate::exit::fail( crate::exit::Exit::Usage, no_checkout_message(&trouble, &here()), ) } /// The directory atgc is standing in, for the message to name. fn here() -> String { shown(Path::new(".")) } /// A directory as a message should name it: absolute, because `.` is what /// nearly every caller in `clients/git/` passes and "not a git repository: ." /// tells a reader nothing they did not already have. /// /// Canonicalizing fails when the directory has been deleted out from under /// the process, which is not a case to paper over — it is the answer. pub(super) fn shown(dir: &Path) -> String { match dir.canonicalize() { Ok(dir) => dir.display().to_string(), Err(_) if dir == Path::new(".") => "the current directory, which no longer exists".into(), Err(_) => dir.display().to_string(), } } /// Whether the process is standing inside a git repository at all. /// /// `rev-parse --git-dir` rather than `--is-inside-work-tree`, because the /// question here is only whether git recognises the place: a bare repo is a /// repository, and the commands that additionally need a working tree fail /// with their own words about that. fn inside_a_repo() -> bool { quiet_in(Path::new("."), &["rev-parse", "--git-dir"]) } /// Every remote this checkout has, in git's own order. /// /// Empty both when there are none and when the question could not be asked — /// which is fine here, since the one caller has already established that this /// is a repository. fn remote_names() -> Vec { git(&["remote"]) .map(|out| out.lines().map(str::to_string).collect()) .unwrap_or_default() } pub fn current_branch() -> Result { match git(&["rev-parse", "--abbrev-ref", "HEAD"]) { Ok(branch) if branch != "HEAD" => Ok(branch), // `HEAD` is what `--abbrev-ref` answers for a detached HEAD, and git // forbids a branch of that name, so this is not a guess. Which // *kind* of detached is the useful half: a rebase git stopped in the // middle of leaves HEAD here too, and telling somebody to check out // a branch would throw away the resolution they are standing in. Ok(_) => Err(no_checkout(match rebase_in_progress() { true => NoCheckout::MidRebase, false => NoCheckout::Detached, })), // The command itself failed, which it does for two known reasons and // an unknown number of others. `symbolic-ref` separates them: it // answers a branch name on an unborn HEAD, where `rev-parse` will // not, and fails on anything else — and for anything else git's own // words beat a guess dressed up as a diagnosis. Err(err) => match inside_a_repo() { false => Err(no_checkout(NoCheckout::NotARepo)), true => match git(&["symbolic-ref", "--quiet", "HEAD"]) { Ok(full) => Err(no_checkout(NoCheckout::Unborn { branch: full .strip_prefix("refs/heads/") .unwrap_or(&full) .to_string(), })), Err(_) => Err(err), }, }, } } /// What `repo create` has to work with here: the branch a new repo would be /// built around, and whether there is anything to push to it yet. /// /// [`current_branch`] answers the same question for every other command, /// where two of these three are refusals — a command that reads its repo out /// of the checkout has nothing to read without one. `repo create` is the /// exception, because it is the command that *makes* the thing the others /// need: an empty directory and a `git init` nobody has committed to are /// both ordinary places to start a repo from, and both used to be errors. #[derive(Debug, PartialEq, Eq)] pub enum Start { /// A checkout with commits on the branch HEAD names. Ready(String), /// A checkout whose HEAD names a branch nothing has created yet: a /// branch name to build the repo around, and no commit to push to it. Unborn(String), /// Not a git repository at all: nothing here yet. Blank, } /// Read one out of the working directory. /// /// A detached HEAD stays the refusal it is everywhere else. It names no /// branch, and which branch a new repo defaults to is not something to /// invent on someone's behalf. pub fn start_point() -> Result { match current_branch() { Ok(branch) => Ok(Start::Ready(branch)), // The two failures it reports that are starting points rather than // dead ends. Everything else it can say — a detached HEAD, or git // failing for reasons of its own — is passed on as the error it // already is. Err(_) if !inside_a_repo() => Ok(Start::Blank), Err(err) => match git(&["symbolic-ref", "--quiet", "--short", "HEAD"]) { Ok(branch) if !branch.is_empty() => Ok(Start::Unborn(branch)), _ => Err(err), }, } } pub fn remote_url(name: &str) -> Result { match git(&["remote", "get-url", name]) { Ok(url) => Ok(url), // Both failures are known exactly, so neither passes git's version // on: `No such remote 'origin'` does not say which directory, and // the not-a-repository wall of text says neither that nor anything // about atgc. This is the first error a caller in the wrong // directory meets, and it used to name no way out at all. Err(_) => Err(no_checkout(match inside_a_repo() { false => NoCheckout::NotARepo, true => NoCheckout::NoRemote { remote: name.to_string(), known: remote_names(), }, })), } } /// Where a push to `remote` actually lands. /// /// Not the same string as [`remote_url`] whenever a checkout carries a /// `pushInsteadOf` rewrite — a mirror, a read-only fetch URL, or the /// scenario tests, which fetch from a mock knot over HTTP and push into a /// bare repo beside it. Anything that wants to look at what a push is about /// to overwrite has to look *there*, or it is asking a different server /// about a different ref. /// /// `None` when git cannot answer, which callers treat as "ask the remote by /// name and let the push find out". pub fn push_url(remote: &str) -> Option { git(&["remote", "get-url", "--push", "--", remote]) .ok() .map(|url| url.trim().to_string()) .filter(|url| !url.is_empty()) } /// The remote's default branch, if `refs/remotes//HEAD` is set /// (it usually is after a clone, but not after `git remote add`). pub fn remote_default_branch(remote: &str) -> Option { let full = git(&["symbolic-ref", &format!("refs/remotes/{remote}/HEAD")]).ok()?; full.strip_prefix(&format!("refs/remotes/{remote}/")) .map(str::to_string) } /// Whether `ancestor` is reachable from `descendant` — `git merge-base /// --is-ancestor`, whose answer is its exit status. /// /// `false` covers both "no" and "the question could not be asked", which is /// the right conflation for the one caller: it refuses on `false`, and a /// revision that cannot be resolved is not a base anything should be /// rewritten onto either. /// Whether a rebase is half-finished in this checkout. /// /// Both spellings, because git has two: `rebase-merge` for the interactive /// machinery (which `--update-refs` uses) and `rebase-apply` for the old /// am-based one. Asked before starting a rebase, since starting a second one /// on top of the first is how the first one's work is lost. pub fn rebase_in_progress() -> bool { ["rebase-merge", "rebase-apply"].iter().any(|dir| { git(&["rev-parse", "--git-path", dir]) .map(|path| Path::new(path.trim()).exists()) .unwrap_or(false) }) } /// Replay the current branch onto `base`, carrying every ref that points into /// the range with it. /// /// `--update-refs` is the only reason this wrapper exists: a stack is cut at /// branch marks, and without it every mark is left behind on a commit the /// branch no longer has. git has had the flag since 2.38 and it is off unless /// asked for, so the tool asks. /// /// A conflict comes back as git's own failure, with git's own text: it is /// mid-rebase advice, it is correct, and rewriting it would only make it /// disagree with what `git status` says a line later. pub fn rebase_update_refs(base: &str) -> Result<()> { let out = Run::new(&[ "rebase", "--update-refs", "--no-autostash", "--end-of-options", base, ]) .run()?; if !out.status.success() { let text = String::from_utf8_lossy(&out.stderr); let text = match text.trim().is_empty() { true => String::from_utf8_lossy(&out.stdout).trim().to_string(), false => text.trim().to_string(), }; bail!( "the rebase stopped:\n{text}\n\ resolve and `git rebase --continue`, or `git rebase --abort` to put the branch \ back\n\ --update-refs moves the marks as the rebase reaches them, so `atgc stack mark` \ is worth a look either way" ); } Ok(()) } pub fn is_ancestor(ancestor: &str, descendant: &str) -> bool { git(&["merge-base", "--is-ancestor", ancestor, descendant]).is_ok() } pub fn ref_exists(name: &str) -> bool { git(&[ "rev-parse", "--verify", "--quiet", &format!("{name}^{{commit}}"), ]) .is_ok() } /// Whether `branch` is on `remote` right now, as the remote itself answers. /// /// `None` when the question could not be asked at all — no network, no such /// remote, a host that refused the connection — which is not the same answer /// as "gone" and must not be treated as one. /// /// [`ref_exists`] cannot stand in for this. `refs/remotes//` /// survives the branch it mirrors until something prunes it, so a branch /// deleted upstream still looks present locally, and a patch built against /// that stale tip quietly contains commits that already landed. The pattern /// is spelled in full as `refs/heads/` because `ls-remote` matches /// patterns against the tail of a ref path, so a bare name would also match /// `refs/heads/anything/`. pub fn remote_branch_exists(remote: &str, branch: &str) -> Option { remote_head(remote, branch).map(|head| head.is_some()) } /// Where `branch` points on `remote`, asked live. /// /// The outer `Option` is [`remote_branch_exists`]'s: `None` is "could not /// ask", which is not "not there". The inner one is the branch itself. /// /// This is the observation the resubmit path leases against — not the /// expectation itself. Reading the head and then leasing against what was /// just read is a compare-and-swap against its own answer, which succeeds by /// construction and overwrites whatever it found; the expectation has to come /// from what the *records* claim (see [`super::patch::head_sha`]). What this /// buys is the diagnosis: a push that is going to be refused can be explained /// in terms of the sha that is actually there, before anything is sent. pub fn remote_head(remote: &str, branch: &str) -> Option> { let listed = git(&[ "ls-remote", "--heads", "--end-of-options", remote, &format!("refs/heads/{branch}"), ]) .ok()?; Some( listed .split_whitespace() .next() .filter(|sha| sha.len() == 40 && sha.chars().all(|c| c.is_ascii_hexdigit())) .map(str::to_string), ) } /// Fetch one branch from one remote. /// /// `--end-of-options` because the branch is rarely the caller's own word: it /// is `target.branch` off a `sh.tangled.repo.pull` record, which anyone with /// an account can write, and `pr diff`/`pr checkout` fetch it as soon as the /// base is not already local. git's option parser permutes, so without the /// marker a branch named `--upload-pack=...` is read as an option to `fetch` /// rather than as the ref to fetch — a repo record naming what git should /// run. Past the marker it is a refspec or it is an error, and the error /// names the string. /// Fetch one branch, saying which of the two things went missing. /// /// **git reports a branch it cannot find as a repository it cannot find.** /// `git fetch origin nonexistent` answers "fatal: repository `` not /// found", because a knot 404s the ref-advertisement for a ref it does not /// serve and git has no way to tell that from the repo being gone. Passing /// that through told somebody their remote was wrong when their branch name /// was, which is a long detour on a typo. /// /// The remote is probed for its ref list first, which separates the two: if /// `ls-remote` answers at all, the repository is there and the branch is /// what is not. pub fn fetch(remote: &str, branch: &str) -> Result<()> { let failed = match git(&["fetch", "--end-of-options", remote, branch]) { Ok(_) => return Ok(()), Err(e) => e, }; match git(&["ls-remote", "--heads", "--end-of-options", remote, branch]) { // The remote answered and knows no such branch. Ok(listed) if listed.trim().is_empty() => bail!( "{remote} has no branch {branch}\n\ `git ls-remote --heads {remote}` lists the ones it does have" ), // The remote answered and does know it, so the fetch failed for some // other reason and git's own words are the best available. Ok(_) => Err(failed), // The remote itself could not be reached or read; git was right. Err(_) => Err(failed), } } /// Takes the checkout explicitly rather than working in the current /// directory: `repo create` adds this remote to a checkout it has just made /// itself, which is not the one atgc is standing in. pub fn add_remote(dir: &Path, name: &str, url: &str) -> Result<()> { git_in(dir, &["remote", "add", name, url]).map(|_| ()) } /// Change an existing remote's fetch URL, leaving its push URL (if one was /// set separately) alone. /// /// `repo configure`'s use of this is the only production caller: rewriting a /// name-based Tangled remote to its repo DID after a rename left the name /// stale. Unlike [`add_remote`] this requires the remote to already exist — /// `git remote set-url` refuses one that does not, and that refusal is /// exactly right here, since there is nothing to rewrite. pub fn set_remote_url(dir: &Path, name: &str, url: &str) -> Result<()> { git_in(dir, &["remote", "set-url", name, url]).map(|_| ()) } /// `git init` a checkout at `dir`, creating the directory if it is not /// there, with `branch` as the name its unborn `HEAD` points at. /// /// The branch is passed rather than left to git because the caller has /// already told a knot which branch this repo's default is, and `git init`'s /// own answer depends on the machine's `init.defaultBranch` — a checkout on /// `master` under a repo whose default is `main` makes the first push land /// on a branch nothing points at. /// /// `--end-of-options` for the reason [`fetch`] gives, reached the way /// [`clone`] reaches it: `dir` is a repo name someone typed. pub fn init(dir: &Path, branch: &str) -> Result<()> { let dir = dir.to_string_lossy().into_owned(); git(&["init", "-q", "-b", branch, "--end-of-options", &dir]).map(|_| ()) } /// Clone `url` into `dir`. Stdio is inherited so progress, host-key prompts /// and credential prompts reach the terminal. `ssh_command` overrides the /// transport for this one clone only (the equivalent setting is written into /// the new checkout afterwards, if it's wanted there). /// /// `--end-of-options` for the reason [`fetch`] gives, reached differently. /// The url is built from a template and cannot lead with a dash, but the /// directory is the repo's *name* when none was given, and that name is the /// last path segment of whatever repo reference was handed to `repo clone` — /// a link off a forum or a chat window as often as something typed. A /// reference ending `/--upload-pack=touch pwned` makes that string the /// directory argument, and git reads it as an option to `clone`. pub fn clone(url: &str, dir: &Path, ssh_command: Option<&str>) -> Result<()> { // Non-interactively, SSH too must fail rather than ask (host keys, // passphrases); BatchMode is that switch. Only this explicit command is // amended — inventing a GIT_SSH_COMMAND otherwise would override a // checkout's core.sshCommand and change which key authenticates the // clone. let ssh = ssh_command.map(|ssh| match crate::term::noinput::active() { true => format!("{ssh} -oBatchMode=yes"), false => ssh.to_string(), }); let env: Vec<(&str, &str)> = ssh .as_deref() .map(|ssh| vec![("GIT_SSH_COMMAND", ssh)]) .unwrap_or_default(); let path = dir.to_string_lossy(); let status = Run::new(&["clone", "--end-of-options", url, &path]) .env(&env) // The checkout this is about is the one it creates, not the one it // runs in. .probing(dir) .inherited() .run() .context("failed to run git clone")? .status; if !status.success() { bail!("git clone failed"); } Ok(()) } /// Push `branch` to `remote` under its own name, without touching the local /// branch's upstream. /// /// Apart from [`push_upstream`] because the two have different jobs. That one /// publishes a branch `repo create` has just made and wants tracking set up; /// this one makes a claim true — a pull recording `source: {branch}` is /// telling every reader that the branch is on the knot — and has no business /// rewriting a tracking configuration the person set for themselves. /// /// The refspec is spelled in full rather than left to `push.default`, which /// is a per-machine setting: `matching` would push every branch whose name /// the remote already has, and `upstream` would push somewhere else entirely /// on a branch tracking a second remote. `--end-of-options` for the reason /// [`fetch`] gives — the branch name reaches here from a checkout, and a /// branch really can be called `--upload-pack=...`. /// /// `lease` is the sha the caller believes `remote` holds, and passing one is /// the only way to move a branch that has diverged. It becomes /// `--force-with-lease=:`: git overwrites the remote branch only /// if it still points at that sha, and refuses otherwise. A round is a /// rewritten branch by definition — that is what a resubmit is — so without /// this every round after a rebase was a non-fast-forward the person had to /// push by hand. /// /// Still no bare `--force`, and none available, for the reason this comment /// used to give for allowing no force at all: a branch that has moved /// somewhere the caller did not predict is a question for the person. The /// lease is what turns that principle into a check instead of a refusal to /// try — and it is deliberately *not* the argument-less /// `--force-with-lease`, whose expectation is the remote-tracking ref: a /// `git fetch` anywhere in the session quietly updates that ref to the /// commits it is supposed to protect, and the lease then passes over them. /// The expectation is passed in from a record instead. pub fn push(remote: &str, branch: &str, lease: Option<&str>) -> Result<()> { // Inherited, like every other push here: progress, host-key questions // and credential prompts all reach the terminal on their own descriptors. let refspec = format!("{branch}:refs/heads/{branch}"); let leased = lease.map(|sha| format!("--force-with-lease={branch}:{sha}")); let mut args: Vec<&str> = vec!["push"]; if let Some(leased) = leased.as_deref() { args.push(leased); } args.extend(["--end-of-options", remote, &refspec]); let status = Run::new(&args) .inherited() .run() .context("failed to run git push")? .status; if !status.success() { bail!("git push {remote} {branch} failed"); } Ok(()) } pub fn push_upstream(remote: &str, branch: &str) -> Result<()> { // Inherit stdio so SSH prompts and progress are visible. let status = Run::new(&["push", "-u", remote, branch]) .inherited() .run() .context("failed to run git push")? .status; if !status.success() { bail!("git push failed"); } Ok(()) } /// Run git with stdio inherited, and report whether it succeeded rather than /// failing — callers here have something to say about the failure. pub(crate) fn status_in(dir: &Path, args: &[&str]) -> Result { crate::logging::debug::log(format!("$ git -C {} {}", dir.display(), args.join(" "))); Ok(Run::new(args).at(dir).inherited().run()?.status.success()) } /// The same, feeding `input` on stdin. `git am` reads its mailbox that way, /// which is what lets a patch be applied without ever touching the disk. pub(crate) fn stdin_in(dir: &Path, args: &[&str], input: &[u8]) -> Result { stdin_inner(dir, args, input, false) } /// And the same again with git's output swallowed, for the probes that run in /// a loop and whose failures are the expected outcome rather than news. pub(crate) fn stdin_quiet_in(dir: &Path, args: &[&str], input: &[u8]) -> bool { stdin_inner(dir, args, input, true).unwrap_or(false) } fn stdin_inner(dir: &Path, args: &[&str], input: &[u8], quiet: bool) -> Result { if !quiet { crate::logging::debug::log(format!( "$ git -C {} {} <{} bytes", dir.display(), args.join(" "), input.len() )); } let run = Run::new(args).at(dir).stdin(input); let run = match quiet { true => run.discarded(), false => run.inherited(), }; Ok(run.run()?.status.success()) } pub(crate) fn quiet_in(dir: &Path, args: &[&str]) -> bool { Run::new(args) .at(dir) .discarded() .run() .map(|out| out.status.success()) .unwrap_or(false) } /// The top of the working tree the process is standing in. /// /// `--show-toplevel` also fails inside a bare repository, which is a /// repository — so the not-a-repo message is only used once that has been /// ruled out, and git's own "must be run in a work tree" survives otherwise. pub(crate) fn repo_root() -> Result { match git_in(Path::new("."), &["rev-parse", "--show-toplevel"]) { Ok(root) => Ok(PathBuf::from(root)), Err(err) if inside_a_repo() => Err(err), Err(_) => Err(no_checkout(NoCheckout::NotARepo)), } } #[cfg(test)] mod tests { /// **git reports a branch it cannot find as a repository it cannot /// find**, and `fetch` now separates the two. /// /// Against a real local remote, because that is the only way to see the /// distinction at all: the integration harness serves pushes through /// `pushInsteadOf` and cannot answer a fetch, so there both halves look /// alike and the test would pass without the code. #[test] fn a_missing_branch_is_not_reported_as_a_missing_repository() { let repo = TempRepo::new("fetch-missing-branch"); repo.commit("one.txt", "one\n", "feat: one"); // A sibling of the checkout, named by an absolute path git can open. let bare = std::env::temp_dir().join("atgc-fetch-missing-branch-remote.git"); let _ = std::fs::remove_dir_all(&bare); repo.git(&["init", "-q", "--bare", &bare.to_string_lossy()]); repo.git(&["remote", "add", "origin", &bare.to_string_lossy()]); repo.git(&["push", "-q", "origin", "HEAD:refs/heads/main"]); // The repository is there and answers; the branch is not. let err = fetch("origin", "no-such-branch").expect_err("the branch does not exist"); let said = format!("{err:#}"); assert!( said.contains("has no branch no-such-branch"), "a missing branch was not named as one: {said}" ); assert!( !said.contains("repository") || said.contains("no branch"), "git's repository-not-found wording leaked through: {said}" ); // A branch that is there still fetches. fetch("origin", "main").expect("main is on the remote"); } use super::{ NoCheckout, add_remote, clone, current_branch, fetch, git_in, no_checkout_message, ref_exists, remote_branch_exists, remote_default_branch, remote_url, status_in, }; use crate::exit::{Exit, classify}; use crate::testutil::TempRepo; use std::path::Path; /// What the git log rests on, end to end: a command that can move a ref /// writes down where `HEAD` was before it ran and where it ended up, and /// a command that only asks a question does not pay for the probe. /// /// Driven through the real runner and a real file rather than through /// [`crate::logging::git`]'s event constructors, because everything in /// between — the ref-moving list, the two probes, the emits, the /// envelope, the mode the file is created at — is what could be wrong. /// /// The log is opened at an explicit path because `ATGC_GIT_LOG` cannot be /// set inside this process (`set_var` is unsafe and this crate forbids /// unsafe), and the `OnceLock` behind it makes that one-shot per test /// binary: this is the only test that opens it, and any future one would /// have to share this file. #[test] fn a_ref_moving_command_records_where_head_was_before_and_after() { let repo = TempRepo::new("git-log"); repo.commit("a.txt", "one\n", "Add a"); let first = repo.git(&["rev-parse", "HEAD"]); repo.git(&["checkout", "-q", "-b", "side"]); repo.commit("b.txt", "two\n", "Add b"); let second = repo.git(&["rev-parse", "HEAD"]); repo.git(&["checkout", "-q", "main"]); let logs = tempfile::tempdir().expect("a temp directory"); let log = logs.path().join("git.jsonl"); crate::logging::git::LOG.open_at(&log); // `status_in` is the runner `pr checkout` moves a branch with. assert!(status_in(Path::new("."), &["checkout", "-q", "side"]).unwrap()); // And a question, to prove the probe is gated rather than universal. git_in(Path::new("."), &["log", "-1", "--format=%s"]).unwrap(); let text = std::fs::read_to_string(&log).expect("the log was written"); let lines: Vec = text .lines() .map(|line| serde_json::from_str(line).expect("every line is JSON")) .collect(); let of = |event: &str| -> Vec<&serde_json::Value> { lines.iter().filter(|l| l["event"] == event).collect() }; let spawn = of("spawn") .into_iter() .find(|l| l["argv"].to_string().contains("side")) .expect("the checkout was recorded on its way out"); assert_eq!(spawn["before"]["sha"], first.as_str()); assert_eq!(spawn["before"]["branch"], "main"); assert_eq!(spawn["stdio"], "inherited"); let exit = of("exit") .into_iter() .find(|l| l["after"]["branch"] == "side") .expect("and on its way back"); assert_eq!(exit["verb"], "checkout"); assert_eq!(exit["code"], 0); assert_eq!(exit["before"]["sha"], first.as_str()); assert_eq!(exit["after"]["sha"], second.as_str()); // Both lines carry the same invocation as everything else atgc // writes, which is what lets this file be laid beside the other two. assert_eq!(spawn["inv"], exit["inv"]); assert_eq!(spawn["inv"], crate::logging::file::invocation().id.as_str()); let asked = of("spawn") .into_iter() .find(|l| l["argv"].to_string().contains("--format=%s")) .expect("the question was recorded too"); assert!( asked["before"].is_null(), "a question must not pay for a probe: {asked}" ); // The same mode as the other two logs, at creation rather than by a // chmod afterwards. #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let mode = std::fs::metadata(&log).unwrap().permissions().mode(); assert_eq!(mode & 0o777, 0o600, "a log names directories and accounts"); } let _ = std::fs::remove_file(&log); } /// The repo the process is parked in, for the callers that work on the /// current checkout rather than one they were handed. #[test] fn reads_the_checked_out_branch() { let repo = TempRepo::new("branch"); repo.commit("a.txt", "one\n", "Add a"); assert_eq!(current_branch().unwrap(), "main"); repo.git(&["checkout", "-q", "-b", "claude/testing"]); assert_eq!(current_branch().unwrap(), "claude/testing"); } /// `pr create` names the PR after the branch, so a detached HEAD has to /// be refused rather than reported as a branch called "HEAD" — and the /// refusal is the command line being wrong for where it was run, not an /// unclassified failure. #[test] fn refuses_a_detached_head() { let repo = TempRepo::new("detached"); repo.commit("a.txt", "one\n", "Add a"); repo.git(&["checkout", "-q", "--detach"]); let err = current_branch().unwrap_err(); assert!(err.to_string().contains("detached HEAD"), "got: {err}"); assert_eq!(classify(&err), Exit::Usage); } /// A repository with no commits is *not* a detached HEAD, and used to be /// reported as neither: `rev-parse --abbrev-ref HEAD` fails on an unborn /// ref, so the check above never ran and git's "ambiguous argument /// 'HEAD'" reached the user instead. #[test] fn a_repository_with_no_commits_says_so() { let _repo = TempRepo::new("unborn"); let err = current_branch().unwrap_err(); let message = err.to_string(); assert!(message.contains("no commits yet"), "got: {message}"); assert!( !message.contains("detached"), "an unborn branch is not a detached HEAD: {message}" ); assert_eq!(classify(&err), Exit::Usage); } /// The remote that is not there names the ones that are, since a checkout /// whose Tangled remote is called `tangled` rather than `origin` is the /// common shape of this failure and `--remote` is one word away. #[test] fn a_missing_remote_names_the_remotes_that_exist() { let repo = TempRepo::new("missing-remote"); repo.commit("a.txt", "one\n", "Add a"); add_remote( Path::new("."), "tangled", "git@tangled.org:permadeath.com/atgc", ) .unwrap(); let err = remote_url("origin").unwrap_err(); let message = err.to_string(); assert!(message.contains("no remote named origin"), "got: {message}"); assert!(message.contains("tangled"), "got: {message}"); assert!(message.contains("--remote"), "got: {message}"); assert_eq!(classify(&err), Exit::Usage); } #[test] fn reads_and_writes_remotes() { let repo = TempRepo::new("remotes"); repo.commit("a.txt", "one\n", "Add a"); // A remote that isn't there is an error, not an empty string — that // is what `repo create` keys off to decide between origin and tangled. assert!(remote_url("origin").is_err()); add_remote( Path::new("."), "origin", "git@tangled.org:permadeath.com/atgc", ) .unwrap(); assert_eq!( remote_url("origin").unwrap(), "git@tangled.org:permadeath.com/atgc" ); } /// `refs/remotes//HEAD` is set by a clone but not by `git remote /// add`, so the None case is the one `repo create` leaves behind and /// `pr create` falls back to "main" for. #[test] fn reads_the_remote_default_branch_only_once_it_is_set() { let repo = TempRepo::new("head"); repo.commit("a.txt", "one\n", "Add a"); add_remote( Path::new("."), "origin", "git@tangled.org:permadeath.com/atgc", ) .unwrap(); assert_eq!(remote_default_branch("origin"), None); repo.git(&["update-ref", "refs/remotes/origin/main", "HEAD"]); repo.git(&[ "symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/main", ]); assert_eq!(remote_default_branch("origin").as_deref(), Some("main")); } /// The branch handed to [`fetch`] is usually not the caller's own word: /// `pr diff` and `pr checkout` take it off a pull request record, which /// any account can write. git's option parser permutes, so a branch /// beginning with a dash used to be read as an option — and /// `--upload-pack` against a remote that is a local path is a command git /// runs on this machine. /// /// The remote here is the repo's own directory, which is what makes that /// reachable without a network. Asserted on the marker file rather than on /// the error text: the fetch fails either way, and the whole question is /// whether it ran the string first. #[test] fn a_branch_that_looks_like_an_option_is_not_one() { let repo = TempRepo::new("fetch-injection"); repo.commit("a.txt", "one\n", "Add a"); let here = std::env::current_dir().expect("inside the temp repo"); let marker = here.join("pwned"); add_remote(Path::new("."), "origin", &here.to_string_lossy()).unwrap(); let attack = format!("--upload-pack=touch {}", marker.display()); assert!(fetch("origin", &attack).is_err()); assert!( !marker.exists(), "a branch name off a pull request record reached git as an option" ); // And the ordinary case still fetches, so the marker did not simply // move the argument somewhere git ignores. fetch("origin", "main").unwrap(); } /// The same bug through the other door. `repo clone` takes the repo's /// name from the last segment of whatever reference it was given — a /// pasted link, usually — and uses it as the directory to clone into /// when `--directory` is absent, so a reference ending /// `/--upload-pack=touch pwned` puts that string where git looks for /// options. /// /// Cloning from a local path rather than a URL, so this needs no network /// and so that `--upload-pack` is a program git would really run. #[test] fn a_clone_directory_that_looks_like_an_option_is_not_one() { let repo = TempRepo::new("clone-injection"); repo.commit("a.txt", "one\n", "Add a"); let here = std::env::current_dir().expect("inside the temp repo"); let marker = here.join("pwned"); let attack = format!("--upload-pack=touch {}", marker.display()); // Whether git can make a directory of that name is not the point and // is not asserted; that it does not run it is. let _ = clone(&here.to_string_lossy(), Path::new(&attack), None); assert!( !marker.exists(), "a repo name off a pasted link reached git as an option" ); // The ordinary clone still works, so the marker above is not simply // an argument git now ignores. clone(&here.to_string_lossy(), &here.join("copy"), None).unwrap(); assert!(here.join("copy/a.txt").exists()); } #[test] fn ref_exists_only_for_refs_that_resolve_to_a_commit() { let repo = TempRepo::new("refs"); repo.commit("a.txt", "one\n", "Add a"); repo.git(&["update-ref", "refs/remotes/origin/main", "HEAD"]); assert!(ref_exists("HEAD")); assert!(ref_exists("main")); assert!(ref_exists("origin/main")); assert!(!ref_exists("origin/nonexistent")); assert!(!ref_exists("definitely-not-a-ref")); } /// The gap this closes, in one test: after a branch is deleted upstream /// the local remote-tracking ref still resolves, so `ref_exists` says yes /// about a branch that is gone. `pr resubmit` used to build its patch on /// exactly that answer. /// /// The remote is the repo's own directory, as in the fetch tests above, /// so this needs no network. #[test] fn a_deleted_branch_is_gone_from_the_remote_but_not_from_the_local_ref() { let repo = TempRepo::new("ls-remote"); repo.commit("a.txt", "one\n", "Add a"); repo.git(&["branch", "feature"]); let here = std::env::current_dir().expect("inside the temp repo"); add_remote(Path::new("."), "origin", &here.to_string_lossy()).unwrap(); fetch("origin", "feature").unwrap(); assert_eq!(remote_branch_exists("origin", "feature"), Some(true)); assert_eq!(remote_branch_exists("origin", "nonexistent"), Some(false)); repo.git(&["branch", "-D", "feature"]); repo.git(&["update-ref", "refs/remotes/origin/feature", "HEAD"]); assert_eq!(remote_branch_exists("origin", "feature"), Some(false)); assert!( ref_exists("origin/feature"), "the stale tracking ref is the whole reason this check exists" ); } /// A remote that cannot be reached answers nothing, and nothing is not /// "the branch is gone" — every caller treats `None` as no complaint. #[test] fn a_remote_that_cannot_be_asked_gives_no_answer() { let repo = TempRepo::new("ls-remote-unreachable"); repo.commit("a.txt", "one\n", "Add a"); let here = std::env::current_dir().expect("inside the temp repo"); add_remote( Path::new("."), "origin", &here.join("not-a-repo").to_string_lossy(), ) .unwrap(); assert_eq!(remote_branch_exists("origin", "main"), None); } /// Failures carry the command and git's own stderr, since that message /// is the whole diagnosis a user gets — everywhere except the handful of /// checkout failures atgc knows better than git does, which the tests /// above cover. #[test] fn failures_name_the_command_that_failed() { let repo = TempRepo::new("errors"); repo.commit("a.txt", "one\n", "Add a"); let err = super::git(&["rev-parse", "--verify", "definitely-not-a-ref"]) .unwrap_err() .to_string(); assert!(err.contains("git rev-parse --verify"), "got: {err}"); assert!(!err.trim().ends_with("failed:"), "no stderr in: {err}"); } /// Every message names the directory atgc looked in. That is the line /// worth the most here: a shell or an agent harness is often standing /// somewhere other than its operator believes, and *which directory* is /// then the whole diagnosis rather than a detail. #[test] fn every_complaint_names_the_directory() { let cases = [ NoCheckout::NotARepo, NoCheckout::NoRemote { remote: "origin".into(), known: vec![], }, NoCheckout::NoRemote { remote: "origin".into(), known: vec!["tangled".into()], }, NoCheckout::Detached, NoCheckout::Unborn { branch: "main".into(), }, ]; for case in &cases { let message = no_checkout_message(case, "/home/someone/Code/atgc"); assert!( message.contains("/home/someone/Code/atgc"), "{case:?} does not say where atgc looked: {message}" ); } } /// And every message names something to do about it. An error that only /// says no leaves the reader to guess which of `cd`, `git remote add`, /// `--remote` and `atgc repo clone` was meant. #[test] fn every_complaint_names_a_way_out() { let ways = [ (NoCheckout::NotARepo, "atgc repo clone"), ( NoCheckout::NoRemote { remote: "origin".into(), known: vec![], }, "git remote add origin", ), ( NoCheckout::NoRemote { remote: "origin".into(), known: vec!["tangled".into()], }, "--remote", ), (NoCheckout::Detached, "git checkout"), ( NoCheckout::Unborn { branch: "main".into(), }, "commit something first", ), ]; for (case, way) in &ways { let message = no_checkout_message(case, "/tmp/scratch"); assert!( message.contains(way), "{case:?} should point at {way:?}: {message}" ); } } /// A checkout with no remotes at all is a different next move from one /// whose remote is spelled differently, and the two must not collapse: /// `--remote ` is useless advice when there is no name to give. #[test] fn a_checkout_with_no_remotes_is_told_to_add_one() { let bare = no_checkout_message( &NoCheckout::NoRemote { remote: "origin".into(), known: vec![], }, "/tmp/scratch", ); assert!(bare.contains("no remotes at all"), "got: {bare}"); assert!( !bare.contains("--remote"), "nothing to name with it: {bare}" ); } }