Skip to main content

git_policy_cli/
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 super::worktree_copy_error::{WorktreeCopyError, failure_with_cause, raw_failure};
15use super::worktree_copy_manifest::{
16    EntryKind, WorktreeCopyEntry, collect_entry_manifest, retained_mode,
17};
18use super::worktree_copy_selection::filesystem_path;
19use crate::json_record::quote;
20use std::io::Read;
21use std::path::{Path, PathBuf};
22
23/// What:
24///  The size of one comparison read.
25/// Why:
26///   Large files are compared in bounded chunks so memory does not grow with file size.
27///
28/// In TS you'd write (pseudocode):
29/// ```ts
30/// const COMPARE_BUFFER_BYTES = 64 * 1_024;
31/// ```
32const COMPARE_BUFFER_BYTES: usize = 64 * 1_024;
33
34/// What:
35///  The metadata of one no-follow entry, or its absence.
36/// Why:
37///   A missing destination entry is a normal answer for the comparison, not a failure.
38///
39/// In TS you'd write (pseudocode):
40/// ```ts
41/// await lstatOrAbsent(path)
42/// ```
43pub fn lstat_or_absent(path: &Path) -> Result<Option<std::fs::Metadata>, WorktreeCopyError> {
44    // `match` separates "no entry" (not an error) from every other failure.
45    match std::fs::symlink_metadata(path) {
46        Ok(metadata) => return Ok(Some(metadata)),
47        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
48        Err(error) => return Err(raw_failure(&error)),
49    }
50}
51
52/// What:
53///  Whether two regular files hold the same bytes, compared in bounded chunks.
54/// Why:
55///   Sizes are compared first, and a short read means the file changed under the comparison.
56///
57/// In TS you'd write (pseudocode):
58/// ```ts
59/// async function regularFileBytesEqual({ leftPath, rightPath }): Promise<boolean>;
60/// ```
61fn regular_file_bytes_equal(
62    left_path: &Path,
63    right_path: &Path,
64) -> Result<bool, WorktreeCopyError> {
65    let mut left: std::fs::File =
66        std::fs::File::open(left_path).map_err(|error| return raw_failure(&error))?;
67    let mut right: std::fs::File =
68        std::fs::File::open(right_path).map_err(|error| return raw_failure(&error))?;
69    let left_size: u64 = left
70        .metadata()
71        .map_err(|error| return raw_failure(&error))?
72        .len();
73    let right_size: u64 = right
74        .metadata()
75        .map_err(|error| return raw_failure(&error))?
76        .len();
77    if left_size != right_size {
78        return Ok(false);
79    }
80    let mut left_buffer: Vec<u8> = vec![0; COMPARE_BUFFER_BYTES];
81    let mut right_buffer: Vec<u8> = vec![0; COMPARE_BUFFER_BYTES];
82    let mut remaining: u64 = left_size;
83    while remaining > 0 {
84        let length: usize = usize::try_from(remaining).map_or(COMPARE_BUFFER_BYTES, |size| {
85            return size.min(COMPARE_BUFFER_BYTES);
86        });
87        let left_chunk: &mut [u8] = &mut left_buffer[..length];
88        let right_chunk: &mut [u8] = &mut right_buffer[..length];
89        // `read_exact` reports a file that ended early as `UnexpectedEof`, which is a difference, not a failure.
90        match (left.read_exact(left_chunk), right.read_exact(right_chunk)) {
91            (Ok(()), Ok(())) => {}
92            (Err(error), _) | (_, Err(error))
93                if error.kind() == std::io::ErrorKind::UnexpectedEof =>
94            {
95                return Ok(false);
96            }
97            (Err(error), _) | (_, Err(error)) => {
98                return Err(raw_failure(&error));
99            }
100        }
101        if left_chunk != right_chunk {
102            return Ok(false);
103        }
104        remaining -= u64::try_from(length).unwrap_or(0);
105    }
106    return Ok(true);
107}
108
109/// What:
110///  The symbolic-link target text of one no-follow entry.
111/// Why:
112///   The target is compared as exact text, never resolved.
113///
114/// In TS you'd write (pseudocode):
115/// ```ts
116/// await readlink(path)
117/// ```
118fn link_target(path: &Path) -> Result<PathBuf, WorktreeCopyError> {
119    return std::fs::read_link(path).map_err(|error| return raw_failure(&error));
120}
121
122/// What:
123///  Whether one actual entry matches an expected entry exactly.
124/// Why:
125///   Kind, mode and content must all match, and an absent entry never matches.
126///
127/// In TS you'd write (pseudocode):
128/// ```ts
129/// async function entryMatches({ expectedRoot, actualRoot, entry }): Promise<boolean>;
130/// ```
131pub fn entry_matches(
132    expected_root: &Path,
133    actual_root: &Path,
134    entry: &WorktreeCopyEntry,
135) -> Result<bool, WorktreeCopyError> {
136    let expected_path: PathBuf = filesystem_path(expected_root, entry.relative_path.as_str())?;
137    let actual_path: PathBuf = filesystem_path(actual_root, entry.relative_path.as_str())?;
138    let Some(actual) = lstat_or_absent(actual_path.as_path())? else {
139        return Ok(false);
140    };
141    if retained_mode(&actual) != entry.mode {
142        return Ok(false);
143    }
144    // `match` on the entry kind selects the one comparison that applies to it.
145    match entry.kind {
146        EntryKind::Directory => return Ok(actual.is_dir()),
147        EntryKind::File => {
148            if !actual.is_file() {
149                return Ok(false);
150            }
151            return regular_file_bytes_equal(expected_path.as_path(), actual_path.as_path());
152        }
153        EntryKind::Symlink => {
154            if !actual.file_type().is_symlink() {
155                return Ok(false);
156            }
157            return Ok(link_target(expected_path.as_path())? == link_target(actual_path.as_path())?);
158        }
159    }
160}
161
162/// What:
163///  Checks that the source still matches the initial manifest after staging,
164///  and that the stage holds exactly that manifest.
165/// Why:
166///   Any difference means the source changed during the copy, so the destination must not receive the stage.
167///
168/// In TS you'd write (pseudocode):
169/// ```ts
170/// await assertFinalSourceEquivalence({ sourceRoot, stageRoot, selectedRoots, excludedSourceRoots, stagedEntries });
171/// ```
172pub fn assert_final_source_equivalence(
173    source_root: &Path,
174    stage_root: &Path,
175    selected_roots: &[String],
176    excluded_source_roots: &[PathBuf],
177    staged_entries: &[WorktreeCopyEntry],
178) -> Result<(), WorktreeCopyError> {
179    let final_source = collect_entry_manifest(source_root, selected_roots, excluded_source_roots)?;
180    let final_stage = collect_entry_manifest(stage_root, selected_roots, &[])?;
181    if final_source.len() != staged_entries.len() || final_stage.len() != staged_entries.len() {
182        return Err(failure_with_cause(
183            "cli-git: ignored source paths changed while snapshot was staged.",
184            None,
185        ));
186    }
187    for (index, staged) in staged_entries.iter().enumerate() {
188        let source_entry: &WorktreeCopyEntry = &final_source[index];
189        let stage_entry: &WorktreeCopyEntry = &final_stage[index];
190        let same_shape: bool = source_entry.kind == staged.kind
191            && source_entry.mode == staged.mode
192            && source_entry.relative_path == staged.relative_path
193            && stage_entry.kind == staged.kind
194            && stage_entry.mode == staged.mode
195            && stage_entry.relative_path == staged.relative_path;
196        // The content check runs last so the cheap shape checks fail fast without reading large files.
197        if !same_shape || !entry_matches(stage_root, source_root, staged)? {
198            let message: String = format!(
199                "cli-git: ignored source entry changed while copying: {}.",
200                quote(staged.relative_path.as_str())
201            );
202            return Err(failure_with_cause(message.as_str(), None));
203        }
204    }
205    return Ok(());
206}
207
208/// Compare controls stay out of the release executable.
209#[cfg(test)]
210#[path = "worktree_copy_compare_tests.rs"]
211mod tests;