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::service_protocol::RELEASE;
23use std::path::Path;
24
25/// The file inside the common Git directory that collects the trigger records of every worktree of the repository.
26pub const TRIGGER_FILE: &str = "cli-git-push-trigger.jsonl";
27
28/// The version of the record's fields;
29/// it changes only when a field's meaning changes,
30/// never when a field is added.
31pub const TRIGGER_SCHEMA_VERSION: i64 = 1;
32
33/// Module tag for diagnostics.
34const TAG: &str = "pushTrigger";
35
36/// How the trigger ended for one command.
37#[derive(Clone, Copy, Debug, Eq, PartialEq)]
38pub enum Outcome {
39 /// The push was handed to the service.
40 HandedOver,
41 /// The trigger did nothing,
42 /// for the reason the record names.
43 Declined,
44}
45
46impl Outcome {
47 /// The word in the record.
48 pub fn word(self) -> &'static str {
49 return match self {
50 Self::HandedOver => "handed-over",
51 Self::Declined => "declined",
52 };
53 }
54}
55
56/// What:
57/// The line for one decision.
58/// Why:
59/// The fields say which command,
60/// which release,
61/// which branch and tips,
62/// and the one reason,
63/// so a missing push is diagnosed from the file alone.
64///
65/// In TS you'd write (pseudocode):
66/// ```ts
67/// JSON.stringify({ schemaVersion, time, pid, release, command, outcome, reason, detail, branch }) + '\n'
68/// ```
69pub fn trigger_line(command: &str, outcome: Outcome, reason: &str, detail: &str, branch: Option<&str>) -> String {
70 let mut record: ObjectWriter = ObjectWriter::new();
71 record
72 .integer("schemaVersion", TRIGGER_SCHEMA_VERSION)
73 .string("time", now_iso().as_str())
74 .integer("pid", i64::from(std::process::id()))
75 .string("release", RELEASE)
76 .string("command", command)
77 .string("outcome", outcome.word())
78 .string("reason", reason)
79 .string("detail", detail);
80 if let Some(name) = branch {
81 record.string("branch", name);
82 }
83 return format!("{}\n", record.finish());
84}
85
86/// What:
87/// Records one decision:
88/// a debug line always (when verbose),
89/// and one appended line when the common Git directory is known.
90/// Why:
91/// The command's result never depends on the record:
92/// a file that cannot be written is only a debug line.
93///
94/// In TS you'd write (pseudocode):
95/// ```ts
96/// recordDecision(commonDir, 'merge', 'declined', 'upstream-equals-tip', detail, branch)
97/// ```
98pub fn record_decision(common_dir: Option<&Path>, command: &str, outcome: Outcome, reason: &str, detail: &str, branch: Option<&str>) {
99 debug(TAG, &format!("{command}: {} ({reason}) {detail}", outcome.word()));
100 let Some(directory) = common_dir else { return };
101 let target = directory.join(TRIGGER_FILE);
102 let line: String = trigger_line(command, outcome, reason, detail, branch);
103 if let Err(error) = append_line(target.as_path(), line.as_bytes()) {
104 debug(TAG, &format!("the trigger record could not be written to {}: {error}", target.display()));
105 }
106}