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
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
//! 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 super::repo_read_index::{IndexEntry, parse_index};
use super::repo_read_layout::ObjectFormat;
use super::repo_read_odb::ObjectDatabase;
use super::repo_read_refs::{Refs, RefValue};
use super::repo_read_tree::{FlatEntry, MODE_GITLINK, flatten_tree, parse_commit};
use super::fs_beneath::{entry_metadata_at, open_directory_beneath, open_root, plain_components};
use super::worktree_clean_content::{ContentJudge, ConversionEnvironment, Place};
use super::subvolume_index::{StatFields, StoredStat, be32, walk_entries};
use super::subvolume_index_files::{fields_of, matches_stored, repository_hash_length};
use super::transaction_git::{GitContext, GitRequest, run_git};
use std::ffi::OsStr;
use std::os::unix::ffi::OsStrExt;
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"];
/// The flags bit of an index entry that tells Git to trust the file without looking (`CE_VALID`).
const ASSUME_VALID: u16 = 0x8000;
/// What:
/// Which kind of state kept a worktree from being proven clean.
/// Why:
/// Callers decide by kind, never by the words of the reason: the snapshot rung restores a worktree whose only trouble is an
/// ordinary change, and refuses every state a restore cannot be proven to undo; the sentence for the person is chosen by kind.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// type CleanRefusalKind = { kind: 'operation-in-progress'; name: string } | { kind: 'unreadable' } | { kind: 'not-plain' }
/// | { kind: 'staged-change' } | { kind: 'file-changed' } | { kind: 'stat-stale' } | { kind: 'racy' } | { kind: 'untracked' };
/// ```
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum CleanRefusalKind {
/// A merge, cherry-pick, revert, bisect, rebase, sequencer or `index.lock` mark exists; the name is the mark's.
OperationInProgress(&'static str),
/// The index, the object database, the configuration or Git itself cannot be read in the way the checks need.
Unreadable,
/// An index or tree entry of a kind a restore cannot be proven to undo: unmerged, intent-to-add, skip-worktree,
/// assume-unchanged, or a submodule.
NotPlain,
/// The index differs from the tree of `HEAD`: a staged addition, deletion, change or rename.
StagedChange,
/// A tracked file is missing, changed its type or executable bit, or differs in content.
FileChanged,
/// A tracked file's size, times or inode differ from its entry, so it probably changed.
StatStale,
/// A tracked file is as new as the index, so its stat fields prove nothing.
Racy,
/// Git lists an untracked file that no ignore rule covers.
Untracked,
}
impl CleanRefusalKind {
/// Whether a reset to a commit can undo the state: only ordinary changes, not an operation in progress or a state Git
/// keeps outside the tree and the files.
pub fn restorable(self) -> bool {
return matches!(self, Self::StagedChange | Self::FileChanged | Self::StatStale | Self::Racy | Self::Untracked);
}
}
/// What:
/// Why a worktree could not be proven clean: the kind and the reason in words.
/// Why:
/// The words are for logs and for people; the kind is for code.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// type CleanRefusal = { kind: CleanRefusalKind; reason: string };
/// ```
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct CleanRefusal {
/// What kind of state refused.
pub kind: CleanRefusalKind,
/// The reason, in words.
pub reason: String,
}
impl CleanRefusal {
/// A refusal of `kind` with its reason.
fn new(kind: CleanRefusalKind, reason: impl Into<String>) -> CleanRefusal {
return CleanRefusal { kind, reason: reason.into() };
}
/// A refusal because something could not be read.
fn unreadable(reason: impl Into<String>) -> CleanRefusal {
return CleanRefusal::new(CleanRefusalKind::Unreadable, reason);
}
}
impl std::fmt::Display for CleanRefusal {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
return formatter.write_str(self.reason.as_str());
}
}
/// What:
/// One tracked file of an index with the stat fields the index recorded for it.
/// Why:
/// `parse_index` leaves the stat fields out, and the comparison with the file on disk needs them.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// type StatEntry = { path: Uint8Array; flags: number; mode: number; stored: StoredStat };
/// ```
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct StatEntry {
/// The path, `/` separated.
pub path: Vec<u8>,
/// The entry's flags (the assume-valid bit among them).
pub flags: u16,
/// The mode with its type bits.
pub mode: u32,
/// The recorded stat fields.
pub stored: StoredStat,
}
/// 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:
/// 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> {
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(); });
}
/// 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:
/// What the stat data of an index entry says about a file.
/// Why:
/// A file the data cannot vouch for (a racily clean entry, a stale stat) is not the same as a file that certainly changed:
/// Git settles the first by content, so a caller that can compare content takes the two apart.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// type StatVerdict = { kind: 'agrees' } | { kind: 'unproven'; reason: string } | { kind: 'differs'; reason: string };
/// ```
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum StatVerdict {
/// Size, times, inode, type and mode all equal the entry.
Agrees,
/// The stat data proves nothing either way; the content decides.
Unproven(String),
/// The file certainly differs: missing type, mode or assume-unchanged mark.
Differs(String),
}
/// What:
/// What the stat data of the entry says about a file whose metadata is `metadata`.
/// Why:
/// The comparison takes the metadata of an inode already in hand, so a caller that holds a descriptor judges the file it
/// holds, not whatever the path names a moment later. Type and executable bit come before the time checks: they differ
/// whatever the content is.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// function metadataVerdict(entry: StatEntry, metadata: Stats, indexSeconds: number): StatVerdict;
/// ```
pub fn metadata_verdict(entry: &StatEntry, metadata: &std::fs::Metadata, index_seconds: u32) -> StatVerdict {
return match stat_finding(entry, metadata, index_seconds) {
None => StatVerdict::Agrees,
Some(CleanRefusal { kind: CleanRefusalKind::Racy | CleanRefusalKind::StatStale, reason }) => StatVerdict::Unproven(reason),
Some(refusal) => StatVerdict::Differs(refusal.reason),
};
}
/// What:
/// What the stat data of the entry says about a file whose metadata is `metadata`, as a refusal with its kind, or nothing
/// when they agree.
/// Why:
/// One comparison serves the verdict that callers of the stat-only checks use and the kind that the snapshot rung decides
/// by. Type and executable bit come before the time checks: they differ whatever the content is.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// function statFinding(entry: StatEntry, metadata: Stats, indexSeconds: number): CleanRefusal | undefined;
/// ```
fn stat_finding(entry: &StatEntry, metadata: &std::fs::Metadata, index_seconds: u32) -> Option<CleanRefusal> {
let StatEntry { path, flags, mode, stored }: &StatEntry = entry;
let shown: String = String::from_utf8_lossy(path).into_owned();
if flags & ASSUME_VALID != 0 {
return Some(CleanRefusal::new(CleanRefusalKind::NotPlain, format!("{shown} is marked assume-unchanged")));
}
let symbolic: bool = *mode & 0o170_000 == 0o120_000;
if symbolic != metadata.file_type().is_symlink() || (!symbolic && !metadata.file_type().is_file()) {
return Some(CleanRefusal::new(CleanRefusalKind::FileChanged, format!("{shown} changed its type")));
}
if !symbolic && (*mode & 0o100 != 0) != (metadata.mode() & 0o100 != 0) {
return Some(CleanRefusal::new(CleanRefusalKind::FileChanged, format!("{shown} changed its executable bit")));
}
if stored.mtime_seconds >= index_seconds {
return Some(CleanRefusal::new(CleanRefusalKind::Racy, format!("{shown} is as new as the index, so its stat fields prove nothing")));
}
let now: StatFields = fields_of(metadata);
if !matches_stored(&now, stored) {
return Some(CleanRefusal::new(CleanRefusalKind::StatStale, format!("{shown} differs from its index entry by size, times or inode")));
}
return None;
}
/// What:
/// The reason a file whose metadata is `metadata` differs from its index entry, or nothing when the two agree.
/// Why:
/// Callers that rely on the stat data alone (the snapshot and clone rungs) treat a file the data cannot vouch for like a
/// file that changed.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// function metadataDiffersFromEntry(entry: StatEntry, metadata: Stats, indexSeconds: number): string | undefined;
/// ```
pub fn metadata_differs_from_entry(entry: &StatEntry, metadata: &std::fs::Metadata, index_seconds: u32) -> Option<String> {
return match metadata_verdict(entry, metadata, index_seconds) {
StatVerdict::Agrees => None,
StatVerdict::Unproven(reason) | StatVerdict::Differs(reason) => Some(reason),
};
}
/// What:
/// The first reason a tracked file of `root` differs from its index entry, or nothing when every file agrees.
/// Why:
/// After a snapshot the same comparison runs against the snapshot's own files, so a source that changed while the
/// snapshot was taken is caught on the copy. The files are reached below a held root with no link followed, so a directory
/// replaced by a link is not read through.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// function treeDiffersFromIndex(root: string, entries: StatEntry[], indexSeconds: number): string | undefined;
/// ```
pub fn tree_differs_from_index(root: &Path, entries: &[StatEntry], index_seconds: u32) -> Option<String> {
return match open_root(root) {
Ok(held) => tree_differs_from_index_beneath(&held, entries, index_seconds),
Err(error) => Some(format!("{} cannot be opened without following a link ({error})", root.display())),
};
}
/// The first refusal of a tracked file of `root` against its index entry, with its kind; the files are reached below a held
/// root with no link followed.
fn tree_refusal(root: &Path, entries: &[StatEntry], index_seconds: u32) -> Option<CleanRefusal> {
return match open_root(root) {
Ok(held) => tree_refusal_beneath(&held, entries, index_seconds),
Err(error) => Some(CleanRefusal::unreadable(format!("{} cannot be opened without following a link ({error})", root.display()))),
};
}
/// What the content decision needs besides the entries: the worktree, its Git directories, the environment and the format.
pub struct ContentTarget<'a> {
/// The worktree root.
pub root: &'a Path,
/// The worktree's Git directory.
pub git_dir: &'a Path,
/// The shared Git directory.
pub common_dir: &'a Path,
/// The configuration and attribute locations.
pub environment: &'a ConversionEnvironment,
/// The object format of the repository.
pub format: ObjectFormat,
}
/// What:
/// The first reason a tracked file of the worktree differs from its index entry, deciding by content where the stat data
/// cannot vouch for the file.
/// Why:
/// Git hashes a racily clean entry and an entry whose stat data is stale; this does the same in process, so a clean
/// worktree that was copied, cloned or written in the same second as its index is not refused. Every file is reached below a
/// held root with no link followed, so a directory replaced by a link is refused instead of being read through. A file that
/// really differs, or one a content conversion could change, is refused with the reason.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// function treeDiffersFromIndexByContent(target: ContentTarget, entries: StatEntry[], indexed: IndexEntry[], indexSeconds: number): string | undefined;
/// ```
fn tree_differs_from_index_by_content(target: &ContentTarget<'_>, entries: &[StatEntry], indexed: &[IndexEntry], index_seconds: u32) -> Option<String> {
if entries.len() != indexed.len() {
return Some(String::from("the index entries and their stat data are not the same files"));
}
let held: std::fs::File = match open_root(target.root) {
Ok(opened) => opened,
Err(error) => return Some(format!("{} cannot be opened without following a link ({error})", target.root.display())),
};
let mut judge: ContentJudge<'_> = ContentJudge::new(&held, target.git_dir, target.common_dir, target.environment, target.format);
let mut cached: Option<(Vec<std::ffi::OsString>, std::fs::File)> = None;
for (entry, committed) in entries.iter().zip(indexed) {
if entry.path != committed.path {
return Some(String::from("the index entries and their stat data are not the same files"));
}
let shown: String = String::from_utf8_lossy(entry.path.as_slice()).into_owned();
let Ok(names) = plain_components(Path::new(OsStr::from_bytes(entry.path.as_slice()))) else { return Some(format!("{shown} is not a plain path")) };
let Some((name, parents)) = names.split_last() else { return Some(format!("{shown} is not a plain path")) };
if cached.as_ref().is_none_or(|(known, _directory): &(Vec<std::ffi::OsString>, std::fs::File)| { return known.as_slice() != parents; }) {
let Ok(directory) = open_directory_beneath(&held, parents) else { return Some(format!("{shown} is missing from the worktree or reached through a link")) };
cached = Some((parents.to_vec(), directory));
}
let Some((_known, directory)) = cached.as_ref() else { return Some(format!("{shown} is missing from the worktree")) };
let Ok(metadata) = entry_metadata_at(directory, name.as_os_str()) else { return Some(format!("{shown} is missing from the worktree")) };
match metadata_verdict(entry, &metadata, index_seconds) {
StatVerdict::Agrees => {}
StatVerdict::Differs(reason) => return Some(reason),
StatVerdict::Unproven(_reason) => {
let place: Place<'_> = Place { directory, parents, name: name.as_os_str() };
if let Err(reason) = judge.agrees(&place, entry, committed.id.as_slice(), index_seconds) {
return Some(reason);
}
}
}
}
return None;
}
/// What:
/// The first reason a tracked file below the held directory `root` differs from its index entry, or nothing when every
/// file agrees; each file is looked at below handles opened without following a link.
/// Why:
/// A snapshot is checked against the index it will carry; a path walk through a link inside the snapshot would compare
/// a file outside it, so every directory on the way must be a real one on the snapshot's device.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// function treeDiffersFromIndexBeneath(root: Dir, entries: StatEntry[], indexSeconds: number): string | undefined;
/// ```
pub fn tree_differs_from_index_beneath(root: &std::fs::File, entries: &[StatEntry], index_seconds: u32) -> Option<String> {
return tree_refusal_beneath(root, entries, index_seconds).map(|refusal: CleanRefusal| { return refusal.reason; });
}
/// The first refusal of a tracked file below the held directory `root` against its index entry, with its kind.
fn tree_refusal_beneath(root: &std::fs::File, entries: &[StatEntry], index_seconds: u32) -> Option<CleanRefusal> {
let changed = |reason: String| -> Option<CleanRefusal> { return Some(CleanRefusal::new(CleanRefusalKind::FileChanged, reason)); };
let mut cached: Option<(Vec<std::ffi::OsString>, std::fs::File)> = None;
for entry in entries {
let shown: String = String::from_utf8_lossy(entry.path.as_slice()).into_owned();
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"))) };
let Some((name, parents)) = names.split_last() else { return Some(CleanRefusal::unreadable(format!("{shown} is not a plain path"))) };
if cached.as_ref().is_none_or(|(held, _directory): &(Vec<std::ffi::OsString>, std::fs::File)| { return held.as_slice() != parents; }) {
let Ok(directory) = open_directory_beneath(root, parents) else { return changed(format!("{shown} is missing from the worktree or reached through a link")) };
cached = Some((parents.to_vec(), directory));
}
let Some((_held, directory)) = cached.as_ref() else { return changed(format!("{shown} is missing from the worktree")) };
let Ok(metadata) = entry_metadata_at(directory, name.as_os_str()) else { return changed(format!("{shown} is missing from the worktree")) };
if let Some(refusal) = stat_finding(entry, &metadata, index_seconds) {
return Some(refusal);
}
}
return None;
}
/// What:
/// A walk below a held directory that keeps the last directory it opened.
/// Why:
/// The paths of an index come in order, so most of them share their parent with the one before; one `openat2` chain per
/// directory instead of per file is what the walk costs, and each file is still reached through handles that follow no link.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// const cursor = new StatCursor(root); cursor.fields(path); // StatFields | undefined
/// ```
pub struct StatCursor<'a> {
/// The directory every path is found below.
root: &'a std::fs::File,
/// The components and the handle of the last directory opened.
held: Option<(Vec<std::ffi::OsString>, std::fs::File)>,
}
impl<'a> StatCursor<'a> {
/// A cursor below `root` that has opened nothing.
pub fn new(root: &'a std::fs::File) -> Self {
return Self { root, held: None };
}
/// The metadata of the file `path` names below the root, or the reason it cannot be found through plain directories.
fn metadata(&mut self, path: &[u8]) -> Result<std::fs::Metadata, String> {
let shown: String = String::from_utf8_lossy(path).into_owned();
let Ok(names) = plain_components(Path::new(OsStr::from_bytes(path))) else { return Err(format!("{shown} is not a plain path")) };
let Some((name, parents)) = names.split_last() else { return Err(format!("{shown} is not a plain path")) };
if self.held.as_ref().is_none_or(|(known, _directory): &(Vec<std::ffi::OsString>, std::fs::File)| { return known.as_slice() != parents; }) {
let Ok(directory) = open_directory_beneath(self.root, parents) else { return Err(format!("{shown} is missing from the worktree or reached through a link")) };
self.held = Some((parents.to_vec(), directory));
}
let Some((_known, directory)) = self.held.as_ref() else { return Err(format!("{shown} is missing from the worktree")) };
return entry_metadata_at(directory, name.as_os_str()).map_err(|_error| { return format!("{shown} is missing from the worktree"); });
}
/// The stat fields of the tracked file `path` below the root, or nothing when it cannot be found through plain directories.
pub fn fields(&mut self, path: &[u8]) -> Option<StatFields> {
return self.metadata(path).ok().map(|found: std::fs::Metadata| { return fields_of(&found); });
}
}
/// What:
/// Judge every tracked file below the held directory `root` against its index entry and return the stat fields of each, in
/// entry order, in one walk.
/// Why:
/// The snapshot's index takes the stat fields of the snapshot's own files, and the snapshot is proven equal to the source's
/// 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
/// verdict was given on.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// const fields = treeFieldsBeneath(root, entries, indexSeconds); // throws a CleanRefusal
/// ```
pub fn tree_fields_beneath(root: &std::fs::File, entries: &[StatEntry], index_seconds: u32) -> Result<Vec<StatFields>, CleanRefusal> {
let mut cursor: StatCursor<'_> = StatCursor::new(root);
let mut fields: Vec<StatFields> = Vec::with_capacity(entries.len());
for entry in entries {
let metadata: std::fs::Metadata = match cursor.metadata(entry.path.as_slice()) {
Ok(found) => found,
Err(reason) if reason.ends_with("is not a plain path") => return Err(CleanRefusal::unreadable(reason)),
Err(reason) => return Err(CleanRefusal::new(CleanRefusalKind::FileChanged, reason)),
};
if let Some(refusal) = stat_finding(entry, &metadata, index_seconds) {
return Err(refusal);
}
fields.push(fields_of(&metadata));
}
return Ok(fields);
}
/// What:
/// The stat fields of the tracked file `path` below the held directory `root`, found through handles, or nothing.
/// Why:
/// The index of a snapshot takes the stat fields of the snapshot's own files; reading them by a path with several
/// components would follow a link substituted below the root.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// function statFieldsBeneath(root: Dir, path: Uint8Array): StatFields | undefined;
/// ```
pub fn stat_fields_beneath(root: &std::fs::File, path: &[u8]) -> Option<StatFields> {
return StatCursor::new(root).fields(path);
}
/// 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 = run_git(context, &request).map_err(|error| { return 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())));
}
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(); }));
}
/// 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 check_tracked(root, git_dir, common_dir, None, false).map_err(|refusal: CleanRefusal| { return 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 check_tracked(root, git_dir, common_dir, Some(environment), owned_index_lock).map_err(|refusal: CleanRefusal| { return 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>)> {
return self.tree
.iter()
.filter(|entry: &&FlatEntry| { return entry.path == b".gitattributes" || entry.path.ends_with(b"/.gitattributes"); })
.map(|entry: &FlatEntry| { return (entry.path.clone(), entry.id.clone()); })
.collect();
}
/// 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 = repository_hash_length(common_dir).ok_or_else(|| { return CleanRefusal::unreadable("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 unreadable = |error: std::io::Error| -> CleanRefusal { return CleanRefusal::unreadable(format!("the index is unreadable ({error})")); };
let mut index_file: std::fs::File = std::fs::File::open(index_path.as_path()).map_err(unreadable)?;
let index_seconds: u32 = index_file.metadata().map_err(unreadable)?.mtime() as u32;
let mut bytes: Vec<u8> = Vec::new();
std::io::Read::read_to_end(&mut index_file, &mut bytes).map_err(unreadable)?;
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"); })?;
let stats = stored_stats(bytes.as_slice(), hash_length).ok_or_else(|| { return CleanRefusal::unreadable("the index entries have no plain layout"); })?;
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"); })?;
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"); })?;
let commit_name: Vec<u8> = super::repo_read_object::from_hex(commit.as_bytes(), format).ok_or_else(|| { return CleanRefusal::unreadable("HEAD names no object"); })?;
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)
.ok_or_else(|| { return CleanRefusal::unreadable("the commit of HEAD has a layout the reader does not know"); })?;
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"); })?;
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 state: State = super::add_timing::step("readIndexAndTree", || { return read_state(git_dir, common_dir); })?;
if let Some(refusal) = super::add_timing::step("indexAgainstTree", || { return index_differs_from_tree(state.entries.as_slice(), state.tree.as_slice()); }) {
return Err(refusal);
}
let differs: Option<CleanRefusal> = match environment {
None => super::add_timing::step("walkSource", || { return tree_refusal(root, state.stats.as_slice(), state.index_seconds); }),
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(|reason: String| { return CleanRefusal::new(CleanRefusalKind::FileChanged, reason); }),
};
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")));
}
}
if let Some(entry) = state.tree.iter().find(|entry: &&FlatEntry| { return entry.mode == MODE_GITLINK; }) {
return Some(CleanRefusal::new(CleanRefusalKind::NotPlain, format!("{} is a submodule in HEAD", String::from_utf8_lossy(&entry.path))));
}
return state.stats.iter().find(|entry: &&StatEntry| { return entry.flags & ASSUME_VALID != 0; }).map(|entry: &StatEntry| {
return CleanRefusal::new(CleanRefusalKind::NotPlain, format!("{} is marked assume-unchanged", String::from_utf8_lossy(&entry.path)));
});
}
/// 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 (tracked, untracked): (Result<CleanSource, CleanRefusal>, Result<Option<String>, CleanRefusal>) = std::thread::scope(|scope| {
let scan = scope.spawn(|| { return super::add_timing::work("untrackedScan", || { return untracked_file(context, root); }); });
let tracked = check_tracked(root, git_dir, common_dir, None, false);
let scanned = scan.join().unwrap_or_else(|_panic| { return Err(CleanRefusal::unreadable("the scan for untracked files stopped unexpectedly")); });
return (tracked, scanned);
});
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;