Skip to main content

git_policy_cli/
subvolume_lock.rs

1//! What:
2//!  The index locks the migration holds: one `index.lock` file for the main index and one for the index of each linked
3//!  worktree, each carrying a token that names this migration.
4//! Why:
5//!  Git writes an index by creating `index.lock` exclusively, writing it and renaming it over `index`. A migration that
6//!  swaps the checkout directory under such a writer, or copies a half-written lock into the new tree, damages the
7//!  index. So the migration creates the lock files itself, in the old tree before its last copy and in the new tree
8//!  before the exchange, and keeps them until the indexes are rewritten. The token makes a lock the migration's own:
9//!  a lock file that exists when the migration tries to create one is accepted only if it holds this migration's token,
10//!  so another Git command's lock is never mistaken for it, never exempted from the refusals, and never removed.
11//!  The linked worktrees' administrative directories live inside `.git`, so their locks and indexes travel with the
12//!  main checkout whether or not the worktree directory is inside it.
13//!
14//! In TS you'd write (pseudocode):
15//! ```ts
16//! for (const lock of lockPaths(tree)) await acquire(lock, token); ... for (const lock of lockPaths(tree)) await release(lock, token);
17//! ```
18
19use super::subvolume_error::MigrateError;
20use super::subvolume_journal::Journal;
21use std::io::Write;
22use std::path::{Path, PathBuf};
23
24/// The name of every Git index lock.
25const LOCK_NAME: &str = "index.lock";
26
27/// What:
28///  The text written into every lock file of this migration.
29/// Why:
30///   The journal's start time is unique to the migration and survives a kill, so a rerun recognises the locks of the
31///  run it resumes and nobody else's.
32///
33/// In TS you'd write (pseudocode):
34/// ```ts
35/// const token = `cli-git migrate-to-subvolume ${journal.started}\n`;
36/// ```
37pub fn token_of(journal: &Journal) -> String {
38    return format!("cli-git migrate-to-subvolume {}\n", journal.started());
39}
40
41/// What:
42///  The lock file paths of a tree: the main index's and one per administrative directory of a linked worktree, sorted.
43/// Why:
44///   Every index that a Git command could rewrite while the migration runs.
45///
46/// In TS you'd write (pseudocode):
47/// ```ts
48/// function lockPaths(tree: string): string[];
49/// ```
50pub fn lock_paths(tree: &Path) -> Vec<PathBuf> {
51    let git_directory: PathBuf = tree.join(".git");
52    let mut found: Vec<PathBuf> = vec![git_directory.join(LOCK_NAME)];
53    if let Ok(names) = std::fs::read_dir(git_directory.join("worktrees")) {
54        for name in names.flatten() {
55            if name.file_type().is_ok_and(|kind: std::fs::FileType| { return kind.is_dir(); }) {
56                found.push(name.path().join(LOCK_NAME));
57            }
58        }
59    }
60    found.sort();
61    return found;
62}
63
64/// What:
65///  The lock file paths of a tree relative to it.
66/// Why:
67///   The tree passes leave these paths alone, in both trees: they are never copied and never deleted.
68///
69/// In TS you'd write (pseudocode):
70/// ```ts
71/// function relativeLockPaths(tree: string): string[];
72/// ```
73pub fn relative_lock_paths(tree: &Path) -> Vec<PathBuf> {
74    return lock_paths(tree)
75        .into_iter()
76        .filter_map(|path: PathBuf| { return path.strip_prefix(tree).ok().map(Path::to_path_buf); })
77        .collect();
78}
79
80/// Whether the lock file holds the token: it is this migration's.
81pub fn is_ours(lock: &Path, token: &str) -> bool {
82    return std::fs::read(lock).is_ok_and(|content: Vec<u8>| { return content == token.as_bytes(); });
83}
84
85/// What:
86///  Create the lock file with the token, or accept one that already holds the token.
87/// Why:
88///   Another Git command's lock means that command is writing an index now; the migration stops and says which file.
89///
90/// In TS you'd write (pseudocode):
91/// ```ts
92/// await acquire(lockPath, token);
93/// ```
94pub fn acquire(lock: &Path, token: &str) -> Result<(), MigrateError> {
95    match std::fs::OpenOptions::new().write(true).create_new(true).open(lock) {
96        Ok(mut file) => {
97            let written: std::io::Result<()> = file.write_all(token.as_bytes()).and_then(|()| { return file.sync_all(); });
98            return written.map_err(|error: std::io::Error| { return MigrateError::io("write the lock", lock, &error); });
99        }
100        Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
101            if is_ours(lock, token) {
102                return Ok(());
103            }
104            return Err(MigrateError::Journal(format!(
105                "{} exists and is not this migration's lock: a Git command is writing that index. Wait for it to finish, or remove the file if no Git command is running, then run the command again",
106                lock.display()
107            )));
108        }
109        Err(error) => return Err(MigrateError::io("create the lock", lock, &error)),
110    }
111}
112
113/// What:
114///  Remove every lock file of the tree that holds the token; report how many were removed.
115/// Why:
116///   Only the migration's own locks are released; a lock that holds other text belongs to a Git command.
117///
118/// In TS you'd write (pseudocode):
119/// ```ts
120/// const released = await releaseAll(tree, token);
121/// ```
122pub fn release_all(tree: &Path, token: &str) -> Result<usize, MigrateError> {
123    let mut released: usize = 0;
124    for lock in lock_paths(tree) {
125        if !is_ours(lock.as_path(), token) {
126            continue;
127        }
128        match std::fs::remove_file(&lock) {
129            Ok(()) => released += 1,
130            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
131            Err(error) => return Err(MigrateError::io("remove", lock.as_path(), &error)),
132        }
133    }
134    return Ok(released);
135}
136
137/// Lock controls stay out of the release executable.
138#[cfg(test)]
139#[path = "subvolume_lock_tests.rs"]
140mod tests;