Skip to main content

git_policy_cli/worktree_copying/
worktree_copy_compare.rs

1//! What:
2//!  Exact comparison of one manifest entry against the filesystem:
3//!  its kind, its retained mode, its symbolic-link target text or its regular-file bytes.
4//!  Also the final check that the source still matches the stage after staging.
5//! Why:
6//!  A destination must receive exactly what was staged from the source,
7//!  and a source that changed during the transfer must block the destination.
8//!
9//! In TS you'd write (pseudocode):
10//! ```ts
11//! await assertFinalSourceEquivalence({ sourceRoot, stageRoot, selectedRoots, excludedSourceRoots, stagedEntries });
12//! ```
13
14use crate::worktree_copy_error::{WorktreeCopyError, failure_with_cause, raw_failure};
15use crate::worktree_copy_manifest::{
16    EntryKind, FileStamp, WorktreeCopyEntry, collect_entry_manifest, collect_pruned_manifest, retained_mode, stamp_of,
17};
18use crate::worktree_copy_prune::Prune;
19use crate::worktree_copy_selection::filesystem_path;
20use crate::json_record::quote;
21use std::io::Read;
22use std::path::{Path, PathBuf};
23
24/// What:
25///  The size of one comparison read.
26/// Why:
27///   Large files are compared in bounded chunks so memory does not grow with file size.
28///
29/// In TS you'd write (pseudocode):
30/// ```ts
31/// const COMPARE_BUFFER_BYTES = 64 * 1_024;
32/// ```
33///  Exempt from a citation under `LQM` (class: buffer size): it sizes one read buffer, and the result of the comparison
34///  does not depend on it. Classed once in `doc/planning/cli-git-shipped-limits-audit.md`, "Exempt limits"; the
35///  coordinator's pick under the owner's delegation, open to the owner's veto.
36const COMPARE_BUFFER_BYTES: usize = 64 * 1_024;
37
38/// What:
39///  The metadata of one no-follow entry, or its absence.
40/// Why:
41///   A missing destination entry is a normal answer for the comparison, not a failure.
42///
43/// In TS you'd write (pseudocode):
44/// ```ts
45/// await lstatOrAbsent(path)
46/// ```
47pub fn lstat_or_absent(path: &Path) -> Result<Option<std::fs::Metadata>, WorktreeCopyError> {
48    // `match` separates "no entry" (not an error) from every other failure.
49    match std::fs::symlink_metadata(path) {
50        Ok(metadata) => return Ok(Some(metadata)),
51        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
52        Err(error) => return Err(raw_failure(&error)),
53    }
54}
55
56/// What:
57///  Whether two regular files hold the same bytes, compared in bounded chunks.
58/// Why:
59///   Sizes are compared first, and a short read means the file changed under the comparison.
60///
61/// In TS you'd write (pseudocode):
62/// ```ts
63/// async function regularFileBytesEqual({ leftPath, rightPath }): Promise<boolean>;
64/// ```
65fn regular_file_bytes_equal(
66    left_path: &Path,
67    right_path: &Path,
68) -> Result<bool, WorktreeCopyError> {
69    let mut left: std::fs::File =
70        std::fs::File::open(left_path).map_err(|error| return raw_failure(&error))?;
71    let mut right: std::fs::File =
72        std::fs::File::open(right_path).map_err(|error| return raw_failure(&error))?;
73    let left_size: u64 = left
74        .metadata()
75        .map_err(|error| return raw_failure(&error))?
76        .len();
77    let right_size: u64 = right
78        .metadata()
79        .map_err(|error| return raw_failure(&error))?
80        .len();
81    if left_size != right_size {
82        return Ok(false);
83    }
84    let mut left_buffer: Vec<u8> = vec![0; COMPARE_BUFFER_BYTES];
85    let mut right_buffer: Vec<u8> = vec![0; COMPARE_BUFFER_BYTES];
86    let mut remaining: u64 = left_size;
87    while remaining > 0 {
88        let length: usize = usize::try_from(remaining).map_or(COMPARE_BUFFER_BYTES, |size| {
89            return size.min(COMPARE_BUFFER_BYTES);
90        });
91        let left_chunk: &mut [u8] = &mut left_buffer[..length];
92        let right_chunk: &mut [u8] = &mut right_buffer[..length];
93        // `read_exact` reports a file that ended early as `UnexpectedEof`, which is a difference, not a failure.
94        match (left.read_exact(left_chunk), right.read_exact(right_chunk)) {
95            (Ok(()), Ok(())) => {}
96            (Err(error), _) | (_, Err(error))
97                if error.kind() == std::io::ErrorKind::UnexpectedEof =>
98            {
99                return Ok(false);
100            }
101            (Err(error), _) | (_, Err(error)) => {
102                return Err(raw_failure(&error));
103            }
104        }
105        if left_chunk != right_chunk {
106            return Ok(false);
107        }
108        remaining -= u64::try_from(length).unwrap_or(0);
109    }
110    return Ok(true);
111}
112
113/// What:
114///  The symbolic-link target text of one no-follow entry.
115/// Why:
116///   The target is compared as exact text, never resolved.
117///
118/// In TS you'd write (pseudocode):
119/// ```ts
120/// await readlink(path)
121/// ```
122fn link_target(path: &Path) -> Result<PathBuf, WorktreeCopyError> {
123    return std::fs::read_link(path).map_err(|error| return raw_failure(&error));
124}
125
126/// What:
127///  Whether one actual entry matches an expected entry exactly.
128/// Why:
129///   Kind, mode and content must all match, and an absent entry never matches.
130///
131/// In TS you'd write (pseudocode):
132/// ```ts
133/// async function entryMatches({ expectedRoot, actualRoot, entry }): Promise<boolean>;
134/// ```
135pub fn entry_matches(
136    expected_root: &Path,
137    actual_root: &Path,
138    entry: &WorktreeCopyEntry,
139) -> Result<bool, WorktreeCopyError> {
140    return entry_matches_stamped(expected_root, actual_root, entry, None);
141}
142
143/// What:
144///  Whether one actual entry matches an expected entry, taking an unchanged stamp of the actual file as proof of its bytes.
145/// Why:
146///   A file the stage cloned holds the very blocks the source held at the clone; when the source's stamp is still the one
147///  recorded around the clone, no write reached it since, so reading both files again could only repeat the clone.
148///
149/// In TS you'd write (pseudocode):
150/// ```ts
151/// async function entryMatchesStamped({ expectedRoot, actualRoot, entry, stamp }): Promise<boolean>;
152/// ```
153pub fn entry_matches_stamped(
154    expected_root: &Path,
155    actual_root: &Path,
156    entry: &WorktreeCopyEntry,
157    stamp: Option<&FileStamp>,
158) -> Result<bool, WorktreeCopyError> {
159    let expected_path: PathBuf = filesystem_path(expected_root, entry.relative_path.as_str())?;
160    let actual_path: PathBuf = filesystem_path(actual_root, entry.relative_path.as_str())?;
161    let Some(actual) = lstat_or_absent(actual_path.as_path())? else {
162        return Ok(false);
163    };
164    if retained_mode(&actual) != entry.mode {
165        return Ok(false);
166    }
167    // `match` on the entry kind selects the one comparison that applies to it.
168    match entry.kind {
169        EntryKind::Directory => return Ok(actual.is_dir()),
170        EntryKind::File => {
171            if !actual.is_file() {
172                return Ok(false);
173            }
174            if let Some(recorded) = stamp {
175                return Ok(stamp_of(&actual).as_ref() == Some(recorded));
176            }
177            return regular_file_bytes_equal(expected_path.as_path(), actual_path.as_path());
178        }
179        EntryKind::Symlink => {
180            if !actual.file_type().is_symlink() {
181                return Ok(false);
182            }
183            return Ok(link_target(expected_path.as_path())? == link_target(actual_path.as_path())?);
184        }
185    }
186}
187
188/// What:
189///  Checks that the source still matches the initial manifest after staging,
190///  and that the stage holds exactly that manifest.
191/// Why:
192///   Any difference means the source changed during the copy, so the destination must not receive the stage.
193///
194/// In TS you'd write (pseudocode):
195/// ```ts
196/// await assertFinalSourceEquivalence({ sourceRoot, stageRoot, selectedRoots, excludedSourceRoots, stagedEntries });
197/// ```
198pub fn assert_final_source_equivalence(
199    source_root: &Path,
200    stage_root: &Path,
201    selected_roots: &[String],
202    excluded_source_roots: &[PathBuf],
203    staged_entries: &[WorktreeCopyEntry],
204) -> Result<(), WorktreeCopyError> {
205    return assert_final_source_equivalence_pruned(
206        source_root,
207        stage_root,
208        selected_roots,
209        excluded_source_roots,
210        staged_entries,
211        &FinalCheck { prune: Prune::Never, stamps: &[] },
212    );
213}
214
215/// What:
216///  The leave-out rule the stage used and the stamps recorded around each clone.
217/// Why:
218///   The final check walks the source with the same rule, and trusts a stamp only for the entry it was recorded for.
219///
220/// In TS you'd write (pseudocode):
221/// ```ts
222/// type FinalCheck = { prune: Prune; stamps: readonly (FileStamp | undefined)[] };
223/// ```
224pub struct FinalCheck<'a> {
225    /// The rule the staged walk used.
226    pub prune: Prune,
227    /// One slot per staged entry; empty when no entry was cloned under a stamp.
228    pub stamps: &'a [Option<FileStamp>],
229}
230
231/// What:
232///  Checks that the source still matches the initial manifest after staging, with the staged walk's leave-out rule,
233///  and that the stage holds exactly that manifest.
234/// Why:
235///   Any difference means the source changed during the copy, so the destination must not receive the stage.
236///
237/// In TS you'd write (pseudocode):
238/// ```ts
239/// await assertFinalSourceEquivalencePruned({ sourceRoot, stageRoot, selectedRoots, excludedSourceRoots, stagedEntries, check });
240/// ```
241pub fn assert_final_source_equivalence_pruned(
242    source_root: &Path,
243    stage_root: &Path,
244    selected_roots: &[String],
245    excluded_source_roots: &[PathBuf],
246    staged_entries: &[WorktreeCopyEntry],
247    check: &FinalCheck<'_>,
248) -> Result<(), WorktreeCopyError> {
249    // The source and the stage are independent trees, so their two walks run side by side.
250    let (source_walked, stage_walked) = std::thread::scope(|scope| {
251        let source_walk = scope.spawn(|| {
252            let mut unreadable: Vec<String> = Vec::new();
253            return collect_pruned_manifest(
254                source_root,
255                selected_roots,
256                excluded_source_roots,
257                check.prune,
258                &mut unreadable,
259            );
260        });
261        let stage_walk = collect_entry_manifest(stage_root, selected_roots, &[]);
262        let source_result = source_walk
263            .join()
264            .unwrap_or_else(|_panic| return Err(raw_failure(&std::io::Error::other("a manifest walk panicked"))));
265        return (source_result, stage_walk);
266    });
267    let final_source: Vec<WorktreeCopyEntry> = source_walked?;
268    let final_stage: Vec<WorktreeCopyEntry> = stage_walked?;
269    if final_source.len() != staged_entries.len() || final_stage.len() != staged_entries.len() {
270        return Err(failure_with_cause(
271            "cli-git: ignored source paths changed while snapshot was staged.",
272            None,
273        ));
274    }
275    for (index, staged) in staged_entries.iter().enumerate() {
276        let source_entry: &WorktreeCopyEntry = &final_source[index];
277        let stage_entry: &WorktreeCopyEntry = &final_stage[index];
278        let same_shape: bool = source_entry.kind == staged.kind
279            && source_entry.mode == staged.mode
280            && source_entry.relative_path == staged.relative_path
281            && stage_entry.kind == staged.kind
282            && stage_entry.mode == staged.mode
283            && stage_entry.relative_path == staged.relative_path;
284        let stamp: Option<&FileStamp> = check.stamps.get(index).and_then(Option::as_ref);
285        // The content check runs last so the cheap shape checks fail fast without reading large files.
286        if !same_shape || !entry_matches_stamped(stage_root, source_root, staged, stamp)? {
287            let message: String = format!(
288                "cli-git: ignored source entry changed while copying: {}.",
289                quote(staged.relative_path.as_str())
290            );
291            return Err(failure_with_cause(message.as_str(), None));
292        }
293    }
294    return Ok(());
295}
296
297/// Compare controls stay out of the release executable.
298#[cfg(test)]
299#[path = "worktree_copy_compare_tests.rs"]
300mod tests;