git_policy_cli/lock/stopped_git_locks.rs
1//! What:
2//! The reference locks a stopped Git child may have left behind, judged and reported once the child and what it started
3//! are gone.
4//! Why:
5//! Git removes its `.lock` files when it ends normally or on a catchable signal, but a child that is killed, or that is
6//! in an uninterruptible call when the stop arrives, cannot. On 2026-11-03 two pushes were stopped at their deadline;
7//! about 37 seconds later, in the same instant, empty `refs/remotes/origin/*.lock` files appeared for exactly their two
8//! tracking refs, with no pid file beside them, and every later `git fetch` of those refs failed with "Unable to create
9//! ... File exists". The service had started those pushes without `core.lockfilePid`, so no pid file was written. That
10//! the creators were the stopped pushes, released by the kernel late, is the inference that fits these facts; nothing
11//! observed the processes themselves.
12//! Releases 0.5.23 to 0.5.26 removed two kinds of file after the stop: a lock whose pid file named an ended member of the
13//! stopped tree, and an empty lock without a pid file that was born after the child started and stayed empty for a settle
14//! time. A cross-vendor review of 2026-11-03T11:33:50-05:00 (finding S-1) showed that neither is an ownership proof: a
15//! live writer of another Git that does not write pid files holds its lock empty with no descriptor open, for as long as
16//! its transaction lasts, and a pid file older than a lock names an earlier holder, so both rules could delete the lock
17//! of a Git that is running, and two writers could then update one reference. The sweep removes nothing on those rules.
18//! It hands the repository to the same judgement that runs before every fetch and push (`stale_ref_locks.rs`), which
19//! removes a reference lock only on a proof and through a conditional removal, and it says what it found and which
20//! command clears a lock it will not remove. The judgement is given one proof of its own, which a container's view of the
21//! machine cannot give it: the numbers of the processes of the stopped tree that have ended. A pid file born at or after a
22//! lock that was itself born after the child started, naming one of them, names a process the wrapper started and saw end, in
23//! its own namespace (the container gate found the sweep leaving its own child's locks without it).
24//! Locks of other protocols (the index, the shallow file, object and pack locks) are not touched or mentioned.
25//!
26//! In TS you'd write (pseudocode):
27//! ```ts
28//! const ledger = LockLedger.beforeStart(command); const tree = await stop(child);
29//! for (const lock of ledger.sweep(tree)) log(`removed ${lock}`);
30//! ```
31
32/// The diagnostic log of the wrapper.
33use crate::diagnostic_log::warn;
34/// The Git directories a command works in.
35use crate::git_directory_roots::roots_of_command;
36/// How reference locks are judged, and what the judge knows of the wrapper's own stopped child.
37use crate::stale_ref_lock_judge::{Judge, OwnEnded};
38/// The processes of the stopped tree and whether each still runs.
39use crate::process_tree::{TreeMember, still_running};
40/// The judgement and conditional removal of the reference locks below some Git directories.
41use crate::stale_ref_locks::clear_stale_ref_locks;
42/// Paths of the repository.
43use std::path::{Path, PathBuf};
44/// The command whose children are stopped.
45use std::process::Command;
46/// The wall clock the kernel stamps files with.
47use std::time::SystemTime;
48
49/// Tag used for diagnostics from this module.
50const TAG: &str = "cli-git";
51
52/// What every Git lock file's name ends with.
53pub(crate) const LOCK_SUFFIX: &str = ".lock";
54
55/// What the pid file of a lock ends with: the lock's name without `.lock`, then this.
56pub(crate) const PID_LOCK_SUFFIX: &str = "~pid.lock";
57
58/// What a pid file's text starts with, before the number.
59const PID_TEXT_PREFIX: &str = "pid ";
60
61/// What:
62/// Where a command's Git child can leave reference locks.
63/// Why:
64/// The sweep after a stop judges the locks of this repository, the one the child worked in; the time taken before the
65/// child started tells a lock born in its life from an older one.
66///
67/// In TS you'd write (pseudocode):
68/// ```ts
69/// type LockLedger = { roots: string[]; since: Date; judge?: Judge };
70/// ```
71#[derive(Clone, Debug)]
72pub struct LockLedger {
73 /// The Git directory and the common Git directory of the repository the command runs in; empty when none is found.
74 roots: Vec<PathBuf>,
75 /// The wall-clock time read before the child started.
76 since: SystemTime,
77 /// The judge of this process, unless a control sets another.
78 judge: Option<Judge>,
79}
80
81impl LockLedger {
82 /// A ledger that finds nothing: for a child that holds no Git lock, such as a reader of objects.
83 pub fn none() -> Self {
84 return Self { roots: Vec::new(), since: SystemTime::now(), judge: None };
85 }
86
87 /// The ledger of a command that has not started yet: its repository comes from `GIT_DIR` in its environment, or from
88 /// its working directory (the process's own when the command sets none).
89 pub fn before_start(command: &Command) -> Self {
90 let since: SystemTime = SystemTime::now();
91 return Self { roots: roots_of_command(command), since, judge: None };
92 }
93
94 /// The same ledger with another judge, so a control does not wait out the margin of a real one.
95 #[cfg(test)]
96 pub fn with_judge(mut self, judge: Judge) -> Self {
97 self.judge = Some(judge);
98 return self;
99 }
100
101 /// What:
102 /// Judge the reference locks of the repository once the child is gone, remove the ones whose holder is proven gone,
103 /// say what was found about the rest, and return the paths removed.
104 /// Why:
105 /// Nothing ties a lock to the stopped child except a proof about its holder: the stopped child's tree, a time of
106 /// birth and an empty file all fit a live writer of another Git as well. The judgement is the one that runs before
107 /// every fetch and push, so the proof, the notice and the conditional removal are the same everywhere.
108 ///
109 /// In TS you'd write (pseudocode):
110 /// ```ts
111 /// function sweep(tree: TreeMember[]): string[];
112 /// ```
113 pub fn sweep(&self, tree: &[TreeMember]) -> Vec<PathBuf> {
114 let mut judge: Judge = self.judge.clone().unwrap_or_else(Judge::here);
115 // The one proof that is the wrapper's own: the numbers of the stopped tree's processes that have ended.
116 let pids: Vec<i64> = tree.iter().filter(|member| { return !still_running(member); }).map(|member| { return i64::from(member.pid); }).collect();
117 judge.own_ended = Some(OwnEnded { pids, since: self.since });
118 let outcome = clear_stale_ref_locks(self.roots.as_slice(), &judge);
119 let notice: String = outcome.notice();
120 if !notice.is_empty() {
121 warn(TAG, notice.trim_end());
122 }
123 return outcome.cleared.iter().map(|found| { return found.lock.clone(); }).collect();
124 }
125}
126
127/// The process number a pid file names, when its text is `pid <number>`.
128///
129/// In TS you'd write (pseudocode):
130/// ```ts
131/// Number(readFileSync(path, 'utf8').trim().replace(/^pid /, ''))
132/// ```
133pub(crate) fn pid_named_by(path: &Path) -> Option<i32> {
134 let text: String = std::fs::read_to_string(path).ok()?;
135 return text.trim().strip_prefix(PID_TEXT_PREFIX)?.trim().parse::<i32>().ok();
136}
137
138/// Lock-sweep behavior is controlled in the test module.
139#[cfg(test)]
140#[path = "stopped_git_locks_tests.rs"]
141mod tests;