Something went wrong. Try again.
atproto git client
Something went wrong. Try again.
Rust
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854//! An append-only, structured record of every git subprocess atgc starts.//!//! # Why this exists//!//! [`crate::logging::pds`] answers "what did atgc write to my repository".//! This one answers the same question about the other durable thing atgc//! touches, which until now nothing recorded at all: **what did atgc do to my//! checkout?**//!//! `pr checkout` creates a branch and applies a patch onto it. `stack create//! --add-change-ids` rewrites every commit on the branch and moves the ref//! with `update-ref`. `repo clone` makes a checkout and writes an identity//! and an SSH command into its config. `pr diff --interdiff` builds a scratch//! worktree, resets it and runs `git am` in it twice. Every one of those//! leaves a `git reflog` entry — and the reflog says a ref moved, never that//! atgc moved it, never with what argument, and never from where. The gap//! that closes is the one where a person looks at a branch that is not where//! they left it and has no way to tell an atgc command from their own.//!//! So: one line per subprocess leaving the process and one per exit, with the//! argv, the directory, the exit status, and — for the commands that can move//! a ref — where `HEAD` stood before and after.//!//! # Where it observes from//!//! [`crate::clients::git::run`], which builds and spawns every git//! subprocess in the tree; `patch.rs`'s stdin-and-environment runner and//! `review.rs`'s runners are built on it rather than on a `Command` of their//! own, so covering it covers them. That is the same argument//! [`crate::logging::pds`] makes about the HTTP transport, reached through a//! different door: no call site can forget to log, and a new one cannot be//! added without being logged, because there is no other way to start git.//!//! # The sha before and after//!//! Reading where `HEAD` stands costs a subprocess, and asking it either side//! of every `git log -1` would more than double the number of processes atgc//! starts to record nothing: the overwhelming majority of git calls here are//! questions — `rev-parse`, `config --get`, `merge-base --is-ancestor`,//! `ls-remote`, `format-patch` — and a question cannot move a ref.//!//! So the probe is gated on an explicit list, [`MOVES_A_REF`], of the//! subcommands that can. A list is worse than a rule in exactly one way — it//! has to be edited when git grows a verb or atgc starts using one — and//! better in every other: it is readable in one glance, it is wrong in a way//! a reader can see, and a heuristic ("does the argv mention a ref?") would//! be wrong in a way nobody could. It is also gated on the log being open at//! all, so a machine that has switched this log off pays nothing.//!//! Two consequences worth stating rather than leaving to be discovered://!//! - The probe reads **`HEAD`**, so a command that moves some *other* ref —//! `push`, or an `update-ref` on a branch that is not checked out — records//! an unchanged `HEAD`. That is not a miss, it is the answer: "atgc ran a//! ref-moving command and your working tree did not move" is the claim `pr//! checkout --worktree` makes, and this is the evidence for it.//! - `commit-tree` is deliberately **not** on the list. It writes a commit//! object and moves nothing; the `update-ref` that follows it is what moves//! the branch, and that is the line where the rewrite shows up.//!//! # What is never written//!//! An argv is the one field in any of atgc's logs that carries a string the//! caller chose, so the [`Fp`] discipline the other two rely on — every field//! that could hold a secret is typed so that holding one does not compile —//! is not available here. What stands in for it is that everything reaching//! this file goes through [`argv_of`], and three things are taken out of it://!//! - **Credentials in a URL.** `https://user:token@host/repo` reaches git as//! an ordinary argument of `clone`, `fetch`, `push` and `remote add`. The//! whole userinfo is replaced by a fingerprint of itself — not just the//! password, because a personal access token presented *as* the username is//! a documented form and no rule tells that from a username.//! - **`-c key=value` overrides.** The key is kept and the value is//! fingerprinted. The key names the mechanism, which is the diagnostic//! half; the value is arbitrary text, and `-c http.extraHeader=...` is how//! a bearer token is passed to git.//! - **`git config` writes.** Same trade, one step further: the value is//! fingerprinted unless the key is one of [`PLAIN_CONFIG_KEYS`]. An//! allowlist rather than a denylist, because `core.sshCommand` and//! `http.*.extraHeader` are both config keys and any list of the dangerous//! ones is incomplete the day git adds another — while the two atgc writes//! on purpose, the `[user]` identity a Tangled checkout carries, are worth//! reading back and are known to be identity rather than credential.//!//! Two more things are left out entirely. **Stdin is never recorded**, only//! its length: it is a commit message or a patch, which is the user's own//! prose, and the same rule [`crate::logging::pds`] states about record//! content. And **environment values are never recorded**, only the names of//! the variables this process set — `GIT_SSH_COMMAND` is built from//! `core.sshCommand` and names a private key file, and "which variables were//! in play" is the whole of what a reader needs.//!//! Git's own stderr *is* kept, on a failure and when it was captured, because//! it is the single most useful field on a line that says something went//! wrong. It is somebody else's words, so it is put through the same URL//! redaction and through [`crate::logging::oauth::scrub_text`] before it//! lands — git says `could not read Username for 'https://…'` with the URL in//! it, and that URL came off a remote.//!//! # Format//!//! One JSON object per line at `~/.config/atgc/git.jsonl`, mode 0600,//! `ATGC_GIT_LOG` to move it and `ATGC_GIT_LOG=0` to switch it off. The//! envelope, the size cap, the rotation and the invocation id are//! [`crate::logging::file`]'s and are identical to the other two logs', so a//! line from each can be laid beside the others and joined on `inv`.
use crate::logging::file::{Fp, Log, MAX_FIELD, clip};use serde::{Deserialize, Serialize};use std::borrow::Cow;use std::path::Path;
/// This log: `~/.config/atgc/git.jsonl`, `ATGC_GIT_LOG` to move or disable/// it.pub static LOG: Log = Log::new("git.jsonl", "ATGC_GIT_LOG");
/// Longest one argument may be before clipping.////// Long enough for a path, a `--format=%(trailers:key=Change-Id,valueonly)`/// or a 40-character sha with a suffix on it, and short enough that a full/// argv still fits in one atomic append.const MAX_ARG: usize = 128;
/// How many arguments are described individually.////// The longest argv atgc builds is eight words (`worktree add -b <branch>/// --quiet -- <path> <start>`), so sixteen is twice the real worst case; past/// that the count is kept instead. The width test below is the authority.const MAX_ARGS: usize = 16;
/// Cap for the shaped fields: a sha, a branch name, a `-C` directory.const MAX_ID: usize = 128;
/// Cap for a directory path, which is the one shaped field that is genuinely/// long — a worktree under `.claude/worktrees/` runs to a hundred characters/// before anything unusual happens.const MAX_PATH: usize = 256;
/// Cap for git's own stderr on a failure. `fatal: not a git repository (or/// any of the parent directories): .git` is seventy characters, and the/// longest real one — a merge conflict list — is worth truncating.const MAX_STDERR: usize = 384;
/// The git subcommands that can move a ref, and so the complete set for which/// `HEAD` is read before and after.////// Kept in alphabetical order rather than in any order of importance, so that/// adding one is a matter of finding the spot rather than of judgement.////// `symbolic-ref` is the near miss and is deliberately absent: it moves `HEAD`/// when given two arguments and answers a question when given one, atgc only/// ever asks, and a verb-level rule cannot tell those apart. If a caller ever/// writes with it, this list is the place that has to change — which is the/// cost of a list, paid knowingly.const MOVES_A_REF: &[&str] = &[ "am", "branch", "checkout", "cherry-pick", "clone", "commit", "fetch", "merge", "pull", "push", "rebase", "reset", "revert", "stash", "switch", "tag", "update-ref", "worktree",];
/// The `git config` keys whose value is recorded as it was written.////// The `[user]` identity `repo clone` and `repo configure` put into a/// checkout, and nothing else. These are the writes worth reading back — "was/// this checkout configured as the account I meant?" is a real question and/// the DID is the answer — and they are identity, which is public by/// construction: the same DID is on every record in the PDS log.const PLAIN_CONFIG_KEYS: &[&str] = &["user.name", "user.email"];
/// How a subprocess's stdout and stderr were wired up.////// Recorded because it decides what this log can know. A command whose output/// was inherited printed straight to the terminal, so its stderr is not here/// and never could have been — and a reader who does not know that would read/// the absence as "git said nothing".#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]#[serde(rename_all = "snake_case")]pub enum Stdio { /// Piped and handed back to the caller — the shape [`crate::clients::git::run::git_in`] uses. Captured, /// Left to the terminal, so git owns the colour, the pager and the /// progress. Inherited, /// Sent to `/dev/null`, for the probes whose failure is the expected /// answer. Discarded,}
/// Where a working tree stood: the commit `HEAD` resolved to, and the branch/// it named.////// Both optional, and the two `None`s mean different things that the reader/// renders alike: no repository there yet (which is every `clone` before it/// runs), or an unborn `HEAD` that no commit has created. Neither is a/// failure to record.#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]pub struct At { #[serde(skip_serializing_if = "Option::is_none")] pub sha: Option<String>, /// `None` on a detached `HEAD`, which is what `--abbrev-ref` spells /// `HEAD` and what git forbids as a branch name, so this is not a guess. #[serde(skip_serializing_if = "Option::is_none")] pub branch: Option<String>,}
impl At { pub fn is_empty(&self) -> bool { self.sha.is_none() && self.branch.is_none() }
/// Read the two lines `git rev-parse HEAD --abbrev-ref HEAD` answers. /// /// One subprocess for both facts: `rev-parse` applies `--abbrev-ref` only /// to the arguments after it, so the first `HEAD` comes back as a full /// sha and the second as the branch name. Splitting this into two calls /// would double the cost of the probe for the sake of tidiness. pub fn read(out: &str) -> At { let mut lines = out.lines().map(str::trim).filter(|l| !l.is_empty()); At { sha: lines.next().map(|s| clip(s, MAX_ID)), branch: lines .next() .filter(|b| *b != "HEAD") .map(|s| clip(s, MAX_ID)), } }}
/// Everything atgc can observe about a git subprocess.////// Serialized with `#[serde(tag = "event")]`, flat, and deserialized back/// through this same enum by [`crate::cmd::logs::git`] — for the reasons/// [`crate::logging::oauth::Event`] gives, and so that a variant added here is/// a compile error in the reader rather than a line that renders as nothing.////// # These variants are public////// `atgc logs git --json` prints the git subprocess log's lines verbatim, so/// every variant name below — as `serde` renames it, `snake_case` into the/// `event` field — and every field name in it is part of the CLI's output/// contract. `docs/output.md` says so, and the stability rules there apply:/// a new variant or a new field is a `feat`, a rename or a removal is a/// breaking `!`. Rename one here and somebody's `jq` filter stops matching.#[derive(Debug, Serialize, Deserialize)]#[serde(tag = "event", rename_all = "snake_case")]pub enum Event { /// First line of every invocation, carrying the same facts as the other /// two logs' head lines and stamped with the same `inv`. /// /// `cwd` here is the directory the process was started in, which is what /// every git call without a `-C` of its own inherits; a call that has one /// carries it as [`Event::Spawn`]'s `dir`. Between the two, every /// subprocess's working directory is on record without repeating an /// unchanging path on every line. Invocation { pid: u32, user: Option<String>, cwd: Option<String>, version: Cow<'static, str>, subcommand: String, rotated_from_bytes: Option<u64>, },
/// A git subprocess was started. No exit status yet. /// /// Paired with exactly one [`Event::Exit`] or [`Event::SpawnFailed`], by /// `inv` and by order. An unpaired `spawn` is itself the finding: git is /// still running, or was killed, or is sitting on a prompt — which is /// what an SSH passphrase question looks like from out here, and is the /// shape of every "atgc hung" report. Spawn { /// The arguments git was given, redacted by [`argv_of`]. Without the /// program name, which is always `git`, and without the `-C` that /// `dir` records. argv: Vec<String>, /// How many arguments there were beyond the ones described. #[serde(skip_serializing_if = "Option::is_none")] argv_omitted: Option<usize>, /// The `-C` directory, when it is somewhere other than here. `None` /// means the process's own working directory, which the head line /// carries — and `-C .`, which is what most callers in /// [`crate::clients::git::run`] pass, is that same directory said /// twice. Printing it as `.` on half the rows would be a column that /// costs a reader something and tells them nothing. #[serde(skip_serializing_if = "Option::is_none")] dir: Option<String>, stdio: Stdio, /// The names of the environment variables this process set for the /// child, never their values. #[serde(skip_serializing_if = "Vec::is_empty", default)] env: Vec<String>, /// How many bytes were fed on stdin. The bytes themselves are a patch /// or a commit message and are not recorded. #[serde(skip_serializing_if = "Option::is_none")] stdin_bytes: Option<usize>, /// Where `HEAD` stood before, for the commands that can move it. /// /// On this line as well as on the exit, so that a subprocess which /// never finished still says where it started from — which is exactly /// the state somebody needs when a command has to be killed. #[serde(skip_serializing_if = "At::is_empty", default)] before: At, },
/// The subprocess finished. Exit { /// The subcommand, repeated from the spawn so that this row reads on /// its own — the same reason [`crate::logging::pds`] repeats its `op` /// on the answer. verb: String, /// `None` when the process was killed by a signal rather than /// exiting. #[serde(skip_serializing_if = "Option::is_none")] code: Option<i32>, #[serde(skip_serializing_if = "Option::is_none")] signal: Option<i32>, #[serde(skip_serializing_if = "At::is_empty", default)] before: At, #[serde(skip_serializing_if = "At::is_empty", default)] after: At, /// git's own words, on a failure and only when stdio was captured. #[serde(skip_serializing_if = "Option::is_none")] stderr: Option<String>, elapsed_ms: u64, },
/// git never started: not on `PATH`, or the working directory is gone. SpawnFailed { verb: String, error: String },
/// A record that could not be appended atomically, replaced by this. Oversize { of: String, bytes: usize },}
/// Open the log and record the invocation. Called once, from `main`.pub fn init(args: &[String]) { LOG.open(); let Some(head) = LOG.head(args) else { return }; emit(Event::Invocation { pid: head.pid, user: head.user, cwd: head.cwd, version: Cow::Borrowed(head.version), subcommand: head.subcommand, rotated_from_bytes: head.rotated_from_bytes, });}
/// Append one event. Never fails, never blocks a command.pub fn emit(event: Event) { LOG.emit(event, |of, bytes| Event::Oversize { of, bytes });}
// ===========================================================================// Reading an argv// ===========================================================================
/// The git subcommand an argv names, skipping the flags in front of it.////// `-c` and `--config-env` take a *value* that is not a flag and would/// otherwise be read as the subcommand — which would be a cosmetic wrong/// answer in the log and a real one in [`moves_a_ref`], since it would take/// the probe off a `checkout`.pub fn verb<'a>(args: &'a [&'a str]) -> Option<&'a str> { let mut skip = false; for arg in args { if skip { skip = false; continue; } if *arg == "-c" || *arg == "--config-env" { skip = true; continue; } if !arg.starts_with('-') { return Some(arg); } } None}
/// Whether this command can move a ref, and so whether `HEAD` is worth/// reading either side of it. See [`MOVES_A_REF`].pub fn moves_a_ref(args: &[&str]) -> bool { verb(args).is_some_and(|verb| MOVES_A_REF.contains(&verb))}
/// An argv as it is recorded: redacted, clipped, and bounded in length.////// The whole audit surface of this file. Every argument any caller hands git/// passes through here, and the three rules the module doc states are these/// three branches.pub fn argv_of(args: &[&str]) -> (Vec<String>, Option<usize>) { let config_write = verb(args) == Some("config"); let key = config_write.then(|| config_key(args)).flatten(); let keep_values = key.is_some_and(|k| { PLAIN_CONFIG_KEYS .iter() .any(|plain| k.eq_ignore_ascii_case(plain)) });
let mut argv = Vec::with_capacity(args.len().min(MAX_ARGS)); let mut plain = 0usize; let mut override_value = false; for arg in args.iter().take(MAX_ARGS) { let recorded = if override_value { // The value half of a `-c key=value` written as two words. override_value = false; redact_pair(arg) } else if *arg == "-c" || *arg == "--config-env" { override_value = true; arg.to_string() } else if let Some(rest) = arg.strip_prefix("-c").filter(|r| r.contains('=')) { format!("-c{}", redact_pair(rest)) } else if arg.starts_with('-') { redact_urls(arg) } else { plain += 1; // `config`, then the key, then the value: only the third and // anything after it is what is being written. match config_write && plain > 2 && !keep_values { true => mask(arg), false => redact_urls(arg), } }; argv.push(clip(&recorded, MAX_ARG)); } let omitted = args.len().saturating_sub(argv.len()); (argv, (omitted > 0).then_some(omitted))}
/// The key a `git config` invocation is about — its second positional word.fn config_key<'a>(args: &'a [&'a str]) -> Option<&'a str> { args.iter().filter(|a| !a.starts_with('-')).nth(1).copied()}
/// `key=value` with the value fingerprinted, for `-c` and its spellings.fn redact_pair(arg: &str) -> String { match arg.split_once('=') { Some((key, value)) => format!("{key}={}", mask(value)), None => redact_urls(arg), }}
/// A value replaced by a fingerprint of itself.////// The marker says a redaction happened rather than leaving a reader to/// wonder whether the value was empty, and the eight characters are/// [`Fp`]'s: enough to say "the same value as that other line", useless for/// recovering it.fn mask(value: &str) -> String { format!("<redacted:{}>", Fp::of(value).as_str())}
/// Replace the credentials in every URL in `text` with a fingerprint.////// Written as a scan over the text rather than as a URL parser because it has/// two jobs: one argument, which is a whole URL, and a line of git's stderr,/// which has a URL somewhere inside it in quotes. A parser would do the first/// and not the second, and two implementations of one promise is how the/// promise stops being true on the path nobody looked at.////// The authority ends at the first character that cannot be inside one, which/// includes the quote and the whitespace that bound it in a sentence.pub fn redact_urls(text: &str) -> String { let mut out = String::with_capacity(text.len()); let mut rest = text; while let Some(at) = rest.find("://") { let (head, tail) = rest.split_at(at + 3); out.push_str(head); let end = tail .find(|c: char| c.is_whitespace() || "/?#'\"<>|".contains(c)) .unwrap_or(tail.len()); let (authority, after) = tail.split_at(end); match authority.rsplit_once('@') { Some((userinfo, host)) if !userinfo.is_empty() => { out.push_str(&mask(userinfo)); out.push('@'); out.push_str(host); } _ => out.push_str(authority), } rest = after; } out.push_str(rest); out}
// ===========================================================================// Building the two events// ===========================================================================
/// The line written before git starts.pub fn spawned( args: &[&str], dir: Option<&Path>, stdio: Stdio, env: &[&str], stdin_bytes: Option<usize>, before: At,) -> Event { let (argv, argv_omitted) = argv_of(args); Event::Spawn { argv, argv_omitted, dir: dir .filter(|d| *d != Path::new(".")) .map(|d| clip(&d.display().to_string(), MAX_PATH)), stdio, env: env.iter().map(|name| clip(name, 64)).collect(), stdin_bytes, before, }}
/// The line written once it is over.////// `stderr` is passed as the bytes that were captured, empty when they were/// not, and is kept only on a failure: a command that worked said whatever it/// said to its caller, and repeating it here would put a `format-patch` into/// the log.pub fn exited( args: &[&str], status: &std::process::ExitStatus, before: At, after: At, stderr: &[u8], elapsed_ms: u64,) -> Event { Event::Exit { verb: clip(verb(args).unwrap_or(""), MAX_ARG), code: status.code(), signal: signal_of(status), before, after, stderr: (!status.success() && !stderr.is_empty()).then(|| { let text = String::from_utf8_lossy(stderr); clip( &crate::logging::oauth::scrub_text(&redact_urls(text.trim())), MAX_STDERR, ) }), elapsed_ms, }}
/// The line written when git never started at all.pub fn spawn_failed(args: &[&str], error: &str) -> Event { Event::SpawnFailed { verb: clip(verb(args).unwrap_or(""), MAX_ARG), error: clip(&redact_urls(error), MAX_FIELD), }}
/// Which signal killed the process, where the platform can say.////// A git killed by `SIGINT` — Ctrl-C at a pager or an SSH prompt — and a git/// that exited 130 are the same thing to a shell and different things here,/// and only one of them has an exit code at all.#[cfg(unix)]fn signal_of(status: &std::process::ExitStatus) -> Option<i32> { use std::os::unix::process::ExitStatusExt; status.signal()}
#[cfg(not(unix))]fn signal_of(_status: &std::process::ExitStatus) -> Option<i32> { None}
#[cfg(test)]mod tests { use super::*; use crate::logging::file::{MAX_LINE, line_for_test};
/// The list is the design, so it is pinned: the questions atgc asks /// constantly must not pay for a probe, and the handful of commands that /// rewrite a checkout must. #[test] fn only_the_commands_that_can_move_a_ref_are_probed() { for moving in [ &["checkout", "-q", "-b", "claude/x", "origin/main"][..], &["am", "--3way", "--quiet"][..], &["update-ref", "-m", "atgc", "refs/heads/x", "abc"][..], &["fetch", "--end-of-options", "origin", "main"][..], &["worktree", "add", "-b", "x", "--", "/tmp/wt", "HEAD"][..], &["clone", "--end-of-options", "url", "dir"][..], &["reset", "--hard", "--quiet", "abc"][..], ] { assert!(moves_a_ref(moving), "{moving:?} moves a ref"); } for asking in [ &["rev-parse", "--abbrev-ref", "HEAD"][..], &["log", "-1", "--format=%s"][..], &["config", "--local", "--get", "user.email"][..], &["merge-base", "--is-ancestor", "a", "b"][..], &["ls-remote", "--heads", "origin"][..], &["format-patch", "--stdout", "main..HEAD"][..], &["status", "--porcelain"][..], &["commit-tree", "tree", "-p", "parent"][..], &["symbolic-ref", "--quiet", "HEAD"][..], &[][..], ] { assert!(!moves_a_ref(asking), "{asking:?} asks a question"); } }
/// A `-c` value is not a subcommand, and reading it as one would take the /// probe off the command it belongs to. #[test] fn a_config_override_is_not_mistaken_for_the_subcommand() { assert_eq!( verb(&["-c", "core.pager=cat", "checkout", "x"]), Some("checkout") ); assert!(moves_a_ref(&["-c", "core.pager=cat", "checkout", "x"])); assert_eq!(verb(&["--quiet", "fetch", "origin"]), Some("fetch")); assert_eq!(verb(&["--version"]), None); }
/// The promise this file makes, checked rather than asserted: a push /// token in a remote URL must not reach the line that records the fetch. #[test] fn a_credential_in_a_remote_url_is_redacted() { let secret = "ghp_aVeryRealLookingPushToken"; let url = format!("https://someone:{secret}@tangled.example/owner/repo"); let (argv, _) = argv_of(&["fetch", "--end-of-options", &url, "main"]); let line = argv.join(" "); assert!(!line.contains(secret), "{line}"); assert!(!line.contains("someone"), "{line}"); // What survives is enough to say which remote it was. assert!(line.contains("tangled.example/owner/repo"), "{line}"); assert!(line.contains("<redacted:"), "{line}");
// A token presented as the username alone is the other real form, and // no rule tells it from a username — so both go. let (bare, _) = argv_of(&[&format!("https://{secret}@host/x")]); assert!(!bare[0].contains(secret), "{bare:?}");
// Two sightings of one credential still match each other. let (again, _) = argv_of(&["push", &url]); assert!( argv[2].contains(&again[1][again[1].find("<redacted:").unwrap()..][..19]), "{argv:?} vs {again:?}" );
// The ordinary Tangled remote is scp-style, has no password to hide, // and stays readable. let (ssh, _) = argv_of(&["remote", "add", "origin", "git@tangled.org:owner/repo"]); assert_eq!(ssh[3], "git@tangled.org:owner/repo"); }
/// The other two doors a credential reaches git through. #[test] fn config_values_are_fingerprinted_unless_they_are_an_identity() { // `core.sshCommand` names a private key file; `-c http.extraHeader` // is how a bearer token is handed to git. let (ssh, _) = argv_of(&[ "config", "--local", "core.sshCommand", "ssh -i /home/me/.ssh/id_ed25519", ]); assert_eq!(ssh[2], "core.sshCommand", "the key is the diagnostic half"); assert!(!ssh[3].contains("id_ed25519"), "{ssh:?}");
let (header, _) = argv_of(&["-c", "http.extraHeader=Authorization: Bearer sec", "push"]); assert!(!header[1].contains("sec"), "{header:?}"); assert!(header[1].starts_with("http.extraHeader="), "{header:?}"); let (attached, _) = argv_of(&["-chttp.extraHeader=Authorization: Bearer sec", "push"]); assert!(!attached[0].contains("sec"), "{attached:?}");
// The identity atgc writes on purpose is worth reading back, and is // the same DID the PDS log carries on every line. let (identity, _) = argv_of(&[ "config", "--local", "user.email", "did:plc:nlzmjyfv6loqtxyzvdcznwgf", ]); assert_eq!(identity[3], "did:plc:nlzmjyfv6loqtxyzvdcznwgf");
// A read has no value to leak, and must not lose its key. let (read, _) = argv_of(&["config", "--local", "--get", "core.sshCommand"]); assert_eq!(read[3], "core.sshCommand"); }
/// git's stderr is somebody else's words with a remote URL in it, and it /// is the most useful field on a failing line — so it is kept, scrubbed. #[test] fn a_failures_stderr_is_kept_but_scrubbed() { let status = std::process::Command::new("false") .status() .expect("`false` exists"); let words = b"fatal: could not read Username for 'https://tok:sec@host/x': terminal prompts disabled"; match exited( &["fetch", "origin"], &status, At::default(), At::default(), words, 3, ) { Event::Exit { stderr, verb, .. } => { let stderr = stderr.expect("a failure keeps git's words"); assert_eq!(verb, "fetch"); assert!(!stderr.contains("sec"), "{stderr}"); assert!(stderr.contains("terminal prompts disabled"), "{stderr}"); assert!(stderr.contains("host/x"), "{stderr}"); } other => panic!("came back as {other:?}"), }
// A command that worked said whatever it said to its caller. let ok = std::process::Command::new("true").status().unwrap(); match exited(&["log"], &ok, At::default(), At::default(), b"noise", 1) { Event::Exit { stderr, code, .. } => { assert_eq!(stderr, None); assert_eq!(code, Some(0)); } other => panic!("came back as {other:?}"), } }
/// The two lines `rev-parse` answers, and the two states that are not a /// failure to record. #[test] fn head_is_read_as_a_sha_and_a_branch() { let at = At::read("1c6fc774339e047f77ff9cfe35a81cec189fa53c\nclaude/git-log\n"); assert_eq!(at.branch.as_deref(), Some("claude/git-log")); assert!(at.sha.unwrap().starts_with("1c6fc77"));
// A detached HEAD answers `HEAD`, which git forbids as a branch name. let detached = At::read("1c6fc774339e047f77ff9cfe35a81cec189fa53c\nHEAD\n"); assert_eq!(detached.branch, None); assert!(detached.sha.is_some());
// An unborn HEAD answers nothing at all. assert!(At::read("").is_empty()); }
/// Stdin is a patch or a commit message; only its size is recorded. #[test] fn stdin_is_counted_and_never_written() { let secret = "a commit message nobody has published yet"; let event = spawned( &["am", "--3way"], Some(Path::new("/tmp/wt")), Stdio::Discarded, &["GIT_TERMINAL_PROMPT"], Some(secret.len()), At::default(), ); let line = String::from_utf8(line_for_test(&event)).unwrap(); assert!(!line.contains("published"), "{line}"); assert!( line.contains(&format!("\"stdin_bytes\":{}", secret.len())), "{line}" ); assert!(line.contains("GIT_TERMINAL_PROMPT"), "{line}"); assert!(line.contains("/tmp/wt"), "{line}"); }
/// The writer and the reader share one description of the format. #[test] fn an_event_survives_a_round_trip_through_json() { let event = Event::Exit { verb: "checkout".into(), code: Some(0), signal: None, before: At { sha: Some("aaa".into()), branch: Some("main".into()), }, after: At { sha: Some("bbb".into()), branch: Some("pr/somebody/3ms-r1".into()), }, stderr: None, elapsed_ms: 91, }; let line = line_for_test(&event); match serde_json::from_slice::<Event>(&line).expect("the writer's own line parses") { Event::Exit { after, code, .. } => { assert_eq!(after.branch.as_deref(), Some("pr/somebody/3ms-r1")); assert_eq!(code, Some(0)); } other => panic!("came back as {other:?}"), } }
/// Every variant, at its widest, has to fit in one atomic append — a full /// argv included, which is what bounds [`MAX_ARGS`] and [`MAX_ARG`]. #[test] fn the_widest_events_fit_within_one_atomic_write() { let long = "y".repeat(MAX_FIELD); let at = || At { sha: Some(clip(&long, MAX_ID)), branch: Some(clip(&long, MAX_ID)), }; let events = vec![ Event::Spawn { argv: (0..MAX_ARGS).map(|_| clip(&long, MAX_ARG)).collect(), argv_omitted: Some(usize::MAX), dir: Some(clip(&long, MAX_PATH)), stdio: Stdio::Captured, env: (0..4).map(|_| "GIT_TERMINAL_PROMPT".to_string()).collect(), stdin_bytes: Some(usize::MAX), before: at(), }, Event::Exit { verb: clip(&long, MAX_ARG), code: Some(i32::MIN), signal: Some(i32::MIN), before: at(), after: at(), stderr: Some(clip(&long, MAX_STDERR)), elapsed_ms: u64::MAX, }, Event::SpawnFailed { verb: clip(&long, MAX_ARG), error: clip(&long, MAX_FIELD), }, Event::Invocation { pid: u32::MAX, user: Some(clip(&long, MAX_FIELD)), cwd: Some(clip(&long, MAX_FIELD)), version: Cow::Borrowed("0.15.0"), subcommand: "pr checkout".into(), rotated_from_bytes: Some(u64::MAX), }, ]; for event in events { let line = line_for_test(&event); assert!(line.len() < MAX_LINE, "{event:?} is {} bytes", line.len()); } }}