git_policy_cli/worktree/inner_git_words.rs
1//! What:
2//! Where the words of a Git run the wrapper or the service started on its own go, now that none of those runs carries
3//! `--quiet` or `-q`: one journal entry, from the service for a run it made and from the wrapper for a run it made while
4//! carrying out a person's command.
5//! Why:
6//! The owner's rule is "No the wrapper should never append --quiet. Letting native git emit whatever it wants to is expected",
7//! and for the wrapper's and the service's own runs the answer was "Drop it everywhere (Recommended)". Every such run is
8//! captured (`run_git` pipes both streams into `GitOutput`), so Git's words would be discarded with the capture; this
9//! keeps them where a failure of the same run is already recorded (`journalctl`). A hook's standard output and the service's
10//! protocol replies are never the destination, and no other option or redirect hides the words in the meantime.
11//!
12//! In TS you'd write (pseudocode):
13//! ```ts
14//! const words = [stdout, stderr].filter((text) => text.trim() !== '').join('\n'); if (words) journal.info(`git ${command} said: ${words}`);
15//! ```
16
17/// Masks the userinfo of any URL in Git's words, as every journal entry of the service is masked.
18use crate::lfs_output_mask::mask_userinfo_text;
19/// The entry from a wrapper a person ran, which only Linux writes.
20#[cfg(target_os = "linux")]
21use crate::client_journal::record_entry;
22/// The priority an entry carries, and the service's way to write one.
23use crate::journald_native::Priority;
24use crate::service_timing::{in_service, log_entry};
25/// The captured output of one Git start.
26use crate::transaction_git::GitOutput;
27
28/// The field a reader filters the journal on to find the entries of this module.
29pub const COMMAND_FIELD: &str = "CLI_GIT_INNER_COMMAND";
30
31/// What:
32/// The text of a Git run's words: its standard output, then its standard error, trimmed, or nothing when it said nothing.
33/// Why:
34/// A run that said nothing makes no entry, so an entry always carries something to read.
35///
36/// In TS you'd write (pseudocode):
37/// ```ts
38/// [stdout, stderr].map((text) => text.trim()).filter(Boolean).join('\n') || undefined
39/// ```
40pub fn words_of(stdout: &[u8], stderr: &[u8]) -> Option<String> {
41 // The two streams in the order they are shown: standard output, then standard error.
42 let mut parts: Vec<String> = Vec::new();
43 for bytes in [stdout, stderr] {
44 // Bytes that are not UTF-8 become the replacement character, so a word is never lost to an encoding.
45 let text: String = String::from_utf8_lossy(bytes).trim().to_owned();
46 if !text.is_empty() {
47 parts.push(text);
48 }
49 }
50 if parts.is_empty() {
51 // `None` is "this run said nothing", which makes no entry.
52 return None;
53 }
54 // `Some(...)` wraps the joined words: "there is something to record".
55 return Some(parts.join("\n"));
56}
57
58/// What:
59/// Record the words of a finished run, whichever program made it: the service's log when this is the service, the client
60/// entry otherwise.
61/// Why:
62/// The auto-push's fetch and fast-forward run in the service and in the wrapper's own stacked push, and the shadow's `init`
63/// in both, so the choice is made here once.
64///
65/// In TS you'd write (pseudocode):
66/// ```ts
67/// recordWords(['fetch', '--no-tags', remote], `${stdout}${stderr}`)
68/// ```
69pub fn record_text<S: AsRef<str>>(arguments: &[S], text: &str) {
70 let trimmed: &str = text.trim();
71 if trimmed.is_empty() {
72 return;
73 }
74 // The command line as it reads in a terminal, so the entry names what ran.
75 let mut words: Vec<&str> = Vec::new();
76 for argument in arguments {
77 words.push(argument.as_ref());
78 }
79 let command: String = words.join(" ");
80 let message: String = mask_userinfo_text(format!("git {command} said: {trimmed}").as_str());
81 let fields: [(&str, String); 1] = [(COMMAND_FIELD, mask_userinfo_text(command.as_str()))];
82 if in_service() {
83 let _delivered = log_entry(Priority::Info, message.as_str(), &fields);
84 return;
85 }
86 deliver_from_wrapper(message.as_str(), &fields);
87}
88
89/// Send the entry of a wrapper a person ran to the journal; whether the socket took it is of no use to the command.
90#[cfg(target_os = "linux")]
91fn deliver_from_wrapper(message: &str, fields: &[(&str, String)]) {
92 let _sent: bool = record_entry(Priority::Info, message, fields);
93}
94
95/// Only Linux writes the wrapper's journal entries; elsewhere the words are not kept.
96// UNTESTED ON WINDOWS: no gate compiles or runs this branch (the crate does not build for Windows; README, Requirements).
97#[cfg(not(target_os = "linux"))]
98fn deliver_from_wrapper(_message: &str, _fields: &[(&str, String)]) {}
99
100/// What:
101/// Record the words of a finished run from its captured output.
102/// Why:
103/// Most runs are captured as a `GitOutput`; this is `record_text` for them.
104///
105/// In TS you'd write (pseudocode):
106/// ```ts
107/// recordWords(['reset', newCommit, '--', path], output)
108/// ```
109pub fn record_words<S: AsRef<str>>(arguments: &[S], output: &GitOutput) {
110 record_streams(arguments, output.stdout.as_slice(), output.stderr.as_slice());
111}
112
113/// What:
114/// Record the words of a finished run from its two streams.
115/// Why:
116/// The auto-push's queries come back as two byte streams, not as a `GitOutput`.
117///
118/// In TS you'd write (pseudocode):
119/// ```ts
120/// recordWords(['merge', '--ff-only', oid], { stdout, stderr })
121/// ```
122pub fn record_streams<S: AsRef<str>>(arguments: &[S], stdout: &[u8], stderr: &[u8]) {
123 if let Some(text) = words_of(stdout, stderr) {
124 record_text(arguments, text.as_str());
125 }
126}
127
128/// Controls for what is recorded.
129#[cfg(test)]
130#[path = "inner_git_words_tests.rs"]
131mod tests;