Skip to main content

git_policy_cli/worktree/
worktree_clean.rs

1//! What:
2//!  Whether a worktree is clean enough to be copied from, or removed without asking Git: no operation in progress, an
3//!  index equal to the tree of `HEAD`, every tracked file unchanged by its recorded size, times and inode, and no
4//!  untracked file that Git does not ignore.
5//! Why:
6//!  A snapshot or a clone carries the bytes of the source's files, so any change the source has not committed would
7//!  reach the new worktree, and a removal by the subvolume ioctl cannot be undone. The tracked-file checks read the
8//!  index in process (no Git process) with the same comparison Git's fast path makes, and refuse every shape they cannot
9//!  prove: a split or sparse index, a skip-worktree or assume-valid entry, a submodule, an entry as new as the index
10//!  file (Git re-hashes those, so their stat fields prove nothing). The one question the index cannot answer, whether
11//!  an untracked file is ignored, goes to one `git ls-files` run. Every refusal returns its reason in words for the log.
12//!
13//! In TS you'd write (pseudocode):
14//! ```ts
15//! const clean = await checkClean({ root, gitDir, commonDir }); // { headCommit, headTree } or throws a reason
16//! ```
17
18use crate::repo_read_index::{IndexEntry, parse_index};
19use crate::repo_read_layout::ObjectFormat;
20use crate::repo_read_odb::ObjectDatabase;
21use crate::repo_read_refs::{Refs, RefValue};
22use crate::repo_read_tree::{FlatEntry, MODE_GITLINK, flatten_tree, parse_commit};
23use crate::fs_beneath::{entry_metadata_at, open_directory_beneath, open_root, plain_components};
24use crate::worktree_clean_content::{ContentJudge, ConversionEnvironment, Place};
25use crate::subvolume_index::{StatFields, StoredStat, be32, walk_entries};
26use crate::subvolume_index_files::{fields_of, matches_stored, repository_hash_length};
27use crate::transaction_git::{GitContext, GitRequest, run_git};
28use std::ffi::OsStr;
29use std::os::unix::ffi::OsStrExt;
30use std::os::unix::fs::MetadataExt;
31use std::path::Path;
32
33/// The entries of a Git directory that mean an operation is in progress and the index or tree is not a plain state.
34const IN_PROGRESS: &[&str] =
35    &["index.lock", "MERGE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD", "BISECT_LOG", "rebase-merge", "rebase-apply", "sequencer"];
36
37/// The flags bit of an index entry that tells Git to trust the file without looking (`CE_VALID`).
38const ASSUME_VALID: u16 = 0x8000;
39
40/// What:
41///  Which kind of state kept a worktree from being proven clean.
42/// Why:
43///   Callers decide by kind, never by the words of the reason: the snapshot rung restores a worktree whose only trouble is an
44///  ordinary change, and refuses every state a restore cannot be proven to undo; the sentence for the person is chosen by kind.
45///
46/// In TS you'd write (pseudocode):
47/// ```ts
48/// type CleanRefusalKind = { kind: 'operation-in-progress'; name: string } | { kind: 'unreadable' } | { kind: 'not-plain' }
49///   | { kind: 'staged-change' } | { kind: 'file-changed' } | { kind: 'stat-stale' } | { kind: 'racy' } | { kind: 'untracked' };
50/// ```
51#[derive(Clone, Copy, Debug, Eq, PartialEq)]
52pub enum CleanRefusalKind {
53    /// A merge, cherry-pick, revert, bisect, rebase, sequencer or `index.lock` mark exists; the name is the mark's.
54    OperationInProgress(&'static str),
55    /// The index, the object database, the configuration or Git itself cannot be read in the way the checks need.
56    Unreadable,
57    /// An index or tree entry of a kind a restore cannot be proven to undo: unmerged, intent-to-add, skip-worktree,
58    ///  assume-unchanged, or a submodule.
59    NotPlain,
60    /// The index differs from the tree of `HEAD`: a staged addition, deletion, change or rename.
61    StagedChange,
62    /// A tracked file is missing, changed its type or executable bit, or differs in content.
63    FileChanged,
64    /// A tracked file's size, times or inode differ from its entry, so it probably changed.
65    StatStale,
66    /// A tracked file is as new as the index, so its stat fields prove nothing.
67    Racy,
68    /// Git lists an untracked file that no ignore rule covers.
69    Untracked,
70}
71
72impl CleanRefusalKind {
73    /// Whether a reset to a commit can undo the state: only ordinary changes, not an operation in progress or a state Git
74    ///  keeps outside the tree and the files.
75    pub fn restorable(self) -> bool {
76        return matches!(self, Self::StagedChange | Self::FileChanged | Self::StatStale | Self::Racy | Self::Untracked);
77    }
78}
79
80/// What:
81///  Why a worktree could not be proven clean: the kind and the reason in words.
82/// Why:
83///   The words are for logs and for people; the kind is for code.
84///
85/// In TS you'd write (pseudocode):
86/// ```ts
87/// type CleanRefusal = { kind: CleanRefusalKind; reason: string };
88/// ```
89#[derive(Clone, Debug, Eq, PartialEq)]
90pub struct CleanRefusal {
91    /// What kind of state refused.
92    pub kind: CleanRefusalKind,
93    /// The reason, in words.
94    pub reason: String,
95}
96
97impl CleanRefusal {
98    /// A refusal of `kind` with its reason.
99    fn new(kind: CleanRefusalKind, reason: impl Into<String>) -> CleanRefusal {
100        return CleanRefusal { kind, reason: reason.into() };
101    }
102
103    /// A refusal because something could not be read.
104    fn unreadable(reason: impl Into<String>) -> CleanRefusal {
105        return CleanRefusal::new(CleanRefusalKind::Unreadable, reason);
106    }
107}
108
109impl std::fmt::Display for CleanRefusal {
110    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
111        return formatter.write_str(self.reason.as_str());
112    }
113}
114
115/// What:
116///  One tracked file of an index with the stat fields the index recorded for it.
117/// Why:
118///   `parse_index` leaves the stat fields out, and the comparison with the file on disk needs them.
119///
120/// In TS you'd write (pseudocode):
121/// ```ts
122/// type StatEntry = { path: Uint8Array; flags: number; mode: number; stored: StoredStat };
123/// ```
124#[derive(Clone, Debug, Eq, PartialEq)]
125pub struct StatEntry {
126    /// The path, `/` separated.
127    pub path: Vec<u8>,
128    /// The entry's flags (the assume-valid bit among them).
129    pub flags: u16,
130    /// The mode with its type bits.
131    pub mode: u32,
132    /// The recorded stat fields.
133    pub stored: StoredStat,
134}
135
136/// What:
137///  What a clean worktree's `HEAD` names.
138/// Why:
139///   The two-tree update that follows a snapshot starts from this tree, so a caller needs not read it again.
140///
141/// In TS you'd write (pseudocode):
142/// ```ts
143/// type CleanSource = { headCommit: string; headTree: Uint8Array; tracked: number; indexSeconds: number; hashLength: number; indexBytes: Uint8Array };
144/// ```
145#[derive(Clone, Debug, Eq, PartialEq)]
146pub struct CleanSource {
147    /// The commit `HEAD` resolves to, as hexadecimal.
148    pub head_commit: String,
149    /// The root tree of that commit, as a raw object name.
150    pub head_tree: Vec<u8>,
151    /// The number of tracked files.
152    pub tracked: usize,
153    /// The whole seconds of the index file's modification time, against which racy entries were judged.
154    pub index_seconds: u32,
155    /// The hash length of the repository in bytes.
156    pub hash_length: usize,
157    /// The `.gitattributes` files of the tree of `HEAD` as path and object name.
158    pub attribute_blobs: Vec<(Vec<u8>, Vec<u8>)>,
159    /// The bytes of the index file that were proven equal to the tree of `HEAD` and to the files. Every later step uses
160    ///  these bytes, never a second read of the file, so what was proven is what is installed.
161    pub index_bytes: Vec<u8>,
162}
163
164/// What:
165///  The first entry of the Git directory that marks an operation in progress, if any.
166/// Why:
167///   A merge, a rebase or a held index lock means the index is not the committed state yet; the lock a caller holds itself
168///  (`owned_index_lock`) does not.
169///
170/// In TS you'd write (pseudocode):
171/// ```ts
172/// function operationInProgress(gitDir: string): string | undefined;
173/// ```
174fn operation_in_progress(git_dir: &Path, owned_index_lock: bool) -> Option<&'static str> {
175    return IN_PROGRESS.iter().copied().filter(|name: &&str| { return !(owned_index_lock && *name == "index.lock"); }).find(|name: &&str| { return git_dir.join(name).symlink_metadata().is_ok(); });
176}
177
178/// What:
179///  The commit `HEAD` resolves to, read from the plain ref files.
180/// Why:
181///   An unusual ref storage answers "unknown", which declines the shortcut rather than guessing.
182///
183/// In TS you'd write (pseudocode):
184/// ```ts
185/// function headCommit(gitDir: string, commonDir: string, format: ObjectFormat): string | undefined;
186/// ```
187pub fn head_commit(git_dir: &Path, common_dir: &Path, format: ObjectFormat) -> Option<String> {
188    let refs: Refs = Refs::new(common_dir, format.hex_length());
189    return match refs.lookup_head(git_dir)? {
190        RefValue::Direct(object) => Some(object),
191        RefValue::Symbolic(name) => refs.resolve(name.as_str()),
192    };
193}
194
195/// What:
196///  The tracked files of the index paired with their recorded stat fields, or nothing when the layout is not plain.
197/// Why:
198///   The comparison with `lstat` needs the fields `parse_index` leaves out.
199///
200/// In TS you'd write (pseudocode):
201/// ```ts
202/// function storedStats(bytes: Uint8Array, hashLength: number): { path: Uint8Array; flags: number; mode: number; stored: StoredStat }[] | undefined;
203/// ```
204pub fn stored_stats(bytes: &[u8], hash_length: usize) -> Option<Vec<StatEntry>> {
205    let (entries, _end) = walk_entries(bytes, hash_length)?;
206    let mut found: Vec<StatEntry> = Vec::with_capacity(entries.len());
207    for entry in entries {
208        let stored: StoredStat = StoredStat {
209            ctime_seconds: be32(bytes, entry.start)?,
210            ctime_nanos: be32(bytes, entry.start + 4)?,
211            mtime_seconds: be32(bytes, entry.start + 8)?,
212            mtime_nanos: be32(bytes, entry.start + 12)?,
213            ino: be32(bytes, entry.start + 20)?,
214            uid: be32(bytes, entry.start + 28)?,
215            gid: be32(bytes, entry.start + 32)?,
216            size: be32(bytes, entry.start + 36)?,
217        };
218        found.push(StatEntry { path: entry.path, flags: entry.flags, mode: entry.mode, stored });
219    }
220    return Some(found);
221}
222
223/// What:
224///  The reason the index differs from the tree of `HEAD`, or nothing when the two are the same files.
225/// Why:
226///   A staged change, a staged deletion or an intent-to-add entry is an uncommitted state the copy must not carry.
227///
228/// In TS you'd write (pseudocode):
229/// ```ts
230/// function indexDiffersFromTree(index: IndexEntry[], tree: FlatEntry[]): string | undefined;
231/// ```
232fn index_differs_from_tree(index: &[IndexEntry], tree: &[FlatEntry]) -> Option<CleanRefusal> {
233    if index.len() != tree.len() {
234        return Some(CleanRefusal::new(CleanRefusalKind::StagedChange, "the index and the tree of HEAD hold different numbers of files"));
235    }
236    for (staged, committed) in index.iter().zip(tree) {
237        if staged.stage != 0 || staged.extended != 0 {
238            return Some(CleanRefusal::new(CleanRefusalKind::NotPlain, format!("the index entry {} is unmerged, intent-to-add or skip-worktree", String::from_utf8_lossy(&staged.path))));
239        }
240        if staged.mode == MODE_GITLINK {
241            return Some(CleanRefusal::new(CleanRefusalKind::NotPlain, format!("{} is a submodule", String::from_utf8_lossy(&staged.path))));
242        }
243        if staged.path != committed.path || staged.mode != committed.mode || staged.id != committed.id {
244            return Some(CleanRefusal::new(CleanRefusalKind::StagedChange, format!("{} is staged with a change", String::from_utf8_lossy(&staged.path))));
245        }
246    }
247    return None;
248}
249
250/// What:
251///  What the stat data of an index entry says about a file.
252/// Why:
253///   A file the data cannot vouch for (a racily clean entry, a stale stat) is not the same as a file that certainly changed:
254///  Git settles the first by content, so a caller that can compare content takes the two apart.
255///
256/// In TS you'd write (pseudocode):
257/// ```ts
258/// type StatVerdict = { kind: 'agrees' } | { kind: 'unproven'; reason: string } | { kind: 'differs'; reason: string };
259/// ```
260#[derive(Clone, Debug, Eq, PartialEq)]
261pub enum StatVerdict {
262    /// Size, times, inode, type and mode all equal the entry.
263    Agrees,
264    /// The stat data proves nothing either way; the content decides.
265    Unproven(String),
266    /// The file certainly differs: missing type, mode or assume-unchanged mark.
267    Differs(String),
268}
269
270/// What:
271///  What the stat data of the entry says about a file whose metadata is `metadata`.
272/// Why:
273///   The comparison takes the metadata of an inode already in hand, so a caller that holds a descriptor judges the file it
274///  holds, not whatever the path names a moment later. Type and executable bit come before the time checks: they differ
275///  whatever the content is.
276///
277/// In TS you'd write (pseudocode):
278/// ```ts
279/// function metadataVerdict(entry: StatEntry, metadata: Stats, indexSeconds: number): StatVerdict;
280/// ```
281pub fn metadata_verdict(entry: &StatEntry, metadata: &std::fs::Metadata, index_seconds: u32) -> StatVerdict {
282    return match stat_finding(entry, metadata, index_seconds) {
283        None => StatVerdict::Agrees,
284        Some(CleanRefusal { kind: CleanRefusalKind::Racy | CleanRefusalKind::StatStale, reason }) => StatVerdict::Unproven(reason),
285        Some(refusal) => StatVerdict::Differs(refusal.reason),
286    };
287}
288
289/// What:
290///  What the stat data of the entry says about a file whose metadata is `metadata`, as a refusal with its kind, or nothing
291///  when they agree.
292/// Why:
293///   One comparison serves the verdict that callers of the stat-only checks use and the kind that the snapshot rung decides
294///  by. Type and executable bit come before the time checks: they differ whatever the content is.
295///
296/// In TS you'd write (pseudocode):
297/// ```ts
298/// function statFinding(entry: StatEntry, metadata: Stats, indexSeconds: number): CleanRefusal | undefined;
299/// ```
300fn stat_finding(entry: &StatEntry, metadata: &std::fs::Metadata, index_seconds: u32) -> Option<CleanRefusal> {
301    let StatEntry { path, flags, mode, stored }: &StatEntry = entry;
302    let shown: String = String::from_utf8_lossy(path).into_owned();
303    if flags & ASSUME_VALID != 0 {
304        return Some(CleanRefusal::new(CleanRefusalKind::NotPlain, format!("{shown} is marked assume-unchanged")));
305    }
306    let symbolic: bool = *mode & 0o170_000 == 0o120_000;
307    if symbolic != metadata.file_type().is_symlink() || (!symbolic && !metadata.file_type().is_file()) {
308        return Some(CleanRefusal::new(CleanRefusalKind::FileChanged, format!("{shown} changed its type")));
309    }
310    if !symbolic && (*mode & 0o100 != 0) != (metadata.mode() & 0o100 != 0) {
311        return Some(CleanRefusal::new(CleanRefusalKind::FileChanged, format!("{shown} changed its executable bit")));
312    }
313    if stored.mtime_seconds >= index_seconds {
314        return Some(CleanRefusal::new(CleanRefusalKind::Racy, format!("{shown} is as new as the index, so its stat fields prove nothing")));
315    }
316    let now: StatFields = fields_of(metadata);
317    if !matches_stored(&now, stored) {
318        return Some(CleanRefusal::new(CleanRefusalKind::StatStale, format!("{shown} differs from its index entry by size, times or inode")));
319    }
320    return None;
321}
322
323/// What:
324///  The reason a file whose metadata is `metadata` differs from its index entry, or nothing when the two agree.
325/// Why:
326///   Callers that rely on the stat data alone (the snapshot and clone rungs) treat a file the data cannot vouch for like a
327///  file that changed.
328///
329/// In TS you'd write (pseudocode):
330/// ```ts
331/// function metadataDiffersFromEntry(entry: StatEntry, metadata: Stats, indexSeconds: number): string | undefined;
332/// ```
333pub fn metadata_differs_from_entry(entry: &StatEntry, metadata: &std::fs::Metadata, index_seconds: u32) -> Option<String> {
334    return match metadata_verdict(entry, metadata, index_seconds) {
335        StatVerdict::Agrees => None,
336        StatVerdict::Unproven(reason) | StatVerdict::Differs(reason) => Some(reason),
337    };
338}
339
340/// What:
341///  The first reason a tracked file of `root` differs from its index entry, or nothing when every file agrees.
342/// Why:
343///   After a snapshot the same comparison runs against the snapshot's own files, so a source that changed while the
344///  snapshot was taken is caught on the copy. The files are reached below a held root with no link followed, so a directory
345///  replaced by a link is not read through.
346///
347/// In TS you'd write (pseudocode):
348/// ```ts
349/// function treeDiffersFromIndex(root: string, entries: StatEntry[], indexSeconds: number): string | undefined;
350/// ```
351pub fn tree_differs_from_index(root: &Path, entries: &[StatEntry], index_seconds: u32) -> Option<String> {
352    return match open_root(root) {
353        Ok(held) => tree_differs_from_index_beneath(&held, entries, index_seconds),
354        Err(error) => Some(format!("{} cannot be opened without following a link ({error})", root.display())),
355    };
356}
357
358/// The first refusal of a tracked file of `root` against its index entry, with its kind; the files are reached below a held
359///  root with no link followed.
360fn tree_refusal(root: &Path, entries: &[StatEntry], index_seconds: u32) -> Option<CleanRefusal> {
361    return match open_root(root) {
362        Ok(held) => tree_refusal_beneath(&held, entries, index_seconds),
363        Err(error) => Some(CleanRefusal::unreadable(format!("{} cannot be opened without following a link ({error})", root.display()))),
364    };
365}
366
367/// What the content decision needs besides the entries: the worktree, its Git directories, the environment and the format.
368pub struct ContentTarget<'a> {
369    /// The worktree root.
370    pub root: &'a Path,
371    /// The worktree's Git directory.
372    pub git_dir: &'a Path,
373    /// The shared Git directory.
374    pub common_dir: &'a Path,
375    /// The configuration and attribute locations.
376    pub environment: &'a ConversionEnvironment,
377    /// The object format of the repository.
378    pub format: ObjectFormat,
379}
380
381/// What:
382///  The first reason a tracked file of the worktree differs from its index entry, deciding by content where the stat data
383///  cannot vouch for the file.
384/// Why:
385///   Git hashes a racily clean entry and an entry whose stat data is stale; this does the same in process, so a clean
386///  worktree that was copied, cloned or written in the same second as its index is not refused. Every file is reached below a
387///  held root with no link followed, so a directory replaced by a link is refused instead of being read through. A file that
388///  really differs, or one a content conversion could change, is refused with the reason.
389///
390/// In TS you'd write (pseudocode):
391/// ```ts
392/// function treeDiffersFromIndexByContent(target: ContentTarget, entries: StatEntry[], indexed: IndexEntry[], indexSeconds: number): string | undefined;
393/// ```
394fn tree_differs_from_index_by_content(target: &ContentTarget<'_>, entries: &[StatEntry], indexed: &[IndexEntry], index_seconds: u32) -> Option<String> {
395    if entries.len() != indexed.len() {
396        return Some(String::from("the index entries and their stat data are not the same files"));
397    }
398    let held: std::fs::File = match open_root(target.root) {
399        Ok(opened) => opened,
400        Err(error) => return Some(format!("{} cannot be opened without following a link ({error})", target.root.display())),
401    };
402    let mut judge: ContentJudge<'_> = ContentJudge::new(&held, target.git_dir, target.common_dir, target.environment, target.format);
403    let mut cached: Option<(Vec<std::ffi::OsString>, std::fs::File)> = None;
404    for (entry, committed) in entries.iter().zip(indexed) {
405        if entry.path != committed.path {
406            return Some(String::from("the index entries and their stat data are not the same files"));
407        }
408        let shown: String = String::from_utf8_lossy(entry.path.as_slice()).into_owned();
409        let Ok(names) = plain_components(Path::new(OsStr::from_bytes(entry.path.as_slice()))) else { return Some(format!("{shown} is not a plain path")) };
410        let Some((name, parents)) = names.split_last() else { return Some(format!("{shown} is not a plain path")) };
411        if cached.as_ref().is_none_or(|(known, _directory): &(Vec<std::ffi::OsString>, std::fs::File)| { return known.as_slice() != parents; }) {
412            let Ok(directory) = open_directory_beneath(&held, parents) else { return Some(format!("{shown} is missing from the worktree or reached through a link")) };
413            cached = Some((parents.to_vec(), directory));
414        }
415        let Some((_known, directory)) = cached.as_ref() else { return Some(format!("{shown} is missing from the worktree")) };
416        let Ok(metadata) = entry_metadata_at(directory, name.as_os_str()) else { return Some(format!("{shown} is missing from the worktree")) };
417        match metadata_verdict(entry, &metadata, index_seconds) {
418            StatVerdict::Agrees => {}
419            StatVerdict::Differs(reason) => return Some(reason),
420            StatVerdict::Unproven(_reason) => {
421                let place: Place<'_> = Place { directory, parents, name: name.as_os_str() };
422                if let Err(reason) = judge.agrees(&place, entry, committed.id.as_slice(), index_seconds) {
423                    return Some(reason);
424                }
425            }
426        }
427    }
428    return None;
429}
430
431/// What:
432///  The first reason a tracked file below the held directory `root` differs from its index entry, or nothing when every
433///  file agrees; each file is looked at below handles opened without following a link.
434/// Why:
435///   A snapshot is checked against the index it will carry; a path walk through a link inside the snapshot would compare
436///  a file outside it, so every directory on the way must be a real one on the snapshot's device.
437///
438/// In TS you'd write (pseudocode):
439/// ```ts
440/// function treeDiffersFromIndexBeneath(root: Dir, entries: StatEntry[], indexSeconds: number): string | undefined;
441/// ```
442pub fn tree_differs_from_index_beneath(root: &std::fs::File, entries: &[StatEntry], index_seconds: u32) -> Option<String> {
443    return tree_refusal_beneath(root, entries, index_seconds).map(|refusal: CleanRefusal| { return refusal.reason; });
444}
445
446/// The first refusal of a tracked file below the held directory `root` against its index entry, with its kind.
447fn tree_refusal_beneath(root: &std::fs::File, entries: &[StatEntry], index_seconds: u32) -> Option<CleanRefusal> {
448    let changed = |reason: String| -> Option<CleanRefusal> { return Some(CleanRefusal::new(CleanRefusalKind::FileChanged, reason)); };
449    let mut cached: Option<(Vec<std::ffi::OsString>, std::fs::File)> = None;
450    for entry in entries {
451        let shown: String = String::from_utf8_lossy(entry.path.as_slice()).into_owned();
452        let Ok(names) = plain_components(Path::new(OsStr::from_bytes(entry.path.as_slice()))) else { return Some(CleanRefusal::unreadable(format!("{shown} is not a plain path"))) };
453        let Some((name, parents)) = names.split_last() else { return Some(CleanRefusal::unreadable(format!("{shown} is not a plain path"))) };
454        if cached.as_ref().is_none_or(|(held, _directory): &(Vec<std::ffi::OsString>, std::fs::File)| { return held.as_slice() != parents; }) {
455            let Ok(directory) = open_directory_beneath(root, parents) else { return changed(format!("{shown} is missing from the worktree or reached through a link")) };
456            cached = Some((parents.to_vec(), directory));
457        }
458        let Some((_held, directory)) = cached.as_ref() else { return changed(format!("{shown} is missing from the worktree")) };
459        let Ok(metadata) = entry_metadata_at(directory, name.as_os_str()) else { return changed(format!("{shown} is missing from the worktree")) };
460        if let Some(refusal) = stat_finding(entry, &metadata, index_seconds) {
461            return Some(refusal);
462        }
463    }
464    return None;
465}
466
467/// What:
468///  A walk below a held directory that keeps the last directory it opened.
469/// Why:
470///   The paths of an index come in order, so most of them share their parent with the one before; one `openat2` chain per
471///  directory instead of per file is what the walk costs, and each file is still reached through handles that follow no link.
472///
473/// In TS you'd write (pseudocode):
474/// ```ts
475/// const cursor = new StatCursor(root); cursor.fields(path); // StatFields | undefined
476/// ```
477pub struct StatCursor<'a> {
478    /// The directory every path is found below.
479    root: &'a std::fs::File,
480    /// The components and the handle of the last directory opened.
481    held: Option<(Vec<std::ffi::OsString>, std::fs::File)>,
482}
483
484impl<'a> StatCursor<'a> {
485    /// A cursor below `root` that has opened nothing.
486    pub fn new(root: &'a std::fs::File) -> Self {
487        return Self { root, held: None };
488    }
489
490    /// The metadata of the file `path` names below the root, or the reason it cannot be found through plain directories.
491    fn metadata(&mut self, path: &[u8]) -> Result<std::fs::Metadata, String> {
492        let shown: String = String::from_utf8_lossy(path).into_owned();
493        let Ok(names) = plain_components(Path::new(OsStr::from_bytes(path))) else { return Err(format!("{shown} is not a plain path")) };
494        let Some((name, parents)) = names.split_last() else { return Err(format!("{shown} is not a plain path")) };
495        if self.held.as_ref().is_none_or(|(known, _directory): &(Vec<std::ffi::OsString>, std::fs::File)| { return known.as_slice() != parents; }) {
496            let Ok(directory) = open_directory_beneath(self.root, parents) else { return Err(format!("{shown} is missing from the worktree or reached through a link")) };
497            self.held = Some((parents.to_vec(), directory));
498        }
499        let Some((_known, directory)) = self.held.as_ref() else { return Err(format!("{shown} is missing from the worktree")) };
500        return entry_metadata_at(directory, name.as_os_str()).map_err(|_error| { return format!("{shown} is missing from the worktree"); });
501    }
502
503    /// The stat fields of the tracked file `path` below the root, or nothing when it cannot be found through plain directories.
504    pub fn fields(&mut self, path: &[u8]) -> Option<StatFields> {
505        return self.metadata(path).ok().map(|found: std::fs::Metadata| { return fields_of(&found); });
506    }
507}
508
509/// What:
510///  Judge every tracked file below the held directory `root` against its index entry and return the stat fields of each, in
511///  entry order, in one walk.
512/// Why:
513///   The snapshot's index takes the stat fields of the snapshot's own files, and the snapshot is proven equal to the source's
514///  index by the same stat data; the same `statx` serves both, so the files are looked at once, and the fields are the ones the
515///  verdict was given on.
516///
517/// In TS you'd write (pseudocode):
518/// ```ts
519/// const fields = treeFieldsBeneath(root, entries, indexSeconds); // throws a CleanRefusal
520/// ```
521pub fn tree_fields_beneath(root: &std::fs::File, entries: &[StatEntry], index_seconds: u32) -> Result<Vec<StatFields>, CleanRefusal> {
522    let mut cursor: StatCursor<'_> = StatCursor::new(root);
523    let mut fields: Vec<StatFields> = Vec::with_capacity(entries.len());
524    for entry in entries {
525        let metadata: std::fs::Metadata = match cursor.metadata(entry.path.as_slice()) {
526            Ok(found) => found,
527            Err(reason) if reason.ends_with("is not a plain path") => return Err(CleanRefusal::unreadable(reason)),
528            Err(reason) => return Err(CleanRefusal::new(CleanRefusalKind::FileChanged, reason)),
529        };
530        if let Some(refusal) = stat_finding(entry, &metadata, index_seconds) {
531            return Err(refusal);
532        }
533        fields.push(fields_of(&metadata));
534    }
535    return Ok(fields);
536}
537
538/// What:
539///  The stat fields of the tracked file `path` below the held directory `root`, found through handles, or nothing.
540/// Why:
541///   The index of a snapshot takes the stat fields of the snapshot's own files; reading them by a path with several
542///  components would follow a link substituted below the root.
543///
544/// In TS you'd write (pseudocode):
545/// ```ts
546/// function statFieldsBeneath(root: Dir, path: Uint8Array): StatFields | undefined;
547/// ```
548pub fn stat_fields_beneath(root: &std::fs::File, path: &[u8]) -> Option<StatFields> {
549    return StatCursor::new(root).fields(path);
550}
551
552/// What:
553///  Whether Git lists an untracked file that no ignore rule covers.
554/// Why:
555///   Only Git's ignore rules can tell an untracked file that matters from one it ignores; `--directory` stops at the
556///  first untracked directory and `--no-empty-directory` leaves out directories without files, as `git status` does.
557///
558/// In TS you'd write (pseudocode):
559/// ```ts
560/// async function untrackedFile(context: GitContext, root: string): Promise<string | undefined>;
561/// ```
562fn untracked_file(context: &GitContext, root: &Path) -> Result<Option<String>, CleanRefusal> {
563    let request: GitRequest = GitRequest {
564        cwd: root.to_path_buf(),
565        arguments: ["ls-files", "--others", "--exclude-standard", "--directory", "--no-empty-directory", "-z"].map(Into::into).to_vec(),
566        without_prefix: true,
567        ..GitRequest::default()
568    };
569    let output = run_git(context, &request).map_err(|error| { return CleanRefusal::unreadable(format!("Git could not list untracked files ({error})")); })?;
570    if output.code != Some(0) {
571        return Err(CleanRefusal::unreadable(format!("Git could not list untracked files: {}", String::from_utf8_lossy(output.stderr.as_slice()).trim())));
572    }
573    return Ok(output.stdout.split(|byte: &u8| { return *byte == 0; }).find(|name: &&[u8]| { return !name.is_empty(); }).map(|name: &[u8]| { return String::from_utf8_lossy(name).into_owned(); }));
574}
575
576/// What:
577///  The checks of `check_clean` that read no untracked file: no operation in progress, an index equal to the tree of `HEAD`,
578///  and every tracked file with the size, times and inode its index entry recorded.
579/// Why:
580///   A caller that copies the whole worktree, untracked files included (the conversion of a worktree into a subvolume),
581///  needs a quiet and unmodified tracked state but has no reason to refuse for an untracked file, and needs no Git process.
582///
583/// In TS you'd write (pseudocode):
584/// ```ts
585/// function checkCleanTracked(root: string, gitDir: string, commonDir: string): CleanSource; // throws the reason
586/// ```
587pub fn check_clean_tracked(root: &Path, git_dir: &Path, common_dir: &Path) -> Result<CleanSource, String> {
588    return check_tracked(root, git_dir, common_dir, None, false).map_err(|refusal: CleanRefusal| { return refusal.reason; });
589}
590
591/// What:
592///  `check_clean_tracked` that settles an entry the stat data cannot vouch for by comparing the file with the committed
593///  blob in process, under the configuration and attribute locations of `environment`.
594/// Why:
595///   The conversion copies the whole worktree and rewrites the index afterwards, and an entry it could not prove keeps its old
596///  stat data, so Git hashes it at the first status; the snapshot and clone rungs install the index they proved and use the
597///  stat-only check.
598///
599/// In TS you'd write (pseudocode):
600/// ```ts
601/// function checkCleanTrackedByContent(root: string, gitDir: string, commonDir: string, environment: ConversionEnvironment, ownedIndexLock: boolean): CleanSource; // throws the reason
602/// ```
603pub fn check_clean_tracked_by_content(root: &Path, git_dir: &Path, common_dir: &Path, environment: &ConversionEnvironment, owned_index_lock: bool) -> Result<CleanSource, String> {
604    return check_tracked(root, git_dir, common_dir, Some(environment), owned_index_lock).map_err(|refusal: CleanRefusal| { return refusal.reason; });
605}
606
607/// What the checks read before they judge: the index with its stat data, the commit `HEAD` names and its flattened tree.
608struct State {
609    /// The bytes of the index file.
610    bytes: Vec<u8>,
611    /// The whole seconds of the index file's modification time.
612    index_seconds: u32,
613    /// The hash length of the repository in bytes.
614    hash_length: usize,
615    /// The object format.
616    format: ObjectFormat,
617    /// The parsed index entries.
618    entries: Vec<IndexEntry>,
619    /// The stat fields of the entries.
620    stats: Vec<StatEntry>,
621    /// The commit `HEAD` names.
622    commit: String,
623    /// The root tree of that commit.
624    tree_id: Vec<u8>,
625    /// The flattened tree.
626    tree: Vec<FlatEntry>,
627}
628
629impl State {
630    /// The `.gitattributes` files of the tree as path and object name.
631    fn attribute_blobs(&self) -> Vec<(Vec<u8>, Vec<u8>)> {
632        return self.tree
633            .iter()
634            .filter(|entry: &&FlatEntry| { return entry.path == b".gitattributes" || entry.path.ends_with(b"/.gitattributes"); })
635            .map(|entry: &FlatEntry| { return (entry.path.clone(), entry.id.clone()); })
636            .collect();
637    }
638
639    /// What `HEAD` names, with the index bytes the caller proved (or none, for a worktree that is not clean).
640    fn into_source(self, index_bytes: Vec<u8>) -> CleanSource {
641        let attribute_blobs: Vec<(Vec<u8>, Vec<u8>)> = self.attribute_blobs();
642        return CleanSource { head_commit: self.commit, head_tree: self.tree_id, tracked: self.entries.len(), index_seconds: self.index_seconds, hash_length: self.hash_length, attribute_blobs, index_bytes };
643    }
644}
645
646/// What:
647///  Read the index and the tree of `HEAD` of a worktree, in process.
648/// Why:
649///   The judgement of cleanliness and the snapshot of a worktree that is not clean start from the same reading; every shape
650///  the readers do not know is a refusal of kind `Unreadable`.
651///
652/// In TS you'd write (pseudocode):
653/// ```ts
654/// function readState(gitDir: string, commonDir: string): State; // throws a CleanRefusal
655/// ```
656fn read_state(git_dir: &Path, common_dir: &Path) -> Result<State, CleanRefusal> {
657    let hash_length: usize = repository_hash_length(common_dir).ok_or_else(|| { return CleanRefusal::unreadable("the repository configuration is unreadable"); })?;
658    let format: ObjectFormat = if hash_length == 32 { ObjectFormat::Sha256 } else { ObjectFormat::Sha1 };
659    // One open file gives both the bytes and the time, so the time belongs to the bytes that were proven.
660    let index_path = git_dir.join("index");
661    let unreadable = |error: std::io::Error| -> CleanRefusal { return CleanRefusal::unreadable(format!("the index is unreadable ({error})")); };
662    let mut index_file: std::fs::File = std::fs::File::open(index_path.as_path()).map_err(unreadable)?;
663    let index_seconds: u32 = index_file.metadata().map_err(unreadable)?.mtime() as u32;
664    let mut bytes: Vec<u8> = Vec::new();
665    std::io::Read::read_to_end(&mut index_file, &mut bytes).map_err(unreadable)?;
666    let entries: Vec<IndexEntry> = parse_index(bytes.as_slice(), format).ok_or_else(|| { return CleanRefusal::unreadable("the index has a layout the reader does not know"); })?;
667    let stats = stored_stats(bytes.as_slice(), hash_length).ok_or_else(|| { return CleanRefusal::unreadable("the index entries have no plain layout"); })?;
668    let commit: String = head_commit(git_dir, common_dir, format).ok_or_else(|| { return CleanRefusal::unreadable("HEAD is not a plain ref to a commit"); })?;
669    let database: ObjectDatabase = ObjectDatabase::open(common_dir, None, format).ok_or_else(|| { return CleanRefusal::unreadable("the object database has a layout the reader does not know"); })?;
670    let commit_name: Vec<u8> = crate::repo_read_object::from_hex(commit.as_bytes(), format).ok_or_else(|| { return CleanRefusal::unreadable("HEAD names no object"); })?;
671    let commit_fields = parse_commit(database.read(commit_name.as_slice()).ok_or_else(|| { return CleanRefusal::unreadable("the commit of HEAD is unreadable"); })?.bytes.as_slice(), format)
672        .ok_or_else(|| { return CleanRefusal::unreadable("the commit of HEAD has a layout the reader does not know"); })?;
673    let tree: Vec<FlatEntry> = flatten_tree(&database, commit_fields.tree.as_slice(), format).ok_or_else(|| { return CleanRefusal::unreadable("the tree of HEAD is unreadable"); })?;
674    return Ok(State { bytes, index_seconds, hash_length, format, entries, stats, commit, tree_id: commit_fields.tree, tree });
675}
676
677/// The checks of `check_clean_tracked`, by stat data alone or, given a `scope`, by content where the stat data is not enough.
678fn check_tracked(root: &Path, git_dir: &Path, common_dir: &Path, environment: Option<&ConversionEnvironment>, owned_index_lock: bool) -> Result<CleanSource, CleanRefusal> {
679    if let Some(name) = operation_in_progress(git_dir, owned_index_lock) {
680        return Err(CleanRefusal::new(CleanRefusalKind::OperationInProgress(name), format!("{name} exists in {}", git_dir.display())));
681    }
682    let state: State = crate::add_timing::step("readIndexAndTree", || { return read_state(git_dir, common_dir); })?;
683    if let Some(refusal) = crate::add_timing::step("indexAgainstTree", || { return index_differs_from_tree(state.entries.as_slice(), state.tree.as_slice()); }) {
684        return Err(refusal);
685    }
686    let differs: Option<CleanRefusal> = match environment {
687        None => crate::add_timing::step("walkSource", || { return tree_refusal(root, state.stats.as_slice(), state.index_seconds); }),
688        Some(found) => tree_differs_from_index_by_content(&ContentTarget { root, git_dir, common_dir, environment: found, format: state.format }, state.stats.as_slice(), state.entries.as_slice(), state.index_seconds)
689            .map(|reason: String| { return CleanRefusal::new(CleanRefusalKind::FileChanged, reason); }),
690    };
691    if let Some(refusal) = differs {
692        return Err(refusal);
693    }
694    let bytes: Vec<u8> = state.bytes.clone();
695    return Ok(state.into_source(bytes));
696}
697
698/// What:
699///  The first entry of a worktree's index or tree that a reset to a commit cannot be proven to undo, if any: unmerged,
700///  intent-to-add and skip-worktree entries, assume-unchanged entries, and submodules in the index or in the tree of `HEAD`.
701/// Why:
702///   The first refusal of the clean check can be an ordinary change that hides one of these further on; a restore needs all of
703///  the index looked at, not the first offender.
704///
705/// In TS you'd write (pseudocode):
706/// ```ts
707/// function entryAResetCannotUndo(state: State): CleanRefusal | undefined;
708/// ```
709fn entry_a_reset_cannot_undo(state: &State) -> Option<CleanRefusal> {
710    for staged in &state.entries {
711        let shown: String = String::from_utf8_lossy(&staged.path).into_owned();
712        if staged.stage != 0 || staged.extended != 0 {
713            return Some(CleanRefusal::new(CleanRefusalKind::NotPlain, format!("the index entry {shown} is unmerged, intent-to-add or skip-worktree")));
714        }
715        if staged.mode == MODE_GITLINK {
716            return Some(CleanRefusal::new(CleanRefusalKind::NotPlain, format!("{shown} is a submodule")));
717        }
718    }
719    if let Some(entry) = state.tree.iter().find(|entry: &&FlatEntry| { return entry.mode == MODE_GITLINK; }) {
720        return Some(CleanRefusal::new(CleanRefusalKind::NotPlain, format!("{} is a submodule in HEAD", String::from_utf8_lossy(&entry.path))));
721    }
722    return state.stats.iter().find(|entry: &&StatEntry| { return entry.flags & ASSUME_VALID != 0; }).map(|entry: &StatEntry| {
723        return CleanRefusal::new(CleanRefusalKind::NotPlain, format!("{} is marked assume-unchanged", String::from_utf8_lossy(&entry.path)));
724    });
725}
726
727/// Cleanliness controls stay out of the release executable.
728/// What:
729///  Check that the worktree at `root`, whose Git directory is `git_dir` and whose shared directory is `common_dir`, is
730///  clean; return what its `HEAD` names, or the reason it cannot be proven clean.
731/// Why:
732///   The caller turns a refusal into a quiet fall to the next rung (or to Git's own removal) and logs the reason.
733///
734/// In TS you'd write (pseudocode):
735/// ```ts
736/// async function checkClean(args: { context: GitContext; root: string; gitDir: string; commonDir: string }): Promise<CleanSource>; // throws a reason
737/// ```
738pub fn check_clean(context: &GitContext, root: &Path, git_dir: &Path, common_dir: &Path) -> Result<CleanSource, CleanRefusal> {
739    // The tracked files (the index, the tree of `HEAD`, a stat of every file) and the untracked scan (one Git run over the
740    //  directory tree) read different things and write nothing, so they run side by side; the tracked refusal still comes first.
741    let (tracked, untracked): (Result<CleanSource, CleanRefusal>, Result<Option<String>, CleanRefusal>) = std::thread::scope(|scope| {
742        let scan = scope.spawn(|| { return crate::add_timing::work("untrackedScan", || { return untracked_file(context, root); }); });
743        let tracked = check_tracked(root, git_dir, common_dir, None, false);
744        let scanned = scan.join().unwrap_or_else(|_panic| { return Err(CleanRefusal::unreadable("the scan for untracked files stopped unexpectedly")); });
745        return (tracked, scanned);
746    });
747    let clean: CleanSource = tracked?;
748    if let Some(name) = untracked? {
749        return Err(CleanRefusal::new(CleanRefusalKind::Untracked, format!("{name} is untracked and not ignored")));
750    }
751    return Ok(clean);
752}
753
754/// What:
755///  What `HEAD` names for a worktree that is not clean, and why it is not, once it is known that the only trouble is ordinary
756///  changes.
757/// Why:
758///   The snapshot rung copies such a worktree and restores the copy to its `HEAD`; the copy's own index is installed, so the
759///  index bytes here are only as read: they name the tracked paths and prove nothing about the files.
760///
761/// In TS you'd write (pseudocode):
762/// ```ts
763/// type DirtySource = { source: CleanSource; why: CleanRefusal };
764/// ```
765#[derive(Clone, Debug, Eq, PartialEq)]
766pub struct DirtySource {
767    /// What `HEAD` names; its `index_bytes` are the index as read, not proven equal to the files.
768    pub source: CleanSource,
769    /// The first reason the worktree is not clean.
770    pub why: CleanRefusal,
771}
772
773/// What:
774///  For a worktree `check_clean` refused with the ordinary change `why`, what `HEAD` names, or the refusal that a restore
775///  cannot undo when the index holds one anywhere.
776/// Why:
777///   The first refusal hides the rest: a staged edit can come before a submodule or a skip-worktree entry, and only a look at
778///  every entry tells a change a reset undoes from a state it does not.
779///
780/// In TS you'd write (pseudocode):
781/// ```ts
782/// function dirtySource(args: { gitDir: string; commonDir: string; why: CleanRefusal }): DirtySource; // throws a CleanRefusal
783/// ```
784pub fn dirty_source(git_dir: &Path, common_dir: &Path, why: CleanRefusal) -> Result<DirtySource, CleanRefusal> {
785    if !why.kind.restorable() {
786        return Err(why);
787    }
788    let state: State = read_state(git_dir, common_dir)?;
789    if let Some(refusal) = entry_a_reset_cannot_undo(&state) {
790        return Err(refusal);
791    }
792    let read: Vec<u8> = state.bytes.clone();
793    return Ok(DirtySource { source: state.into_source(read), why });
794}
795
796#[cfg(test)]
797#[path = "worktree_clean_tests.rs"]
798mod tests;