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
//! What:
//! Which registration of the invoking repository a worktree directory belongs to, and whether Git would pick that same
//! worktree for the name the person typed.
//! Why:
//! Git removes only a worktree that its own list for the repository it runs in contains, found first by the last
//! components of every registered path and only then by the resolved path (`find_worktree` in Git's `worktree.c`). The
//! `.git` file of a worktree and the `gitdir` file of an administrative directory that name each other prove only that
//! the pair is consistent, not that the repository the command runs in registered it, and a directory that exists under
//! the typed name proves nothing about the worktree Git would remove for that name. Anything but a registration of the
//! invoking repository that Git would also pick is left to Git, which words its own answer.
//!
//! In TS you'd write (pseudocode):
//! ```ts
//! const registration = registrationOf({ commonDir, target, operand }); // throws the reason Git must decide
//! ```
use crate::worktree_path_failure_message::path_failure;
use std::ffi::{OsStr, OsString};
use std::os::unix::ffi::{OsStrExt, OsStringExt};
use std::path::{Path, PathBuf};
/// What:
/// The registration of one linked worktree in the repository the command runs in.
/// Why:
/// The administrative directory is where the registration lives; its name is the worktree's id.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// type Registration = { id: string; admin: string; worktrees: string; gitdirText: Uint8Array };
/// ```
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Registration {
/// The name of the administrative directory below `worktrees`.
pub id: OsString,
/// The canonical administrative directory, `<common>/worktrees/<id>`.
pub admin: PathBuf,
/// The canonical `<common>/worktrees` directory.
pub worktrees: PathBuf,
/// The bytes of the `gitdir` file as they were proven to name the worktree, to compare again before the removal.
pub gitdir_text: Vec<u8>,
}
/// The line of a file without its final newline.
fn without_newline(text: &[u8]) -> &[u8] {
return text.strip_suffix(b"\n").unwrap_or(text);
}
/// What:
/// Whether Git's match by last components finds `candidate` for `operand`: the operand is non-empty, ends the path, and
/// starts at a directory boundary. ASCII case is ignored, which also covers `core.ignorecase`, so a match is never missed.
/// Why:
/// Git's `find_worktree_by_suffix`; a looser match only sends more removals to Git.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// function endsWithName(candidate: string, operand: string): boolean;
/// ```
fn ends_with_name(candidate: &[u8], operand: &[u8]) -> bool {
if operand.is_empty() || operand.len() > candidate.len() {
return false;
}
let start: usize = candidate.len() - operand.len();
let at_boundary: bool = start == 0 || candidate[start - 1] == b'/';
return at_boundary && candidate[start..].eq_ignore_ascii_case(operand);
}
/// What:
/// Every worktree path the repository lists: the main one, and each `gitdir` of `worktrees/*`, as Git reads them.
/// Why:
/// The list Git matches the typed name against. A `gitdir` file Git skips (unreadable or empty) is skipped; a
/// `worktrees` directory that cannot be listed leaves the list unknown, which is an error.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// function registeredPaths(commonDir: string): string[];
/// ```
fn registered_paths(common_dir: &Path, worktrees: &Path) -> Result<Vec<Vec<u8>>, String> {
let mut paths: Vec<Vec<u8>> = Vec::new();
let main: &[u8] = common_dir.as_os_str().as_bytes();
paths.push(main.strip_suffix(b"/.git").unwrap_or(main).to_vec());
let listing = match std::fs::read_dir(worktrees) {
Ok(listing) => listing,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(paths),
Err(error) => return Err(format!("the registrations in {} cannot be listed ({error})", worktrees.display())),
};
for found in listing {
let entry: std::fs::DirEntry = match found {
Ok(listed) => listed,
Err(error) => return Err(format!("the registrations in {} cannot be listed ({error})", worktrees.display())),
};
let Ok(text) = std::fs::read(entry.path().join("gitdir")) else { continue };
let trimmed: &[u8] = text.trim_ascii_end();
if trimmed.is_empty() {
continue;
}
let path: &[u8] = trimmed.strip_suffix(b"/.git").unwrap_or(trimmed);
if path.starts_with(b"/") {
paths.push(path.to_vec());
} else {
// A relative record is relative to the administrative directory.
let joined: PathBuf = entry.path().join(OsStr::from_bytes(path));
let resolved: PathBuf = std::fs::canonicalize(joined.as_path()).unwrap_or(joined);
paths.push(resolved.into_os_string().into_vec());
}
}
return Ok(paths);
}
/// What:
/// The registration of `target` in the repository whose common Git directory is `common_dir`, when it is a linked
/// worktree of that repository that Git would also pick for `operand`.
/// Why:
/// See the module description. The error is the reason, for the log; the removal is then left to Git.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// function registrationOf(args: { commonDir: string; target: string; operand: string }): Registration; // throws the reason
/// ```
pub fn registration_of(common_dir: &Path, target: &Path, operand: &OsStr) -> Result<Registration, String> {
let pointer_path: PathBuf = target.join(".git");
let pointer: Vec<u8> = match std::fs::read(pointer_path.as_path()) {
Ok(bytes) => bytes,
Err(error) => return Err(format!("{} cannot be read as a file ({error}), so it is not a linked worktree", pointer_path.display())),
};
let line: &[u8] = without_newline(pointer.as_slice());
let Some(named) = line.strip_prefix(b"gitdir: ") else {
return Err(format!("{} does not start with `gitdir: `", pointer_path.display()));
};
let admin: PathBuf = match std::fs::canonicalize(target.join(OsStr::from_bytes(named))) {
Ok(resolved) => resolved,
Err(error) => return Err(format!("the administrative directory {} that {} names cannot be resolved ({error})", String::from_utf8_lossy(named), pointer_path.display())),
};
let worktrees: PathBuf = match std::fs::canonicalize(common_dir.join("worktrees")) {
Ok(resolved) => resolved,
Err(error) => return Err(format!("{} cannot be resolved ({error}), so this repository has no linked worktree", common_dir.join("worktrees").display())),
};
if admin.parent() != Some(worktrees.as_path()) {
return Err(format!(
"the administrative directory {} of {} is not one of the registrations in {}, the repository this command runs in",
admin.display(),
target.display(),
worktrees.display()
));
}
let Some(admin_name) = admin.file_name() else {
return Err(format!("{} has no name", admin.display()));
};
let id: OsString = admin_name.to_os_string();
let gitdir_text: Vec<u8> = path_failure(std::fs::read(admin.join("gitdir")), admin.join("gitdir").as_path(), "cannot be read")?;
let recorded: PathBuf = PathBuf::from(OsStr::from_bytes(without_newline(gitdir_text.as_slice())));
if recorded != pointer_path {
return Err(format!("{} records {}, not {}", admin.join("gitdir").display(), recorded.display(), pointer_path.display()));
}
let registered: Vec<Vec<u8>> = registered_paths(common_dir, worktrees.as_path())?;
for path in registered {
if ends_with_name(path.as_slice(), operand.as_bytes()) && path.as_slice() != target.as_os_str().as_bytes() {
return Err(format!(
"Git matches the name {} to the registered worktree {} by its last components, not to {}",
operand.to_string_lossy(),
String::from_utf8_lossy(path.as_slice()),
target.display()
));
}
}
return Ok(Registration { id, admin, worktrees, gitdir_text });
}
/// Registry controls stay out of the release executable.
#[cfg(test)]
#[path = "worktree_remove_registry_tests.rs"]
mod tests;