monochromatic-git-policy-cli 0.6.0

Repository-owned Git policy wrapper: forwards Git commands and checks commits against configured policies.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
//! What:
//!  Whether a worktree is clean enough to be copied from, or removed without asking Git: no operation in progress, an
//!  index equal to the tree of `HEAD`, every tracked file unchanged by its recorded size, times and inode, and no
//!  untracked file that Git does not ignore.
//! Why:
//!  A snapshot or a clone carries the bytes of the source's files, so any change the source has not committed would
//!  reach the new worktree, and a removal by the subvolume ioctl cannot be undone. The tracked-file checks read the
//!  index in process (no Git process) with the same comparison Git's fast path makes, and refuse every shape they cannot
//!  prove: a split or sparse index, a skip-worktree or assume-valid entry, a submodule, an entry as new as the index
//!  file (Git re-hashes those, so their stat fields prove nothing). The one question the index cannot answer, whether
//!  an untracked file is ignored, goes to one `git ls-files` run. Every refusal returns its reason in words for the log.
//!
//! In TS you'd write (pseudocode):
//! ```ts
//! const clean = await checkClean({ root, gitDir, commonDir }); // { headCommit, headTree } or throws a reason
//! ```

use crate::add_timing::{begin_step, begin_work, end_step, end_work};
use crate::side_by_side_threads::run_beside_caller;
use crate::repo_read_index::{IndexEntry, parse_index};
use crate::repo_read_layout::ObjectFormat;
use crate::repo_read_odb::ObjectDatabase;
use crate::repo_read_refs::{Refs, RefValue};
use crate::repo_read_tree::{FlatEntry, MODE_GITLINK, flatten_tree, parse_commit};
use crate::worktree_clean_content::ConversionEnvironment;
pub use crate::worktree_clean_stat_verdicts::{CleanRefusal, CleanRefusalKind, StatEntry, StatVerdict, metadata_differs_from_entry, metadata_verdict};
use crate::worktree_clean_stat_verdicts::{ASSUME_VALID, file_changed};
pub use crate::worktree_clean_tree_walk::{ContentTarget, StatCursor, stat_fields_beneath, tree_differs_from_index, tree_differs_from_index_beneath, tree_fields_beneath};
use crate::worktree_clean_tree_walk::{tree_differs_from_index_by_content, tree_refusal};
use crate::subvolume_index::{StoredStat, be32, walk_entries};
use crate::subvolume_index_files::repository_hash_length;
use crate::transaction_git::{GitContext, GitRequest, run_git};
use std::os::unix::fs::MetadataExt;
use std::path::Path;

/// The entries of a Git directory that mean an operation is in progress and the index or tree is not a plain state.
const IN_PROGRESS: &[&str] =
    &["index.lock", "MERGE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD", "BISECT_LOG", "rebase-merge", "rebase-apply", "sequencer"];

/// What:
///  What a clean worktree's `HEAD` names.
/// Why:
///   The two-tree update that follows a snapshot starts from this tree, so a caller needs not read it again.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// type CleanSource = { headCommit: string; headTree: Uint8Array; tracked: number; indexSeconds: number; hashLength: number; indexBytes: Uint8Array };
/// ```
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct CleanSource {
    /// The commit `HEAD` resolves to, as hexadecimal.
    pub head_commit: String,
    /// The root tree of that commit, as a raw object name.
    pub head_tree: Vec<u8>,
    /// The number of tracked files.
    pub tracked: usize,
    /// The whole seconds of the index file's modification time, against which racy entries were judged.
    pub index_seconds: u32,
    /// The hash length of the repository in bytes.
    pub hash_length: usize,
    /// The `.gitattributes` files of the tree of `HEAD` as path and object name.
    pub attribute_blobs: Vec<(Vec<u8>, Vec<u8>)>,
    /// The bytes of the index file that were proven equal to the tree of `HEAD` and to the files. Every later step uses
    ///  these bytes, never a second read of the file, so what was proven is what is installed.
    pub index_bytes: Vec<u8>,
}

/// What:
///  A refusal that the index or the Git data could not be read, when `found` holds nothing.
/// Why:
///   Every read of the repository's own files refuses in the same way, saying which read failed.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// found ?? throw unreadable(reason)
/// ```
fn or_unreadable<T>(found: Option<T>, reason: &'static str) -> Result<T, CleanRefusal> {
    return match found {
        Some(value) => Ok(value),
        None => Err(CleanRefusal::unreadable(reason)),
    };
}

/// What:
///  A refusal that the index file could not be opened, examined or read.
/// Why:
///   Three steps of reading the index word their failure the same way.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// unreadable(`the index is unreadable (${error})`)
/// ```
fn index_unreadable(error: std::io::Error) -> CleanRefusal {
    return CleanRefusal::unreadable(format!("the index is unreadable ({error})"));
}

/// What:
///  Whether a byte is NUL, the end of each name in Git's `-z` output.
/// Why:
///   The untracked names are split on it.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// byte === 0
/// ```
fn is_nul_byte(byte: &u8) -> bool {
    return *byte == 0;
}

/// What:
///  The first entry of the Git directory that marks an operation in progress, if any.
/// Why:
///   A merge, a rebase or a held index lock means the index is not the committed state yet; the lock a caller holds itself
///  (`owned_index_lock`) does not.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// function operationInProgress(gitDir: string): string | undefined;
/// ```
fn operation_in_progress(git_dir: &Path, owned_index_lock: bool) -> Option<&'static str> {
    for name in IN_PROGRESS.iter().copied() {
        if owned_index_lock && name == "index.lock" {
            continue;
        }
        if git_dir.join(name).symlink_metadata().is_ok() {
            return Some(name);
        }
    }
    return None;
}

/// What:
///  The commit `HEAD` resolves to, read from the plain ref files.
/// Why:
///   An unusual ref storage answers "unknown", which declines the shortcut rather than guessing.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// function headCommit(gitDir: string, commonDir: string, format: ObjectFormat): string | undefined;
/// ```
pub fn head_commit(git_dir: &Path, common_dir: &Path, format: ObjectFormat) -> Option<String> {
    let refs: Refs = Refs::new(common_dir, format.hex_length());
    return match refs.lookup_head(git_dir)? {
        RefValue::Direct(object) => Some(object),
        RefValue::Symbolic(name) => refs.resolve(name.as_str()),
    };
}

/// What:
///  The tracked files of the index paired with their recorded stat fields, or nothing when the layout is not plain.
/// Why:
///   The comparison with `lstat` needs the fields `parse_index` leaves out.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// function storedStats(bytes: Uint8Array, hashLength: number): { path: Uint8Array; flags: number; mode: number; stored: StoredStat }[] | undefined;
/// ```
pub fn stored_stats(bytes: &[u8], hash_length: usize) -> Option<Vec<StatEntry>> {
    let (entries, _end) = walk_entries(bytes, hash_length)?;
    let mut found: Vec<StatEntry> = Vec::with_capacity(entries.len());
    for entry in entries {
        let stored: StoredStat = StoredStat {
            ctime_seconds: be32(bytes, entry.start)?,
            ctime_nanos: be32(bytes, entry.start + 4)?,
            mtime_seconds: be32(bytes, entry.start + 8)?,
            mtime_nanos: be32(bytes, entry.start + 12)?,
            ino: be32(bytes, entry.start + 20)?,
            uid: be32(bytes, entry.start + 28)?,
            gid: be32(bytes, entry.start + 32)?,
            size: be32(bytes, entry.start + 36)?,
        };
        found.push(StatEntry { path: entry.path, flags: entry.flags, mode: entry.mode, stored });
    }
    return Some(found);
}

/// What:
///  The reason the index differs from the tree of `HEAD`, or nothing when the two are the same files.
/// Why:
///   A staged change, a staged deletion or an intent-to-add entry is an uncommitted state the copy must not carry.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// function indexDiffersFromTree(index: IndexEntry[], tree: FlatEntry[]): string | undefined;
/// ```
fn index_differs_from_tree(index: &[IndexEntry], tree: &[FlatEntry]) -> Option<CleanRefusal> {
    if index.len() != tree.len() {
        return Some(CleanRefusal::new(CleanRefusalKind::StagedChange, "the index and the tree of HEAD hold different numbers of files"));
    }
    for (staged, committed) in index.iter().zip(tree) {
        if staged.stage != 0 || staged.extended != 0 {
            return Some(CleanRefusal::new(CleanRefusalKind::NotPlain, format!("the index entry {} is unmerged, intent-to-add or skip-worktree", String::from_utf8_lossy(&staged.path))));
        }
        if staged.mode == MODE_GITLINK {
            return Some(CleanRefusal::new(CleanRefusalKind::NotPlain, format!("{} is a submodule", String::from_utf8_lossy(&staged.path))));
        }
        if staged.path != committed.path || staged.mode != committed.mode || staged.id != committed.id {
            return Some(CleanRefusal::new(CleanRefusalKind::StagedChange, format!("{} is staged with a change", String::from_utf8_lossy(&staged.path))));
        }
    }
    return None;
}

/// What:
///  Whether Git lists an untracked file that no ignore rule covers.
/// Why:
///   Only Git's ignore rules can tell an untracked file that matters from one it ignores; `--directory` stops at the
///  first untracked directory and `--no-empty-directory` leaves out directories without files, as `git status` does.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// async function untrackedFile(context: GitContext, root: string): Promise<string | undefined>;
/// ```
fn untracked_file(context: &GitContext, root: &Path) -> Result<Option<String>, CleanRefusal> {
    let request: GitRequest = GitRequest {
        cwd: root.to_path_buf(),
        arguments: ["ls-files", "--others", "--exclude-standard", "--directory", "--no-empty-directory", "-z"].map(Into::into).to_vec(),
        without_prefix: true,
        ..GitRequest::default()
    };
    let output = match run_git(context, &request) {
        Ok(finished) => finished,
        Err(error) => return Err(CleanRefusal::unreadable(format!("Git could not list untracked files ({error})"))),
    };
    if output.code != Some(0) {
        return Err(CleanRefusal::unreadable(format!("Git could not list untracked files: {}", String::from_utf8_lossy(output.stderr.as_slice()).trim())));
    }
    // The first name that is not empty, as `find` chose it.
    for name in output.stdout.split(is_nul_byte) {
        if !name.is_empty() {
            return Ok(Some(String::from_utf8_lossy(name).into_owned()));
        }
    }
    return Ok(None);
}

/// What:
///  The checks of `check_clean` that read no untracked file: no operation in progress, an index equal to the tree of `HEAD`,
///  and every tracked file with the size, times and inode its index entry recorded.
/// Why:
///   A caller that copies the whole worktree, untracked files included (the conversion of a worktree into a subvolume),
///  needs a quiet and unmodified tracked state but has no reason to refuse for an untracked file, and needs no Git process.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// function checkCleanTracked(root: string, gitDir: string, commonDir: string): CleanSource; // throws the reason
/// ```
pub fn check_clean_tracked(root: &Path, git_dir: &Path, common_dir: &Path) -> Result<CleanSource, String> {
    return match check_tracked(root, git_dir, common_dir, None, false) {
        Ok(source) => Ok(source),
        Err(refusal) => Err(refusal.reason),
    };
}

/// What:
///  `check_clean_tracked` that settles an entry the stat data cannot vouch for by comparing the file with the committed
///  blob in process, under the configuration and attribute locations of `environment`.
/// Why:
///   The conversion copies the whole worktree and rewrites the index afterwards, and an entry it could not prove keeps its old
///  stat data, so Git hashes it at the first status; the snapshot and clone rungs install the index they proved and use the
///  stat-only check.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// function checkCleanTrackedByContent(root: string, gitDir: string, commonDir: string, environment: ConversionEnvironment, ownedIndexLock: boolean): CleanSource; // throws the reason
/// ```
pub fn check_clean_tracked_by_content(root: &Path, git_dir: &Path, common_dir: &Path, environment: &ConversionEnvironment, owned_index_lock: bool) -> Result<CleanSource, String> {
    return match check_tracked(root, git_dir, common_dir, Some(environment), owned_index_lock) {
        Ok(source) => Ok(source),
        Err(refusal) => Err(refusal.reason),
    };
}

/// What the checks read before they judge: the index with its stat data, the commit `HEAD` names and its flattened tree.
struct State {
    /// The bytes of the index file.
    bytes: Vec<u8>,
    /// The whole seconds of the index file's modification time.
    index_seconds: u32,
    /// The hash length of the repository in bytes.
    hash_length: usize,
    /// The object format.
    format: ObjectFormat,
    /// The parsed index entries.
    entries: Vec<IndexEntry>,
    /// The stat fields of the entries.
    stats: Vec<StatEntry>,
    /// The commit `HEAD` names.
    commit: String,
    /// The root tree of that commit.
    tree_id: Vec<u8>,
    /// The flattened tree.
    tree: Vec<FlatEntry>,
}

impl State {
    /// The `.gitattributes` files of the tree as path and object name.
    fn attribute_blobs(&self) -> Vec<(Vec<u8>, Vec<u8>)> {
        let mut blobs: Vec<(Vec<u8>, Vec<u8>)> = Vec::new();
        for entry in &self.tree {
            if entry.path == b".gitattributes" || entry.path.ends_with(b"/.gitattributes") {
                blobs.push((entry.path.clone(), entry.id.clone()));
            }
        }
        return blobs;
    }

    /// What `HEAD` names, with the index bytes the caller proved (or none, for a worktree that is not clean).
    fn into_source(self, index_bytes: Vec<u8>) -> CleanSource {
        let attribute_blobs: Vec<(Vec<u8>, Vec<u8>)> = self.attribute_blobs();
        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 };
    }
}

/// What:
///  Read the index and the tree of `HEAD` of a worktree, in process.
/// Why:
///   The judgement of cleanliness and the snapshot of a worktree that is not clean start from the same reading; every shape
///  the readers do not know is a refusal of kind `Unreadable`.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// function readState(gitDir: string, commonDir: string): State; // throws a CleanRefusal
/// ```
fn read_state(git_dir: &Path, common_dir: &Path) -> Result<State, CleanRefusal> {
    let hash_length: usize = or_unreadable(repository_hash_length(common_dir), "the repository configuration is unreadable")?;
    let format: ObjectFormat = if hash_length == 32 { ObjectFormat::Sha256 } else { ObjectFormat::Sha1 };
    // One open file gives both the bytes and the time, so the time belongs to the bytes that were proven.
    let index_path = git_dir.join("index");
    let mut index_file: std::fs::File = std::fs::File::open(index_path.as_path()).map_err(index_unreadable)?;
    let index_seconds: u32 = index_file.metadata().map_err(index_unreadable)?.mtime() as u32;
    let mut bytes: Vec<u8> = Vec::new();
    std::io::Read::read_to_end(&mut index_file, &mut bytes).map_err(index_unreadable)?;
    let entries: Vec<IndexEntry> = or_unreadable(parse_index(bytes.as_slice(), format), "the index has a layout the reader does not know")?;
    let stats = or_unreadable(stored_stats(bytes.as_slice(), hash_length), "the index entries have no plain layout")?;
    let commit: String = or_unreadable(head_commit(git_dir, common_dir, format), "HEAD is not a plain ref to a commit")?;
    let database: ObjectDatabase = or_unreadable(ObjectDatabase::open(common_dir, None, format), "the object database has a layout the reader does not know")?;
    let commit_name: Vec<u8> = or_unreadable(crate::repo_read_object::from_hex(commit.as_bytes(), format), "HEAD names no object")?;
    let commit_object = or_unreadable(database.read(commit_name.as_slice()), "the commit of HEAD is unreadable")?;
    let commit_fields = or_unreadable(parse_commit(commit_object.bytes.as_slice(), format), "the commit of HEAD has a layout the reader does not know")?;
    let tree: Vec<FlatEntry> = or_unreadable(flatten_tree(&database, commit_fields.tree.as_slice(), format), "the tree of HEAD is unreadable")?;
    return Ok(State { bytes, index_seconds, hash_length, format, entries, stats, commit, tree_id: commit_fields.tree, tree });
}

/// The checks of `check_clean_tracked`, by stat data alone or, given a `scope`, by content where the stat data is not enough.
fn check_tracked(root: &Path, git_dir: &Path, common_dir: &Path, environment: Option<&ConversionEnvironment>, owned_index_lock: bool) -> Result<CleanSource, CleanRefusal> {
    if let Some(name) = operation_in_progress(git_dir, owned_index_lock) {
        return Err(CleanRefusal::new(CleanRefusalKind::OperationInProgress(name), format!("{name} exists in {}", git_dir.display())));
    }
    let read_index_and_tree_entered: bool = begin_step("readIndexAndTree");
    let read_index_and_tree_outcome = read_state(git_dir, common_dir);
    end_step(read_index_and_tree_entered);
    let state: State = read_index_and_tree_outcome?;
    let index_against_tree_entered: bool = begin_step("indexAgainstTree");
    let index_against_tree_outcome = index_differs_from_tree(state.entries.as_slice(), state.tree.as_slice());
    end_step(index_against_tree_entered);
    if let Some(refusal) = index_against_tree_outcome {
        return Err(refusal);
    }
    let differs: Option<CleanRefusal> = match environment {
        None => {
            let walk_source_entered: bool = begin_step("walkSource");
            let walked: Option<CleanRefusal> = tree_refusal(root, state.stats.as_slice(), state.index_seconds);
            end_step(walk_source_entered);
            walked
        }
        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)
            .map(file_changed),
    };
    if let Some(refusal) = differs {
        return Err(refusal);
    }
    let bytes: Vec<u8> = state.bytes.clone();
    return Ok(state.into_source(bytes));
}

/// What:
///  The first entry of a worktree's index or tree that a reset to a commit cannot be proven to undo, if any: unmerged,
///  intent-to-add and skip-worktree entries, assume-unchanged entries, and submodules in the index or in the tree of `HEAD`.
/// Why:
///   The first refusal of the clean check can be an ordinary change that hides one of these further on; a restore needs all of
///  the index looked at, not the first offender.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// function entryAResetCannotUndo(state: State): CleanRefusal | undefined;
/// ```
fn entry_a_reset_cannot_undo(state: &State) -> Option<CleanRefusal> {
    for staged in &state.entries {
        let shown: String = String::from_utf8_lossy(&staged.path).into_owned();
        if staged.stage != 0 || staged.extended != 0 {
            return Some(CleanRefusal::new(CleanRefusalKind::NotPlain, format!("the index entry {shown} is unmerged, intent-to-add or skip-worktree")));
        }
        if staged.mode == MODE_GITLINK {
            return Some(CleanRefusal::new(CleanRefusalKind::NotPlain, format!("{shown} is a submodule")));
        }
    }
    for entry in &state.tree {
        if entry.mode == MODE_GITLINK {
            return Some(CleanRefusal::new(CleanRefusalKind::NotPlain, format!("{} is a submodule in HEAD", String::from_utf8_lossy(&entry.path))));
        }
    }
    for entry in &state.stats {
        if entry.flags & ASSUME_VALID != 0 {
            return Some(CleanRefusal::new(CleanRefusalKind::NotPlain, format!("{} is marked assume-unchanged", String::from_utf8_lossy(&entry.path))));
        }
    }
    return None;
}

/// What:
///  The untracked scan of `check_clean`, timed as the work `untrackedScan` because it runs on a thread of its own.
/// Why:
///   The scan is one Git run over the directory tree; the add timing record sums the work of threads by name.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// const began = beginWork(); const scanned = untrackedFile(context, root); endWork('untrackedScan', began);
/// ```
fn scan_for_untracked_file((context, root): (&GitContext, &Path)) -> Result<Option<String>, CleanRefusal> {
    let began: std::time::Instant = begin_work();
    let scanned: Result<Option<String>, CleanRefusal> = untracked_file(context, root);
    end_work("untrackedScan", began);
    return scanned;
}

/// The tracked-file proof of `check_clean`, which runs on the calling thread beside the untracked scan.
fn check_tracked_files((root, git_dir, common_dir): (&Path, &Path, &Path)) -> Result<CleanSource, CleanRefusal> {
    return check_tracked(root, git_dir, common_dir, None, false);
}

/// Cleanliness controls stay out of the release executable.
/// What:
///  Check that the worktree at `root`, whose Git directory is `git_dir` and whose shared directory is `common_dir`, is
///  clean; return what its `HEAD` names, or the reason it cannot be proven clean.
/// Why:
///   The caller turns a refusal into a quiet fall to the next rung (or to Git's own removal) and logs the reason.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// async function checkClean(args: { context: GitContext; root: string; gitDir: string; commonDir: string }): Promise<CleanSource>; // throws a reason
/// ```
pub fn check_clean(context: &GitContext, root: &Path, git_dir: &Path, common_dir: &Path) -> Result<CleanSource, CleanRefusal> {
    // The tracked files (the index, the tree of `HEAD`, a stat of every file) and the untracked scan (one Git run over the
    //  directory tree) read different things and write nothing, so they run side by side; the tracked refusal still comes first.
    let (scan_joined, tracked): (std::thread::Result<Result<Option<String>, CleanRefusal>>, Result<CleanSource, CleanRefusal>) =
        run_beside_caller(((context, root), scan_for_untracked_file), ((root, git_dir, common_dir), check_tracked_files));
    let untracked: Result<Option<String>, CleanRefusal> = match scan_joined {
        Ok(found) => found,
        Err(_panic) => Err(CleanRefusal::unreadable("the scan for untracked files stopped unexpectedly")),
    };
    let clean: CleanSource = tracked?;
    if let Some(name) = untracked? {
        return Err(CleanRefusal::new(CleanRefusalKind::Untracked, format!("{name} is untracked and not ignored")));
    }
    return Ok(clean);
}

/// What:
///  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
///  changes.
/// Why:
///   The snapshot rung copies such a worktree and restores the copy to its `HEAD`; the copy's own index is installed, so the
///  index bytes here are only as read: they name the tracked paths and prove nothing about the files.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// type DirtySource = { source: CleanSource; why: CleanRefusal };
/// ```
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DirtySource {
    /// What `HEAD` names; its `index_bytes` are the index as read, not proven equal to the files.
    pub source: CleanSource,
    /// The first reason the worktree is not clean.
    pub why: CleanRefusal,
}

/// What:
///  For a worktree `check_clean` refused with the ordinary change `why`, what `HEAD` names, or the refusal that a restore
///  cannot undo when the index holds one anywhere.
/// Why:
///   The first refusal hides the rest: a staged edit can come before a submodule or a skip-worktree entry, and only a look at
///  every entry tells a change a reset undoes from a state it does not.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// function dirtySource(args: { gitDir: string; commonDir: string; why: CleanRefusal }): DirtySource; // throws a CleanRefusal
/// ```
pub fn dirty_source(git_dir: &Path, common_dir: &Path, why: CleanRefusal) -> Result<DirtySource, CleanRefusal> {
    if !why.kind.restorable() {
        return Err(why);
    }
    let state: State = read_state(git_dir, common_dir)?;
    if let Some(refusal) = entry_a_reset_cannot_undo(&state) {
        return Err(refusal);
    }
    let read: Vec<u8> = state.bytes.clone();
    return Ok(DirtySource { source: state.into_source(read), why });
}

#[cfg(test)]
#[path = "worktree_clean_tests.rs"]
mod tests;