Skip to main content

git_policy_cli/worktree/
worktree_remove_registry.rs

1//! What:
2//!  Which registration of the invoking repository a worktree directory belongs to, and whether Git would pick that same
3//!  worktree for the name the person typed.
4//! Why:
5//!  Git removes only a worktree that its own list for the repository it runs in contains, found first by the last
6//!  components of every registered path and only then by the resolved path (`find_worktree` in Git's `worktree.c`). The
7//!  `.git` file of a worktree and the `gitdir` file of an administrative directory that name each other prove only that
8//!  the pair is consistent, not that the repository the command runs in registered it, and a directory that exists under
9//!  the typed name proves nothing about the worktree Git would remove for that name. Anything but a registration of the
10//!  invoking repository that Git would also pick is left to Git, which words its own answer.
11//!
12//! In TS you'd write (pseudocode):
13//! ```ts
14//! const registration = registrationOf({ commonDir, target, operand }); // throws the reason Git must decide
15//! ```
16
17use std::ffi::{OsStr, OsString};
18use std::os::unix::ffi::{OsStrExt, OsStringExt};
19use std::path::{Path, PathBuf};
20
21/// What:
22///  The registration of one linked worktree in the repository the command runs in.
23/// Why:
24///   The administrative directory is where the registration lives; its name is the worktree's id.
25///
26/// In TS you'd write (pseudocode):
27/// ```ts
28/// type Registration = { id: string; admin: string; worktrees: string; gitdirText: Uint8Array };
29/// ```
30#[derive(Clone, Debug, Eq, PartialEq)]
31pub struct Registration {
32    /// The name of the administrative directory below `worktrees`.
33    pub id: OsString,
34    /// The canonical administrative directory, `<common>/worktrees/<id>`.
35    pub admin: PathBuf,
36    /// The canonical `<common>/worktrees` directory.
37    pub worktrees: PathBuf,
38    /// The bytes of the `gitdir` file as they were proven to name the worktree, to compare again before the removal.
39    pub gitdir_text: Vec<u8>,
40}
41
42/// The line of a file without its final newline.
43fn without_newline(text: &[u8]) -> &[u8] {
44    return text.strip_suffix(b"\n").unwrap_or(text);
45}
46
47/// What:
48///  Whether Git's match by last components finds `candidate` for `operand`: the operand is non-empty, ends the path, and
49///  starts at a directory boundary. ASCII case is ignored, which also covers `core.ignorecase`, so a match is never missed.
50/// Why:
51///   Git's `find_worktree_by_suffix`; a looser match only sends more removals to Git.
52///
53/// In TS you'd write (pseudocode):
54/// ```ts
55/// function endsWithName(candidate: string, operand: string): boolean;
56/// ```
57fn ends_with_name(candidate: &[u8], operand: &[u8]) -> bool {
58    if operand.is_empty() || operand.len() > candidate.len() {
59        return false;
60    }
61    let start: usize = candidate.len() - operand.len();
62    let at_boundary: bool = start == 0 || candidate[start - 1] == b'/';
63    return at_boundary && candidate[start..].eq_ignore_ascii_case(operand);
64}
65
66/// What:
67///  Every worktree path the repository lists: the main one, and each `gitdir` of `worktrees/*`, as Git reads them.
68/// Why:
69///   The list Git matches the typed name against. A `gitdir` file Git skips (unreadable or empty) is skipped; a
70///  `worktrees` directory that cannot be listed leaves the list unknown, which is an error.
71///
72/// In TS you'd write (pseudocode):
73/// ```ts
74/// function registeredPaths(commonDir: string): string[];
75/// ```
76fn registered_paths(common_dir: &Path, worktrees: &Path) -> Result<Vec<Vec<u8>>, String> {
77    let mut paths: Vec<Vec<u8>> = Vec::new();
78    let main: &[u8] = common_dir.as_os_str().as_bytes();
79    paths.push(main.strip_suffix(b"/.git").unwrap_or(main).to_vec());
80    let listing = match std::fs::read_dir(worktrees) {
81        Ok(listing) => listing,
82        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(paths),
83        Err(error) => return Err(format!("the registrations in {} cannot be listed ({error})", worktrees.display())),
84    };
85    for found in listing {
86        let entry: std::fs::DirEntry = found.map_err(|error: std::io::Error| { return format!("the registrations in {} cannot be listed ({error})", worktrees.display()); })?;
87        let Ok(text) = std::fs::read(entry.path().join("gitdir")) else { continue };
88        let trimmed: &[u8] = text.trim_ascii_end();
89        if trimmed.is_empty() {
90            continue;
91        }
92        let path: &[u8] = trimmed.strip_suffix(b"/.git").unwrap_or(trimmed);
93        if path.starts_with(b"/") {
94            paths.push(path.to_vec());
95        } else {
96            // A relative record is relative to the administrative directory.
97            let joined: PathBuf = entry.path().join(OsStr::from_bytes(path));
98            let resolved: PathBuf = std::fs::canonicalize(joined.as_path()).unwrap_or(joined);
99            paths.push(resolved.into_os_string().into_vec());
100        }
101    }
102    return Ok(paths);
103}
104
105/// What:
106///  The registration of `target` in the repository whose common Git directory is `common_dir`, when it is a linked
107///  worktree of that repository that Git would also pick for `operand`.
108/// Why:
109///   See the module description. The error is the reason, for the log; the removal is then left to Git.
110///
111/// In TS you'd write (pseudocode):
112/// ```ts
113/// function registrationOf(args: { commonDir: string; target: string; operand: string }): Registration; // throws the reason
114/// ```
115pub fn registration_of(common_dir: &Path, target: &Path, operand: &OsStr) -> Result<Registration, String> {
116    let pointer_path: PathBuf = target.join(".git");
117    let pointer: Vec<u8> = std::fs::read(pointer_path.as_path()).map_err(|error: std::io::Error| { return format!("{} cannot be read as a file ({error}), so it is not a linked worktree", pointer_path.display()); })?;
118    let line: &[u8] = without_newline(pointer.as_slice());
119    let named: &[u8] = line.strip_prefix(b"gitdir: ").ok_or_else(|| { return format!("{} does not start with `gitdir: `", pointer_path.display()); })?;
120    let admin: PathBuf = std::fs::canonicalize(target.join(OsStr::from_bytes(named)))
121        .map_err(|error: std::io::Error| { return format!("the administrative directory {} that {} names cannot be resolved ({error})", String::from_utf8_lossy(named), pointer_path.display()); })?;
122    let worktrees: PathBuf = std::fs::canonicalize(common_dir.join("worktrees"))
123        .map_err(|error: std::io::Error| { return format!("{} cannot be resolved ({error}), so this repository has no linked worktree", common_dir.join("worktrees").display()); })?;
124    if admin.parent() != Some(worktrees.as_path()) {
125        return Err(format!(
126            "the administrative directory {} of {} is not one of the registrations in {}, the repository this command runs in",
127            admin.display(),
128            target.display(),
129            worktrees.display()
130        ));
131    }
132    let id: OsString = admin.file_name().ok_or_else(|| { return format!("{} has no name", admin.display()); })?.to_os_string();
133    let gitdir_text: Vec<u8> = std::fs::read(admin.join("gitdir")).map_err(|error: std::io::Error| { return format!("{} cannot be read ({error})", admin.join("gitdir").display()); })?;
134    let recorded: PathBuf = PathBuf::from(OsStr::from_bytes(without_newline(gitdir_text.as_slice())));
135    if recorded != pointer_path {
136        return Err(format!("{} records {}, not {}", admin.join("gitdir").display(), recorded.display(), pointer_path.display()));
137    }
138    let registered: Vec<Vec<u8>> = registered_paths(common_dir, worktrees.as_path())?;
139    for path in registered {
140        if ends_with_name(path.as_slice(), operand.as_bytes()) && path.as_slice() != target.as_os_str().as_bytes() {
141            return Err(format!(
142                "Git matches the name {} to the registered worktree {} by its last components, not to {}",
143                operand.to_string_lossy(),
144                String::from_utf8_lossy(path.as_slice()),
145                target.display()
146            ));
147        }
148    }
149    return Ok(Registration { id, admin, worktrees, gitdir_text });
150}
151
152/// Registry controls stay out of the release executable.
153#[cfg(test)]
154#[path = "worktree_remove_registry_tests.rs"]
155mod tests;