git_policy_cli/lock/
stale_ref_lock_message.rs1use 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
21const LINE_PREFIX: &str = "cli-git: ";
23
24fn is_plain(byte: u8) -> bool {
26 return byte.is_ascii_alphanumeric() || b"_@%+=:,./-".contains(&byte);
27}
28
29pub 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
60fn 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
71fn 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
81fn 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
90fn 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 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
138pub 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#[cfg(test)]
145#[path = "stale_ref_lock_message_tests.rs"]
146mod tests;