git_policy_cli/worktree_copy_registry.rs
1//! What:
2//! The linked worktrees a worktree-creating Git command registered:
3//! the administrative identities before real Git ran,
4//! the identities after it returned,
5//! and the canonical root of each new one.
6//! Why:
7//! A worktree that Git registered before failing still receives the ignored files,
8//! so the comparison runs after Git returns whatever its status was.
9//! The main worktree is observed only when the caller names repository paths to copy from it
10//! (`observe_copy_source`), so its other invocations cost no filesystem reads.
11//!
12//! In TS you'd write (pseudocode):
13//! ```ts
14//! const observation = await observeWorktreeRepository({ args, gitPath });
15//! const { created, registeredRoots } = await findCreatedWorktrees({ observation, gitPath });
16//! ```
17
18use super::git_metadata::{path_from_git_bytes, strip_git_line};
19use super::transaction_git::{GitContext, GitRequest, run_git};
20use super::worktree_copy_error::{WorktreeCopyError, failure_with_cause, raw_failure};
21use super::worktree_copy_selection::CopySelection;
22use super::worktree_identity::WorktreeIdentity;
23use crate::json_record::quote;
24use std::collections::{BTreeMap, BTreeSet};
25use std::path::{Path, PathBuf};
26
27/// What:
28/// The Git-file prefix that introduces a linked worktree's administrative path.
29/// Why:
30/// A `.git` file that does not start with it marks the main worktree.
31///
32/// In TS you'd write (pseudocode):
33/// ```ts
34/// const GITDIR_PREFIX = 'gitdir: ';
35/// ```
36const GITDIR_PREFIX: &str = "gitdir: ";
37
38/// What:
39/// The facts captured before real Git runs for an applicable invocation.
40/// Why:
41/// The comparison after Git needs the administrative directory, the identities that
42/// already existed and the roots that decide where copies land.
43///
44/// In TS you'd write (pseudocode):
45/// ```ts
46/// type WorktreeCopyObservation = { adminRoot; beforeAdminIds; commonDir; effectiveCwd; sourceRoot? };
47/// ```
48#[derive(Clone, Debug, Eq, PartialEq)]
49pub struct WorktreeCopyObservation {
50 /// Common-directory location holding linked-worktree administration.
51 pub admin_root: PathBuf,
52 /// Linked-worktree administrative identities present before real Git ran.
53 pub before_admin_ids: BTreeSet<String>,
54 /// Canonical common Git directory, which owns the journals.
55 pub common_dir: PathBuf,
56 /// Working directory the Git queries run in.
57 pub effective_cwd: PathBuf,
58 /// Canonical source worktree root;
59 /// absent for a bare repository.
60 pub source_root: Option<PathBuf>,
61 /// Which of the source's ignored paths the copy takes.
62 pub selection: CopySelection,
63}
64
65/// What:
66/// One linked worktree registered while real Git ran.
67/// Why:
68/// The administrative identity is stable across renames of the destination path.
69///
70/// In TS you'd write (pseudocode):
71/// ```ts
72/// type CreatedWorktree = { adminId: string; root: string };
73/// ```
74#[derive(Clone, Debug, Eq, PartialEq)]
75pub struct CreatedWorktree {
76 /// Basename of the linked administrative directory.
77 pub admin_id: String,
78 /// Canonical created worktree root.
79 pub root: PathBuf,
80}
81
82/// What:
83/// The created worktrees and every registered root that recursive copying must exclude.
84/// Why:
85/// A nested registered worktree is never copied into its parent's copy.
86///
87/// In TS you'd write (pseudocode):
88/// ```ts
89/// { created: CreatedWorktree[]; registeredRoots: string[] }
90/// ```
91#[derive(Clone, Debug, Eq, PartialEq)]
92pub struct CreatedWorktrees {
93 /// Newly registered linked worktrees, sorted by administrative identity.
94 pub created: Vec<CreatedWorktree>,
95 /// Every registered root that exists on the filesystem.
96 pub registered_roots: Vec<PathBuf>,
97}
98
99/// What:
100/// The linked worktrees Git currently lists, keyed by administrative identity.
101/// Why:
102/// The after-Git lookup maps each new identity to its root.
103///
104/// In TS you'd write (pseudocode):
105/// ```ts
106/// { rootsByAdminId: Map<string, string>; roots: string[] }
107/// ```
108#[derive(Clone, Debug, Eq, PartialEq)]
109struct RegisteredWorktrees {
110 /// Root of each linked worktree by administrative identity.
111 roots_by_admin_id: BTreeMap<String, PathBuf>,
112 /// Every existing registered root.
113 roots: Vec<PathBuf>,
114}
115
116/// What:
117/// The administrative directory names under a common worktree-administration root.
118/// Why:
119/// A missing directory means no linked worktree has ever been registered,
120/// so it is an empty set rather than a failure.
121///
122/// In TS you'd write (pseudocode):
123/// ```ts
124/// async function readAdminIds(adminRoot: string): Promise<ReadonlySet<string>>;
125/// ```
126pub fn read_admin_ids(admin_root: &Path) -> Result<BTreeSet<String>, WorktreeCopyError> {
127 // `match` separates a missing directory (empty) from every other failure (fatal).
128 match std::fs::read_dir(admin_root) {
129 Ok(entries) => {
130 let mut names: BTreeSet<String> = BTreeSet::new();
131 for entry in entries {
132 let listing: std::fs::DirEntry =
133 entry.map_err(|error| return inspect_failure(admin_root, &error))?;
134 // `file_type` does not follow symbolic links, as the incumbent's `Dirent` does not.
135 let is_directory: bool = listing
136 .file_type()
137 .map_err(|error| return inspect_failure(admin_root, &error))?
138 .is_dir();
139 if is_directory {
140 names.insert(listing.file_name().to_string_lossy().into_owned());
141 }
142 }
143 return Ok(names);
144 }
145 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
146 return Ok(BTreeSet::new());
147 }
148 Err(error) => {
149 return Err(inspect_failure(admin_root, &error));
150 }
151 }
152}
153
154/// What:
155/// The failure of reading linked-worktree administration.
156/// Why:
157/// Names the directory so the user can see which repository state could not be read.
158///
159/// In TS you'd write (pseudocode):
160/// ```ts
161/// new WorktreeCopyError(`cli-git: could not inspect linked-worktree administration at ${JSON.stringify(adminRoot)}.`, error)
162/// ```
163fn inspect_failure(admin_root: &Path, error: &std::io::Error) -> WorktreeCopyError {
164 let message: String = format!(
165 "cli-git: could not inspect linked-worktree administration at {}.",
166 quote(admin_root.to_string_lossy().as_ref())
167 );
168 return failure_with_cause(message.as_str(), Some(error));
169}
170
171/// What:
172/// Captures what the post-Git comparison needs for one repository identity.
173/// Why:
174/// The main worktree and a repository outside any worktree return `None` before any read,
175/// so their invocations stay on the plain forwarding path.
176///
177/// In TS you'd write (pseudocode):
178/// ```ts
179/// async function observeWorktreeRepository({ args, gitPath }): Promise<WorktreeCopyObservation | typeof WORKTREE_COPY_NOT_APPLICABLE>;
180/// ```
181pub fn observe_worktree_repository(
182 identity: &WorktreeIdentity,
183 effective_cwd: &Path,
184) -> Result<Option<WorktreeCopyObservation>, WorktreeCopyError> {
185 // `match` decides applicability from the identity alone; only the two repository kinds reach a read.
186 match identity {
187 WorktreeIdentity::OutsideWorktree | WorktreeIdentity::MainWorktree { .. } => {
188 return Ok(None);
189 }
190 WorktreeIdentity::BareRepository { common_dir, .. } => {
191 return observation(
192 common_dir.as_path(),
193 None,
194 effective_cwd,
195 CopySelection::Ignored,
196 )
197 .map(Some);
198 }
199 WorktreeIdentity::LinkedWorktree {
200 common_dir,
201 worktree_root,
202 ..
203 } => {
204 return observation(
205 common_dir.as_path(),
206 Some(worktree_root.as_path()),
207 effective_cwd,
208 CopySelection::Ignored,
209 )
210 .map(Some);
211 }
212 }
213}
214
215/// What:
216/// The observation that also covers a main checkout, when the caller names repository paths to copy from it.
217/// Why:
218/// A main checkout is not a bypass any more for the named paths (the generated rules file a fresh worktree needs),
219/// but it is never copied as a whole: its ignored trees are the repository's heaviest.
220/// With no named paths the main checkout stays unobserved, so its invocations cost no filesystem reads.
221///
222/// In TS you'd write (pseudocode):
223/// ```ts
224/// observeCopySource({ identity, effectiveCwd, mainCheckoutPaths }): Promise<WorktreeCopyObservation | typeof WORKTREE_COPY_NOT_APPLICABLE>;
225/// ```
226pub fn observe_copy_source(
227 identity: &WorktreeIdentity,
228 effective_cwd: &Path,
229 main_checkout_paths: &[String],
230) -> Result<Option<WorktreeCopyObservation>, WorktreeCopyError> {
231 // `match` with a guard: only a main checkout with named paths takes the restricted selection.
232 match identity {
233 WorktreeIdentity::MainWorktree {
234 common_dir,
235 worktree_root,
236 ..
237 } if !main_checkout_paths.is_empty() => {
238 return observation(
239 common_dir.as_path(),
240 Some(worktree_root.as_path()),
241 effective_cwd,
242 CopySelection::Paths(main_checkout_paths.to_vec()),
243 )
244 .map(Some);
245 }
246 _ => return observe_worktree_repository(identity, effective_cwd),
247 }
248}
249
250/// What:
251/// The observation of one repository with its optional source worktree.
252/// Why:
253/// Both repository kinds share the administrative read; only the source differs.
254///
255/// In TS you'd write (pseudocode):
256/// ```ts
257/// { adminRoot: join(commonDir, 'worktrees'), beforeAdminIds: await readAdminIds(adminRoot), commonDir, effectiveCwd, sourceRoot }
258/// ```
259fn observation(
260 common_dir: &Path,
261 source_root: Option<&Path>,
262 effective_cwd: &Path,
263 selection: CopySelection,
264) -> Result<WorktreeCopyObservation, WorktreeCopyError> {
265 let admin_root: PathBuf = common_dir.join("worktrees");
266 let before_admin_ids: BTreeSet<String> = read_admin_ids(admin_root.as_path())?;
267 return Ok(WorktreeCopyObservation {
268 admin_root,
269 before_admin_ids,
270 common_dir: common_dir.to_path_buf(),
271 effective_cwd: effective_cwd.to_path_buf(),
272 source_root: source_root.map(Path::to_path_buf),
273 selection,
274 });
275}
276
277/// What:
278/// The linked-worktree administrative identity named by a root's `.git` file,
279/// or `None` for the main worktree.
280/// Why:
281/// Only a linked worktree's `.git` is a file that names an administrative directory;
282/// a `.git` directory is the main worktree.
283///
284/// In TS you'd write (pseudocode):
285/// ```ts
286/// async function readLinkedAdminId(root: string): Promise<string | typeof MAIN_WORKTREE_ADMIN_ID>;
287/// ```
288fn read_linked_admin_id(root: &Path) -> Result<Option<String>, WorktreeCopyError> {
289 let pointer_path: PathBuf = root.join(".git");
290 // `read` fails with `IsADirectory` for a main worktree's `.git` directory, which is not an error here.
291 match std::fs::read(pointer_path.as_path()) {
292 Ok(bytes) => {
293 let line: &[u8] = strip_git_line(bytes.as_slice());
294 let Some(gitdir) = line.strip_prefix(GITDIR_PREFIX.as_bytes()) else {
295 return Ok(None);
296 };
297 let raw_admin: PathBuf =
298 path_from_git_bytes(gitdir).ok_or_else(|| return identity_failure(root, None))?;
299 let admin_path: PathBuf = if raw_admin.is_absolute() {
300 raw_admin
301 } else {
302 root.join(raw_admin)
303 };
304 let canonical: PathBuf = std::fs::canonicalize(admin_path.as_path())
305 .map_err(|error| return identity_failure(root, Some(&error)))?;
306 return Ok(canonical
307 .file_name()
308 .map(|name| return name.to_string_lossy().into_owned()));
309 }
310 Err(error) if error.kind() == std::io::ErrorKind::IsADirectory => {
311 return Ok(None);
312 }
313 Err(error) => {
314 return Err(identity_failure(root, Some(&error)));
315 }
316 }
317}
318
319/// What:
320/// The failure of resolving a registered root's linked identity.
321/// Why:
322/// Names the root so the user can find the worktree whose pointer could not be read.
323///
324/// In TS you'd write (pseudocode):
325/// ```ts
326/// new WorktreeCopyError(`cli-git: could not resolve worktree identity for ${JSON.stringify(root)}.`, error)
327/// ```
328fn identity_failure(root: &Path, cause: Option<&dyn std::fmt::Display>) -> WorktreeCopyError {
329 let message: String = format!(
330 "cli-git: could not resolve worktree identity for {}.",
331 quote(root.to_string_lossy().as_ref())
332 );
333 return failure_with_cause(message.as_str(), cause);
334}
335
336/// What:
337/// The canonical root of a Git-reported worktree path,
338/// or `None` when Git still lists a root that no longer exists.
339/// Why:
340/// Git keeps a registration after its directory is deleted;
341/// such a root cannot be copied into or excluded by identity, so it is skipped.
342///
343/// In TS you'd write (pseudocode):
344/// ```ts
345/// async function canonicalRegisteredRoot(root: string): Promise<string | typeof REGISTERED_ROOT_MISSING>;
346/// ```
347fn canonical_registered_root(root: &Path) -> Result<Option<PathBuf>, WorktreeCopyError> {
348 match std::fs::canonicalize(root) {
349 Ok(canonical) => return Ok(Some(canonical)),
350 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
351 Err(error) => return Err(raw_failure(&error)),
352 }
353}
354
355/// What:
356/// The linked worktrees Git lists right now, keyed by administrative identity.
357/// Why:
358/// Git's porcelain listing with NUL separators is the one authority for registered roots,
359/// so the copy never guesses a root from the administrative directory name.
360///
361/// In TS you'd write (pseudocode):
362/// ```ts
363/// async function readRegisteredWorktrees({ observation, gitPath }): Promise<{ rootsByAdminId; roots }>;
364/// ```
365fn read_registered_worktrees(
366 context: &GitContext,
367 observation: &WorktreeCopyObservation,
368) -> Result<RegisteredWorktrees, WorktreeCopyError> {
369 let request: GitRequest = GitRequest {
370 cwd: observation.effective_cwd.clone(),
371 arguments: vec![
372 "--git-dir".into(),
373 observation.common_dir.as_os_str().to_os_string(),
374 "worktree".into(),
375 "list".into(),
376 "--porcelain".into(),
377 "-z".into(),
378 ],
379 without_prefix: true,
380 ..GitRequest::default()
381 };
382 let output = run_git(context, &request).map_err(|error| return raw_failure(&error))?;
383 if !output.succeeded() {
384 return Err(raw_failure(&String::from_utf8_lossy(
385 output.stderr.as_slice(),
386 )));
387 }
388 let mut roots: Vec<PathBuf> = Vec::new();
389 let mut listed: Vec<PathBuf> = Vec::new();
390 // `filter_map` keeps only fields of the `worktree <path>` form.
391 for path in output
392 .stdout
393 .split(|byte| return *byte == 0)
394 .filter_map(|field| return field.strip_prefix(b"worktree "))
395 .filter_map(path_from_git_bytes)
396 {
397 listed.push(path);
398 }
399 for path in listed {
400 if let Some(canonical) = canonical_registered_root(path.as_path())? {
401 roots.push(canonical);
402 }
403 }
404 let mut roots_by_admin_id: BTreeMap<String, PathBuf> = BTreeMap::new();
405 for root in &roots {
406 // The main worktree is recognized by its root being the common directory of a non-bare repository.
407 let is_main: bool = observation.source_root.is_none() && root == &observation.common_dir;
408 if is_main {
409 continue;
410 }
411 if let Some(admin_id) = read_linked_admin_id(root.as_path())? {
412 roots_by_admin_id.insert(admin_id, root.clone());
413 }
414 }
415 return Ok(RegisteredWorktrees {
416 roots_by_admin_id,
417 roots,
418 });
419}
420
421/// What:
422/// The worktrees newly registered since the observation, with every registered root.
423/// Why:
424/// Runs after real Git returned, whatever its status,
425/// so a worktree Git registered before failing still counts.
426///
427/// In TS you'd write (pseudocode):
428/// ```ts
429/// async function findCreatedWorktrees({ observation, gitPath }): Promise<{ created; registeredRoots }>;
430/// ```
431pub fn find_created_worktrees(
432 context: &GitContext,
433 observation: &WorktreeCopyObservation,
434) -> Result<CreatedWorktrees, WorktreeCopyError> {
435 let after_admin_ids: BTreeSet<String> = read_admin_ids(observation.admin_root.as_path())?;
436 let created_ids: Vec<String> = after_admin_ids
437 .difference(&observation.before_admin_ids)
438 .cloned()
439 .collect();
440 if created_ids.is_empty() {
441 return Ok(CreatedWorktrees {
442 created: Vec::new(),
443 registered_roots: Vec::new(),
444 });
445 }
446 let registered: RegisteredWorktrees = read_registered_worktrees(context, observation)?;
447 let mut created: Vec<CreatedWorktree> = Vec::new();
448 for admin_id in created_ids {
449 // A new identity whose root Git no longer lists cannot receive a copy, so the copy fails.
450 let Some(root) = registered.roots_by_admin_id.get(&admin_id) else {
451 return Err(failure_with_cause(
452 format!(
453 "cli-git: linked worktree {} was registered but its root could not be resolved.",
454 quote(admin_id.as_str())
455 )
456 .as_str(),
457 None,
458 ));
459 };
460 created.push(CreatedWorktree {
461 admin_id,
462 root: root.clone(),
463 });
464 }
465 return Ok(CreatedWorktrees {
466 created,
467 registered_roots: registered.roots,
468 });
469}
470
471/// Registry controls stay out of the release executable.
472#[cfg(test)]
473#[path = "worktree_copy_registry_tests.rs"]
474mod tests;