Skip to main content

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;