Skip to main content

git_policy_cli/
worktree_copy_selection.rs

1//! What:
2//!  Which source paths the linked-worktree copy selects:
3//!  the ignored roots Git reports under the standard exclusion stack,
4//!  the ignored special entries (sockets, FIFOs, devices) Git's listing omits,
5//!  and the validation every repository path passes before it touches the filesystem.
6//! Why:
7//!  Git decides what is ignored; the copy only adds the entries Git's index-oriented listing
8//!  cannot report, and it never trusts a path that could leave the worktree.
9//!
10//! In TS you'd write (pseudocode):
11//! ```ts
12//! const roots = await readIgnoredRoots({ sourceRoot, gitPath, excludedRoots });
13//! ```
14
15use super::transaction_git::{GitContext, GitRequest, run_git};
16use super::worktree_copy_error::{WorktreeCopyError, raw_failure};
17use crate::json_record::quote;
18use std::cmp::Ordering;
19use std::path::{Path, PathBuf};
20
21/// What:
22///  The Git directory marker that `ls-files --directory` appends to ignored directories.
23/// Why:
24///   The copy works on the directory itself, so the marker is removed before validation.
25///
26/// In TS you'd write (pseudocode):
27/// ```ts
28/// const DIRECTORY_SUFFIX = '/';
29/// ```
30const DIRECTORY_SUFFIX: char = '/';
31
32/// What:
33///  The private stage prefix the copy reserves; no selected path may contain a component that starts with it.
34/// Why:
35///   A selected path that is a private stage would copy the copy into itself.
36///
37/// In TS you'd write (pseudocode):
38/// ```ts
39/// export const STAGE_PREFIX = '.cli-git-worktree-copy-';
40/// ```
41pub const STAGE_PREFIX: &str = ".cli-git-worktree-copy-";
42
43/// What:
44///  Whether a repository path equals or descends from another repository path.
45/// Why:
46///   Comparison is by whole components, so `cache2` is not inside `cache`.
47///
48/// In TS you'd write (pseudocode):
49/// ```ts
50/// (candidate === parent) || candidate.startsWith(`${parent}/`)
51/// ```
52pub fn repository_path_within(candidate: &str, parent: &str) -> bool {
53    // `||` keeps the exact-equality case and the component-prefix case in one expression.
54    return candidate == parent || candidate.starts_with(format!("{parent}/").as_str());
55}
56
57/// What:
58///  Validates one slash-separated path that Git reported before it is joined to a filesystem root.
59/// Why:
60///   An empty, absolute, `.` or `..` component could select a path outside the worktree.
61///
62/// In TS you'd write (pseudocode):
63/// ```ts
64/// function assertSafeRepositoryPath(repositoryPath: string): void;
65/// ```
66pub fn assert_safe_repository_path(repository_path: &str) -> Result<(), WorktreeCopyError> {
67    let unsafe_path: bool = repository_path.is_empty()
68        || repository_path.starts_with('/')
69        || Path::new(repository_path).is_absolute()
70        || repository_path
71            .split('/')
72            .any(|component| return component.is_empty() || component == "." || component == "..");
73    if unsafe_path {
74        return Err(WorktreeCopyError(format!(
75            "cli-git: Git returned unsafe ignored path {}.",
76            quote(repository_path)
77        )));
78    }
79    return Ok(());
80}
81
82/// What:
83///  The native path of one validated repository path under a filesystem root.
84/// Why:
85///   Every filesystem access of the copy goes through this join, so the validation cannot be skipped.
86///
87/// In TS you'd write (pseudocode):
88/// ```ts
89/// join(root, ...repositoryPath.split('/'));
90/// ```
91pub fn filesystem_path(root: &Path, repository_path: &str) -> Result<PathBuf, WorktreeCopyError> {
92    assert_safe_repository_path(repository_path)?;
93    let mut path: PathBuf = root.to_path_buf();
94    for component in repository_path.split('/') {
95        path.push(component);
96    }
97    return Ok(path);
98}
99
100/// What:
101///  Sorts strings the way JavaScript's default `toSorted` does: by UTF-16 code units.
102/// Why:
103///   Manifest and journal order must match the incumbent's, which differs from byte order
104///  only for supplementary-plane characters.
105///
106/// In TS you'd write (pseudocode):
107/// ```ts
108/// [...values].toSorted()
109/// ```
110pub fn js_order(left: &str, right: &str) -> Ordering {
111    return left.encode_utf16().cmp(right.encode_utf16());
112}
113
114/// What:
115///  Keeps the outermost of several ignored roots, dropping every root under another one.
116/// Why:
117///   Copying a parent already copies its descendants, so a second copy would duplicate work and entries.
118///
119/// In TS you'd write (pseudocode):
120/// ```ts
121/// function collapseIgnoredRoots(paths: readonly string[]): readonly string[];
122/// ```
123fn collapse_ignored_roots(paths: &[String]) -> Vec<String> {
124    let mut candidates: Vec<String> = paths.to_vec();
125    candidates.sort_by(|left, right| return js_order(left, right));
126    candidates.dedup();
127    let mut roots: Vec<String> = Vec::new();
128    for candidate in candidates {
129        let owned: bool = roots
130            .iter()
131            .any(|root| return repository_path_within(candidate.as_str(), root.as_str()));
132        if !owned {
133            roots.push(candidate);
134        }
135    }
136    return roots;
137}
138
139/// What:
140///  Whether Git's standard exclusion stack ignores one special entry.
141/// Why:
142///   `check-ignore --no-index` answers the same question for a path Git's listing never names;
143///  exit status 1 is "not ignored" and every other failure is fatal.
144///
145/// In TS you'd write (pseudocode):
146/// ```ts
147/// await runMetadataGit({ args: ['-C', sourceRoot, 'check-ignore', '--quiet', '--no-index', '--', repositoryPath] })
148/// ```
149fn is_ignored_special_path(
150    context: &GitContext,
151    source_root: &Path,
152    repository_path: &str,
153) -> Result<bool, WorktreeCopyError> {
154    let request: GitRequest = GitRequest {
155        cwd: source_root.to_path_buf(),
156        arguments: vec![
157            "check-ignore".into(),
158            "--quiet".into(),
159            "--no-index".into(),
160            "--".into(),
161            repository_path.into(),
162        ],
163        without_prefix: true,
164        ..GitRequest::default()
165    };
166    let output = run_git(context, &request).map_err(|error| return raw_failure(&error))?;
167    // `match` on the exit code: 0 is ignored, 1 is not ignored, anything else is a failure.
168    match output.code {
169        Some(0) => return Ok(true),
170        Some(1) => return Ok(false),
171        _ => {
172            return Err(raw_failure(&String::from_utf8_lossy(
173                output.stderr.as_slice(),
174            )));
175        }
176    }
177}
178
179/// What:
180///  Whether a native path lies at or under one of the excluded absolute directories.
181/// Why:
182///   Walks skip registered worktrees and the private stage, so they never copy themselves.
183///
184/// In TS you'd write (pseudocode):
185/// ```ts
186/// isFilesystemPathWithin({ candidate, parent })
187/// ```
188fn is_excluded(candidate: &Path, excluded_roots: &[PathBuf]) -> bool {
189    return excluded_roots
190        .iter()
191        .any(|root| return candidate.starts_with(root.as_path()));
192}
193
194/// What:
195///  One pending directory of the special-entry walk, with its repository path.
196/// Why:
197///   The walk is an explicit stack so depth cannot exhaust the call stack.
198///
199/// In TS you'd write (pseudocode):
200/// ```ts
201/// const pending: string[] = ['.'];
202/// ```
203fn special_entries(
204    context: &GitContext,
205    source_root: &Path,
206    selected_roots: &[String],
207    excluded_roots: &[PathBuf],
208) -> Result<Vec<String>, WorktreeCopyError> {
209    let mut pending: Vec<String> = vec![String::from(".")];
210    let mut special_paths: Vec<String> = Vec::new();
211    while let Some(current) = pending.pop() {
212        let directory_path: PathBuf = if current == "." {
213            source_root.to_path_buf()
214        } else {
215            filesystem_path(source_root, current.as_str())?
216        };
217        let listing = std::fs::read_dir(directory_path.as_path())
218            .map_err(|error| return raw_failure(&error))?;
219        for listed in listing {
220            let entry: std::fs::DirEntry = listed.map_err(|error| return raw_failure(&error))?;
221            let name: String = entry.file_name().to_string_lossy().into_owned();
222            let repository_path: String = if current == "." {
223                name
224            } else {
225                format!("{current}/{name}")
226            };
227            if repository_path == ".git" {
228                continue;
229            }
230            let source_path: PathBuf = filesystem_path(source_root, repository_path.as_str())?;
231            if is_excluded(source_path.as_path(), excluded_roots) {
232                continue;
233            }
234            let selected_ancestor: bool = selected_roots
235                .iter()
236                .any(|root| return repository_path_within(repository_path.as_str(), root.as_str()));
237            if selected_ancestor {
238                continue;
239            }
240            let file_type: std::fs::FileType = entry
241                .file_type()
242                .map_err(|error| return raw_failure(&error))?;
243            // A directory is walked; a file or symbolic link is already selected by Git's listing;
244            // anything else is a special entry that needs Git's own ignore answer.
245            if file_type.is_dir() {
246                pending.push(repository_path);
247            } else if !file_type.is_file() && !file_type.is_symlink() {
248                special_paths.push(repository_path);
249            }
250        }
251    }
252    let mut ignored: Vec<String> = Vec::new();
253    for repository_path in special_paths {
254        if is_ignored_special_path(context, source_root, repository_path.as_str())? {
255            ignored.push(repository_path);
256        }
257    }
258    return Ok(ignored);
259}
260
261/// What:
262///  Every existing source path ignored by Git's standard exclusion stack,
263///  as collapsed repository paths.
264/// Why:
265///   This is the one selection the copy stages, so the same validation and collapse apply everywhere.
266///
267/// In TS you'd write (pseudocode):
268/// ```ts
269/// async function readIgnoredRoots({ sourceRoot, gitPath, excludedRoots }): Promise<readonly string[]>;
270/// ```
271pub fn read_ignored_roots(
272    context: &GitContext,
273    source_root: &Path,
274    excluded_roots: &[PathBuf],
275) -> Result<Vec<String>, WorktreeCopyError> {
276    let request: GitRequest = GitRequest {
277        cwd: source_root.to_path_buf(),
278        arguments: vec![
279            "ls-files".into(),
280            "--others".into(),
281            "--ignored".into(),
282            "--exclude-standard".into(),
283            "--directory".into(),
284            "-z".into(),
285        ],
286        without_prefix: true,
287        ..GitRequest::default()
288    };
289    let output = run_git(context, &request).map_err(|error| return raw_failure(&error))?;
290    if output.code != Some(0) {
291        return Err(raw_failure(&String::from_utf8_lossy(
292            output.stderr.as_slice(),
293        )));
294    }
295    let mut paths: Vec<String> = Vec::new();
296    for field in output.stdout.split(|byte| return *byte == 0) {
297        if field.is_empty() {
298            continue;
299        }
300        let text: String = String::from_utf8_lossy(field).into_owned();
301        // `strip_suffix` removes the directory marker only when present, as the incumbent does.
302        let path: String = match text.strip_suffix(DIRECTORY_SUFFIX) {
303            Some(stripped) => String::from(stripped),
304            None => text,
305        };
306        assert_safe_repository_path(path.as_str())?;
307        paths.push(path);
308    }
309    let selected: Vec<String> = paths.clone();
310    let special: Vec<String> =
311        special_entries(context, source_root, selected.as_slice(), excluded_roots)?;
312    let mut all: Vec<String> = paths;
313    all.extend(special);
314    return Ok(collapse_ignored_roots(all.as_slice()));
315}
316
317/// What:
318///  Which of a source's ignored paths one copy takes:
319///  every ignored root, or only the named repository paths.
320/// Why:
321///   A linked or bare source hands over all of its ignored state, as the incumbent does;
322///  a main checkout holds the repository's heavy ignored trees, so it hands over only what the caller names.
323///
324/// In TS you'd write (pseudocode):
325/// ```ts
326/// type CopySelection = { kind: 'ignored' } | { kind: 'paths'; paths: readonly string[] };
327/// ```
328#[derive(Clone, Debug, Eq, PartialEq)]
329pub enum CopySelection {
330    /// Every path Git's standard exclusion stack ignores, plus the ignored special entries.
331    Ignored,
332    /// Only these repository paths, and only the ones Git ignores and the source has.
333    Paths(Vec<String>),
334}
335
336/// What:
337///  The named repository paths that exist in the source and that Git's standard exclusion stack ignores.
338/// Why:
339///   A configured file such as the forbidden-strings rules is copied by name, so the source is never enumerated;
340///  a name Git does not list exactly (absent, tracked, not ignored, or a directory) selects nothing,
341///  which leaves the destination without it instead of inventing it.
342///
343/// In TS you'd write (pseudocode):
344/// ```ts
345/// async function readNamedIgnoredRoots({ sourceRoot, paths, gitPath }): Promise<readonly string[]>;
346/// ```
347pub fn read_named_ignored_roots(
348    context: &GitContext,
349    source_root: &Path,
350    paths: &[String],
351) -> Result<Vec<String>, WorktreeCopyError> {
352    let mut selected: Vec<String> = Vec::new();
353    for path in paths {
354        assert_safe_repository_path(path.as_str())?;
355        let request: GitRequest = GitRequest {
356            cwd: source_root.to_path_buf(),
357            arguments: vec![
358                "ls-files".into(),
359                "--others".into(),
360                "--ignored".into(),
361                "--exclude-standard".into(),
362                "-z".into(),
363                "--".into(),
364                // `top` anchors the name at the worktree root and `literal` turns off globbing and magic.
365                format!(":(top,literal){path}").into(),
366            ],
367            without_prefix: true,
368            ..GitRequest::default()
369        };
370        let output = run_git(context, &request).map_err(|error| return raw_failure(&error))?;
371        if output.code != Some(0) {
372            return Err(raw_failure(&String::from_utf8_lossy(
373                output.stderr.as_slice(),
374            )));
375        }
376        // Git lists a directory's files rather than the directory, so only an exact name is a file Git ignores.
377        let listed: bool = output
378            .stdout
379            .split(|byte| return *byte == 0)
380            .any(|field| return field == path.as_bytes());
381        if listed {
382            selected.push(path.clone());
383        }
384    }
385    return Ok(collapse_ignored_roots(selected.as_slice()));
386}
387
388/// What:
389///  The collapsed repository paths one copy takes from a source under the chosen selection.
390/// Why:
391///   Staging asks one question whichever source it copies from, so the selection mode is decided in one place.
392///
393/// In TS you'd write (pseudocode):
394/// ```ts
395/// const roots = selection.kind === 'ignored' ? await readIgnoredRoots(...) : await readNamedIgnoredRoots(...);
396/// ```
397pub fn select_roots(
398    context: &GitContext,
399    source_root: &Path,
400    excluded_roots: &[PathBuf],
401    selection: &CopySelection,
402) -> Result<Vec<String>, WorktreeCopyError> {
403    // `match` on the selection: the full listing for linked and bare sources, the named lookup otherwise.
404    match selection {
405        CopySelection::Ignored => return read_ignored_roots(context, source_root, excluded_roots),
406        CopySelection::Paths(paths) => {
407            return read_named_ignored_roots(context, source_root, paths.as_slice());
408        }
409    }
410}
411
412/// Selection controls stay out of the release executable.
413#[cfg(test)]
414#[path = "worktree_copy_selection_tests.rs"]
415mod tests;