Skip to main content

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;