Skip to main content

git_policy_cli/worktree/
worktree_snapshot_source.rs

1//! What:
2//!  Whether the creating worktree can be the source of a snapshot for a new worktree, decided before real Git runs, and
3//!  everything the snapshot needs to know about it.
4//! Why:
5//!  A snapshot copies the whole subvolume, so the source must be a subvolume root on the same filesystem as the
6//!  destination, clean, and free of every feature whose result would differ from Git's checkout: a sparse checkout, a
7//!  worktree-specific configuration, a submodule, and a nested linked worktree that is an ordinary directory (a
8//!  snapshot carries its files, and removing them one by one costs more than the checkout saves). Each refusal returns
9//!  its reason in words; the caller logs it and falls to the next rung without a word to the person.
10//!
11//! In TS you'd write (pseudocode):
12//! ```ts
13//! const source = await chooseSnapshotSource({ context, identity, plan }); // throws the reason it cannot be one
14//! ```
15
16use crate::config_snapshot::{ConfigEntry, ConfigSnapshot, read_config_snapshot};
17use crate::fs_kind::{FilesystemKind, filesystem_kind, is_subvolume_root};
18use crate::fs_mount_options::read_mount_facts;
19use crate::transaction_git::{GitContext, GitRequest, run_git};
20use crate::worktree_add_plan::AddPlan;
21use crate::worktree_clean::{CleanRefusal, CleanRefusalKind, CleanSource, check_clean, dirty_source};
22use crate::worktree_identity::WorktreeIdentity;
23use std::os::unix::ffi::OsStrExt;
24use std::os::unix::fs::MetadataExt;
25use std::path::{Path, PathBuf};
26
27/// What:
28///  The source a snapshot is taken from and what the later steps need to know about it.
29/// Why:
30///   The checks ran once before Git; the steps after Git use their results instead of asking again.
31///
32/// In TS you'd write (pseudocode):
33/// ```ts
34/// type SnapshotSource = { root: string; gitDir: string; commonDir: string; clean: CleanSource; nested: string[]; config: ConfigSnapshot };
35/// ```
36#[derive(Clone, Debug, Eq, PartialEq)]
37pub struct SnapshotSource {
38    /// The subvolume root of the creating worktree.
39    pub root: PathBuf,
40    /// Its Git directory: its own for a linked worktree, the common one for a main checkout.
41    pub git_dir: PathBuf,
42    /// The common Git directory.
43    pub common_dir: PathBuf,
44    /// What its `HEAD` names, proven clean.
45    pub clean: CleanSource,
46    /// Registered linked worktrees whose roots lie inside the source; the snapshot holds an empty stub for each.
47    pub nested: Vec<PathBuf>,
48    /// The configuration the checks read, which names the abbreviation length of the line Git's checkout prints.
49    pub config: ConfigSnapshot,
50    /// Whether Git ignores the place a snapshot of the main checkout sets its left-out directories aside; false for a linked
51    ///  worktree, which sets nothing aside.
52    pub trash_ignored: bool,
53    /// The uncommitted changes of a source that is not clean, when it is snapshotted anyway and restored to its `HEAD`;
54    ///  nothing for a clean source.
55    pub dirty: Option<ChangedPaths>,
56}
57
58/// What:
59///  What Git's status lists as changed or untracked in a worktree.
60/// Why:
61///   The person is told how many paths and one name; the restore refuses a changed `.gitattributes` file, whose rules could
62///  change how the restore writes other files.
63///
64/// In TS you'd write (pseudocode):
65/// ```ts
66/// type ChangedPaths = { count: number; first?: string; attributesChanged: boolean };
67/// ```
68#[derive(Clone, Debug, Eq, PartialEq)]
69pub struct ChangedPaths {
70    /// How many paths `git status` lists; none when Git finds every file equal to its commit by content although the stat data
71    ///  of the index cannot vouch for it (files as new as the index, or touched without a change).
72    pub count: usize,
73    /// The first of them, with control characters shown as `?`; nothing when `count` is zero.
74    pub first: Option<String>,
75    /// Whether a `.gitattributes` file is changed or untracked.
76    pub attributes_changed: bool,
77}
78
79/// What:
80///  The text with every control character replaced by `?`.
81/// Why:
82///   A path in a sentence on a terminal or in the journal must not carry an escape sequence or start a line.
83///
84/// In TS you'd write (pseudocode):
85/// ```ts
86/// text.replace(/\p{Cc}/gu, '?')
87/// ```
88pub fn plain(text: &str) -> String {
89    return text.chars().map(|character: char| { return if character.is_control() { '?' } else { character }; }).collect();
90}
91
92impl ChangedPaths {
93    /// Whether Git lists any change; false for a worktree whose files equal its commit by content.
94    pub fn has_changes(&self) -> bool {
95        return self.count > 0;
96    }
97}
98
99/// What:
100///  The paths `git status` lists as changed or untracked in the worktree at `root`, counted, with the first, and whether a
101///  `.gitattributes` file is among them (also one inside an untracked directory); or the words that say which Git run failed
102///  and how.
103/// Why:
104///   Only Git's own status tells a real change from a stale record of file times. The read takes no optional lock, so it never
105///  disturbs the checkout a person works in. A listing with nothing in it is an answer (every file equals its commit by content),
106///  never a failure: the stat data of an index that was just written, or of a file that was touched, proves nothing, and Git
107///  settles those by content.
108///
109/// In TS you'd write (pseudocode):
110/// ```ts
111/// const changed = await changedPaths(context, root); // throws the reason Git could not say
112/// ```
113pub fn changed_paths(context: &GitContext, root: &Path) -> Result<ChangedPaths, String> {
114    let ask = |arguments: &[&str]| -> Result<Vec<u8>, String> {
115        let words: String = arguments.iter().skip(1).copied().take(1).collect::<Vec<&str>>().join(" ");
116        let request: GitRequest = GitRequest { cwd: root.to_path_buf(), arguments: arguments.iter().map(|word: &&str| { return (*word).into(); }).collect(), without_prefix: true, ..GitRequest::default() };
117        return match run_git(context, &request) {
118            Ok(found) if found.code == Some(0) => Ok(found.stdout),
119            Ok(found) => {
120                let said: String = String::from_utf8_lossy(found.stderr.as_slice()).trim().to_string();
121                let code: String = found.code.map_or_else(|| { return String::from("was stopped by a signal"); }, |number: i32| { return format!("ended with exit code {number}"); });
122                crate::diagnostic_log::debug("changedPaths", format!("Git could not list the changes (`git {words}` {code}: {said})").as_str());
123                Err(if said.is_empty() { format!("`git {words}` {code} and said nothing") } else { format!("`git {words}` {code}: {}", plain(said.as_str())) })
124            }
125            Err(error) => {
126                crate::diagnostic_log::debug("changedPaths", format!("Git could not list the changes ({error})").as_str());
127                Err(format!("`git {words}` could not be started ({error})"))
128            }
129        };
130    };
131    let listing: Vec<u8> = ask(&["--no-optional-locks", "status", "--porcelain=v1", "-z", "--untracked-files=normal", "--no-renames"])?;
132    let untracked_attributes: Vec<u8> = ask(&["--no-optional-locks", "ls-files", "--others", "--exclude-standard", "-z", "--", ":(glob)**/.gitattributes"])?;
133    // Each status entry is two status letters, a space, then the path.
134    let paths: Vec<&[u8]> = listing.split(|byte: &u8| { return *byte == 0; }).filter(|entry: &&[u8]| { return !entry.is_empty(); }).filter_map(|entry: &[u8]| { return entry.get(3..); }).collect();
135    let attributes_changed: bool = !untracked_attributes.is_empty() || paths.iter().any(|path: &&[u8]| { return *path == b".gitattributes" || path.ends_with(b"/.gitattributes"); });
136    return Ok(ChangedPaths { count: paths.len(), first: paths.first().map(|path: &&[u8]| { return plain(String::from_utf8_lossy(path).as_ref()); }), attributes_changed });
137}
138
139/// What:
140///  The roots of the linked worktrees registered in a repository, read from their `gitdir` files.
141/// Why:
142///   Git lists the same paths; reading them here costs no Git process.
143///
144/// In TS you'd write (pseudocode):
145/// ```ts
146/// async function registeredRoots(commonDir: string): Promise<string[]>;
147/// ```
148pub fn registered_roots(common_dir: &Path) -> Vec<PathBuf> {
149    let Ok(listing) = std::fs::read_dir(common_dir.join("worktrees")) else { return Vec::new() };
150    let mut roots: Vec<PathBuf> = Vec::new();
151    for entry in listing.flatten() {
152        let Ok(recorded) = std::fs::read(entry.path().join("gitdir")) else { continue };
153        let line: &[u8] = recorded.strip_suffix(b"\n").unwrap_or(recorded.as_slice());
154        let pointer: PathBuf = PathBuf::from(std::ffi::OsStr::from_bytes(line));
155        if let Some(root) = pointer.parent() {
156            roots.push(root.to_path_buf());
157        }
158    }
159    return roots;
160}
161
162/// The configuration key that turns a sparse checkout on; Git copies the patterns into a new worktree, a snapshot cannot.
163const SPARSE_KEY: &str = "core.sparsecheckout";
164
165/// What:
166///  The reason the configuration rules the source out, or nothing.
167/// Why:
168///   Git copies the filtered worktree configuration and the sparse-checkout patterns into a new worktree, and conversion
169///  settings that live only in a worktree would disagree between the two worktrees.
170///
171/// In TS you'd write (pseudocode):
172/// ```ts
173/// function configurationRefusal(entries: ConfigEntry[]): string | undefined;
174/// ```
175fn configuration_refusal(entries: &[ConfigEntry]) -> Option<String> {
176    for entry in entries {
177        if entry.key == SPARSE_KEY {
178            return Some(String::from("core.sparseCheckout is set"));
179        }
180        if entry.scope == "worktree" {
181            return Some(format!("the worktree-specific configuration sets {}", entry.key));
182        }
183    }
184    return None;
185}
186
187/// What:
188///  Whether the mounts that hold two paths are one btrfs filesystem.
189/// Why:
190///   A snapshot and a clone cross subvolumes of one filesystem only; the mount table names the device, which is the
191///  same for every subvolume of a filesystem.
192///
193/// In TS you'd write (pseudocode):
194/// ```ts
195/// function onOneBtrfs(first: string, second: string): boolean;
196/// ```
197fn on_one_btrfs(first: &Path, second: &Path) -> bool {
198    if filesystem_kind(first) != Ok(FilesystemKind::Btrfs) || filesystem_kind(second) != Ok(FilesystemKind::Btrfs) {
199        return false;
200    }
201    let (Ok(Some(one)), Ok(Some(other))) = (read_mount_facts(first), read_mount_facts(second)) else { return false };
202    return one.source == other.source;
203}
204
205/// What:
206///  The registered worktrees inside the source that are not empty stubs, if any.
207/// Why:
208///   A nested worktree that is a subvolume appears in a snapshot as an empty directory; an ordinary directory would be
209///  carried with all its files.
210///
211/// In TS you'd write (pseudocode):
212/// ```ts
213/// function nestedOrdinaryWorktree(nested: string[]): string | undefined;
214/// ```
215fn nested_ordinary_worktree(nested: &[PathBuf]) -> Option<PathBuf> {
216    return nested
217        .iter()
218        .find(|path: &&PathBuf| { return path.exists() && !is_subvolume_root(path.as_path()).unwrap_or(false); })
219        .cloned();
220}
221
222/// What:
223///  The registered worktrees inside the source whose recorded path is not their real path, if any.
224/// Why:
225///   A recorded path reached through a link names a directory the snapshot carries as a link, not as a stub; the
226///  snapshot rung leaves such a source to the next rung.
227///
228/// In TS you'd write (pseudocode):
229/// ```ts
230/// function nestedThroughLink(nested: string[]): string | undefined;
231/// ```
232fn nested_through_link(nested: &[PathBuf]) -> Option<PathBuf> {
233    return nested
234        .iter()
235        .find(|path: &&PathBuf| { return path.exists() && std::fs::canonicalize(path.as_path()).ok().as_ref() != Some(*path); })
236        .cloned();
237}
238
239/// What:
240///  The root, Git directory and common directory of the creating worktree, or why there is none.
241/// Why:
242///   A bare repository and a location outside any worktree have no files to copy from.
243///
244/// In TS you'd write (pseudocode):
245/// ```ts
246/// function worktreeOf(identity: WorktreeIdentity): { root: string; gitDir: string; commonDir: string }; // throws a reason
247/// ```
248fn worktree_of(identity: &WorktreeIdentity) -> Result<(&PathBuf, &PathBuf, &PathBuf), String> {
249    return match identity {
250        WorktreeIdentity::MainWorktree { worktree_root, git_dir, common_dir } | WorktreeIdentity::LinkedWorktree { worktree_root, git_dir, common_dir } => Ok((worktree_root, git_dir, common_dir)),
251        WorktreeIdentity::BareRepository { .. } | WorktreeIdentity::OutsideWorktree => Err(String::from("there is no worktree to copy from")),
252    };
253}
254
255/// What:
256///  The cheap reasons the snapshot rung cannot serve a planned add: the source is no subvolume root, or it and the
257///  destination are not on one btrfs filesystem.
258/// Why:
259///   These need two `statfs` calls and one inode number, so they run before the dear checks.
260///
261/// In TS you'd write (pseudocode):
262/// ```ts
263/// function snapshotRefusal(identity: WorktreeIdentity, plan: AddPlan): string | undefined;
264/// ```
265pub fn snapshot_refusal(identity: &WorktreeIdentity, plan: &AddPlan) -> Option<String> {
266    let (root, _git_dir, _common_dir): (&PathBuf, &PathBuf, &PathBuf) = match worktree_of(identity) {
267        Ok(found) => found,
268        Err(reason) => return Some(reason),
269    };
270    let Some(parent) = plan.destination.parent() else { return Some(String::from("the destination has no parent directory")) };
271    if !is_subvolume_root(root.as_path()).unwrap_or(false) {
272        return Some(format!("{} is not a btrfs subvolume", root.display()));
273    }
274    if !on_one_btrfs(root.as_path(), parent) {
275        return Some(format!("{} and {} are not on one btrfs filesystem", root.display(), parent.display()));
276    }
277    return None;
278}
279
280/// What:
281///  Whether the filesystem of `path` may share data blocks between files: btrfs, XFS (reflink is a per-filesystem
282///  feature that the first clone reveals) and overlayfs (a clone inside the overlay reaches the layers' filesystem).
283/// Why:
284///   Other filesystems never clone, so the clone rung is not tried there.
285///
286/// In TS you'd write (pseudocode):
287/// ```ts
288/// function mayClone(path: string): boolean;
289/// ```
290fn may_clone(path: &Path) -> bool {
291    if matches!(filesystem_kind(path), Ok(FilesystemKind::Btrfs | FilesystemKind::Overlayfs)) {
292        return true;
293    }
294    return crate::fs_kind::is_xfs(path).unwrap_or(false);
295}
296
297/// What:
298///  The cheap reasons the clone rung cannot serve a planned add: either side is on a filesystem that never clones, or the
299///  two are not in one clone domain (one btrfs filesystem, or one device elsewhere).
300/// Why:
301///   A clone crosses subvolumes of one btrfs filesystem and nothing else; an overlay or XFS is one device.
302///
303/// In TS you'd write (pseudocode):
304/// ```ts
305/// function cloneRefusal(identity: WorktreeIdentity, plan: AddPlan): string | undefined;
306/// ```
307pub fn clone_refusal(identity: &WorktreeIdentity, plan: &AddPlan) -> Option<String> {
308    let (root, _git_dir, _common_dir): (&PathBuf, &PathBuf, &PathBuf) = match worktree_of(identity) {
309        Ok(found) => found,
310        Err(reason) => return Some(reason),
311    };
312    let Some(parent) = plan.destination.parent() else { return Some(String::from("the destination has no parent directory")) };
313    if !may_clone(root.as_path()) || !may_clone(parent) {
314        return Some(String::from("the filesystem does not share data blocks between files"));
315    }
316    let one_domain: bool = if filesystem_kind(root.as_path()) == Ok(FilesystemKind::Btrfs) {
317        on_one_btrfs(root.as_path(), parent)
318    } else {
319        std::fs::metadata(root.as_path()).map(|found: std::fs::Metadata| { return found.dev(); }).ok() == std::fs::metadata(parent).map(|found: std::fs::Metadata| { return found.dev(); }).ok()
320    };
321    return if one_domain { None } else { Some(format!("{} and {} are on different filesystems", root.display(), parent.display())) };
322}
323
324/// Which kind of check refused a source, so the person can be told the cause in words that fit it.
325#[derive(Clone, Copy, Debug, Eq, PartialEq)]
326pub enum RefusalKind {
327    /// The source could not be proven clean, for the kind of reason the clean check gives.
328    Unclean(CleanRefusalKind),
329    /// A feature a copy of the files does not reproduce, or a state the checks cannot read.
330    Unsupported,
331    /// The stat data could not vouch for the files, and the Git run that settles it by content failed.
332    StatusFailed,
333}
334
335/// What:
336///  Why a source cannot be copied, and which kind of check said so.
337/// Why:
338///   The reason is for the log and for the sentence the person reads; the kind lets the sentence be chosen without reading
339///  the reason's words.
340///
341/// In TS you'd write (pseudocode):
342/// ```ts
343/// type Refusal = { reason: string; kind: 'unclean' | 'unsupported' | 'status-failed' };
344/// ```
345#[derive(Clone, Debug, Eq, PartialEq)]
346pub struct Refusal {
347    /// The reason, in words.
348    pub reason: String,
349    /// The kind of check that refused.
350    pub kind: RefusalKind,
351}
352
353impl Refusal {
354    /// A refusal by a feature or a state the checks cannot read.
355    fn unsupported(reason: impl Into<String>) -> Refusal {
356        return Refusal { reason: reason.into(), kind: RefusalKind::Unsupported };
357    }
358}
359
360/// What:
361///  Check the creating worktree for everything a copy of its files needs, from the cheapest check to the dearest, and
362///  collect what the later steps use; the refusal is the error.
363/// Why:
364///   The dear checks (the clean check starts one Git run) happen once for both rungs, and only after a cheap check let a
365///  rung through. The kind of the refusal lets the ladder tell the person the cause in fitting words.
366///
367/// In TS you'd write (pseudocode):
368/// ```ts
369/// async function sourceRefusal(context: GitContext, identity: WorktreeIdentity): Promise<SnapshotSource>; // throws a Refusal
370/// ```
371pub fn source_refusal(context: &GitContext, identity: &WorktreeIdentity) -> Result<SnapshotSource, Refusal> {
372    return checked_source(context, identity, false);
373}
374
375/// What:
376///  `source_refusal`, except that a main checkout with only ordinary uncommitted changes is a source too, carrying the list of
377///  its changes; every other refusal stays.
378/// Why:
379///   The ladder snapshots such a source and restores the copy to the new worktree's `HEAD`; the clone rung and the other
380///  callers of the clean answer never see it.
381///
382/// In TS you'd write (pseudocode):
383/// ```ts
384/// async function sourceOrDirty(context: GitContext, identity: WorktreeIdentity): Promise<SnapshotSource>; // throws a Refusal
385/// ```
386pub fn source_or_dirty(context: &GitContext, identity: &WorktreeIdentity) -> Result<SnapshotSource, Refusal> {
387    return checked_source(context, identity, true);
388}
389
390/// The checks of `source_refusal`, and of `source_or_dirty` when `allow_dirty` is set.
391fn checked_source(context: &GitContext, identity: &WorktreeIdentity, allow_dirty: bool) -> Result<SnapshotSource, Refusal> {
392    let (root, git_dir, common_dir): (&PathBuf, &PathBuf, &PathBuf) = worktree_of(identity).map_err(Refusal::unsupported)?;
393    if git_dir.join("info/sparse-checkout").exists() || common_dir.join("info/sparse-checkout").exists() {
394        return Err(Refusal::unsupported("the source has a sparse checkout"));
395    }
396    let nested: Vec<PathBuf> = registered_roots(common_dir.as_path()).into_iter().filter(|path: &PathBuf| { return path != root && path.starts_with(root); }).collect();
397    // The configuration, the cleanliness of the files and (for a main checkout) the ignore rule for the place left-out directories
398    //  are set aside read different things and write nothing, so they run side by side; the verdicts are read in the order the
399    //  checks have always had.
400    let (read_config, checked, trash_ignored): (Option<ConfigSnapshot>, Result<CleanSource, CleanRefusal>, bool) = std::thread::scope(|scope| {
401        let config = scope.spawn(|| { return crate::add_timing::work("readConfig", || { return read_config_snapshot(context, root.as_path()); }); });
402        let trash = scope.spawn(|| { return crate::add_timing::work("trashIgnored", || { return git_dir == common_dir && crate::worktree_trash::trash_location_is_ignored(context, root.as_path()); }); });
403        let checked = check_clean(context, root.as_path(), git_dir.as_path(), common_dir.as_path());
404        return (config.join().unwrap_or(None), checked, trash.join().unwrap_or(false));
405    });
406    let snapshot: ConfigSnapshot = read_config.ok_or_else(|| { return Refusal::unsupported("the Git configuration could not be read"); })?;
407    if let Some(reason) = configuration_refusal(snapshot.entries()) {
408        return Err(Refusal::unsupported(reason));
409    }
410    let unclean = |why: CleanRefusal| -> Refusal { return Refusal { reason: why.reason, kind: RefusalKind::Unclean(why.kind) }; };
411    let (clean, dirty): (CleanSource, Option<ChangedPaths>) = match checked {
412        Ok(found) => (found, None),
413        Err(why) if allow_dirty && restorable_here(identity) && why.kind.restorable() => {
414            let found = dirty_source(git_dir.as_path(), common_dir.as_path(), why).map_err(unclean)?;
415            // A source whose files Git finds equal to the commit is snapshotted too: the stat data was all that kept it from being clean.
416            let changed: ChangedPaths = changed_paths(context, root.as_path())
417                .map_err(|reason: String| { return Refusal { reason, kind: RefusalKind::StatusFailed }; })?;
418            if changed.attributes_changed {
419                return Err(Refusal::unsupported("a .gitattributes file is among the uncommitted changes, and its rules could change how the restore writes other files"));
420            }
421            crate::diagnostic_log::debug("sourceOrDirty", format!("{} is snapshotted with {} uncommitted paths, the first {:?}; the restore follows ({})", root.display(), changed.count, changed.first, found.why).as_str());
422            (found.source, Some(changed))
423        }
424        Err(why) => return Err(unclean(why)),
425    };
426    return Ok(SnapshotSource { root: root.clone(), git_dir: git_dir.clone(), common_dir: common_dir.clone(), clean, nested, config: snapshot, trash_ignored, dirty });
427}
428
429/// What:
430///  Whether a source that is not clean can be copied and restored: the main checkout, whose index lies inside the snapshot.
431/// Why:
432///   The restore starts from the index the snapshot carries, which is one instant with the files; a linked worktree's index
433///  lies outside its directory and is not in the snapshot.
434///
435/// In TS you'd write (pseudocode):
436/// ```ts
437/// function restorableHere(identity: WorktreeIdentity): boolean;
438/// ```
439fn restorable_here(identity: &WorktreeIdentity) -> bool {
440    return matches!(identity, WorktreeIdentity::MainWorktree { worktree_root, git_dir, .. } if *git_dir == worktree_root.join(".git"));
441}
442
443/// What:
444///  Check the creating worktree for everything a copy of its files needs; the reason is the error.
445/// Why:
446///   Callers that only log or compare the reason ask for the words; the ladder asks `source_refusal` for the kind too.
447///
448/// In TS you'd write (pseudocode):
449/// ```ts
450/// async function commonSource(context: GitContext, identity: WorktreeIdentity): Promise<SnapshotSource>; // throws a reason
451/// ```
452pub fn common_source(context: &GitContext, identity: &WorktreeIdentity) -> Result<SnapshotSource, String> {
453    return source_refusal(context, identity).map_err(|refusal: Refusal| { return refusal.reason; });
454}
455
456/// What:
457///  The registered worktree inside the source that would be carried into a snapshot with all its files, if any.
458/// Why:
459///   Only the snapshot rung cares: a nested worktree that is a subvolume is an empty stub in a snapshot.
460///
461/// In TS you'd write (pseudocode):
462/// ```ts
463/// function snapshotBlocker(source: SnapshotSource): string | undefined;
464/// ```
465pub fn snapshot_blocker(source: &SnapshotSource) -> Option<String> {
466    if let Some(path) = nested_through_link(source.nested.as_slice()) {
467        return Some(format!("the worktree {} inside the source is reached through a symbolic link", path.display()));
468    }
469    // A snapshot of the main checkout sets the nested ordinary worktrees aside (`worktree_trash.rs`), so they do not block it.
470    if source.git_dir == source.common_dir {
471        return None;
472    }
473    return nested_ordinary_worktree(source.nested.as_slice()).map(|path: PathBuf| { return format!("the worktree {} inside the source is an ordinary directory; `git cli-git migrate-to-subvolume` makes it a subvolume", path.display()); });
474}
475
476/// What:
477///  Decide whether the creating worktree of a planned add can be the snapshot source: the cheap refusals, the shared
478///  checks, then what only a snapshot minds.
479/// Why:
480///   The ladder asks the three in turn so the clone rung can share the dear checks; this is the whole question for the
481///  snapshot alone.
482///
483/// In TS you'd write (pseudocode):
484/// ```ts
485/// async function chooseSnapshotSource(args: { context: GitContext; identity: WorktreeIdentity; plan: AddPlan }): Promise<SnapshotSource>; // throws a reason
486/// ```
487pub fn choose_snapshot_source(context: &GitContext, identity: &WorktreeIdentity, plan: &AddPlan) -> Result<SnapshotSource, String> {
488    if let Some(reason) = snapshot_refusal(identity, plan) {
489        return Err(reason);
490    }
491    let source: SnapshotSource = common_source(context, identity)?;
492    return match snapshot_blocker(&source) {
493        Some(reason) => Err(reason),
494        None => Ok(source),
495    };
496}
497
498/// Source selection controls stay out of the release executable.
499#[cfg(test)]
500#[path = "worktree_snapshot_source_tests.rs"]
501mod tests;