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
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
use std::fs::{self, File};
use std::io::{self, Read, Write};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use super::{LockError, MaintenanceLock};
use crate::Repository;
/// Ownership of `gc.pid`, separate from the maintenance task-runner lock.
///
/// Hold this guard while running explicit gc and retain caller-owned
/// [`crate::retention::MaintenanceIsolation`] for destructive phases. Drop removes only the owned
/// pid file on a best-effort basis; [`Self::release`] reports cleanup failures. A crash can leave
/// a file requiring manual inspection. Girt never steals stale or malformed pid files.
#[derive(Debug)]
#[must_use = "dropping the guard releases the gc pid file"]
pub struct GcLock(MaintenanceLock);
impl GcLock {
/// Removes the owned pid file, preserving a replaced path.
///
/// # Errors
///
/// Returns ownership-check or cleanup failures from [`MaintenanceLock::release`].
pub fn release(self) -> Result<(), LockError> {
self.0.release()
}
}
/// A previous gc diagnostic that may suppress an automatic detached run.
#[derive(Clone, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum GcLog {
/// No nonempty recent log was found, or `gc.autoDetach=false` bypassed the log check.
NotBlocking,
/// A nonempty log newer than `gc.logExpiry` exists. Preserve it and report its raw bytes.
/// The caller should fix its cause before choosing to remove it and retry.
Suppressed(Vec<u8>),
}
/// Why gc coordination or log observation could not complete.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum GcCoordinationError {
/// A pid-file writer or existing gc owner prevented acquisition.
#[error(transparent)]
Lock(#[from] LockError),
/// The supplied host is empty, longer than 255 bytes, non-ASCII or contains whitespace.
#[error("gc hostname must be a nonempty ASCII name without whitespace (at most 255 bytes)")]
Hostname,
/// A pid/log file could not be created, published, read or inspected.
#[error("cannot coordinate gc")]
Io(#[from] io::Error),
/// A consulted gc configuration value is malformed or uses an unsupported date expression.
#[error("invalid gc coordination configuration")]
Config(#[from] crate::config::InvalidValue),
/// The supplied clock or expiry cutoff cannot be represented.
#[error("gc log expiry clock cannot be represented")]
Clock,
/// A recent log exceeded the caller's byte budget; its contents remain untouched.
#[error("gc log byte limit exceeded")]
LogLimit,
}
impl Repository {
/// Publishes this process's pid and the supplied local hostname under Git's gc writer lock.
///
/// Supply the actual local hostname used by Git so it can check the pid's liveness. Girt
/// never discovers the hostname by spawning a process. Publication holds `gc.pid.lock` and
/// installs complete `pid hostname` bytes without replacing any existing `gc.pid`. Existing
/// files, including stale/malformed files, return [`LockError::Busy`]; inspect them manually.
/// Git may reclaim sufficiently old pid files, so the caller must keep this work bounded and
/// exclude non-cooperating writers. The lock alone never establishes reader isolation.
///
/// # Errors
///
/// Returns [`GcCoordinationError`] for invalid hostname, contention, or filesystem failure.
/// A failure after publication can leave `gc.pid` requiring manual inspection. No object,
/// reference, pack or reflog changes occur.
///
/// ```
/// use girt::{InitKind, ObjectFormat, Repository};
/// let directory = tempfile::tempdir()?;
/// let repo = Repository::init(
/// ObjectFormat::Sha1,
/// directory.path().join("r"),
/// InitKind::Bare,
/// )?;
/// // The fixture never runs Git concurrently. A real caller supplies its local hostname.
/// let guard = repo.try_lock_gc("fixture-host")?;
/// assert!(repo.common_dir().join("gc.pid").is_file());
/// guard.release()?;
/// assert!(!repo.common_dir().join("gc.pid").exists());
/// # Ok::<(), Box<dyn std::error::Error>>(())
/// ```
pub fn try_lock_gc(&self, hostname: &str) -> Result<GcLock, GcCoordinationError> {
if hostname.is_empty()
|| hostname.len() > 255
|| !hostname.is_ascii()
|| hostname
.bytes()
.any(|byte| byte.is_ascii_whitespace() || byte == 0)
{
return Err(GcCoordinationError::Hostname);
}
let _operation = crate::locks::operation();
let _writer = MaintenanceLock::acquire(self.common_dir().join("gc.pid.lock"))?;
let path = self.common_dir().join("gc.pid");
match fs::symlink_metadata(&path) {
Ok(_) => return Err(LockError::Busy.into()),
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => return Err(error.into()),
}
let mut builder = tempfile::Builder::new();
builder.prefix(".girt-gc-");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
// Use ordinary lock-file permissions, restricted by the process umask, so another
// user in a shared repository can read the pid rather than ignore an unreadable lock.
builder.permissions(fs::Permissions::from_mode(0o666));
}
let mut temporary = builder.tempfile_in(self.common_dir())?;
writeln!(temporary, "{} {hostname}", std::process::id())?;
temporary.as_file().sync_all()?;
let file = temporary.persist_noclobber(&path).map_err(|error| {
if error.error.kind() == io::ErrorKind::AlreadyExists {
GcCoordinationError::Lock(LockError::Busy)
} else {
GcCoordinationError::Io(error.error)
}
})?;
Ok(GcLock(MaintenanceLock::from_file(path, file)?))
}
/// Reads a previous gc log with a caller byte budget, without changing it.
///
/// As observed with Git 2.56, `gc.autoDetach=false` bypasses this suppression check. Otherwise
/// a nonempty `gc.log` newer than `gc.logExpiry` (one day by default) suppresses an automatic
/// detached run. Empty or expired logs do not block. `never` keeps a nonempty log blocking
/// regardless of age; `now`/`all` disable age-based suppression. Date syntax is the supported
/// subset documented by [`Self::configured_gc_policy`]. Nothing is detached or printed.
///
/// This observation can become stale. Execution must recheck it under gc coordination.
/// Errors and suppression do not authorize deleting the log; retain its diagnostic bytes.
///
/// # Errors
///
/// Returns [`GcCoordinationError`] for invalid config/clock, filesystem errors or a recent
/// log exceeding `max_bytes`. Expired logs are not read into memory.
///
/// ```
/// use std::time::SystemTime;
///
/// use girt::maintenance::GcLog;
/// use girt::{InitKind, ObjectFormat, Repository};
/// let directory = tempfile::tempdir()?;
/// let repo = Repository::init(
/// ObjectFormat::Sha1,
/// directory.path().join("r"),
/// InitKind::Bare,
/// )?;
/// assert_eq!(
/// repo.check_auto_gc_log(SystemTime::now(), 4096)?,
/// GcLog::NotBlocking
/// );
/// # Ok::<(), Box<dyn std::error::Error>>(())
/// ```
pub fn check_auto_gc_log(
&self,
now: SystemTime,
max_bytes: usize,
) -> Result<GcLog, GcCoordinationError> {
if !self
.config()
.boolean("gc", None, "autoDetach")?
.unwrap_or(true)
{
return Ok(GcLog::NotBlocking);
}
let seconds = now
.duration_since(UNIX_EPOCH)
.map_err(|_| GcCoordinationError::Clock)?
.as_secs();
let value = match self.config().value("gc", None, "logExpiry") {
None => Some(&b"1 day ago"[..]),
Some(value) => value,
};
let cutoff = value
.and_then(|value| crate::retention::parse_expiry_date(value, i128::from(seconds)))
.ok_or_else(|| crate::config::InvalidValue::new("gc", "logExpiry", value))?;
let path = self.common_dir().join("gc.log");
let file = match File::open(&path) {
Ok(file) => file,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(GcLog::NotBlocking),
Err(error) => return Err(error.into()),
};
let metadata = file.metadata()?;
if metadata.len() == 0 || cutoff == Some(i128::from(u64::MAX)) {
return Ok(GcLog::NotBlocking);
}
if let Some(cutoff) = cutoff {
let cutoff = u64::try_from(cutoff).map_err(|_| GcCoordinationError::Clock)?;
let cutoff = UNIX_EPOCH
.checked_add(Duration::from_secs(cutoff))
.ok_or(GcCoordinationError::Clock)?;
if metadata.modified()? <= cutoff {
return Ok(GcLog::NotBlocking);
}
}
if metadata.len() > max_bytes as u64 {
return Err(GcCoordinationError::LogLimit);
}
let mut bytes = Vec::new();
file.take((max_bytes as u64).saturating_add(1))
.read_to_end(&mut bytes)?;
if bytes.len() > max_bytes {
return Err(GcCoordinationError::LogLimit);
}
Ok(if bytes.is_empty() {
GcLog::NotBlocking
} else {
GcLog::Suppressed(bytes)
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{InitKind, ObjectFormat};
fn fixture(config: &str) -> (tempfile::TempDir, Repository) {
let root = tempfile::tempdir().unwrap();
let repo =
Repository::init(ObjectFormat::Sha1, root.path().join("r"), InitKind::Bare).unwrap();
let path = repo.common_dir().join("config");
let mut bytes = fs::read(&path).unwrap();
bytes.extend_from_slice(config.as_bytes());
fs::write(path, bytes).unwrap();
let repo = Repository::open(repo.git_dir()).unwrap();
(root, repo)
}
#[test]
fn pid_is_complete_and_other_owners_are_preserved() {
let (_root, repo) = fixture("");
let guard = repo.try_lock_gc("fixture-host").unwrap();
let pid = repo.common_dir().join("gc.pid");
assert_eq!(
fs::read_to_string(&pid).unwrap(),
format!("{} fixture-host\n", std::process::id())
);
assert!(!repo.common_dir().join("gc.pid.lock").exists());
assert!(matches!(
repo.try_lock_gc("fixture-host"),
Err(GcCoordinationError::Lock(LockError::Busy))
));
guard.release().unwrap();
assert!(!pid.exists());
fs::write(&pid, b"stale or malformed owner").unwrap();
assert!(matches!(
repo.try_lock_gc("fixture-host"),
Err(GcCoordinationError::Lock(LockError::Busy))
));
assert_eq!(fs::read(&pid).unwrap(), b"stale or malformed owner");
}
#[cfg(unix)]
#[test]
fn pid_permissions_match_ordinary_lock_files() {
use std::os::unix::fs::PermissionsExt;
let (_root, repo) = fixture("");
let sibling = repo.common_dir().join("sibling.lock");
let file = crate::locks::lock_options().open(&sibling).unwrap();
let expected = file.metadata().unwrap().permissions().mode() & 0o777;
let _guard = repo.try_lock_gc("fixture-host").unwrap();
let actual = fs::metadata(repo.common_dir().join("gc.pid"))
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(actual, expected);
}
#[test]
fn invalid_hostname_does_not_create_locks() {
let (_root, repo) = fixture("");
for hostname in ["", "two hosts", "a\nb", "a\0b", "nonascii-é"] {
assert!(matches!(
repo.try_lock_gc(hostname),
Err(GcCoordinationError::Hostname)
));
}
assert!(!repo.common_dir().join("gc.pid.lock").exists());
assert!(!repo.common_dir().join("gc.pid").exists());
}
#[test]
fn recent_logs_are_bounded_preserved_and_expire() {
let (_root, repo) = fixture("");
let now = UNIX_EPOCH + Duration::from_secs(1_800_000_000);
let path = repo.common_dir().join("gc.log");
assert_eq!(repo.check_auto_gc_log(now, 10).unwrap(), GcLog::NotBlocking);
fs::write(&path, b"failure").unwrap();
let file = File::options().read(true).write(true).open(&path).unwrap();
file.set_modified(now).unwrap();
assert_eq!(
repo.check_auto_gc_log(now, 7).unwrap(),
GcLog::Suppressed(b"failure".to_vec())
);
assert!(matches!(
repo.check_auto_gc_log(now, 6),
Err(GcCoordinationError::LogLimit)
));
assert_eq!(fs::read(&path).unwrap(), b"failure");
file.set_modified(now - Duration::from_secs(2 * 86400))
.unwrap();
assert_eq!(repo.check_auto_gc_log(now, 0).unwrap(), GcLog::NotBlocking);
}
#[test]
fn foreground_bypasses_log_suppression() {
let (_root, repo) = fixture("\n[gc]\nautoDetach=false\n");
fs::write(repo.common_dir().join("gc.log"), b"failure").unwrap();
assert_eq!(
repo.check_auto_gc_log(SystemTime::now(), 0).unwrap(),
GcLog::NotBlocking
);
}
#[test]
fn git_respects_published_pid_then_runs_after_release() {
let (root, repo) = fixture("\n[gc]\nauto=256\nautoDetach=false\n");
let sample = repo.object_dir().join("17");
fs::create_dir(&sample).unwrap();
for number in 1..=2 {
fs::write(
sample.join(format!("{number:038x}")),
b"invalid selection fixture",
)
.unwrap();
}
let mut guard = Some(repo.try_lock_gc("girt-fixture-host").unwrap());
for held in [true, false] {
let mut command = std::process::Command::new("git");
for (key, _) in std::env::vars_os() {
if key.to_string_lossy().starts_with("GIT_") {
command.env_remove(key);
}
}
let output = command
.current_dir(repo.git_dir())
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_CONFIG_GLOBAL", root.path().join("absent-config"))
.env("GIT_AUTHOR_NAME", "Maintenance Fixture")
.env("GIT_AUTHOR_EMAIL", "maintenance@example.com")
.env("GIT_COMMITTER_NAME", "Maintenance Fixture")
.env("GIT_COMMITTER_EMAIL", "maintenance@example.com")
.env("GIT_AUTHOR_DATE", "@1700000000 +0000")
.env("GIT_COMMITTER_DATE", "@1700000000 +0000")
.args(["-c", "commit.gpgsign=false", "gc", "--auto"])
.output()
.unwrap();
assert_eq!(output.status.success(), held, "{output:?}");
if held {
drop(guard.take());
}
}
}
}