monochromatic-git-policy-cli 0.5.33

Repository-owned Git policy wrapper: forwards Git commands and checks commits against configured policies.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
//! What:
//!  The removal of a clean linked worktree that is a btrfs subvolume, by one subvolume destroy call and the
//!  deletion of its administrative directory, in place of Git's file-by-file removal.
//! Why:
//!  Git unlinks every file of the worktree (0.7 s without ignored state, 3.6 s with a large ignored tree in the
//!  measurements of `doc/planning/cli-git-btrfs-worktree.md`), and the subvolume call takes milliseconds. The call
//!  cannot be undone, so the wrapper takes it only when the same conditions hold that make Git remove without
//!  `--force`: the worktree is registered in the repository the command runs in, under the name Git would pick, is not
//!  locked, is clean (`worktree_clean.rs`), and holds no submodule. The mount must also allow the call to an
//!  unprivileged user (`user_subvol_rm_allowed`). Every other case, and every call that the kernel refuses, is left to
//!  Git, which then words the refusal itself.
//!
//!  The check and the destroy are not one step, as Git's check and its deletion are not. Three things keep the
//!  window small and its worst case no worse than Git's: the directories are held open from before the proof of
//!  cleanliness to the destroy and the destroy names the subvolume by id (or by name below the held parent), the lock,
//!  the identity of the worktree directory and of the registration are looked at again immediately before the destroy,
//!  and the registration is emptied through its held descriptor, never by a path that could name another directory by
//!  then. What remains is a write or a lock made by another process in the milliseconds between the last look and the
//!  destroy; Git has the same window for its whole deletion, which takes seconds (`SPEC.md`).
//!
//! In TS you'd write (pseudocode):
//! ```ts
//! const outcome = await removeSubvolumeWorktree(request); // 'left-to-git': run git worktree remove
//! ```

use crate::diagnostic_log::{debug, warn};
use crate::fs_beneath::{ObjectId, handle_path, identity_at, identity_of, open_directory_at, open_file_at, open_root, remove_at};
use crate::fs_btrfs_ioctl::{snapshot_destroy_by_id, snapshot_destroy_in, subvolume_id};
use crate::fs_kind::is_subvolume_root;
use crate::fs_mount_options::read_mount_facts;
use crate::subvolume_remove_walk::{empty_directory, verify_below};
use crate::transaction_git::GitContext;
use crate::worktree_clean::check_clean;
use crate::worktree_remove_plan::RemovePlan;
use crate::worktree_remove_registry::{Registration, registration_of};
use std::ffi::OsString;
use std::fs::File;
use std::io::Read;
use std::path::{Path, PathBuf};

/// Tag used for diagnostics from this module.
const TAG: &str = "worktreeRemove";

/// The mount option that lets a regular user destroy a subvolume with the ioctl.
const REMOVAL_OPTION: &str = "user_subvol_rm_allowed";

/// What the command says on standard error whenever it destroys a subvolume.
pub const DESTROY_NOTICE: &str = "cli-git: destroyed the btrfs subvolume of the removed worktree in one step; this cannot be undone, and anything another process wrote or locked in the moments since the last check went with it (the risk of git worktree remove, here faster).";

/// What:
///  Whether the administrative directory holds a `locked` entry.
/// Why:
///   A lock is a promise to keep the worktree. Only an entry that is certainly absent (`NotFound`) means unlocked; any
///  other failure to look it up leaves the lock unknown, and an unknown lock is not a reason to destroy.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// function lockState(admin: string): boolean; // throws when the entry cannot be looked up
/// ```
fn lock_state(admin: &Path) -> Result<bool, String> {
    let entry: PathBuf = admin.join("locked");
    return match entry.symlink_metadata() {
        Ok(_found) => Ok(true),
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
        Err(error) => Err(format!("the lock entry {} cannot be looked up ({error}), so the worktree is not known to be unlocked", entry.display())),
    };
}

/// What:
///  How one attempt to remove a worktree with a subvolume destroy ended.
/// Why:
///   Three endings need three different next steps: Git removes it (nothing changed), the work is done, or the files
///  are gone and the registration is not, which only the person can finish and which must never reach Git.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// type RemoveOutcome = { kind: 'left-to-git' } | { kind: 'removed' } | { kind: 'incomplete'; report: string };
/// ```
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum RemoveOutcome {
    /// Nothing was changed; real Git removes the worktree or states its refusal.
    LeftToGit,
    /// The subvolume and the administrative directory are gone.
    Removed,
    /// The subvolume was destroyed but the registration could not be removed; the report is for standard error.
    Incomplete(String),
}

/// What:
///  What one removal is asked to do and in which repository.
/// Why:
///   The repository that was invoked decides which registrations count; the worktree's own pointers never do.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// type RemoveRequest = { context: GitContext; plan: RemovePlan; commonDir: string };
/// ```
#[derive(Clone, Copy, Debug)]
pub struct RemoveRequest<'a> {
    /// How to start real Git for the cleanliness proof.
    pub context: &'a GitContext,
    /// The directory the person named.
    pub plan: &'a RemovePlan,
    /// The canonical common Git directory of the repository the command was run in.
    pub common_dir: &'a Path,
}

/// What:
///  The directories one removal holds open from before the proof of cleanliness to the destroy, and the identities they
///  had when they were looked at.
/// Why:
///   A descriptor names the object it was opened on, whatever happens to the path afterwards; the identities let the
///  last look before the destroy tell a swapped name from the held object.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// type Held = { parent: FileHandle; name: string; target: FileHandle; identity: ObjectId; admin: FileHandle; ... };
/// ```
#[derive(Debug)]
pub struct Held {
    /// The directory that holds the worktree directory.
    pub parent: File,
    /// The canonical path of `parent`, for messages and for the check that the path still names it.
    pub parent_path: PathBuf,
    /// The name of the worktree directory in `parent`.
    pub name: OsString,
    /// The worktree directory itself (the root of its subvolume).
    pub target: File,
    /// The identity of `target` when it was opened.
    pub identity: ObjectId,
    /// The identity of `parent` when it was opened.
    pub parent_identity: ObjectId,
    /// The `worktrees` directory of the repository.
    pub worktrees: File,
    /// The administrative directory of the worktree.
    pub admin: File,
    /// The identity of `admin` when it was opened.
    pub admin_identity: ObjectId,
}

/// What:
///  The operations of one removal that touch the kernel or start Git, behind one trait.
///  `&self` borrows the implementation read-only; `dyn RemoveOperations` is how a caller holds "any implementation"
///  (a TS interface).
/// Why:
///   The ordering of the checks is the same on any filesystem; a stand-in lets the controls run it on a plain
///  directory tree, and swap objects between two steps to prove the identity checks.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// interface RemoveOperations { isSubvolumeRoot(path): boolean; removalAllowed(path): boolean; checkClean(...): void; destroy(held): void }
/// ```
pub trait RemoveOperations {
    /// Whether `path` is the root of a subvolume.
    fn is_subvolume_root(&self, path: &Path) -> bool;
    /// Whether an unprivileged destroy is allowed on the mount holding `path`.
    fn removal_allowed(&self, path: &Path) -> bool;
    /// The proof that the worktree is clean, or the reason it is not proven.
    fn check_clean(&self, request: &RemoveRequest<'_>, admin: &Path, common_dir: &Path) -> Result<(), String>;
    /// Destroy the subvolume that `held` holds, and nothing else.
    fn destroy(&self, held: &Held) -> Result<(), String>;
}

/// The kernel and Git, as they are.
#[derive(Clone, Copy, Debug, Default)]
pub struct LinuxOperations;

/// Whether an errno from the destroy by id means this kernel lacks the call, so that the destroy by name below the held
///  parent is the way left.
fn by_id_unavailable(errno: i32) -> bool {
    return errno == libc::ENOTTY || errno == libc::EOPNOTSUPP || errno == libc::EINVAL;
}

/// Every operation is the primitive of the same name.
impl RemoveOperations for LinuxOperations {
    fn is_subvolume_root(&self, path: &Path) -> bool {
        return is_subvolume_root(path).unwrap_or(false);
    }

    fn removal_allowed(&self, path: &Path) -> bool {
        return read_mount_facts(path).ok().flatten().is_some_and(|facts| { return facts.has_option(REMOVAL_OPTION); });
    }

    fn check_clean(&self, request: &RemoveRequest<'_>, admin: &Path, common_dir: &Path) -> Result<(), String> {
        return check_clean(request.context, request.plan.target.as_path(), admin, common_dir).map(drop).map_err(|refusal| { return refusal.reason; });
    }

    fn destroy(&self, held: &Held) -> Result<(), String> {
        let shown: PathBuf = held.parent_path.join(held.name.as_os_str());
        // The id names the subvolume that was held, whatever path it has by now.
        match subvolume_id(&held.target, shown.as_path()) {
            Ok(id) => match snapshot_destroy_by_id(&held.parent, id, shown.as_path()) {
                Ok(()) => return Ok(()),
                Err(error) if by_id_unavailable(error.errno) => {
                    debug(TAG, format!("the destroy of {} by id is not available ({error}); destroying it by name below the held parent", shown.display()).as_str());
                }
                Err(error) => return Err(error.to_string()),
            },
            Err(error) if by_id_unavailable(error.errno) => {
                debug(TAG, format!("the id of the subvolume {} is not available ({error}); destroying it by name below the held parent", shown.display()).as_str());
            }
            Err(error) => return Err(error.to_string()),
        }
        return snapshot_destroy_in(&held.parent, held.name.as_os_str(), held.parent_path.as_path()).map_err(|error| { return error.to_string(); });
    }
}

/// What:
///  Open the worktree directory, its parent, and its registration, and record what each one is.
/// Why:
///   Everything after this point is about these objects, not about the paths that name them.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// async function hold(target: string, registration: Registration): Promise<Held>; // throws the reason
/// ```
fn hold(target: &Path, registration: &Registration) -> Result<Held, String> {
    let parent_path: PathBuf = target.parent().ok_or_else(|| { return format!("{} has no parent directory", target.display()); })?.to_path_buf();
    let name: OsString = target.file_name().ok_or_else(|| { return format!("{} has no last component", target.display()); })?.to_os_string();
    let refused = |what: &str, path: &Path, error: std::io::Error| -> String { return format!("{what} {} cannot be opened without following a link ({error})", path.display()); };
    let parent: File = open_root(parent_path.as_path()).map_err(|error: std::io::Error| { return refused("the directory", parent_path.as_path(), error); })?;
    let held_target: File = open_directory_at(&parent, name.as_os_str()).map_err(|error: std::io::Error| { return refused("the worktree", target, error); })?;
    let worktrees: File = open_root(registration.worktrees.as_path()).map_err(|error: std::io::Error| { return refused("the directory", registration.worktrees.as_path(), error); })?;
    let admin: File = open_directory_at(&worktrees, registration.id.as_os_str()).map_err(|error: std::io::Error| { return refused("the registration", registration.admin.as_path(), error); })?;
    let identity_error = |error: std::io::Error| -> String { return format!("the identity of {} cannot be read ({error})", target.display()); };
    return Ok(Held {
        identity: identity_of(&held_target).map_err(identity_error)?,
        parent_identity: identity_of(&parent).map_err(identity_error)?,
        admin_identity: identity_of(&admin).map_err(identity_error)?,
        parent,
        parent_path,
        name,
        target: held_target,
        worktrees,
        admin,
    });
}

/// What:
///  The last look before the destroy: the lock, and that the names still lead to the objects that were held and proven.
/// Why:
///   The proof of cleanliness takes time (it starts Git); a lock taken, a directory moved away and another put in its
///  place, an ancestor replaced by a link, or a registration replaced in that time must stop the destroy.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// async function confirm(held: Held, registration: Registration): Promise<void>; // throws the reason
/// ```
fn confirm(held: &Held, registration: &Registration) -> Result<(), String> {
    let shown: PathBuf = held.parent_path.join(held.name.as_os_str());
    if lock_state(handle_path(&held.admin).as_path())? {
        return Err(format!("{} was locked after it was proven clean", shown.display()));
    }
    let named_now: ObjectId = identity_at(&held.parent, held.name.as_os_str()).map_err(|error: std::io::Error| { return format!("{} cannot be looked up again ({error})", shown.display()); })?;
    if named_now != held.identity {
        return Err(format!("{} is not the directory that was proven clean any more", shown.display()));
    }
    let parent_now: File = open_root(held.parent_path.as_path()).map_err(|error: std::io::Error| { return format!("{} cannot be opened again without following a link ({error})", held.parent_path.display()); })?;
    if identity_of(&parent_now).map_err(|error: std::io::Error| { return format!("{} cannot be looked up again ({error})", held.parent_path.display()); })? != held.parent_identity {
        return Err(format!("{} is not the directory that held the worktree any more", held.parent_path.display()));
    }
    let admin_now: ObjectId = identity_at(&held.worktrees, registration.id.as_os_str()).map_err(|error: std::io::Error| { return format!("{} cannot be looked up again ({error})", registration.admin.display()); })?;
    if admin_now != held.admin_identity {
        return Err(format!("{} is not the registration that was proven any more", registration.admin.display()));
    }
    let recorded: Vec<u8> = read_held_file(held, "gitdir").map_err(|error: std::io::Error| { return format!("{} cannot be read again ({error})", registration.admin.join("gitdir").display()); })?;
    if recorded != registration.gitdir_text {
        return Err(format!("{} changed after it was proven", registration.admin.join("gitdir").display()));
    }
    return Ok(());
}

/// The bytes of a file of the held administrative directory.
fn read_held_file(held: &Held, name: &str) -> std::io::Result<Vec<u8>> {
    let mut file: File = open_file_at(&held.admin, std::ffi::OsStr::new(name))?;
    let mut bytes: Vec<u8> = Vec::new();
    file.read_to_end(&mut bytes)?;
    return Ok(bytes);
}

/// The command that forgets a registration whose directory is gone, aimed at the repository of `common_dir`.
fn prune_command(common_dir: &Path) -> String {
    return match common_dir.to_str().and_then(|text: &str| { return text.strip_suffix("/.git"); }) {
        Some(main) => format!("git -C {main} worktree prune"),
        None => format!("git --git-dir={} worktree prune", common_dir.display()),
    };
}

/// What:
///  The report of a removal that destroyed the files but left the registration: what is gone, what is left and where, why,
///  and each way to finish.
/// Why:
///   Git would now answer that the path is not a working tree, so the person needs the leftover's name and the command
///  that removes it, and must not be told that the removal succeeded.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// function incompleteReport(args: { target: string; admin: string; commonDir: string; reason: string }): string;
/// ```
fn incomplete_report(target: &Path, registration: &Registration, common_dir: &Path, reason: &str) -> String {
    let prune: String = prune_command(common_dir);
    return format!(
        "cli-git: the worktree {target} was removed (its subvolume was destroyed), but its registration {admin} was not: {reason}.\n\
         Git still lists the worktree as missing. To finish, run `{prune}`; it forgets every registration whose directory is \
         missing, and `{prune} --dry-run --verbose` lists them first.\n\
         If that fails the same way, make {admin} and {worktrees} writable by you (`chmod --recursive u+rwx {admin}`) and \
         remove {admin} yourself (`rm --recursive {admin}`).\n\
         Running `git worktree remove` for {target} again cannot finish this: the directory is already gone.",
        target = target.display(),
        admin = registration.admin.display(),
        worktrees = registration.worktrees.display(),
        reason = reason,
        prune = prune
    );
}

/// What:
///  After the destroy, remove the registration through the descriptors that were held, and report how that ended.
/// Why:
///   The registration is emptied through its held descriptor and removed from the held `worktrees` directory by name, which
///  succeeds only for an empty directory; a registration that another process replaced is therefore never touched.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// async function finish(held: Held, registration: Registration, request: RemoveRequest): Promise<RemoveOutcome>;
/// ```
fn finish(held: &Held, registration: &Registration, request: &RemoveRequest<'_>) -> RemoveOutcome {
    let target: &Path = request.plan.target.as_path();
    let incomplete = |reason: String| -> RemoveOutcome {
        let report: String = incomplete_report(target, registration, request.common_dir, reason.as_str());
        warn(TAG, report.as_str());
        return RemoveOutcome::Incomplete(report);
    };
    match identity_at(&held.worktrees, registration.id.as_os_str()) {
        Ok(found) if found == held.admin_identity => {}
        Ok(_other) => return incomplete(String::from("another registration took its place while the worktree was being removed, and it was left untouched")),
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
            debug(TAG, format!("the registration {} is already gone", registration.admin.display()).as_str());
            return RemoveOutcome::Removed;
        }
        Err(error) => return incomplete(format!("it cannot be looked up ({error})")),
    }
    let admin: File = match held.admin.try_clone() {
        Ok(admin) => admin,
        Err(error) => return incomplete(format!("its directory cannot be used ({error})")),
    };
    if let Err(error) = verify_below(&admin, registration.admin.as_path()).and_then(|()| { return empty_directory(&admin, registration.admin.as_path()); }) {
        return incomplete(format!("its content cannot be removed ({error})"));
    }
    if let Err(error) = remove_at(&held.worktrees, registration.id.as_os_str(), true) {
        return incomplete(format!("the directory cannot be removed from {} ({error})", registration.worktrees.display()));
    }
    // Git removes the `worktrees` directory when the last registration goes; a directory that still holds others stays.
    let _kept = std::fs::remove_dir(registration.worktrees.as_path());
    debug(TAG, format!("destroyed the subvolume {} and its administrative directory", target.display()).as_str());
    return RemoveOutcome::Removed;
}

/// What:
///  Remove the worktree if every condition holds, with the kernel and Git as they are.
/// Why:
///   See `remove_with`.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// async function removeSubvolumeWorktree(request: RemoveRequest): Promise<RemoveOutcome>;
/// ```
pub fn remove_subvolume_worktree(request: &RemoveRequest<'_>) -> RemoveOutcome {
    return remove_with(&LinuxOperations, request);
}

/// What:
///  Remove the worktree if every condition holds; report how it ended.
/// Why:
///   `LeftToGit` means Git has to do the removal, and nothing was changed.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// async function removeWith(operations: RemoveOperations, request: RemoveRequest): Promise<RemoveOutcome>;
/// ```
pub fn remove_with(operations: &dyn RemoveOperations, request: &RemoveRequest<'_>) -> RemoveOutcome {
    let target: &Path = request.plan.target.as_path();
    let registration: Registration = match registration_of(request.common_dir, target, request.plan.operand.as_os_str()) {
        Ok(found) => found,
        Err(reason) => {
            debug(TAG, format!("{} is not served ({reason}); Git decides", target.display()).as_str());
            return RemoveOutcome::LeftToGit;
        }
    };
    match lock_state(registration.admin.as_path()) {
        Ok(false) => {}
        Ok(true) => {
            debug(TAG, format!("{} is locked; Git decides", target.display()).as_str());
            return RemoveOutcome::LeftToGit;
        }
        Err(reason) => {
            debug(TAG, format!("{reason}; Git decides").as_str());
            return RemoveOutcome::LeftToGit;
        }
    }
    if !operations.is_subvolume_root(target) {
        debug(TAG, format!("{} is not a subvolume; Git removes it file by file", target.display()).as_str());
        return RemoveOutcome::LeftToGit;
    }
    if !operations.removal_allowed(target) {
        debug(TAG, format!("the mount of {} lacks {REMOVAL_OPTION}; Git removes the worktree file by file", target.display()).as_str());
        return RemoveOutcome::LeftToGit;
    }
    let held: Held = match hold(target, &registration) {
        Ok(held) => held,
        Err(reason) => {
            debug(TAG, format!("{reason}; Git decides").as_str());
            return RemoveOutcome::LeftToGit;
        }
    };
    if let Err(reason) = operations.check_clean(request, registration.admin.as_path(), request.common_dir) {
        debug(TAG, format!("{} is not proven clean ({reason}); Git decides", target.display()).as_str());
        return RemoveOutcome::LeftToGit;
    }
    if let Err(reason) = confirm(&held, &registration) {
        debug(TAG, format!("{reason}; Git decides").as_str());
        return RemoveOutcome::LeftToGit;
    }
    if let Err(error) = operations.destroy(&held) {
        debug(TAG, format!("the subvolume of {} was not destroyed ({error}); Git removes it", target.display()).as_str());
        return RemoveOutcome::LeftToGit;
    }
    return finish(&held, &registration, request);
}

/// Subvolume removal controls stay out of the release executable.
#[cfg(test)]
#[path = "worktree_remove_tests.rs"]
mod tests;

/// Controls that swap objects between the steps of a removal run on a stand-in for the kernel.
#[cfg(test)]
#[path = "worktree_remove_guard_tests.rs"]
mod guard_tests;