Skip to main content

git_policy_cli/worktree/
add_timing.rs

1//! What:
2//!  The record of where one wrapped `git worktree add` spent its time, step by step, appended to a size-capped file in the
3//!  common Git directory beside the commit timing record.
4//! Why:
5//!  An add that took thirteen seconds could not be split afterwards without a `strace` that distorted it. Each step is a
6//!  stretch of the command (a walk of the source, a Git run, the snapshot call, a durable write), timed on the monotonic
7//!  clock; a step inside another is taken out of the outer one, so the steps are disjoint and what none covers is reported
8//!  as `otherMs`. The record goes to the file only, in one append when the command ends, with no flush to disk, and it
9//!  never changes what the add does.
10//!
11//! In TS you'd write (pseudocode):
12//! ```ts
13//! begin(commonDir);
14//! const value = step('walkSource', () => walk());
15//! finish(exitCode); // appends JSON.stringify(record) + '\n' to join(commonDir, 'cli-git-worktree-add-timing.jsonl')
16//! ```
17
18use crate::diagnostic_log::debug;
19use crate::discard_audit::append_line;
20use crate::iso_time::now_iso;
21use crate::json_record::quote;
22use crate::quiet_ledger::{drain, object_json};
23use crate::service_protocol::RELEASE;
24use std::cell::RefCell;
25use std::ffi::{OsStr, OsString};
26use std::path::{Path, PathBuf};
27use std::sync::Mutex;
28use std::time::{Duration, Instant};
29
30/// The file inside the common Git directory that collects the add timing records of every worktree of the repository.
31pub const ADD_TIMING_FILE: &str = "cli-git-worktree-add-timing.jsonl";
32
33/// The version of the record's fields; it changes only when a field's meaning changes, never when a field is added.
34pub const ADD_TIMING_SCHEMA_VERSION: u32 = 1;
35
36/// Module tag for diagnostics.
37const TAG: &str = "addTiming";
38
39/// A step that has been entered and not yet left.
40#[derive(Debug)]
41struct Frame {
42    /// The step's name.
43    name: String,
44    /// When the step was entered, since the recorder began.
45    entered: Duration,
46    /// The time of the steps entered and left inside this one.
47    inside: Duration,
48}
49
50/// What one step name has spent so far.
51#[derive(Debug)]
52struct Spent {
53    /// The step's name.
54    name: String,
55    /// The time of the step itself, the steps inside it taken out.
56    own: Duration,
57    /// How many times the step was entered.
58    calls: usize,
59}
60
61/// What:
62///  The steps of one add, filled as the add runs and written when it ends.
63/// Why:
64///   The accounting is separate from the clock so that the controls can drive it with chosen times.
65///
66/// In TS you'd write (pseudocode):
67/// ```ts
68/// type Recorder = { commonDir: string; open: Frame[]; spent: Spent[]; notes: [string, string][] };
69/// ```
70#[derive(Debug)]
71pub struct Recorder {
72    /// The common Git directory that holds the file.
73    common_dir: PathBuf,
74    /// The steps entered and not left, the innermost last.
75    open: Vec<Frame>,
76    /// The time per step name, in the order the names first appeared.
77    spent: Vec<Spent>,
78    /// Facts about the add that are words, not times (the route taken, why not the snapshot), in the order they were noted.
79    notes: Vec<(String, String)>,
80    /// Milliseconds since the Unix epoch at the start, for finding the add among other logs.
81    start_millis: u128,
82    /// Time spent on worker threads by name, summed over the threads; threads overlap, so it is not part of the sum rule.
83    work: Vec<Spent>,
84    /// The longest quiet gap of every Git step and tool the add ran, by name, taken from the ledger when the record is written.
85    quiet: Vec<(String, Duration)>,
86}
87
88impl Recorder {
89    /// A recorder for an add in the repository whose common Git directory is `common_dir`.
90    pub fn new(common_dir: PathBuf) -> Self {
91        let start_millis: u128 = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|elapsed: Duration| { return elapsed.as_millis(); }).unwrap_or_default();
92        return Self { common_dir, open: Vec::new(), spent: Vec::new(), notes: Vec::new(), start_millis, work: Vec::new(), quiet: Vec::new() };
93    }
94
95    /// Enter the step `name` at `at`, a time since the recorder began.
96    pub fn enter(&mut self, name: &str, at: Duration) {
97        self.open.push(Frame { name: name.to_string(), entered: at, inside: Duration::ZERO });
98    }
99
100    /// Leave the innermost step at `at`; its own time is what the steps inside it left, and the outer step loses the whole.
101    pub fn leave(&mut self, at: Duration) {
102        let Some(frame) = self.open.pop() else { return };
103        let whole: Duration = at.saturating_sub(frame.entered);
104        let own: Duration = whole.saturating_sub(frame.inside);
105        if let Some(outer) = self.open.last_mut() {
106            outer.inside += whole;
107        }
108        match self.spent.iter_mut().find(|known: &&mut Spent| { return known.name == frame.name; }) {
109            Some(known) => {
110                known.own += own;
111                known.calls += 1;
112            }
113            None => self.spent.push(Spent { name: frame.name, own, calls: 1 }),
114        }
115    }
116
117    /// Note a fact about the add; a later note of the same key replaces the earlier one.
118    pub fn note(&mut self, key: &str, value: &str) {
119        match self.notes.iter_mut().find(|known: &&mut (String, String)| { return known.0 == key; }) {
120            Some(known) => known.1 = value.to_string(),
121            None => self.notes.push((key.to_string(), value.to_string())),
122        }
123    }
124
125    /// Add `own` to the work named `name`, as one call.
126    pub fn add_work(&mut self, name: &str, own: Duration) {
127        match self.work.iter_mut().find(|known: &&mut Spent| { return known.name == name; }) {
128            Some(known) => {
129                known.own += own;
130                known.calls += 1;
131            }
132            None => self.work.push(Spent { name: name.to_string(), own, calls: 1 }),
133        }
134    }
135
136    /// The record as one line of JSON ending in a newline; `total` is the add's whole time.
137    ///
138    /// One `write` per line keeps the lines of concurrent adds from interleaving; a word with a quote or a newline is escaped
139    /// by `quote`, so the line stays one line.
140    pub fn line(&self, total: Duration, exit_code: i32) -> String {
141        let micros_to_ms = |time: Duration| -> String { return format!("{:.3}", time.as_micros() as f64 / 1000.0); };
142        let covered: Duration = self.spent.iter().map(|each: &Spent| { return each.own; }).sum();
143        let steps: Vec<String> = self.spent.iter().map(|each: &Spent| { return format!("{}:{{\"ms\":{},\"calls\":{}}}", quote(each.name.as_str()), micros_to_ms(each.own), each.calls); }).collect();
144        let work: Vec<String> = self.work.iter().map(|each: &Spent| { return format!("{}:{{\"ms\":{},\"calls\":{}}}", quote(each.name.as_str()), micros_to_ms(each.own), each.calls); }).collect();
145        let notes: Vec<String> = self.notes.iter().map(|(key, value): &(String, String)| { return format!("{}:{}", quote(key.as_str()), quote(value.as_str())); }).collect();
146        return format!(
147            "{{\"schemaVersion\":{ADD_TIMING_SCHEMA_VERSION},\"time\":{},\"startMillis\":{},\"pid\":{},\"release\":{},\"exitCode\":{exit_code},\"totalMs\":{},\"steps\":{{{}}},\"otherMs\":{},\"work\":{{{}}},\"notes\":{{{}}},\"quiet\":{}}}\n",
148            quote(now_iso().as_str()),
149            self.start_millis,
150            std::process::id(),
151            quote(RELEASE),
152            micros_to_ms(total),
153            steps.join(","),
154            micros_to_ms(total.saturating_sub(covered)),
155            work.join(","),
156            notes.join(","),
157            object_json(self.quiet.as_slice()),
158        );
159    }
160
161    /// The directory that holds the record's file.
162    pub fn common_dir(&self) -> &Path {
163        return self.common_dir.as_path();
164    }
165}
166
167/// The work of worker threads while an add is recorded: none outside a recording, so a stray call keeps nothing.
168static WORK: Mutex<Option<Vec<(String, Duration)>>> = Mutex::new(None);
169
170thread_local! {
171    /// The recorder of the add this thread is running, and when it began; none outside an add.
172    static ACTIVE: RefCell<Option<(Instant, Recorder)>> = const { RefCell::new(None) };
173}
174
175/// Begin recording the add of the repository whose common Git directory is `common_dir`, replacing any earlier recording
176///  of this thread.
177pub fn begin(common_dir: PathBuf) {
178    ACTIVE.with(|active: &RefCell<Option<(Instant, Recorder)>>| { *active.borrow_mut() = Some((Instant::now(), Recorder::new(common_dir))); });
179    if let Ok(mut work) = WORK.lock() {
180        *work = Some(Vec::new());
181    }
182}
183
184/// Run `body` as the step `name`; with no recording begun, `body` runs alone.
185pub fn step<T>(name: &str, body: impl FnOnce() -> T) -> T {
186    let entered: bool = ACTIVE.with(|active: &RefCell<Option<(Instant, Recorder)>>| {
187        return match active.borrow_mut().as_mut() {
188            Some((began, recorder)) => {
189                recorder.enter(name, began.elapsed());
190                true
191            }
192            None => false,
193        };
194    });
195    let value: T = body();
196    if entered {
197        ACTIVE.with(|active: &RefCell<Option<(Instant, Recorder)>>| {
198            if let Some((began, recorder)) = active.borrow_mut().as_mut() {
199                recorder.leave(began.elapsed());
200            }
201        });
202    }
203    return value;
204}
205
206/// What:
207///  The step name of one Git run: `git ` and the subcommand, found past the global options that take a value (`-C`, `-c`,
208///  `--git-dir`, `--work-tree`) and the flags that do not.
209/// Why:
210///   The record says how many Git processes an add started and how long each kind took, which names the avoidable ones.
211///
212/// In TS you'd write (pseudocode):
213/// ```ts
214/// git_step_label(['-C', dir, '--no-optional-locks', 'status']) // 'git status'
215/// ```
216pub fn git_step_label(arguments: &[OsString]) -> String {
217    let takes_value = |word: &OsStr| -> bool { return ["-C", "-c", "--git-dir", "--work-tree", "--namespace", "--exec-path"].iter().any(|known: &&str| { return word == OsStr::new(known); }); };
218    let mut words = arguments.iter().map(OsString::as_os_str);
219    while let Some(word) = words.next() {
220        if takes_value(word) {
221            words.next();
222            continue;
223        }
224        if word.to_string_lossy().starts_with('-') {
225            continue;
226        }
227        return format!("git {}", word.to_string_lossy());
228    }
229    return String::from("git");
230}
231
232/// What:
233///  Run `body` as work named `name` on any thread, adding its time to the name's sum; with no recording begun, `body` runs alone.
234/// Why:
235///   The repair of package managers runs its tools on worker threads, which the thread's own steps cannot see; the sum over
236///  threads says how much work a step held, and the step that waited for it says how long the add waited.
237///
238/// In TS you'd write (pseudocode):
239/// ```ts
240/// const value = work('clone', () => cloneTree());
241/// ```
242pub fn work<T>(name: &str, body: impl FnOnce() -> T) -> T {
243    let began: Instant = Instant::now();
244    let value: T = body();
245    let spent: Duration = began.elapsed();
246    if let Ok(mut work) = WORK.lock()
247        && let Some(list) = work.as_mut()
248    {
249        list.push((name.to_string(), spent));
250    }
251    return value;
252}
253
254/// Note a fact about the add being recorded; nothing outside a recording.
255pub fn note(key: &str, value: &str) {
256    ACTIVE.with(|active: &RefCell<Option<(Instant, Recorder)>>| {
257        if let Some((_began, recorder)) = active.borrow_mut().as_mut() {
258            recorder.note(key, value);
259        }
260    });
261}
262
263/// What:
264///  End the recording and append its record to the repository's add timing file, best effort.
265/// Why:
266///   The command's result never depends on the record: a file that cannot be written is only a debug line. The directory is
267///  never created and nothing is flushed to disk, so the record costs one `write`.
268///
269/// In TS you'd write (pseudocode):
270/// ```ts
271/// finish(exitCode);
272/// ```
273pub fn finish(exit_code: i32) {
274    let taken: Option<(Instant, Recorder)> = ACTIVE.with(|active: &RefCell<Option<(Instant, Recorder)>>| { return active.borrow_mut().take(); });
275    let drained: Option<Vec<(String, Duration)>> = WORK.lock().ok().and_then(|mut work: std::sync::MutexGuard<'_, Option<Vec<(String, Duration)>>>| { return work.take(); });
276    let Some((began, mut recorder)) = taken else { return };
277    for (name, spent) in drained.unwrap_or_default() {
278        recorder.add_work(name.as_str(), spent);
279    }
280    recorder.quiet = drain();
281    let target: PathBuf = recorder.common_dir().join(ADD_TIMING_FILE);
282    let line: String = recorder.line(began.elapsed(), exit_code);
283    if let Err(error) = append_line(target.as_path(), line.as_bytes()) {
284        debug(TAG, &format!("the add timing record could not be written to {}: {error}", target.display()));
285    }
286}
287
288/// The accounting, the line and the writer.
289#[cfg(test)]
290#[path = "add_timing_tests.rs"]
291mod tests;