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;