//! 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 /// --quiet -- `), 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, /// `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, } 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, cwd: Option, version: Cow<'static, str>, subcommand: String, rotated_from_bytes: Option, }, /// 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, /// How many arguments there were beyond the ones described. #[serde(skip_serializing_if = "Option::is_none")] argv_omitted: Option, /// 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, 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, /// 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, /// 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, #[serde(skip_serializing_if = "Option::is_none")] signal: Option, #[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, 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, Option) { 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!("", 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, 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 { use std::os::unix::process::ExitStatusExt; status.signal() } #[cfg(not(unix))] fn signal_of(_status: &std::process::ExitStatus) -> Option { 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(" { 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::(&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()); } } }