Skip to main content

git_policy_cli/lock/
stale_ref_lock_message.rs

1//! What:
2//!  The words the wrapper prints about reference locks: what it removed, what it left and why, and the exact command that
3//!  clears a lock it will not remove.
4//! Why:
5//!  A lock that is not proven stale must never be removed, so the person is told what was found and given a command that
6//!  is safe to paste. Path names come from the file system, so the command quotes every byte a shell or a terminal could
7//!  read as something other than part of the name.
8//!
9//! In TS you'd write (pseudocode):
10//! ```ts
11//! process.stderr.write(outcome.notice()); // "cli-git: left the reference lock ... alone: ...\ncli-git:   rm -- '...'\n"
12//! ```
13
14use crate::iso_time::format_iso_milliseconds;
15use crate::stale_ref_lock_found::{Found, Outcome, PidFile};
16use crate::stale_ref_lock_judge::{Proof, Verdict};
17use std::os::unix::ffi::OsStrExt;
18use std::path::{Path, PathBuf};
19use std::time::{Duration, SystemTime};
20
21/// The start of every line the wrapper prints.
22const LINE_PREFIX: &str = "cli-git: ";
23
24/// Bytes a shell reads as part of a plain word.
25fn is_plain(byte: u8) -> bool {
26    return byte.is_ascii_alphanumeric() || b"_@%+=:,./-".contains(&byte);
27}
28
29/// A path as a shell word that stands for exactly that path.
30///  Plain names are written as they are; names with other printable characters go in single quotes, with each single
31///  quote written `'\''`; names with a control character or a byte that is not UTF-8 use the `$'...'` form, which shells
32///  of the Bash family read byte for byte and which keeps a terminal escape sequence from being printed raw.
33///
34/// In TS you'd write (pseudocode):
35/// ```ts
36/// function shellQuote(path: string): string;
37/// ```
38pub fn shell_quote(path: &Path) -> String {
39    let bytes: &[u8] = path.as_os_str().as_bytes();
40    if !bytes.is_empty() && bytes.iter().all(|byte| { return is_plain(*byte); }) {
41        return String::from_utf8_lossy(bytes).into_owned();
42    }
43    let printable: bool = std::str::from_utf8(bytes).is_ok_and(|text| { return text.chars().all(|character| { return !character.is_control(); }); });
44    if printable {
45        return format!("'{}'", String::from_utf8_lossy(bytes).replace('\'', "'\\''"));
46    }
47    let mut quoted: String = String::from("$'");
48    for byte in bytes {
49        match *byte {
50            b'\'' => quoted.push_str("\\'"),
51            b'\\' => quoted.push_str("\\\\"),
52            0x20..=0x7e => quoted.push(char::from(*byte)),
53            other => quoted.push_str(&format!("\\x{other:02x}")),
54        }
55    }
56    quoted.push('\'');
57    return quoted;
58}
59
60/// How long ago, in the largest whole unit.
61fn age_text(age: Duration) -> String {
62    let seconds: u64 = age.as_secs();
63    return match seconds {
64        0..=119 => format!("{seconds} seconds"),
65        120..=7199 => format!("{} minutes", seconds / 60),
66        7200..=172_799 => format!("{} hours", seconds / 3600),
67        _ => format!("{} days", seconds / 86_400),
68    };
69}
70
71/// When a lock was made, if known, and how long ago.
72fn born_text(born: Option<SystemTime>) -> String {
73    let Some(at) = born else {
74        return String::new();
75    };
76    let milliseconds: i64 = at.duration_since(SystemTime::UNIX_EPOCH).ok().and_then(|elapsed| { return i64::try_from(elapsed.as_millis()).ok(); }).unwrap_or(0);
77    let age: Duration = SystemTime::now().duration_since(at).unwrap_or_default();
78    return format!(", made {} ({} ago)", format_iso_milliseconds(milliseconds), age_text(age));
79}
80
81/// Why a lock was removed.
82fn proof_text(proof: &Proof) -> String {
83    return match proof {
84        Proof::PidGone(pid) => format!("process {pid}, named by the pid file beside it, has ended"),
85        Proof::PidReused { pid, command } => format!("process {pid}, named by the pid file beside it, has ended and the number now belongs to `{command}`"),
86        Proof::OrphanStaging => String::from("Git makes this staging file only while it holds `packed-refs.lock`, and that lock is gone"),
87    };
88}
89
90/// The paths a person clears by hand: the lock and what goes with it, quoted.
91fn clearing_words(found: &Found) -> String {
92    let mut paths: Vec<String> = vec![shell_quote(found.lock.as_path())];
93    paths.extend(found.companions.iter().map(|path| { return shell_quote(path.as_path()); }));
94    return paths.join(" ");
95}
96
97impl Outcome {
98    /// What the wrapper prints for this pass, as lines that each start with `cli-git: `; empty when there is nothing to say.
99    ///
100    /// In TS you'd write (pseudocode):
101    /// ```ts
102    /// outcome.notice(): string
103    /// ```
104    pub fn notice(&self) -> String {
105        let mut lines: Vec<String> = Vec::new();
106        for found in &self.cleared {
107            if let Verdict::Stale(proof) = &found.verdict {
108                let what: &str = if matches!(proof, Proof::OrphanStaging) { "the packed-references staging file" } else { "the reference lock" };
109                lines.push(format!("{LINE_PREFIX}removed {what} {}, left by a Git that is gone: {}.", shell_quote(found.lock.as_path()), proof_text(proof)));
110            }
111        }
112        let mut hint: bool = false;
113        for found in &self.left {
114            match &found.verdict {
115                Verdict::Held(text) => lines.push(format!("{LINE_PREFIX}the reference lock {} is in use: {text}; it was left alone.", shell_quote(found.lock.as_path()))),
116                Verdict::Unproven(reason) => {
117                    lines.push(format!("{LINE_PREFIX}left the reference lock {} alone: {reason}.", shell_quote(found.lock.as_path())));
118                    lines.push(format!("{LINE_PREFIX}it is {} bytes{}. If no Git process is running in this repository, clear it with:", found.size, born_text(found.born)));
119                    lines.push(format!("{LINE_PREFIX}  rm -- {}", clearing_words(found)));
120                    hint = hint || found.pid_file == PidFile::Absent;
121                }
122                Verdict::TooNew | Verdict::Stale(_) => {}
123            }
124        }
125        for path in &self.displaced {
126            lines.push(format!("{LINE_PREFIX}a lock file was set aside while a new lock took its place, and is left at {}; look at it before removing it.", shell_quote(path.as_path())));
127        }
128        if hint {
129            lines.push(format!("{LINE_PREFIX}Git writes a process number beside its locks only when `core.lockfilePid` is on. The wrapper turns it on for the Git it starts; `git config --global core.lockfilePid true` does it for the rest, so the next lock can be proven."));
130        }
131        if lines.is_empty() {
132            return String::new();
133        }
134        return format!("{}\n", lines.join("\n"));
135    }
136}
137
138/// The paths of a list, for a log line.
139pub fn path_list(paths: &[PathBuf]) -> String {
140    return paths.iter().map(|path| { return shell_quote(path.as_path()); }).collect::<Vec<String>>().join(" ");
141}
142
143/// Controls for the notices and the quoting are in the test module.
144#[cfg(test)]
145#[path = "stale_ref_lock_message_tests.rs"]
146mod tests;