Skip to main content

git_policy_cli/
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 super::diagnostic_log::debug;
19use super::discard_audit::append_line;
20use super::iso_time::now_iso;
21use super::json_record::ObjectWriter;
22use super::lfs_output_mask::mask_userinfo_text;
23use super::service_instance::instance_of;
24use super::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;