git_policy_cli/push/push_trigger_log.rs
1//! What:
2//! The record of every decision of the push trigger after a command that adds commits:
3//! handed over,
4//! or declined with the step that declined and why.
5//! Why:
6//! A merge that was never pushed left nothing to look at,
7//! because the trigger returned silently at eight places
8//! and the reason sat only in the code.
9//! The record is one JSON line appended to a size-capped file in the common Git directory beside the commit timing records,
10//! and a debug line under `MONOCHROMATIC_VERBOSE=true`;
11//! the person's terminal shows neither.
12//!
13//! In TS you'd write (pseudocode):
14//! ```ts
15//! await appendFile(join(commonDir, 'cli-git-push-trigger.jsonl'), JSON.stringify({ command, outcome, reason }) + '\n');
16//! ```
17
18use crate::diagnostic_log::debug;
19use crate::discard_audit::append_line;
20use crate::iso_time::now_iso;
21use crate::json_record::ObjectWriter;
22use crate::lfs_output_mask::mask_userinfo_text;
23use crate::service_instance::instance_of;
24use crate::service_protocol::RELEASE;
25use std::path::Path;
26
27/// The file inside the common Git directory that collects the trigger records of every worktree of the repository.
28pub const TRIGGER_FILE: &str = "cli-git-push-trigger.jsonl";
29
30/// The version of the record's fields;
31/// it changes only when a field's meaning changes,
32/// never when a field is added.
33pub const TRIGGER_SCHEMA_VERSION: i64 = 1;
34
35/// Module tag for diagnostics.
36const TAG: &str = "pushTrigger";
37
38/// How the trigger ended for one command.
39#[derive(Clone, Copy, Debug, Eq, PartialEq)]
40pub enum Outcome {
41 /// The push was handed to the service.
42 HandedOver,
43 /// The trigger did nothing,
44 /// for the reason the record names.
45 Declined,
46}
47
48impl Outcome {
49 /// The word in the record.
50 pub fn word(self) -> &'static str {
51 return match self {
52 Self::HandedOver => "handed-over",
53 Self::Declined => "declined",
54 };
55 }
56}
57
58/// The worktree a decision was made in; the file is shared by every worktree of the repository, so a line must say which.
59#[derive(Clone, Copy, Debug, Eq, PartialEq)]
60pub struct Origin<'place> {
61 /// The worktree's top level.
62 pub worktree: &'place Path,
63 /// The worktree's own Git directory, which names its service instance.
64 pub git_dir: &'place Path,
65}
66
67/// Everything one line states.
68#[derive(Clone, Copy, Debug, Eq, PartialEq)]
69pub struct Decision<'record> {
70 /// The common Git directory that holds the file; nothing is written without it.
71 pub common_dir: Option<&'record Path>,
72 /// The worktree the command ran in.
73 pub origin: Option<Origin<'record>>,
74 /// The command word.
75 pub command: &'record str,
76 /// How the trigger ended.
77 pub outcome: Outcome,
78 /// The step, in a stable word.
79 pub reason: &'record str,
80 /// What the step read, as text.
81 pub detail: &'record str,
82 /// The branch the command was on, when it was known.
83 pub branch: Option<&'record str>,
84}
85
86/// The reasons that mean the wrapper could not decide or could not hand the push over, so a reader scanning the file
87/// finds them; every other reason is a decision working as intended (a branch that did not grow, a detached `HEAD`).
88const WARN_REASONS: [&str; 9] = [
89 "layout-declined",
90 "head-unreadable",
91 "branch-has-no-tip",
92 "object-database-unopened",
93 "upstream-config-unreadable",
94 "walk-undecided-growth",
95 "walk-undecided-ahead",
96 "no-route",
97 "handover-failed",
98];
99
100/// The level a line states: `warn` for a decision the wrapper could not make or a hand-over that failed, else `info`.
101pub fn level_of(outcome: Outcome, reason: &str) -> &'static str {
102 if outcome == Outcome::Declined && WARN_REASONS.contains(&reason) {
103 return "warn";
104 }
105 return "info";
106}
107
108/// What:
109/// The line for one decision.
110/// Why:
111/// The fields say which worktree (`worktree`, `gitDir`, `instance`), which command, which release, which branch and the
112/// one reason with what it read, so a missing push is diagnosed from the file alone; the userinfo of a URL in the
113/// detail is masked, because a failure the service reports can name a remote.
114///
115/// In TS you'd write (pseudocode):
116/// ```ts
117/// JSON.stringify({ schemaVersion, time, level, pid, release, worktree, gitDir, instance, command, outcome, reason, detail, branch }) + '\n'
118/// ```
119pub fn trigger_line(decision: &Decision<'_>) -> String {
120 let mut record: ObjectWriter = ObjectWriter::new();
121 record
122 .integer("schemaVersion", TRIGGER_SCHEMA_VERSION)
123 .string("time", now_iso().as_str())
124 .string("level", level_of(decision.outcome, decision.reason))
125 .integer("pid", i64::from(std::process::id()))
126 .string("release", RELEASE);
127 if let Some(origin) = decision.origin {
128 record
129 .string("worktree", &origin.worktree.to_string_lossy())
130 .string("gitDir", &origin.git_dir.to_string_lossy())
131 .string("instance", instance_of(origin.git_dir).as_str());
132 }
133 record
134 .string("command", decision.command)
135 .string("outcome", decision.outcome.word())
136 .string("reason", decision.reason)
137 .string("detail", mask_userinfo_text(decision.detail).as_str());
138 if let Some(name) = decision.branch {
139 record.string("branch", name);
140 }
141 return format!("{}\n", record.finish());
142}
143
144/// What:
145/// Records one decision:
146/// a debug line always (when verbose),
147/// and one appended line when the common Git directory is known.
148/// Why:
149/// The command's result never depends on the record:
150/// a file that cannot be written is only a debug line.
151///
152/// In TS you'd write (pseudocode):
153/// ```ts
154/// recordDecision({ commonDir, origin, command: 'merge', outcome: 'declined', reason: 'upstream-equals-tip', detail, branch })
155/// ```
156pub fn record_decision(decision: &Decision<'_>) {
157 debug(TAG, &format!("{}: {} ({}) {}", decision.command, decision.outcome.word(), decision.reason, mask_userinfo_text(decision.detail)));
158 let Some(directory) = decision.common_dir else { return };
159 let target = directory.join(TRIGGER_FILE);
160 let line: String = trigger_line(decision);
161 if let Err(error) = append_line(target.as_path(), line.as_bytes()) {
162 debug(TAG, &format!("the trigger record could not be written to {}: {error}", target.display()));
163 }
164}
165
166/// Who the record names, its level and its masking.
167#[cfg(test)]
168#[path = "push_trigger_log_tests.rs"]
169mod tests;