Something went wrong. Try again.
atproto git client
Something went wrong. Try again.
Rust
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494//! `atgc logs git` — reading `~/.config/atgc/git.jsonl` back out.//!//! # What is here//!//! Only what is about git subprocesses. Everything about *reading a log* —//! the filters, the palette, the grouping by invocation, `-f`, `--json`, the//! rotation handling — is [`crate::cmd::logs::render`]'s and is the same code//! `atgc logs oauth` and `atgc logs pds` run, so an `inv` tag here means the//! same run as the same tag there.//!//! What differs is the columns. The PDS log's rows lead with a record; these//! lead with the command — the argv as git received it — and then say what it//! did: the exit status, how long it took, and where `HEAD` was standing//! before and after for the commands that can move it.//!//! # `--failures` means something slightly different here//!//! In the other two logs a failure is a fault. In this one a non-zero exit is//! very often the *answer*: `merge-base --is-ancestor` replies by exit status,//! `rev-parse --verify` is how [`crate::clients::git::run::ref_exists`] asks//! whether a ref exists, and `git apply --check` is expected to say no for//! most of the candidates it is offered. So `--failures` here selects//! "everything that did not return 0", which is a wider net than the word//! suggests, and the row itself is what tells the two apart.//!//! # What this must not do//!//! [`crate::logging::git`] redacts credentials out of an argv before it is//! written, and this file cannot recover any: what it has is//! `<redacted:xxxxxxxx>`, eight characters of a SHA-256, and the only use it//! makes of one is showing it. Reading a value back out of the checkout to//! pair a fingerprint with what it stands for is not done here and should not//! be added — the same rule [`crate::cmd::logs::pds`] states about record//! content.
use super::render::{Args, Kind, Palette, Severity, Source, tilde};use crate::logging::git::{self as gitlog, At, Event, Stdio};use anyhow::Result;
/// Which log this is, and what to say when the file is not there yet.const SOURCE: Source = Source { log: &gitlog::LOG, noun: "git subprocess", how_to_fill: "It is written by any command that runs git at all: `atgc pr list` inside a \ checkout is the cheapest.",};
/// Every event kind, for `--event` to validate against.////// In the order a subprocess goes through them, which is also the order they/// appear in [`Event`]: the spawn, then whichever of the two ends it came to.const KINDS: &[&str] = &["invocation", "spawn", "exit", "spawn_failed", "oversize"];
/// How many characters of a sha to print.////// Seven is what git itself abbreviates to in `log --oneline` and what a/// person pastes into `git show`. The full forty are in the file, which is/// what `--json` and a `git reset` want.const SHORT_SHA: usize = 7;
/// The command: `atgc logs git`.////// No analysis hook. `--incident` answers a question about OAuth sessions/// specifically; the nearest thing here would be "which subprocess never/// came back", which the default rendering already shows as a `spawn` with/// nothing under it.pub(super) fn log(args: Args) -> Result<()> { super::render::run::<Event>(args, &SOURCE, None)}
impl Kind for Event { fn kinds() -> &'static [&'static str] { KINDS }
fn name(&self) -> &'static str { match self { Event::Invocation { .. } => "invocation", Event::Spawn { .. } => "spawn", Event::Exit { .. } => "exit", Event::SpawnFailed { .. } => "spawn_failed", Event::Oversize { .. } => "oversize", } }
fn detail(&self, p: &Palette) -> String { detail(self, p) }
/// A git that exited non-zero or was killed, and one that never started. /// An `oversize` is not an error either but marks a hole in the evidence, /// which is exactly what somebody asking "what did atgc run" needs to /// know about. fn is_failure(&self) -> bool { match self { Event::Exit { code, signal, .. } => *code != Some(0) || signal.is_some(), Event::SpawnFailed { .. } | Event::Oversize { .. } => true, _ => false, } }
/// The DIDs an argv names, which in practice is the one `repo configure` /// writes into a checkout's `[user]` section — the write whose whole /// point is which account this checkout commits as. fn dids(&self) -> Vec<&str> { match self { Event::Spawn { argv, .. } => argv .iter() .filter(|arg| arg.starts_with("did:")) .map(String::as_str) .collect(), _ => Vec::new(), } }
/// Green on the line that says a ref moved, because in this log that is /// the news: nearly every git atgc runs is a question, and the handful /// that change the checkout are what a person is scrolling for. fn severity(&self) -> Severity { match self { Event::Exit { before, after, .. } if !after.is_empty() && after != before => { Severity::Good } _ if self.is_failure() => Severity::Bad, Event::Invocation { .. } => Severity::Note, _ => Severity::Plain, } }
fn is_head(&self) -> bool { matches!(self, Event::Invocation { .. }) }}
// ===========================================================================// Rendering a row// ===========================================================================
/// The payload of one row.////// A `spawn` leads with the argv and an `exit` with the subcommand, because/// that is what a person scans for — "did it run `am`, and did `am` work" —/// and because repeating the whole argv on the exit would double the widest/// field in the file to say something already two lines up.fn detail(event: &Event, p: &Palette) -> String { match event { Event::Invocation { pid, user, cwd, version, subcommand, rotated_from_bytes, } => { // Deliberately the same shape as the other two logs' head lines. // Somebody reading all three should not have to re-learn the // heading, and `cwd` here is load-bearing rather than context: // it is the working directory every git call without a `-C` of // its own inherited. let mut s = format!("{subcommand} · pid {pid} · atgc {version}"); if let Some(user) = user { s += &format!(" · user {user}"); } if let Some(cwd) = cwd { s += &format!(" · cwd {}", tilde(cwd)); } if let Some(bytes) = rotated_from_bytes { s += &p.warn(&format!(" · rotated at {} MiB", bytes / (1024 * 1024))); } s }
Event::Spawn { argv, argv_omitted, dir, stdio, env, stdin_bytes, before, } => { let mut s = format!("git {}", argv.join(" ")); if let Some(omitted) = argv_omitted { s += &p.dim(&format!(" … +{omitted}")); } if let Some(dir) = dir { s += &p.dim(&format!(" · in {}", tilde(dir))); } if let Some(bytes) = stdin_bytes { s += &p.dim(&format!(" · stdin {bytes}B")); } // The stdio wiring decides what this log can know, so it is on // the row rather than only in the docs: an `inherited` line will // never carry git's stderr, however hard it failed. s += &p.dim(&format!(" · {}", wiring(*stdio))); if !env.is_empty() { s += &p.dim(&format!(" · env {}", env.join(","))); } if !before.is_empty() { s += &p.dim(&format!(" · from {}", stood(before))); } s }
Event::Exit { verb, code, signal, before, after, stderr, elapsed_ms, } => { let mut s = verb.clone(); s += &match (code, signal) { (_, Some(signal)) => p.bad(&format!(" · killed by signal {signal}")), (Some(0), _) => p.dim(" · ok"), (Some(code), _) => p.bad(&format!(" · exit {code}")), (None, None) => p.bad(" · no status"), }; s += &moved(before, after, p); if let Some(stderr) = stderr { s += &format!(" · {stderr}"); } s + &p.dim(&format!(" · {elapsed_ms}ms")) }
Event::SpawnFailed { verb, error } => { format!("{verb} · {}", p.bad(error)) }
Event::Oversize { of, bytes } => p.warn(&format!( "a {of} record was {bytes} bytes and was dropped rather than torn" )), }}
/// Where `HEAD` was, in the least room that still says it.fn stood(at: &At) -> String { let sha = at.sha.as_deref().unwrap_or("(none)"); let sha = &sha[..SHORT_SHA.min(sha.len())]; match &at.branch { Some(branch) => format!("{sha} ({branch})"), // Detached, or a repository with no commits — both of which read as // "no branch" and neither of which is a gap in the record. None => sha.to_string(), }}
/// The whole reason this log exists, on one line: what the command did to the/// ref it could have moved.////// "unchanged" is printed rather than left out. A `push` moves a ref on the/// far end and an `update-ref` can move one that is not checked out, so an/// unmoved `HEAD` is a real answer — "atgc ran a ref-moving command and your/// working tree stayed where it was" is the claim `pr checkout --worktree`/// makes, and this is the evidence for it.fn moved(before: &At, after: &At, p: &Palette) -> String { if before.is_empty() && after.is_empty() { return String::new(); } if before == after { return p.dim(&format!(" · HEAD unchanged at {}", stood(after))); } p.good(&format!(" · HEAD {} → {}", stood(before), stood(after)))}
/// How stdout and stderr were wired, as a word.fn wiring(stdio: Stdio) -> &'static str { match stdio { Stdio::Captured => "captured", Stdio::Inherited => "inherited", Stdio::Discarded => "discarded", }}
#[cfg(test)]mod tests { use super::*;
fn plain() -> Palette { Palette::none() }
fn at(sha: &str, branch: Option<&str>) -> At { At { sha: Some(sha.into()), branch: branch.map(str::to_string), } }
/// Every kind this reader validates `--event` against must be a kind the /// writer can actually produce, or `--event exit` is a promise about a /// name nothing writes. #[test] fn every_advertised_kind_is_one_an_event_reports() { let events = [ Event::Invocation { pid: 1, user: None, cwd: None, version: "0.1.0".into(), subcommand: "pr checkout".into(), rotated_from_bytes: None, }, Event::Spawn { argv: vec!["status".into()], argv_omitted: None, dir: None, stdio: Stdio::Captured, env: Vec::new(), stdin_bytes: None, before: At::default(), }, Event::Exit { verb: "status".into(), code: Some(0), signal: None, before: At::default(), after: At::default(), stderr: None, elapsed_ms: 1, }, Event::SpawnFailed { verb: "status".into(), error: "No such file or directory".into(), }, Event::Oversize { of: "spawn".into(), bytes: 5000, }, ]; for event in &events { assert!( KINDS.contains(&event.name()), "{} is not advertised", event.name() ); } assert_eq!(events.len(), KINDS.len(), "a kind has no example here"); }
/// The row this log exists for: a checkout that moved the branch out from /// under somebody, said in one line. #[test] fn a_ref_move_is_the_news_on_the_row_and_in_the_colour() { let exit = Event::Exit { verb: "checkout".into(), code: Some(0), signal: None, before: at("1c6fc774339e047f77ff9cfe35a81cec189fa53c", Some("main")), after: at( "3a33f3a119e047f77ff9cfe35a81cec189fa53cd", Some("pr/x/3ms-r1"), ), stderr: None, elapsed_ms: 42, }; let row = exit.detail(&plain()); assert!(row.starts_with("checkout · ok"), "{row}"); assert!( row.contains("HEAD 1c6fc77 (main) → 3a33f3a (pr/x/3ms-r1)"), "{row}" ); assert!(row.contains("42ms"), "{row}"); assert_eq!(exit.severity(), Severity::Good); assert!(!exit.is_failure()); }
/// A ref-moving command that left `HEAD` alone says so rather than /// printing nothing — that is what `pr checkout --worktree` and every /// `push` look like, and an omission would read as "not recorded". #[test] fn a_command_that_moved_nothing_says_so() { let exit = Event::Exit { verb: "push".into(), code: Some(0), signal: None, before: at("1c6fc774339e047f77ff9cfe35a81cec189fa53c", Some("main")), after: at("1c6fc774339e047f77ff9cfe35a81cec189fa53c", Some("main")), stderr: None, elapsed_ms: 900, }; let row = exit.detail(&plain()); assert!(row.contains("HEAD unchanged at 1c6fc77 (main)"), "{row}"); assert_eq!(exit.severity(), Severity::Plain); }
/// A failure keeps git's own words, and `--failures` finds it. The exit /// code of a probe is the same shape as the exit code of a fault, which /// is why the row has to print both the number and what git said. #[test] fn a_failure_names_the_status_and_what_git_said() { let exit = Event::Exit { verb: "fetch".into(), code: Some(128), signal: None, before: At::default(), after: At::default(), stderr: Some("fatal: could not read Username for 'https://host/x'".into()), elapsed_ms: 7, }; let row = exit.detail(&plain()); assert!(row.contains("exit 128"), "{row}"); assert!(row.contains("could not read Username"), "{row}"); assert!(exit.is_failure()); assert_eq!(exit.severity(), Severity::Bad);
// Killed rather than exited: a Ctrl-C at a pager is not exit 130. let killed = Event::Exit { verb: "am".into(), code: None, signal: Some(2), before: At::default(), after: At::default(), stderr: None, elapsed_ms: 3, }; assert!(killed.detail(&plain()).contains("killed by signal 2"),); assert!(killed.is_failure()); }
/// The spawn row is the argv, because that is the thing `git reflog` /// could never say — and it carries the stdio wiring, which is what /// explains an exit row with no stderr on it. #[test] fn a_spawn_row_is_the_command_as_git_received_it() { let spawn = Event::Spawn { argv: vec!["am".into(), "--3way".into(), "--empty=keep".into()], argv_omitted: Some(2), dir: Some("/home/someone/Code/atgc".into()), stdio: Stdio::Discarded, env: vec!["GIT_TERMINAL_PROMPT".into()], stdin_bytes: Some(4096), before: at("1c6fc774339e047f77ff9cfe35a81cec189fa53c", None), }; let row = spawn.detail(&plain()); assert!(row.starts_with("git am --3way --empty=keep"), "{row}"); assert!(row.contains("… +2"), "{row}"); assert!(row.contains("in /home/someone/Code/atgc"), "{row}"); assert!(row.contains("stdin 4096B"), "{row}"); assert!(row.contains("discarded"), "{row}"); assert!(row.contains("GIT_TERMINAL_PROMPT"), "{row}"); // Detached: a sha and no branch, which is not a missing field. assert!( row.contains("from 1c6fc77 ·") || row.ends_with("from 1c6fc77"), "{row}" ); }
/// `--did` has one thing to match in this log, and it is the write whose /// whole point is which account a checkout commits as. #[test] fn the_identity_write_is_the_one_event_that_names_an_account() { let spawn = Event::Spawn { argv: vec![ "config".into(), "--local".into(), "user.email".into(), "did:plc:nlzmjyfv6loqtxyzvdcznwgf".into(), ], argv_omitted: None, dir: None, stdio: Stdio::Captured, env: Vec::new(), stdin_bytes: None, before: At::default(), }; assert_eq!(spawn.dids(), vec!["did:plc:nlzmjyfv6loqtxyzvdcznwgf"]); }
/// The head line has to carry the same facts as the other two logs', or /// the three files stop being readable side by side. #[test] fn the_head_line_says_what_ran_where() { let head = Event::Invocation { pid: 4242, user: Some("jmm".into()), cwd: Some("/home/jmm/Code/atgc".into()), version: "0.15.0".into(), subcommand: "stack create".into(), rotated_from_bytes: None, }; let row = head.detail(&plain()); assert!(row.contains("stack create"), "{row}"); assert!(row.contains("pid 4242"), "{row}"); assert!(row.contains("atgc 0.15.0"), "{row}"); // Written with `~` for `$HOME` when it is under one, which is why the // assertion is on the tail rather than on the whole path. assert!(row.contains("cwd ") && row.contains("Code/atgc"), "{row}"); assert!(head.is_head()); }}