git_policy_cli/subvolume/subvolume_index_files.rs
1//! What:
2//! The linked worktrees of a main checkout, found from `.git/worktrees/*/gitdir`, and the stat rewrite applied to the
3//! index files of the checkout and of those worktrees on disk.
4//! Why:
5//! The subvolume migration copies the main checkout and the linked worktrees inside it, so each of their indexes needs
6//! the rewrite of `subvolume_index.rs`. A file in `.git/worktrees/<name>/gitdir` holds the path of the worktree's
7//! `.git` file, so the worktree is its parent directory (the same reading the migration script used). An index is
8//! replaced through `index.lock` as Git replaces it, so a running Git command is never raced: the lock is taken
9//! before the index is read, and when it exists the index is left as copied and the report says so. The migration
10//! holds the locks itself while it completes the new checkout, and its rewrite then runs under them.
11//!
12//! Why the lock stays:
13//! `index.lock` is Git's protocol for replacing an index; the rewrite reads the index, computes new stat fields and renames
14//! a file over it, which is lost if a Git writer replaces the index in between, and only Git's own lock excludes Git. The
15//! rewrite takes it with `O_EXCL`, never waits (a lock that exists skips the index and says so), holds it for one index and
16//! only from the read to the rename, and gives it back as Git does. Concurrency among this wrapper's own commands never
17//! reaches this file: `single_owner_claim.rs` gives one command a checkout or a worktree without a lock.
18//!
19//! In TS you'd write (pseudocode):
20//! ```ts
21//! for (const { adminDirectory, worktree } of linkedWorktrees(root)) refreshIndexFile(join(adminDirectory, 'index'), worktree);
22//! ```
23
24use crate::parallel_budget::{BudgetRequest, HostResources, worker_budget};
25use crate::parallel_runner::{Event, OrderedRun, run_ordered};
26use crate::subvolume_index::{IndexOutcome, RewriteContext, StatFields, StoredStat, rewrite_index_stat_with};
27use crate::subvolume_lock::is_ours;
28use std::ffi::OsStr;
29use std::io::Write;
30use std::os::unix::ffi::OsStrExt;
31use std::os::unix::fs::MetadataExt;
32use std::path::{Path, PathBuf};
33
34/// What:
35/// One linked worktree of the checkout.
36/// Why:
37/// The index lives in the administrative directory; the files it describes live in the worktree.
38///
39/// In TS you'd write (pseudocode):
40/// ```ts
41/// type LinkedWorktree = { adminDirectory: string; worktree: string };
42/// ```
43#[derive(Clone, Debug, Eq, PartialEq)]
44pub struct LinkedWorktree {
45 /// `<root>/.git/worktrees/<name>`.
46 pub admin_directory: PathBuf,
47 /// The directory the worktree's files are in.
48 pub worktree: PathBuf,
49}
50
51/// What:
52/// The linked worktrees of `root` whose directory is inside `root` and exists.
53/// Why:
54/// Only worktrees inside the checkout are copied with it. An administrative directory without a readable `gitdir`
55/// file belongs to a worktree Git would prune, so it is skipped.
56///
57/// In TS you'd write (pseudocode):
58/// ```ts
59/// function linkedWorktreesInside(root: string): LinkedWorktree[];
60/// ```
61pub fn linked_worktrees_inside(root: &Path) -> Vec<LinkedWorktree> {
62 let admin: PathBuf = root.join(".git").join("worktrees");
63 let Ok(names) = std::fs::read_dir(&admin) else { return Vec::new() };
64 let mut found: Vec<LinkedWorktree> = Vec::new();
65 for name in names.flatten() {
66 let Ok(recorded) = std::fs::read(name.path().join("gitdir")) else { continue };
67 // The recorded path is raw bytes: a directory named with bytes that are not UTF-8 is a valid path on Linux.
68 let Some(worktree) = Path::new(OsStr::from_bytes(recorded.trim_ascii())).parent() else { continue };
69 if worktree.starts_with(root) && worktree != root && worktree.is_dir() {
70 found.push(LinkedWorktree { admin_directory: name.path(), worktree: worktree.to_path_buf() });
71 }
72 }
73 found.sort_by(|left: &LinkedWorktree, right: &LinkedWorktree| { return left.worktree.cmp(&right.worktree); });
74 return found;
75}
76
77/// What:
78/// What `lstat` of `tree/path` reports now, in index units, or nothing for a file that cannot be examined.
79/// Why:
80/// The rewrite leaves an entry as Git recorded it when its file is missing.
81///
82/// In TS you'd write (pseudocode):
83/// ```ts
84/// function statFields(tree: string, path: Uint8Array): StatFields | undefined;
85/// ```
86pub(crate) fn stat_fields(tree: &Path, path: &[u8]) -> Option<StatFields> {
87 let metadata: std::fs::Metadata = std::fs::symlink_metadata(tree.join(OsStr::from_bytes(path))).ok()?;
88 return Some(fields_of(&metadata));
89}
90
91/// What:
92/// The stat fields of metadata in hand, in index units.
93/// Why:
94/// A caller that holds the object it made records the fields of that object, not of whatever its path names later.
95///
96/// In TS you'd write (pseudocode):
97/// ```ts
98/// function fieldsOf(stats: Stats): StatFields;
99/// ```
100pub(crate) fn fields_of(metadata: &std::fs::Metadata) -> StatFields {
101 return StatFields {
102 ctime_seconds: metadata.ctime() as u32,
103 ctime_nanos: metadata.ctime_nsec() as u32,
104 mtime_seconds: metadata.mtime() as u32,
105 mtime_nanos: metadata.mtime_nsec() as u32,
106 dev: metadata.dev() as u32,
107 ino: metadata.ino() as u32,
108 uid: metadata.uid(),
109 gid: metadata.gid(),
110 size: metadata.size() as u32,
111 };
112}
113
114/// What:
115/// How the rewrite of one index file is protected from a Git command that writes the same index.
116/// `&'a str` borrows the token of the migration whose lock is already held.
117/// Why:
118/// Git replaces an index through `index.lock`. A rewrite that runs by itself takes that lock, as Git does, before it
119/// reads the index and holds it until the new index is in place. The migration already holds the lock of every index
120/// while it completes the new checkout, so its rewrite uses the held lock instead of creating one.
121///
122/// In TS you'd write (pseudocode):
123/// ```ts
124/// type IndexLock = { kind: 'acquire' } | { kind: 'held'; token: string };
125/// ```
126#[derive(Clone, Copy, Debug, Eq, PartialEq)]
127pub enum IndexLock<'a> {
128 /// Create `index.lock` for the duration of the rewrite and remove it after.
129 Acquire,
130 /// `index.lock` holds this token already; leave it in place for its owner to release.
131 Held(&'a str),
132}
133
134/// What:
135/// What a rewrite of one index file is told beyond the paths.
136/// `&'a [PathBuf]` borrows the list of trees.
137/// Why:
138/// The lock decides how the index is protected, the references decide which entries Git recorded for the files the
139/// copy was made from, and the hash length (read from the repository's configuration) decides how an index with an
140/// all-zero checksum is read.
141///
142/// In TS you'd write (pseudocode):
143/// ```ts
144/// type RefreshOptions = { lock: IndexLock; references: string[]; hashLength?: number };
145/// ```
146#[derive(Clone, Copy, Debug)]
147pub struct RefreshOptions<'a> {
148 /// How the index is locked while it is rewritten.
149 pub lock: IndexLock<'a>,
150 /// Trees whose files the index may describe (the tree the copy was made from). An entry is refreshed only if the
151 /// file in one of them still has the stat fields the entry records; empty means every entry may be refreshed.
152 pub references: &'a [PathBuf],
153 /// The repository's hash length in bytes, when known.
154 pub hash_length: Option<usize>,
155}
156
157/// Whether a file's present stat equals what an entry recorded (the device is not compared: a subvolume has its own).
158pub(crate) fn matches_stored(now: &StatFields, stored: &StoredStat) -> bool {
159 return now.size == stored.size
160 && now.ino == stored.ino
161 && now.uid == stored.uid
162 && now.gid == stored.gid
163 && now.ctime_seconds == stored.ctime_seconds
164 && (stored.ctime_nanos == 0 || now.ctime_nanos == stored.ctime_nanos)
165 && now.mtime_seconds == stored.mtime_seconds
166 && (stored.mtime_nanos == 0 || now.mtime_nanos == stored.mtime_nanos);
167}
168
169/// What:
170/// Rewrite one index file in place and report what happened in one line.
171/// Why:
172/// A missing index is not an error (a worktree never checked out has none); a held `index.lock` and every declined
173/// shape are reported, not hidden, because the cost is a slow first `git status` there. The lock comes first, then
174/// the read, so a Git command that installs a newer index in between is never overwritten with an older one.
175///
176/// In TS you'd write (pseudocode):
177/// ```ts
178/// function refreshIndexFileWith(indexPath: string, tree: string, options: RefreshOptions): string;
179/// ```
180pub fn refresh_index_file_with(index_path: &Path, tree: &Path, options: &RefreshOptions<'_>) -> String {
181 return refresh_index_file_seamed(index_path, tree, options, &mut || {});
182}
183
184/// `refresh_index_file_with` with a call at the moment right after the rewrite, before the lock is looked at again, where a
185/// control may let another command take the lock name.
186pub fn refresh_index_file_seamed(index_path: &Path, tree: &Path, options: &RefreshOptions<'_>, after_rewrite: &mut dyn FnMut()) -> String {
187 return refresh_index_file_report_seamed(index_path, tree, options, after_rewrite).line;
188}
189
190/// How a refresh ended, apart from the words that describe it.
191#[derive(Clone, Copy, Debug, Eq, PartialEq)]
192pub enum RefreshStatus {
193 /// The new index was installed.
194 Refreshed,
195 /// There is no index file, which is nothing to refresh and no failure.
196 NoIndex,
197 /// The index was left as it was, for the reason in the line.
198 Skipped,
199}
200
201/// A refresh's line for the person and its status for the caller.
202#[derive(Clone, Debug, Eq, PartialEq)]
203pub struct RefreshReport {
204 /// The one-line report.
205 pub line: String,
206 /// How it ended.
207 pub status: RefreshStatus,
208}
209
210/// `refresh_index_file_with` that says how it ended, so a caller that must not go on after a failed refresh can stop.
211pub fn refresh_index_file_report(index_path: &Path, tree: &Path, options: &RefreshOptions<'_>) -> RefreshReport {
212 return refresh_index_file_report_seamed(index_path, tree, options, &mut || {});
213}
214
215/// `refresh_index_file_report` with a call at the moment right after the rewrite.
216pub fn refresh_index_file_report_seamed(index_path: &Path, tree: &Path, options: &RefreshOptions<'_>, after_rewrite: &mut dyn FnMut()) -> RefreshReport {
217 let mut lock_name: std::ffi::OsString = index_path.as_os_str().to_os_string();
218 lock_name.push(".lock");
219 let lock_path: PathBuf = PathBuf::from(lock_name);
220 let mut created: Option<std::fs::File> = None;
221 match options.lock {
222 IndexLock::Acquire => match std::fs::OpenOptions::new().write(true).create_new(true).open(&lock_path) {
223 Ok(opened) => created = Some(opened),
224 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
225 return RefreshReport { line: String::from("skipped (index.lock exists: a Git command is running there; its first status will be slow)"), status: RefreshStatus::Skipped };
226 }
227 Err(error) => return RefreshReport { line: format!("skipped (cannot create the lock: {error})"), status: RefreshStatus::Skipped },
228 },
229 IndexLock::Held(token) => {
230 if !is_ours(lock_path.as_path(), token) {
231 return RefreshReport { line: String::from("skipped (index.lock is held by another Git command; its first status will be slow)"), status: RefreshStatus::Skipped };
232 }
233 }
234 }
235 let (outcome, status): (String, RefreshStatus) = rewrite_under_lock(index_path, tree, options, created.as_mut(), &lock_path);
236 after_rewrite();
237 // Once the lock was renamed over the index the name is any Git command's to take; it is never touched again. Before that,
238 // the lock is removed only if the name still holds the file this call made.
239 if let (Some(file), true) = (created.as_ref(), status != RefreshStatus::Refreshed) {
240 remove_if_same_file(lock_path.as_path(), file);
241 }
242 return RefreshReport { line: outcome, status };
243}
244
245/// Remove the file at `path` if it is the very file `held` is open on (same device and inode).
246fn remove_if_same_file(path: &Path, held: &std::fs::File) {
247 let same: bool = match (std::fs::symlink_metadata(path), held.metadata()) {
248 (Ok(named), Ok(own)) => named.dev() == own.dev() && named.ino() == own.ino() && named.file_type().is_file(),
249 _ => false,
250 };
251 if same {
252 let _removed = std::fs::remove_file(path);
253 }
254}
255
256/// The read, rewrite and install of one index while its lock is held; `lock_file` is the lock when this call made it. The flag
257/// says whether the new index was published, after which the lock name is not this call's any more.
258fn rewrite_under_lock(index_path: &Path, tree: &Path, options: &RefreshOptions<'_>, lock_file: Option<&mut std::fs::File>, lock_path: &Path) -> (String, RefreshStatus) {
259 let index_mtime: Option<(u32, u32)> = match std::fs::metadata(index_path) {
260 Ok(metadata) => Some((metadata.mtime() as u32, metadata.mtime_nsec() as u32)),
261 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return (String::from("no index file"), RefreshStatus::NoIndex),
262 Err(error) => return (format!("skipped (cannot examine it: {error})"), RefreshStatus::Skipped),
263 };
264 let bytes: Vec<u8> = match std::fs::read(index_path) {
265 Ok(found) => found,
266 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return (String::from("no index file"), RefreshStatus::NoIndex),
267 Err(error) => return (format!("skipped (cannot read it: {error})"), RefreshStatus::Skipped),
268 };
269 return rewrite_and_install(bytes.as_slice(), index_mtime, [index_path, tree, lock_path], options, lock_file);
270}
271
272/// The rewrite of the stat fields of `bytes` and the install of the result over `index_path`, with `paths` naming the index, the tree it
273/// describes and the lock file.
274fn rewrite_and_install(bytes: &[u8], index_mtime: Option<(u32, u32)>, paths: [&Path; 3], options: &RefreshOptions<'_>, lock_file: Option<&mut std::fs::File>) -> (String, RefreshStatus) {
275 let [index_path, tree, lock_path]: [&Path; 3] = paths;
276 let context: RewriteContext = RewriteContext { hash_length: options.hash_length, index_mtime };
277 let outcome: IndexOutcome = rewrite_index_stat_with(
278 bytes,
279 &context,
280 &mut |path: &[u8]| { return stat_fields(tree, path); },
281 &mut |path: &[u8], stored: &StoredStat| {
282 if options.references.is_empty() {
283 return true;
284 }
285 return options.references.iter().any(|reference: &PathBuf| {
286 return stat_fields(reference.as_path(), path).is_some_and(|now: StatFields| { return matches_stored(&now, stored); });
287 });
288 },
289 );
290 let (rewritten, updated, total): (Vec<u8>, usize, usize) = match outcome {
291 IndexOutcome::Rewritten { bytes: new_bytes, updated, total } => (new_bytes, updated, total),
292 IndexOutcome::Skipped(reason) => return (format!("skipped ({reason})"), RefreshStatus::Skipped),
293 };
294 // With its own lock the new index is written into the lock file and the lock is renamed over the index, as Git does.
295 // Under a held lock the lock stays, and a sibling file is renamed over the index instead.
296 let installed: std::io::Result<()> = match lock_file {
297 Some(file) => file.write_all(rewritten.as_slice()).and_then(|()| { return file.sync_all(); }).and_then(|()| { return std::fs::rename(lock_path, index_path); }),
298 None => stage_and_replace(index_path, rewritten.as_slice()),
299 };
300 if let Err(error) = installed {
301 return (format!("skipped (cannot install the new index: {error})"), RefreshStatus::Skipped);
302 }
303 return (format!("{updated} of {total} entries refreshed"), RefreshStatus::Refreshed);
304}
305
306/// What:
307/// Write `bytes` to a staging file beside `index_path`, created exclusively, and rename it over the index.
308/// Why:
309/// Under a held lock no Git command writes the index, but the staging name is still checked: it is created with
310/// `O_EXCL`, which fails on an existing file or link instead of following or truncating it, written and synced through its
311/// own descriptor, and renamed only while the name still holds that file. A staging file that cannot be proven the call's
312/// is left where it is.
313///
314/// In TS you'd write (pseudocode):
315/// ```ts
316/// await stageAndReplace(indexPath, bytes);
317/// ```
318fn stage_and_replace(index_path: &Path, bytes: &[u8]) -> std::io::Result<()> {
319 let mut sibling_name: std::ffi::OsString = index_path.as_os_str().to_os_string();
320 sibling_name.push(".cli-git-new");
321 let sibling: PathBuf = PathBuf::from(sibling_name);
322 let mut staged: std::fs::File = std::fs::OpenOptions::new().write(true).create_new(true).open(&sibling)?;
323 let written: std::io::Result<()> = staged.write_all(bytes).and_then(|()| { return staged.sync_all(); }).and_then(|()| {
324 let named = std::fs::symlink_metadata(&sibling)?;
325 let own = staged.metadata()?;
326 if named.dev() != own.dev() || named.ino() != own.ino() {
327 return Err(std::io::Error::other("the staging file was replaced while the index was written"));
328 }
329 return std::fs::rename(&sibling, index_path);
330 });
331 if written.is_err() {
332 remove_if_same_file(sibling.as_path(), &staged);
333 }
334 return written;
335}
336
337/// What:
338/// Install `seed` as the index of the new worktree whose administrative directory is the open handle `admin`, with the stat
339/// fields of `stats` for the entries it knows, and nothing for the others.
340/// Why:
341/// The index of a new worktree must never be written in place and never replace one that exists: `index.lock` is created
342/// first (exclusively), the absence of an index is checked under it, the finished index is written into the lock, and
343/// that open file is linked under the name of the index without replacing anything; the lock name is released afterwards
344/// only if it still holds that file, because once it is released it is another Git process's to take. The stat fields come from the objects the caller made, so an entry whose file was
345/// replaced afterwards no longer matches its inode and Git hashes it. Everything is done through the handle.
346///
347/// In TS you'd write (pseudocode):
348/// ```ts
349/// installIndexIn(admin, seed, stats, hashLength); // throws a reason
350/// ```
351pub fn install_index_in(admin: &std::fs::File, seed: &[u8], stats: &mut dyn FnMut(&[u8]) -> Option<StatFields>, hash_length: usize) -> Result<String, String> {
352 return install_index_in_with(admin, seed, stats, hash_length, &mut InstallSeams { before_publish: &mut || {}, after_publish: &mut || {} });
353}
354
355/// The moments of an index install at which the controls move the tree.
356pub struct InstallSeams<'a> {
357 /// Called after the index was written into the lock and before it is linked under its name.
358 pub before_publish: &'a mut dyn FnMut(),
359 /// Called right after the index got its name and before the lock is released.
360 pub after_publish: &'a mut dyn FnMut(),
361}
362
363/// `install_index_in` with the moments at which a control may move the tree.
364pub fn install_index_in_with(admin: &std::fs::File, seed: &[u8], stats: &mut dyn FnMut(&[u8]) -> Option<StatFields>, hash_length: usize, seams: &mut InstallSeams<'_>) -> Result<String, String> {
365 use crate::fs_beneath::{create_file_at, entry_metadata_at, identity_of, link_open_file_at};
366 let lock: &OsStr = OsStr::new("index.lock");
367 let index: &OsStr = OsStr::new("index");
368 let mut created: std::fs::File = match create_file_at(admin, lock, 0o600) {
369 Ok(opened) => opened,
370 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => return Err(String::from("index.lock exists: a Git command is running there")),
371 Err(error) => return Err(format!("the lock cannot be created ({error})")),
372 };
373 let ours = identity_of(&created).map_err(|error| { return format!("the lock cannot be examined ({error})"); })?;
374 // Before the publication the lock is ours to remove, but only if the name still holds the file made here.
375 let give_up = |reason: String| -> Result<String, String> {
376 let _kept = crate::fs_beneath::remove_if_identical(admin, lock, ours);
377 return Err(reason);
378 };
379 match entry_metadata_at(admin, index) {
380 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
381 Err(error) => return give_up(format!("the index cannot be examined ({error})")),
382 Ok(_) => return give_up(String::from("an index appeared in the new worktree while it was made")),
383 }
384 let now: std::time::Duration = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
385 let context: RewriteContext = RewriteContext { hash_length: Some(hash_length), index_mtime: Some((now.as_secs() as u32, now.subsec_nanos())) };
386 let (rewritten, updated, total): (Vec<u8>, usize, usize) = match rewrite_index_stat_with(seed, &context, stats, &mut |_path: &[u8], _stored: &StoredStat| { return true; }) {
387 IndexOutcome::Rewritten { bytes, updated, total } => (bytes, updated, total),
388 IndexOutcome::Skipped(reason) => return give_up(format!("the index cannot be refreshed ({reason})")),
389 };
390 if let Err(error) = created.write_all(rewritten.as_slice()).and_then(|()| { return created.sync_all(); }) {
391 return give_up(format!("the new index cannot be written ({error})"));
392 }
393 (seams.before_publish)();
394 // The open file is linked under the name of the index, which fails if the name exists: what is published is the file
395 // written here, whatever the lock name refers to by now. The lock stays until the index has its name.
396 if let Err(error) = link_open_file_at(&created, admin, index) {
397 return give_up(format!("the new index cannot be put in place ({error})"));
398 }
399 (seams.after_publish)();
400 // The lock name and the index are the same file now; the lock is released only if the name still holds it, so the lock
401 // of the next Git command is never removed.
402 let _released = crate::fs_beneath::remove_if_identical(admin, lock, ours);
403 return Ok(format!("{updated} of {total} entries refreshed"));
404}
405
406/// What:
407/// Rewrite one index file in place under a lock, trusting every entry; see `refresh_index_file_with`.
408/// Why:
409/// The shape the controls use when nothing else is known about the index.
410///
411/// In TS you'd write (pseudocode):
412/// ```ts
413/// function refreshIndexFileLocked(indexPath: string, tree: string, lock: IndexLock): string;
414/// ```
415pub fn refresh_index_file_locked(index_path: &Path, tree: &Path, lock: IndexLock<'_>) -> String {
416 return refresh_index_file_with(index_path, tree, &RefreshOptions { lock, references: &[], hash_length: None });
417}
418
419/// What:
420/// Rewrite one index file in place under a lock the rewrite takes itself; see `refresh_index_file_locked`.
421/// Why:
422/// The common case: nothing holds the lock yet.
423///
424/// In TS you'd write (pseudocode):
425/// ```ts
426/// function refreshIndexFile(indexPath: string, tree: string): string;
427/// ```
428pub fn refresh_index_file(index_path: &Path, tree: &Path) -> String {
429 return refresh_index_file_locked(index_path, tree, IndexLock::Acquire);
430}
431
432/// What:
433/// The repository's hash length in bytes from its configuration file: 32 when `extensions.objectformat` is `sha256`,
434/// 20 when the file has no such setting, nothing when the file cannot be read.
435/// Why:
436/// An index does not say which hash it uses; the configuration does. The reading is the plain `[section]` and
437/// `key = value` grammar, case-insensitive for section and key names, without includes.
438///
439/// In TS you'd write (pseudocode):
440/// ```ts
441/// function repositoryHashLength(gitDirectory: string): 20 | 32 | undefined;
442/// ```
443pub fn repository_hash_length(git_directory: &Path) -> Option<usize> {
444 let text: String = String::from_utf8_lossy(std::fs::read(git_directory.join("config")).ok()?.as_slice()).into_owned();
445 let mut in_extensions: bool = false;
446 for raw in text.lines() {
447 let line: &str = raw.trim();
448 if let Some(header) = line.strip_prefix('[') {
449 in_extensions = header.split(']').next().is_some_and(|name: &str| { return name.trim().eq_ignore_ascii_case("extensions"); });
450 continue;
451 }
452 if !in_extensions {
453 continue;
454 }
455 let Some((key, value)) = line.split_once('=') else { continue };
456 if key.trim().eq_ignore_ascii_case("objectformat") && value.trim().eq_ignore_ascii_case("sha256") {
457 return Some(32);
458 }
459 }
460 return Some(20);
461}
462
463/// The number of copies of an index file one rewrite holds at once: the bytes read, the parsed entries, and the bytes written.
464/// The budget multiplies the largest index file by it to estimate what one rewrite holds.
465/// Measured with `refresh_index_file` on throwaway repositories of this repository's tracked files, resident size read every 0.2 ms
466/// in a fresh process (2026-11-03): an index of 2,786,564 bytes grew the process by 13,123,584 bytes (4.7 copies) and one of
467/// 1,066,894 bytes by 7,929,856 bytes (7.4 copies), which is 3.0 copies of the index plus about 4.7 MB that does not grow with
468/// it. Four copies cover the growth for an index over 4.7 MB, the indexes this estimate exists for. Kept at 4 as the coordinator's pick under the owner's delegation, open to the owner's veto (`doc/planning/cli-git-shipped-limits-audit.md`, "Picked for these follow-ups", option A).
469const INDEX_REWRITE_COPIES: u64 = 4;
470
471/// What one rewrite of an index file is: the file, the tree it describes, and the trees its entries were copied from.
472struct IndexTarget {
473 /// The index file.
474 index: PathBuf,
475 /// The tree whose files the index describes.
476 tree: PathBuf,
477 /// The trees the entries were copied from (each is the reference tree joined with the worktree's place in the checkout).
478 references: Vec<PathBuf>,
479}
480
481/// The rewrite of one index file, replaceable so a control can watch or delay it.
482pub type RewriteOne<'a> = dyn Fn(&Path, &Path, &RefreshOptions<'_>) -> String + Sync + 'a;
483
484/// What:
485/// What a rewrite of every index of a checkout is told.
486/// Why:
487/// One value names the inputs: the checkout, how its indexes are locked, the trees they were copied from, the host the
488/// budget is derived from, and the rewrite of one file.
489///
490/// In TS you'd write (pseudocode):
491/// ```ts
492/// type AllIndexes = { root: string; lock: IndexLock; references: string[]; host: HostResources; rewrite: RewriteOne };
493/// ```
494pub struct AllIndexes<'a> {
495 /// The main checkout.
496 pub root: &'a Path,
497 /// How each index is protected while it is rewritten.
498 pub lock: IndexLock<'a>,
499 /// The trees the checkout was copied from; each linked worktree is looked up under the same relative path in them.
500 pub references: &'a [PathBuf],
501 /// The CPUs and memory the budget is derived from.
502 pub host: &'a HostResources,
503 /// The rewrite of one index file.
504 pub rewrite: &'a RewriteOne<'a>,
505}
506
507/// What:
508/// Rewrite the index of the checkout and of every linked worktree inside it, reporting each through `say`.
509/// `&mut dyn FnMut(&str)` is a borrowed callback that receives one report line at a time.
510/// `references` are the trees the checkout was copied from (empty trusts every entry); each linked worktree is looked up
511/// under the same relative path in them.
512/// Why:
513/// The first `git status` of each of them is otherwise a full re-hash.
514///
515/// In TS you'd write (pseudocode):
516/// ```ts
517/// function refreshAllIndexes(root: string, lock: IndexLock, references: string[], say: (line: string) => void): void;
518/// ```
519pub fn refresh_all_indexes(root: &Path, lock: IndexLock<'_>, references: &[PathBuf], say: &mut dyn FnMut(&str)) -> usize {
520 return refresh_all_indexes_with(&AllIndexes { root, lock, references, host: &HostResources::read(), rewrite: &refresh_index_file_with }, say);
521}
522
523/// What:
524/// `refresh_all_indexes` with the host and the rewrite of one file chosen by the caller; returns how many indexes it covered.
525/// Why:
526/// Each index is its own file under its own lock describing its own tree, so the rewrites run side by side within a budget
527/// and are reported in the order the checkout's index first, then each linked worktree's by path.
528///
529/// In TS you'd write (pseudocode):
530/// ```ts
531/// const count = await refreshAllIndexesWith({ root, lock, references, host, rewrite }, say);
532/// ```
533pub fn refresh_all_indexes_with(request: &AllIndexes<'_>, say: &mut dyn FnMut(&str)) -> usize {
534 let AllIndexes { root, lock, references, host, rewrite } = *request;
535 let hash_length: Option<usize> = repository_hash_length(root.join(".git").as_path());
536 let mut targets: Vec<IndexTarget> = vec![IndexTarget { index: root.join(".git").join("index"), tree: root.to_path_buf(), references: references.to_vec() }];
537 for linked in linked_worktrees_inside(root) {
538 let relative: &Path = linked.worktree.strip_prefix(root).unwrap_or(Path::new(""));
539 targets.push(IndexTarget {
540 index: linked.admin_directory.join("index"),
541 tree: linked.worktree.clone(),
542 references: references.iter().map(|reference: &PathBuf| { return reference.join(relative); }).collect(),
543 });
544 }
545 let largest: u64 = targets.iter().map(|target: &IndexTarget| { return std::fs::metadata(&target.index).map_or(0, |found: std::fs::Metadata| { return found.len(); }); }).max().unwrap_or(0);
546 let workers: usize = worker_budget(&BudgetRequest { items: targets.len(), per_item_memory: largest.saturating_mul(INDEX_REWRITE_COPIES), ceiling: None, cap: None, host });
547 let lanes: Vec<Vec<usize>> = (0..targets.len()).map(|index: usize| { return vec![index]; }).collect();
548 run_ordered(OrderedRun {
549 items: targets.as_slice(),
550 lanes: lanes.as_slice(),
551 workers,
552 work: &|_index: usize, target: &IndexTarget, _worker_say: &mut dyn FnMut(&str)| {
553 return rewrite(target.index.as_path(), target.tree.as_path(), &RefreshOptions { lock, references: target.references.as_slice(), hash_length });
554 },
555 on_event: &mut |event: Event<'_, String>| {
556 if let Event::End(index, line) = event {
557 say(format!("index {}: {line}", targets[index].index.display()).as_str());
558 }
559 },
560 });
561 return targets.len();
562}
563
564/// Index file controls stay out of the release executable.
565#[cfg(test)]
566#[path = "subvolume_index_files_tests.rs"]
567mod tests;
568
569/// The controls for indexes rewritten side by side stay out of the release executable.
570#[cfg(test)]
571#[path = "subvolume_index_parallel_tests.rs"]
572mod parallel_tests;