Something went wrong. Try again.
atproto git client
Something went wrong. Try again.
Rust
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397//! 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<String> { 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<u8>, pub stderr: Vec<u8>,}
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<Finished> { // 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<Finished> { 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<String> { 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<String> { 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<String> { 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<String> }, /// 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 <branch>` /// 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 <owner>/<name>`" ), 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:<owner>/<name>`, or work in a clone" ), NoCheckout::NoRemote { remote, known } => format!( "no remote named {remote} in {dir}: it has {}; name one with `--remote <name>`", known.join(", ") ), NoCheckout::Detached => format!( "detached HEAD in {dir}: atgc reads the branch it works from out of the \ checkout, so `git checkout <branch>` 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<String> { git(&["remote"]) .map(|out| out.lines().map(str::to_string).collect()) .unwrap_or_default()}
pub fn current_branch() -> Result<String> { 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<Start> { 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<String> { 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<String> { 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/<remote>/HEAD` is set/// (it usually is after a clone, but not after `git remote add`).pub fn remote_default_branch(remote: &str) -> Option<String> { 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/<remote>/<branch>`/// 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/<branch>` because `ls-remote` matches/// patterns against the tail of a ref path, so a bare name would also match/// `refs/heads/anything/<branch>`.pub fn remote_branch_exists(remote: &str, branch: &str) -> Option<bool> { 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<Option<String>> { 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 `<url>` 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=<branch>:<sha>`: 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<bool> { 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<bool> { 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<bool> { 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<PathBuf> { 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<serde_json::Value> = 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/<remote>/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 <name>` 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}" ); }}