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;