1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
//! What:
//! The lock files a stopped Git child leaves behind, found and removed once the child and what it started are gone.
//! Why:
//! Git removes its `.lock` files when it ends normally or on a catchable signal, but a child that is killed, or that is
//! in an uninterruptible call when the stop arrives, cannot. On 2026-11-03 two pushes were stopped at their deadline;
//! about 37 seconds later, in the same instant, empty `refs/remotes/origin/*.lock` files appeared for exactly their two
//! tracking refs, with no pid file beside them, and every later `git fetch` of those refs failed with "Unable to create
//! ... File exists". The service had started those pushes without `core.lockfilePid`, so no pid file was written. That
//! the creators were the stopped pushes, released by the kernel late, is the inference that fits these facts; nothing
//! observed the processes themselves.
//! Two kinds of file are removed, and nothing else:
//! a lock whose `~pid.lock` file (written by `core.lockfilePid`, which the wrapper turns on for every Git it starts)
//! names a process of the stopped tree that has ended, with that pid file;
//! an empty lock with no pid file, born after the child started and still empty after a settle time, because the
//! child can die between creating the lock and writing the pid file, and an empty file holds nothing.
//! A lock another Git process holds is never matched: its pid file names a process outside the stopped tree, or it
//! has content, or it is older than the child. No lock of the wrapper's own is taken to do this.
//!
//! In TS you'd write (pseudocode):
//! ```ts
//! const ledger = LockLedger.beforeStart(command); const tree = await stop(child);
//! for (const lock of ledger.sweep(tree)) log(`removed ${lock}`);
//! ```
/// The diagnostic log of the wrapper.
use super::diagnostic_log::warn;
/// The processes of the stopped tree and whether each still runs.
use super::process_tree::{TreeMember, still_running};
/// The Git directories a command works in.
use super::git_directory_roots::roots_of_command;
/// Owned OS strings, for the names of directories the walk skips.
use std::ffi::OsStr;
/// Paths of the repository.
use std::path::{Path, PathBuf};
/// The command whose children are stopped.
use std::process::Command;
/// Time spans and the wall clock the kernel stamps files with.
use std::time::{Duration, SystemTime};
/// Tag used for diagnostics from this module.
const TAG: &str = "cli-git";
/// What every Git lock file's name ends with.
pub(crate) const LOCK_SUFFIX: &str = ".lock";
/// What the pid file of a lock ends with: the lock's name without `.lock`, then this.
pub(crate) const PID_LOCK_SUFFIX: &str = "~pid.lock";
/// What a pid file's text starts with, before the number.
const PID_TEXT_PREFIX: &str = "pid ";
/// How far a file's birth may precede the clock reading taken before the child started.
/// The kernel stamps files from a coarse clock that lags the one `SystemTime` reads by up to one tick.
pub(crate) const COARSE_CLOCK_SLACK: Duration = Duration::from_millis(50);
/// How long an empty lock without a pid file must have stayed empty before it counts as left behind.
/// Git creates a ref lock empty and fills it a moment later, so a lock that a live process just made is empty for
/// milliseconds; one that is still empty after this long has no writer. The sweep waits out what remains of it.
const EMPTY_LOCK_SETTLE: Duration = Duration::from_secs(1);
/// The directory inside `objects` that Git locks files in; the other directories there hold only loose objects.
const SEARCHED_OBJECT_DIRECTORIES: [&str; 2] = ["info", "pack"];
/// Directories that never hold a Git lock file and can hold very many files.
const SKIPPED_DIRECTORIES: [&str; 1] = ["hooks"];
/// What:
/// Where a command's Git child can leave lock files, and when the child started.
/// Why:
/// Taken before the child starts, so a lock born earlier is never taken for the child's.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// type LockLedger = { roots: string[]; since: Date };
/// ```
#[derive(Clone, Debug)]
pub struct LockLedger {
/// The Git directory and the common Git directory of the repository the command runs in; empty when none is found.
roots: Vec<PathBuf>,
/// The wall-clock time read before the child started.
since: SystemTime,
/// How long an empty lock without a pid file must have stayed empty before it is removed.
settle: Duration,
}
impl LockLedger {
/// A ledger that finds nothing: for a child that holds no Git lock, such as a reader of objects.
pub fn none() -> Self {
return Self { roots: Vec::new(), since: SystemTime::now(), settle: EMPTY_LOCK_SETTLE };
}
/// The ledger of a command that has not started yet: its repository comes from `GIT_DIR` in its environment, or from
/// its working directory (the process's own when the command sets none).
pub fn before_start(command: &Command) -> Self {
let since: SystemTime = SystemTime::now();
return Self { roots: roots_of_command(command), since, settle: EMPTY_LOCK_SETTLE };
}
/// When a file was born; a file system that keeps no birth time still stamps a never-written file with its creation time.
fn born_at(path: &Path) -> Option<SystemTime> {
let metadata: std::fs::Metadata = std::fs::symlink_metadata(path).ok()?;
return metadata.created().or_else(|_| { return metadata.modified(); }).ok();
}
/// Whether a file was born at or after the child started.
fn born_since(&self, path: &Path) -> bool {
return Self::born_at(path).is_some_and(|born| { return born + COARSE_CLOCK_SLACK >= self.since; });
}
/// Whether `path` is a regular file with nothing in it.
fn is_empty_file(path: &Path) -> bool {
return std::fs::symlink_metadata(path).is_ok_and(|metadata| { return metadata.is_file() && metadata.len() == 0; });
}
/// The same ledger with another settle time, so a test does not wait out the real one.
#[cfg(test)]
pub fn with_settle(mut self, settle: Duration) -> Self {
self.settle = settle;
return self;
}
/// Whether an empty lock without a pid file is the stopped child's: born after it started, and still empty after the
/// settle time, which this waits out.
fn left_empty(&self, path: &Path) -> bool {
if !self.born_since(path) || !Self::is_empty_file(path) {
return false;
}
let age: Duration = Self::born_at(path).and_then(|born| { return born.elapsed().ok(); }).unwrap_or_default();
if age < self.settle {
std::thread::sleep(self.settle - age);
}
return Self::is_empty_file(path);
}
/// What:
/// Remove the locks the stopped tree left, and return the paths removed.
/// Why:
/// Called once the child is gone. A pid file counts only when the process it names belongs to `tree` and has ended;
/// an empty lock without a pid file counts only when the whole tree has ended.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// function sweep(tree: TreeMember[]): string[];
/// ```
pub fn sweep(&self, tree: &[TreeMember]) -> Vec<PathBuf> {
let tree_ended: bool = !tree.is_empty() && tree.iter().all(|member| { return !still_running(member); });
let mut removed: Vec<PathBuf> = Vec::new();
for path in lock_files(self.roots.as_slice()) {
let name: String = path.file_name().map(|name| { return name.to_string_lossy().into_owned(); }).unwrap_or_default();
if let Some(stem) = name.strip_suffix(PID_LOCK_SUFFIX) {
let Some(pid) = pid_named_by(path.as_path()) else {
continue;
};
let Some(member) = tree.iter().find(|member| { return member.pid == pid; }) else {
continue;
};
if still_running(member) || !self.born_since(path.as_path()) {
continue;
}
let lock: PathBuf = path.with_file_name(format!("{stem}{LOCK_SUFFIX}"));
remove_recording(lock.as_path(), &mut removed);
remove_recording(path.as_path(), &mut removed);
} else if tree_ended
&& !path.with_file_name(format!("{}{PID_LOCK_SUFFIX}", name.trim_end_matches(LOCK_SUFFIX))).exists()
&& self.left_empty(path.as_path())
{
remove_recording(path.as_path(), &mut removed);
}
}
return removed;
}
}
/// The process number a pid file names, when its text is `pid <number>`.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// Number(readFileSync(path, 'utf8').trim().replace(/^pid /, ''))
/// ```
pub(crate) fn pid_named_by(path: &Path) -> Option<i32> {
let text: String = std::fs::read_to_string(path).ok()?;
return text.trim().strip_prefix(PID_TEXT_PREFIX)?.trim().parse::<i32>().ok();
}
/// Remove one file and record it when it was removed; a file that is already gone is not an error.
fn remove_recording(path: &Path, removed: &mut Vec<PathBuf>) {
match std::fs::remove_file(path) {
Ok(()) => {
warn(TAG, &format!("removed {}, a lock file the stopped Git process left behind", path.display()));
removed.push(path.to_path_buf());
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => {
warn(TAG, &format!("could not remove {}, a lock file the stopped Git process left behind: {error}", path.display()));
}
}
}
/// Every regular file below `roots` whose name ends in `.lock`, in one pass over the directories that can hold one.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// walk(roots, { skip: ['hooks', 'objects/xx'] }).filter((path) => path.endsWith('.lock'))
/// ```
fn lock_files(roots: &[PathBuf]) -> Vec<PathBuf> {
let mut found: Vec<PathBuf> = Vec::new();
let mut pending: Vec<PathBuf> = roots.to_vec();
while let Some(directory) = pending.pop() {
let Ok(entries) = std::fs::read_dir(directory.as_path()) else {
continue;
};
let in_objects: bool =
directory.file_name() == Some(OsStr::new("objects")) && directory.parent().is_some_and(|parent| { return roots.iter().any(|root| { return root == parent; }); });
for entry in entries.flatten() {
let Ok(kind) = entry.file_type() else {
continue;
};
let name: String = entry.file_name().to_string_lossy().into_owned();
if kind.is_dir() {
let searched: bool = if in_objects { SEARCHED_OBJECT_DIRECTORIES.contains(&name.as_str()) } else { !SKIPPED_DIRECTORIES.contains(&name.as_str()) };
if searched {
pending.push(entry.path());
}
} else if kind.is_file() && name.ends_with(LOCK_SUFFIX) {
found.push(entry.path());
}
}
}
found.sort();
found.dedup();
return found;
}
/// Lock-sweep behavior is controlled in the test module.
#[cfg(test)]
#[path = "stopped_git_locks_tests.rs"]
mod tests;