Skip to main content

git_policy_cli/
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//! In TS you'd write (pseudocode):
13//! ```ts
14//! for (const { adminDirectory, worktree } of linkedWorktrees(root)) refreshIndexFile(join(adminDirectory, 'index'), worktree);
15//! ```
16
17use super::subvolume_index::{IndexOutcome, RewriteContext, StatFields, StoredStat, rewrite_index_stat_with};
18use super::subvolume_lock::is_ours;
19use std::ffi::OsStr;
20use std::io::Write;
21use std::os::unix::ffi::OsStrExt;
22use std::os::unix::fs::MetadataExt;
23use std::path::{Path, PathBuf};
24
25/// What:
26///  One linked worktree of the checkout.
27/// Why:
28///   The index lives in the administrative directory; the files it describes live in the worktree.
29///
30/// In TS you'd write (pseudocode):
31/// ```ts
32/// type LinkedWorktree = { adminDirectory: string; worktree: string };
33/// ```
34#[derive(Clone, Debug, Eq, PartialEq)]
35pub struct LinkedWorktree {
36    /// `<root>/.git/worktrees/<name>`.
37    pub admin_directory: PathBuf,
38    /// The directory the worktree's files are in.
39    pub worktree: PathBuf,
40}
41
42/// What:
43///  The linked worktrees of `root` whose directory is inside `root` and exists.
44/// Why:
45///   Only worktrees inside the checkout are copied with it. An administrative directory without a readable `gitdir`
46///  file belongs to a worktree Git would prune, so it is skipped.
47///
48/// In TS you'd write (pseudocode):
49/// ```ts
50/// function linkedWorktreesInside(root: string): LinkedWorktree[];
51/// ```
52pub fn linked_worktrees_inside(root: &Path) -> Vec<LinkedWorktree> {
53    let admin: PathBuf = root.join(".git").join("worktrees");
54    let Ok(names) = std::fs::read_dir(&admin) else { return Vec::new() };
55    let mut found: Vec<LinkedWorktree> = Vec::new();
56    for name in names.flatten() {
57        let Ok(recorded) = std::fs::read(name.path().join("gitdir")) else { continue };
58        // The recorded path is raw bytes: a directory named with bytes that are not UTF-8 is a valid path on Linux.
59        let Some(worktree) = Path::new(OsStr::from_bytes(recorded.trim_ascii())).parent() else { continue };
60        if worktree.starts_with(root) && worktree != root && worktree.is_dir() {
61            found.push(LinkedWorktree { admin_directory: name.path(), worktree: worktree.to_path_buf() });
62        }
63    }
64    found.sort_by(|left: &LinkedWorktree, right: &LinkedWorktree| { return left.worktree.cmp(&right.worktree); });
65    return found;
66}
67
68/// What:
69///  What `lstat` of `tree/path` reports now, in index units, or nothing for a file that cannot be examined.
70/// Why:
71///   The rewrite leaves an entry as Git recorded it when its file is missing.
72///
73/// In TS you'd write (pseudocode):
74/// ```ts
75/// function statFields(tree: string, path: Uint8Array): StatFields | undefined;
76/// ```
77fn stat_fields(tree: &Path, path: &[u8]) -> Option<StatFields> {
78    let metadata: std::fs::Metadata = std::fs::symlink_metadata(tree.join(OsStr::from_bytes(path))).ok()?;
79    return Some(StatFields {
80        ctime_seconds: metadata.ctime() as u32,
81        ctime_nanos: metadata.ctime_nsec() as u32,
82        mtime_seconds: metadata.mtime() as u32,
83        mtime_nanos: metadata.mtime_nsec() as u32,
84        dev: metadata.dev() as u32,
85        ino: metadata.ino() as u32,
86        uid: metadata.uid(),
87        gid: metadata.gid(),
88        size: metadata.size() as u32,
89    });
90}
91
92/// What:
93///  How the rewrite of one index file is protected from a Git command that writes the same index.
94///  `&'a str` borrows the token of the migration whose lock is already held.
95/// Why:
96///   Git replaces an index through `index.lock`. A rewrite that runs by itself takes that lock, as Git does, before it
97///  reads the index and holds it until the new index is in place. The migration already holds the lock of every index
98///  while it completes the new checkout, so its rewrite uses the held lock instead of creating one.
99///
100/// In TS you'd write (pseudocode):
101/// ```ts
102/// type IndexLock = { kind: 'acquire' } | { kind: 'held'; token: string };
103/// ```
104#[derive(Clone, Copy, Debug, Eq, PartialEq)]
105pub enum IndexLock<'a> {
106    /// Create `index.lock` for the duration of the rewrite and remove it after.
107    Acquire,
108    /// `index.lock` holds this token already; leave it in place for its owner to release.
109    Held(&'a str),
110}
111
112/// What:
113///  What a rewrite of one index file is told beyond the paths.
114///  `&'a [PathBuf]` borrows the list of trees.
115/// Why:
116///   The lock decides how the index is protected, the references decide which entries Git recorded for the files the
117///  copy was made from, and the hash length (read from the repository's configuration) decides how an index with an
118///  all-zero checksum is read.
119///
120/// In TS you'd write (pseudocode):
121/// ```ts
122/// type RefreshOptions = { lock: IndexLock; references: string[]; hashLength?: number };
123/// ```
124#[derive(Clone, Copy, Debug)]
125pub struct RefreshOptions<'a> {
126    /// How the index is locked while it is rewritten.
127    pub lock: IndexLock<'a>,
128    /// Trees whose files the index may describe (the tree the copy was made from). An entry is refreshed only if the
129    ///  file in one of them still has the stat fields the entry records; empty means every entry may be refreshed.
130    pub references: &'a [PathBuf],
131    /// The repository's hash length in bytes, when known.
132    pub hash_length: Option<usize>,
133}
134
135/// Whether a file's present stat equals what an entry recorded (the device is not compared: a subvolume has its own).
136fn matches_stored(now: &StatFields, stored: &StoredStat) -> bool {
137    return now.size == stored.size
138        && now.ino == stored.ino
139        && now.uid == stored.uid
140        && now.gid == stored.gid
141        && now.ctime_seconds == stored.ctime_seconds
142        && (stored.ctime_nanos == 0 || now.ctime_nanos == stored.ctime_nanos)
143        && now.mtime_seconds == stored.mtime_seconds
144        && (stored.mtime_nanos == 0 || now.mtime_nanos == stored.mtime_nanos);
145}
146
147/// What:
148///  Rewrite one index file in place and report what happened in one line.
149/// Why:
150///   A missing index is not an error (a worktree never checked out has none); a held `index.lock` and every declined
151///  shape are reported, not hidden, because the cost is a slow first `git status` there. The lock comes first, then
152///  the read, so a Git command that installs a newer index in between is never overwritten with an older one.
153///
154/// In TS you'd write (pseudocode):
155/// ```ts
156/// function refreshIndexFileWith(indexPath: string, tree: string, options: RefreshOptions): string;
157/// ```
158pub fn refresh_index_file_with(index_path: &Path, tree: &Path, options: &RefreshOptions<'_>) -> String {
159    let mut lock_name: std::ffi::OsString = index_path.as_os_str().to_os_string();
160    lock_name.push(".lock");
161    let lock_path: PathBuf = PathBuf::from(lock_name);
162    let mut created: Option<std::fs::File> = None;
163    match options.lock {
164        IndexLock::Acquire => match std::fs::OpenOptions::new().write(true).create_new(true).open(&lock_path) {
165            Ok(opened) => created = Some(opened),
166            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
167                return String::from("skipped (index.lock exists: a Git command is running there; its first status will be slow)");
168            }
169            Err(error) => return format!("skipped (cannot create the lock: {error})"),
170        },
171        IndexLock::Held(token) => {
172            if !is_ours(lock_path.as_path(), token) {
173                return String::from("skipped (index.lock is held by another Git command; its first status will be slow)");
174            }
175        }
176    }
177    let outcome: String = rewrite_under_lock(index_path, tree, options, created.as_mut(), &lock_path);
178    if created.is_some() {
179        let _removed = std::fs::remove_file(&lock_path);
180    }
181    return outcome;
182}
183
184/// The read, rewrite and install of one index while its lock is held; `lock_file` is the lock when this call made it.
185fn rewrite_under_lock(index_path: &Path, tree: &Path, options: &RefreshOptions<'_>, lock_file: Option<&mut std::fs::File>, lock_path: &Path) -> String {
186    let index_mtime: Option<(u32, u32)> = match std::fs::metadata(index_path) {
187        Ok(metadata) => Some((metadata.mtime() as u32, metadata.mtime_nsec() as u32)),
188        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return String::from("no index file"),
189        Err(error) => return format!("skipped (cannot examine it: {error})"),
190    };
191    let bytes: Vec<u8> = match std::fs::read(index_path) {
192        Ok(found) => found,
193        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return String::from("no index file"),
194        Err(error) => return format!("skipped (cannot read it: {error})"),
195    };
196    let context: RewriteContext = RewriteContext { hash_length: options.hash_length, index_mtime };
197    let outcome: IndexOutcome = rewrite_index_stat_with(
198        bytes.as_slice(),
199        &context,
200        &mut |path: &[u8]| { return stat_fields(tree, path); },
201        &mut |path: &[u8], stored: &StoredStat| {
202            if options.references.is_empty() {
203                return true;
204            }
205            return options.references.iter().any(|reference: &PathBuf| {
206                return stat_fields(reference.as_path(), path).is_some_and(|now: StatFields| { return matches_stored(&now, stored); });
207            });
208        },
209    );
210    let (rewritten, updated, total): (Vec<u8>, usize, usize) = match outcome {
211        IndexOutcome::Rewritten { bytes: new_bytes, updated, total } => (new_bytes, updated, total),
212        IndexOutcome::Skipped(reason) => return format!("skipped ({reason})"),
213    };
214    // With its own lock the new index is written into the lock file and the lock is renamed over the index, as Git does.
215    // Under a held lock the lock stays, and a sibling file is renamed over the index instead.
216    let installed: std::io::Result<()> = match lock_file {
217        Some(file) => file.write_all(rewritten.as_slice()).and_then(|()| { return file.sync_all(); }).and_then(|()| { return std::fs::rename(lock_path, index_path); }),
218        None => {
219            let mut sibling_name: std::ffi::OsString = index_path.as_os_str().to_os_string();
220            sibling_name.push(".cli-git-new");
221            let sibling: PathBuf = PathBuf::from(sibling_name);
222            let written: std::io::Result<()> = std::fs::write(&sibling, rewritten.as_slice())
223                .and_then(|()| { return std::fs::File::open(&sibling); })
224                .and_then(|file: std::fs::File| { return file.sync_all(); })
225                .and_then(|()| { return std::fs::rename(&sibling, index_path); });
226            if written.is_err() {
227                let _removed = std::fs::remove_file(&sibling);
228            }
229            written
230        }
231    };
232    if let Err(error) = installed {
233        return format!("skipped (cannot install the new index: {error})");
234    }
235    return format!("{updated} of {total} entries refreshed");
236}
237
238/// What:
239///  Rewrite one index file in place under a lock, trusting every entry; see `refresh_index_file_with`.
240/// Why:
241///   The shape the controls use when nothing else is known about the index.
242///
243/// In TS you'd write (pseudocode):
244/// ```ts
245/// function refreshIndexFileLocked(indexPath: string, tree: string, lock: IndexLock): string;
246/// ```
247pub fn refresh_index_file_locked(index_path: &Path, tree: &Path, lock: IndexLock<'_>) -> String {
248    return refresh_index_file_with(index_path, tree, &RefreshOptions { lock, references: &[], hash_length: None });
249}
250
251/// What:
252///  Rewrite one index file in place under a lock the rewrite takes itself; see `refresh_index_file_locked`.
253/// Why:
254///   The common case: nothing holds the lock yet.
255///
256/// In TS you'd write (pseudocode):
257/// ```ts
258/// function refreshIndexFile(indexPath: string, tree: string): string;
259/// ```
260pub fn refresh_index_file(index_path: &Path, tree: &Path) -> String {
261    return refresh_index_file_locked(index_path, tree, IndexLock::Acquire);
262}
263
264/// What:
265///  The repository's hash length in bytes from its configuration file: 32 when `extensions.objectformat` is `sha256`,
266///  20 when the file has no such setting, nothing when the file cannot be read.
267/// Why:
268///   An index does not say which hash it uses; the configuration does. The reading is the plain `[section]` and
269///  `key = value` grammar, case-insensitive for section and key names, without includes.
270///
271/// In TS you'd write (pseudocode):
272/// ```ts
273/// function repositoryHashLength(gitDirectory: string): 20 | 32 | undefined;
274/// ```
275pub fn repository_hash_length(git_directory: &Path) -> Option<usize> {
276    let text: String = String::from_utf8_lossy(std::fs::read(git_directory.join("config")).ok()?.as_slice()).into_owned();
277    let mut in_extensions: bool = false;
278    for raw in text.lines() {
279        let line: &str = raw.trim();
280        if let Some(header) = line.strip_prefix('[') {
281            in_extensions = header.split(']').next().is_some_and(|name: &str| { return name.trim().eq_ignore_ascii_case("extensions"); });
282            continue;
283        }
284        if !in_extensions {
285            continue;
286        }
287        let Some((key, value)) = line.split_once('=') else { continue };
288        if key.trim().eq_ignore_ascii_case("objectformat") && value.trim().eq_ignore_ascii_case("sha256") {
289            return Some(32);
290        }
291    }
292    return Some(20);
293}
294
295/// What:
296///  Rewrite the index of the checkout and of every linked worktree inside it, reporting each through `say`.
297///  `&mut dyn FnMut(&str)` is a borrowed callback that receives one report line at a time.
298///  `references` are the trees the checkout was copied from (empty trusts every entry); each linked worktree is looked up
299///  under the same relative path in them.
300/// Why:
301///   The first `git status` of each of them is otherwise a full re-hash.
302///
303/// In TS you'd write (pseudocode):
304/// ```ts
305/// function refreshAllIndexes(root: string, lock: IndexLock, references: string[], say: (line: string) => void): void;
306/// ```
307pub fn refresh_all_indexes(root: &Path, lock: IndexLock<'_>, references: &[PathBuf], say: &mut dyn FnMut(&str)) -> usize {
308    let hash_length: Option<usize> = repository_hash_length(root.join(".git").as_path());
309    let main_index: PathBuf = root.join(".git").join("index");
310    let main_options: RefreshOptions<'_> = RefreshOptions { lock, references, hash_length };
311    say(format!("index {}: {}", main_index.display(), refresh_index_file_with(main_index.as_path(), root, &main_options)).as_str());
312    let mut count: usize = 1;
313    for linked in linked_worktrees_inside(root) {
314        let index: PathBuf = linked.admin_directory.join("index");
315        let relative: &Path = linked.worktree.strip_prefix(root).unwrap_or(Path::new(""));
316        let inside: Vec<PathBuf> = references.iter().map(|reference: &PathBuf| { return reference.join(relative); }).collect();
317        let options: RefreshOptions<'_> = RefreshOptions { lock, references: inside.as_slice(), hash_length };
318        say(format!("index {}: {}", index.display(), refresh_index_file_with(index.as_path(), linked.worktree.as_path(), &options)).as_str());
319        count += 1;
320    }
321    return count;
322}
323
324/// Index file controls stay out of the release executable.
325#[cfg(test)]
326#[path = "subvolume_index_files_tests.rs"]
327mod tests;