Skip to main content

git_policy_cli/commit/
commit_timing.rs

1//! What:
2//!  The record of where one wrapped `git commit` spent its time,
3//! appended to a size-capped file in the common Git directory
4//!  beside the discard audit:
5//! preparation,
6//! building,
7//! setting up the service,
8//! reaching it,
9//! waiting for its answer,
10//! the work
11//!  after landing,
12//! the post-commit gate and the hand-over of the push.
13//! Why:
14//!  A commit that took seventeen seconds could not be split afterwards,
15//! because nothing outside verbose mode said which phase
16//!  held the time.
17//! The numbers are taken from the monotonic clock as the commit runs (one `Instant` read per phase edge,
18//! no
19//!  Git start,
20//! no flush to disk) and written once,
21//! in one append,
22//! when the command ends.
23//! The record goes to the file only:
24//!  the person's terminal never shows it.
25//!
26//! In TS you'd write (pseudocode):
27//! ```ts
28//! await appendFile(join(commonDir, 'cli-git-commit-timing.jsonl'), JSON.stringify(timing) + '\n');
29//! ```
30
31use crate::diagnostic_log::debug;
32use crate::discard_audit::append_line;
33use crate::iso_time::now_iso;
34use crate::json_record::{quote, string_array};
35use crate::quiet_ledger::{drain, longest_healthy, object_json};
36use crate::service_instance::instance_of;
37use crate::service_protocol::RELEASE;
38use std::path::{Path, PathBuf};
39use std::time::{Duration, Instant};
40
41/// The file inside the common Git directory that collects the timing records of every worktree of the repository.
42/// It moves
43///  aside to `cli-git-commit-timing.jsonl.1`, replacing the older one (a timing log keeps one generation), at the size limit
44///  `discard_audit::append_line` applies.
45pub const TIMING_FILE: &str = "cli-git-commit-timing.jsonl";
46
47/// The version of the record's fields;
48/// it changes only when a field's meaning changes,
49/// never when a field is added.
50pub const TIMING_SCHEMA_VERSION: u32 = 1;
51
52/// Module tag for diagnostics.
53const TAG: &str = "commitTiming";
54
55/// The word written for a release a service did not announce (the services of the earliest releases sent no release).
56const UNANNOUNCED: &str = "unannounced";
57
58/// What:
59///  What the client did to reach a service that speaks its protocol and was started from its release.
60/// Why:
61///   The 17-second commit began with a service of another release being asked to retire;
62/// the tries,
63/// the wait for a start,
64/// the
65///  retires sent and the releases that answered tell that apart from a slow landing.
66///
67/// In TS you'd write (pseudocode):
68/// ```ts
69/// type ReachTrace = { tries: number; refused: number; startWaited: number; retiresSent: number; releasesSeen: string[]; answeredBy?: string };
70/// ```
71#[derive(Clone, Debug, Default, Eq, PartialEq)]
72pub struct ReachTrace {
73    /// Connection attempts made,
74    /// each one a `connect` call.
75    pub tries: usize,
76    /// Attempts that found nothing listening or no greeting,
77    /// which is the service being started by the manager.
78    pub refused: usize,
79    /// Time from the first attempt of a reach to the greeting,
80    /// summed over the reaches that needed more than one attempt.
81    pub start_waited: Duration,
82    /// Retire messages sent to services of another release.
83    pub retires_sent: usize,
84    /// The release of every greeting,
85    /// in the order received,
86    /// without repeats.
87    pub releases_seen: Vec<String>,
88    /// The release of the service that finally took the request.
89    pub answered_by: Option<String>,
90    /// Time spent reaching,
91    /// retire rounds included.
92    pub elapsed: Duration,
93}
94
95impl ReachTrace {
96    /// Notes the release a greeting named.
97    pub fn saw_release(&mut self, release: Option<&str>) {
98        let word: String = release.unwrap_or(UNANNOUNCED).to_string();
99        if !self.releases_seen.contains(&word) {
100            self.releases_seen.push(word);
101        }
102    }
103}
104
105/// What:
106///  What a waiting notice of the service says of the holder of the index lock.
107/// Why:
108///   The notice is the only thing about the holder that reaches the client,
109/// and the record should say whether the wait was for a process proven to hold the lock or for one nobody could prove alive.
110///
111/// In TS you'd write (pseudocode):
112/// ```ts
113/// holderKindOf(text) // 'proven-alive' | 'unproven'
114/// ```
115pub fn holder_kind_of(text: &str) -> &'static str {
116    return if text.contains("(proven by ") { "proven-alive" } else { "unproven" };
117}
118
119/// What:
120///  What the client did to hand one request to the service and get its answer.
121/// Why:
122///   The queue wait,
123/// the wait for a foreign index lock and the landing itself all happen between the send and the answer;
124///  the service's own log line for the request splits that wait,
125/// and this record says how long the whole of it was.
126///
127/// In TS you'd write (pseudocode):
128/// ```ts
129/// type SubmitTrace = { total: number; reach: ReachTrace; answerWait: number; positions: number[]; waitingNotices: number; holderKind?: string };
130/// ```
131#[derive(Clone, Debug, Default, Eq, PartialEq)]
132pub struct SubmitTrace {
133    /// The whole of the sends,
134    /// reaches included.
135    pub total: Duration,
136    /// How the service was reached.
137    pub reach: ReachTrace,
138    /// Time from a request being written to the answer (or the connection breaking),
139    /// summed over the sends.
140    pub answer_wait: Duration,
141    /// The queue positions the service reported,
142    /// in order,
143    /// without repeats.
144    pub positions: Vec<usize>,
145    /// Notices the service sent while it waited for another program's hold on the index.
146    pub waiting_notices: usize,
147    /// What the last of those notices said of the holder:
148    /// `proven-alive` when it named a process proven to hold the lock,
149    /// `unproven` when it said the owner could not be proven alive.
150    pub holder_kind: Option<&'static str>,
151    /// Times the connection broke before an answer and the same request was sent again.
152    pub reconnects: usize,
153    /// Requests written.
154    pub sends: usize,
155}
156
157/// What:
158///  The common Git directory of a worktree's own Git directory:
159/// the directory its `commondir` file names,
160/// else the directory
161///  itself.
162/// Why:
163///   A linked worktree's own Git directory goes away with the worktree,
164/// while the repository's one timing file lives in the
165///  common directory.
166/// The service's review module reads the file the same way (Git strips every trailing carriage return and
167///  line feed and resolves a relative path against the Git directory),
168/// but that module belongs to the service run and the client
169///  path may not name it (`standards_review_boundary_tests.rs`),
170/// so the few lines are repeated here.
171/// A path that cannot be
172///  resolved is used as written.
173///
174/// In TS you'd write (pseudocode):
175/// ```ts
176/// existsSync(join(gitDir, 'commondir')) ? realpath(resolve(gitDir, readFile(...).trimEnd())) : gitDir
177/// ```
178fn common_dir_for(git_dir: &Path) -> PathBuf {
179    let Ok(text) = std::fs::read_to_string(git_dir.join("commondir")) else {
180        return git_dir.to_path_buf();
181    };
182    let named: &str = text.trim_end_matches(['\r', '\n']);
183    if named.is_empty() {
184        return git_dir.to_path_buf();
185    }
186    let joined: PathBuf = git_dir.join(named);
187    return std::fs::canonicalize(&joined).unwrap_or(joined);
188}
189
190/// Where the record goes and which service instance it belongs to.
191#[derive(Clone, Debug, Eq, PartialEq)]
192struct Place {
193    /// The worktree's own Git directory.
194    git_dir: PathBuf,
195    /// The common Git directory,
196    /// which holds the file.
197    common_dir: PathBuf,
198    /// The instance name that `journalctl --user --unit=cli-git@<instance>.service` takes.
199    instance: String,
200}
201
202/// The whole milliseconds of a duration.
203fn whole_milliseconds(elapsed: Duration) -> u128 {
204    return elapsed.as_millis();
205}
206
207/// The JSON word for a field that has no value.
208fn null_word() -> String {
209    return String::from("null");
210}
211
212impl Place {
213    /// The instance as a JSON string.
214    fn instance_json(&self) -> String {
215        return quote(self.instance.as_str());
216    }
217
218    /// The worktree's own Git directory as a JSON string.
219    fn git_dir_json(&self) -> String {
220        return quote(&self.git_dir.to_string_lossy());
221    }
222}
223
224/// What:
225///  The phases of one wrapped commit,
226/// filled as the commit runs and written when it ends.
227/// Why:
228///   Each field is one disjoint stretch of the command,
229/// so the phases add up to no more than the total,
230/// and what they do not
231///  cover is reported as `otherMs` instead of being hidden.
232///
233/// In TS you'd write (pseudocode):
234/// ```ts
235/// type CommitTiming = { began: number; prepare: number; build: number; provision: number; submit: SubmitTrace; /* ... */ };
236/// ```
237#[derive(Clone, Debug)]
238pub struct CommitTiming {
239    /// The monotonic start.
240    began: Instant,
241    /// Milliseconds since the Unix epoch at the start,
242    /// for finding the commit among the service's journal lines.
243    start_millis: u128,
244    /// Known once the worktree's Git directory was found.
245    place: Option<Place>,
246    /// The branch (or `HEAD`) the first preparation found.
247    pub reference: Option<String>,
248    /// The identity the commit's request carries, which the service's `answered` line states as `request` and as the
249    /// journal field `CLI_GIT_REQUEST_ID`; the key that joins this record to the service's.
250    pub request: Option<String>,
251    /// How the command ended:
252    /// `landed`,
253    /// `ended` (blocked or declined,
254    /// nothing landed) or `stopped` (a failure).
255    pub outcome: &'static str,
256    /// Preparations made:
257    /// one,
258    /// and one more for each time the service sent the commit back or Git built it again.
259    pub attempts: usize,
260    /// Policies,
261    /// hooks,
262    /// the private index,
263    /// the tree and the objects of the commit,
264    /// summed over the attempts.
265    pub prepare: Duration,
266    /// The message,
267    /// the identities,
268    /// the tree check and Git's own build of the commit,
269    /// with the scratch hand-over.
270    pub build: Duration,
271    /// Setting up socket activation,
272    /// which is the manager's unit comparison and,
273    /// after an upgrade,
274    /// a rewrite and a reload.
275    pub provision: Duration,
276    /// Sending the request and waiting for the answer.
277    pub submit: SubmitTrace,
278    /// The summary line,
279    /// the names it needs,
280    /// and the files a policy corrected,
281    /// after the branch moved.
282    pub after_landing: Duration,
283    /// The post-commit gate.
284    pub gate: Duration,
285    /// Leaving the push and the upkeep for the service and waking it.
286    pub handoff: Duration,
287    /// The longest quiet gap of every Git step and tool this command ran, by step name, taken from the ledger when the record
288    ///  is written; a step that was ended for being quiet is listed apart from the steps that ended by themselves.
289    pub quiet: Vec<(String, Duration)>,
290}
291
292impl CommitTiming {
293    /// Starts the clocks:
294    /// the monotonic one for the durations and the wall clock for the stamp.
295    pub fn start() -> Self {
296        let start_millis: u128 = std::time::SystemTime::now()
297            .duration_since(std::time::UNIX_EPOCH)
298            .map(whole_milliseconds)
299            .unwrap_or_default();
300        return Self {
301            began: Instant::now(),
302            start_millis,
303            place: None,
304            reference: None,
305            request: None,
306            outcome: "stopped",
307            attempts: 0,
308            prepare: Duration::ZERO,
309            build: Duration::ZERO,
310            provision: Duration::ZERO,
311            submit: SubmitTrace::default(),
312            after_landing: Duration::ZERO,
313            gate: Duration::ZERO,
314            handoff: Duration::ZERO,
315            quiet: Vec::new(),
316        };
317    }
318
319    /// Names the worktree whose Git directory the command found;
320    /// a command outside a repository writes no record.
321    pub fn place_at(&mut self, git_dir: &Path) {
322        self.place = Some(Place { git_dir: git_dir.to_path_buf(), common_dir: common_dir_for(git_dir), instance: instance_of(git_dir) });
323    }
324
325    /// What the phases leave uncovered out of `total`.
326    fn other(&self, total: Duration) -> Duration {
327        let covered: Duration = self.prepare + self.build + self.provision + self.submit.total + self.after_landing + self.gate + self.handoff;
328        return total.saturating_sub(covered);
329    }
330
331    /// What:
332    ///  The record as one line of JSON ending in a newline.
333    /// Why:
334    ///   One `write` call per line keeps lines of concurrent commits from interleaving;
335    /// a branch name with a quote or a
336    ///  newline is escaped by `quote`,
337    /// so the line stays one line.
338    ///
339    /// In TS you'd write (pseudocode):
340    /// ```ts
341    /// JSON.stringify(record) + '\n'
342    /// ```
343    pub fn line(&self, total: Duration, exit_code: i32) -> String {
344        let place: Option<&Place> = self.place.as_ref();
345        let branch: String = self.reference.as_deref().map_or_else(null_word, quote);
346        let answered_by: String = self.submit.reach.answered_by.as_deref().map_or_else(null_word, quote);
347        let request: String = self.request.as_deref().map_or_else(null_word, quote);
348        let level: &str = if self.outcome == "stopped" { "warn" } else { "info" };
349        let positions: Vec<String> = self.submit.positions.iter().map(usize::to_string).collect();
350        return format!(
351            "{{\"schemaVersion\":{TIMING_SCHEMA_VERSION},\"time\":{},\"level\":\"{level}\",\"startMillis\":{},\"pid\":{},\"release\":{},\"instance\":{},\"gitDir\":{},\
352             \"requestId\":{request},\"branch\":{branch},\"outcome\":{},\"exitCode\":{exit_code},\"totalMs\":{},\
353             \"prepareMs\":{},\"attempts\":{},\"buildMs\":{},\"provisionMs\":{},\
354             \"submitMs\":{},\"sends\":{},\"reachMs\":{},\"connectTries\":{},\"refusedConnects\":{},\"startWaitMs\":{},\
355             \"updateRounds\":{},\"releasesSeen\":{},\"answeredByRelease\":{answered_by},\
356             \"answerWaitMs\":{},\"positions\":[{}],\"waitingNotices\":{},\"lockHolderKind\":{},\"reconnects\":{},\
357             \"afterLandingMs\":{},\"gateMs\":{},\"handoffMs\":{},\"otherMs\":{},\
358             \"longestQuietMs\":{},\"quietMs\":{}}}\n",
359            quote(now_iso().as_str()),
360            self.start_millis,
361            std::process::id(),
362            quote(RELEASE),
363            place.map_or_else(null_word, Place::instance_json),
364            place.map_or_else(null_word, Place::git_dir_json),
365            quote(self.outcome),
366            total.as_millis(),
367            self.prepare.as_millis(),
368            self.attempts,
369            self.build.as_millis(),
370            self.provision.as_millis(),
371            self.submit.total.as_millis(),
372            self.submit.sends,
373            self.submit.reach.elapsed.as_millis(),
374            self.submit.reach.tries,
375            self.submit.reach.refused,
376            self.submit.reach.start_waited.as_millis(),
377            self.submit.reach.retires_sent,
378            string_array(self.submit.reach.releases_seen.as_slice()),
379            self.submit.answer_wait.as_millis(),
380            positions.join(","),
381            self.submit.waiting_notices,
382            self.submit.holder_kind.map_or_else(null_word, quote),
383            self.submit.reconnects,
384            self.after_landing.as_millis(),
385            self.gate.as_millis(),
386            self.handoff.as_millis(),
387            self.other(total).as_millis(),
388            longest_healthy(self.quiet.as_slice()),
389            object_json(self.quiet.as_slice()),
390        );
391    }
392
393    /// What:
394    ///  Appends the record to the repository's timing file,
395    /// best effort.
396    /// Why:
397    ///   The command's result never depends on the record:
398    /// a file that cannot be written is only a debug line.
399    /// The directory
400    ///  is never created,
401    /// and nothing is flushed to disk,
402    /// so the record costs one `write`.
403    ///
404    /// In TS you'd write (pseudocode):
405    /// ```ts
406    /// recordTiming(timing, exitCode)
407    /// ```
408    pub fn finish(&self, exit_code: i32) {
409        let Some(place) = self.place.as_ref() else { return };
410        let target: PathBuf = place.common_dir.join(TIMING_FILE);
411        // The ledger holds what this command's Git steps and tools showed; the record takes it and the next command starts empty.
412        let mut with_quiet: CommitTiming = self.clone();
413        with_quiet.quiet = drain();
414        let line: String = with_quiet.line(self.began.elapsed(), exit_code);
415        if let Err(error) = append_line(target.as_path(), line.as_bytes()) {
416            debug(TAG, &format!("the timing record could not be written to {}: {error}", target.display()));
417        }
418    }
419}
420
421/// The line,
422/// the sum rule,
423/// and the escaping.
424#[cfg(test)]
425#[path = "commit_timing_tests.rs"]
426mod tests;