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;