Skip to main content

git_policy_cli/subvolume/
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//! Why these locks stay:
15//!  They are Git's own protocol for writing an index, not a lock of this wrapper's invention: Git creates `index.lock`
16//!  exclusively and treats its presence as "an index writer is active", so no design that leaves the name alone can keep
17//!  Git out of an index while the checkout directory is exchanged. Each lock covers one index (never the repository), is
18//!  created with `O_EXCL` and never waited for (a foreign lock stops the migration with the file named), and is held from
19//!  before the last copy to the end of the index rewrite, which is the span in which a Git writer could be lost in the
20//!  exchange. Which command may migrate a checkout at all is not decided here: `single_owner_claim.rs` decides that
21//!  without a lock. Owner rule: concurrency is lock-free first; a lock that must stay covers the smallest unit, with its
22//!  reason beside it.
23//!
24//! In TS you'd write (pseudocode):
25//! ```ts
26//! for (const lock of lockPaths(tree)) await acquire(lock, token); ... for (const lock of lockPaths(tree)) await release(lock, token);
27//! ```
28
29use crate::subvolume_error::MigrateError;
30use crate::subvolume_journal::Journal;
31use std::io::Write;
32use std::path::{Path, PathBuf};
33
34/// The name of every Git index lock.
35const LOCK_NAME: &str = "index.lock";
36
37/// What:
38///  The text written into every lock file of this migration.
39/// Why:
40///   The journal's start time is unique to the migration and survives a kill, so a rerun recognises the locks of the
41///  run it resumes and nobody else's.
42///
43/// In TS you'd write (pseudocode):
44/// ```ts
45/// const token = `cli-git migrate-to-subvolume ${journal.started}\n`;
46/// ```
47pub fn token_of(journal: &Journal) -> String {
48    return format!("cli-git migrate-to-subvolume {}\n", journal.started());
49}
50
51/// What:
52///  The lock file paths of a tree: the main index's and one per administrative directory of a linked worktree, sorted.
53/// Why:
54///   Every index that a Git command could rewrite while the migration runs.
55///
56/// In TS you'd write (pseudocode):
57/// ```ts
58/// function lockPaths(tree: string): string[];
59/// ```
60pub fn lock_paths(tree: &Path) -> Vec<PathBuf> {
61    let git_directory: PathBuf = tree.join(".git");
62    let mut found: Vec<PathBuf> = vec![git_directory.join(LOCK_NAME)];
63    if let Ok(names) = std::fs::read_dir(git_directory.join("worktrees")) {
64        for name in names.flatten() {
65            if name.file_type().is_ok_and(|kind: std::fs::FileType| { return kind.is_dir(); }) {
66                found.push(name.path().join(LOCK_NAME));
67            }
68        }
69    }
70    found.sort();
71    return found;
72}
73
74/// What:
75///  The lock file paths of a tree relative to it.
76/// Why:
77///   The tree passes leave these paths alone, in both trees: they are never copied and never deleted.
78///
79/// In TS you'd write (pseudocode):
80/// ```ts
81/// function relativeLockPaths(tree: string): string[];
82/// ```
83pub fn relative_lock_paths(tree: &Path) -> Vec<PathBuf> {
84    return lock_paths(tree)
85        .into_iter()
86        .filter_map(|path: PathBuf| { return path.strip_prefix(tree).ok().map(Path::to_path_buf); })
87        .collect();
88}
89
90/// Whether the lock file holds the token: it is this migration's.
91pub fn is_ours(lock: &Path, token: &str) -> bool {
92    return std::fs::read(lock).is_ok_and(|content: Vec<u8>| { return content == token.as_bytes(); });
93}
94
95/// What:
96///  Create the lock file with the token, or accept one that already holds the token.
97/// Why:
98///   Another Git command's lock means that command is writing an index now; the migration stops and says which file.
99///
100/// In TS you'd write (pseudocode):
101/// ```ts
102/// await acquire(lockPath, token);
103/// ```
104pub fn acquire(lock: &Path, token: &str) -> Result<(), MigrateError> {
105    match std::fs::OpenOptions::new().write(true).create_new(true).open(lock) {
106        Ok(mut file) => {
107            let written: std::io::Result<()> = file.write_all(token.as_bytes()).and_then(|()| { return file.sync_all(); });
108            return written.map_err(|error: std::io::Error| { return MigrateError::io("write the lock", lock, &error); });
109        }
110        Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
111            if is_ours(lock, token) {
112                return Ok(());
113            }
114            return Err(MigrateError::Journal(format!(
115                "{} 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",
116                lock.display()
117            )));
118        }
119        Err(error) => return Err(MigrateError::io("create the lock", lock, &error)),
120    }
121}
122
123/// What:
124///  Remove every lock file of the tree that holds the token; report how many were removed.
125/// Why:
126///   Only the migration's own locks are released; a lock that holds other text belongs to a Git command.
127///
128/// In TS you'd write (pseudocode):
129/// ```ts
130/// const released = await releaseAll(tree, token);
131/// ```
132pub fn release_all(tree: &Path, token: &str) -> Result<usize, MigrateError> {
133    let mut released: usize = 0;
134    for lock in lock_paths(tree) {
135        if !is_ours(lock.as_path(), token) {
136            continue;
137        }
138        match std::fs::remove_file(&lock) {
139            Ok(()) => released += 1,
140            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
141            Err(error) => return Err(MigrateError::io("remove", lock.as_path(), &error)),
142        }
143    }
144    return Ok(released);
145}
146
147/// Lock controls stay out of the release executable.
148#[cfg(test)]
149#[path = "subvolume_lock_tests.rs"]
150mod tests;