Skip to main content

drep/diff/
uncommitted.rs

1//! What a staged-mode linter would read that the commit does not hold.
2
3use std::path::{Path, PathBuf};
4
5use super::git::{GitEnv, spawn_git_bytes, spawn_git_bytes_with_input};
6use super::prefix::{from_prefix, paths_from};
7use super::quoting::path_from_bytes;
8use super::{GitError, StagedView};
9
10/// What the working tree does not hold of the committing index, each path
11/// named from the directory drep runs in.
12#[derive(Debug, Default, PartialEq, Eq)]
13pub struct Uncommitted {
14    /// Files whose working-tree state the committing index does not hold:
15    /// tracked files whose working-tree content differs from that index,
16    /// untracked files git does not ignore, and flagged files edited behind
17    /// their flags. This is what a linter reading the working tree would see
18    /// that the commit does not hold.
19    pub differing: Vec<PathBuf>,
20    /// Files marked `skip-worktree` and absent from the working tree, as a
21    /// sparse checkout leaves them. No tool can read one, so none refuses a
22    /// tool that reads beyond its files; but one can be the configuration
23    /// the commit gives a file whose working tree shows none.
24    pub index_only: Vec<PathBuf>,
25}
26
27/// The committing index's paths the working tree does not hold.
28///
29/// The deterministic layer's tools read the working tree, so after a partial
30/// `git add -p`, an edit made after staging, or a source file never added at
31/// all they would lint content the commit does not contain - and a
32/// lint-failing staged blob could pass. This listing is what lets the caller
33/// refuse those runs. The index read is the committing one
34/// (`StagedView::env`), so a hook's alternate `GIT_INDEX_FILE` is honoured
35/// the same way it is for the staged diff itself.
36///
37/// The diff argv is deliberately not `DIFF`: that set carries hunk-only pins
38/// and `--diff-filter=ACMRT`, which would drop the deletions this query
39/// exists to see (a staged file deleted from the working tree differs
40/// exactly as an edited one does). What stays is the subset a name-only
41/// listing needs: naming every file from the top level and ignoring external
42/// diff drivers and textconv. Both listings are NUL-separated and read as raw
43/// bytes, never through `spawn_git`, which trims what it reads, so each name
44/// is taken byte for byte. The untracked listing takes `-- :/` for the same reason the diff takes `--no-relative`:
45/// bare `ls-files` limits its answer to the working directory, so an
46/// untracked helper above it would go unlisted.
47pub async fn uncommitted_paths(root: &Path) -> Result<Uncommitted, GitError> {
48    let view = StagedView::of(root).await?;
49    // `-z`: a name is listed unquoted, with a leading space or a newline of
50    // its own intact.
51    let (tracked, untracked, listing) = tokio::join!(
52        spawn_git_bytes(
53            root,
54            &[
55                // A read under a hook must not write: porcelain diff otherwise
56                // rewrites the index it compared to refresh its stat data, and
57                // that index can be the commit's own lock file.
58                "--no-optional-locks",
59                "diff",
60                "--name-only",
61                "-z",
62                // A dirty submodule a staged file builds against differs even
63                // where configuration says to ignore submodules.
64                "--ignore-submodules=none",
65                "--no-relative",
66                "--no-ext-diff",
67                "--no-textconv",
68            ],
69            view.env(),
70        ),
71        spawn_git_bytes(
72            root,
73            &[
74                "--no-optional-locks",
75                "ls-files",
76                "-z",
77                "--others",
78                "--exclude-standard",
79                "--full-name",
80                "--",
81                ":/",
82            ],
83            view.env(),
84        ),
85        spawn_git_bytes(
86            root,
87            &[
88                "--no-optional-locks",
89                "ls-files",
90                "-z",
91                "--stage",
92                "-v",
93                "--full-name",
94                "--",
95                ":/",
96            ],
97            view.env(),
98        )
99    );
100    let (tracked, untracked) = (tracked?, untracked?);
101    let mut paths = paths_from(
102        listed(&tracked).chain(listed(&untracked)).collect(),
103        &view.prefix,
104    );
105    let listing = listing?;
106    let (flagged, filtered) = tokio::join!(
107        flagged_differences(root, &view, &listing),
108        filtered_differences(root, &view, &listing)
109    );
110    let flagged = flagged?;
111    paths.extend(filtered?);
112    paths.extend(flagged.differing);
113    paths.sort();
114    paths.dedup();
115    Ok(Uncommitted {
116        differing: paths,
117        index_only: flagged.index_only,
118    })
119}
120
121/// What the working tree does not hold of commit `commit`, each path named
122/// from the directory drep runs in.
123///
124/// The branch queries (`--diff`, `--tip`, a pre-commit pre-push range) review
125/// one commit's diff while the deterministic tools read the working tree, which
126/// differs from that commit whenever the pushed ref is not the one checked out
127/// or the checkout carries edits of its own. Linting it there judged content
128/// the push does not contain, so a lint-failing pushed commit passed.
129///
130/// The working tree holds a path of `commit` whenever it holds the committing
131/// index's entry and that entry is `commit`'s, so the answer is
132/// [`uncommitted_paths`] together with every path where the index and `commit`
133/// differ, deletions in either direction included. A path that differs from
134/// the index but has been restored to `commit`'s content is listed too: that is
135/// the conservative direction, because the alternative is a second definition
136/// of "differs" beside the one staged mode already trusts.
137pub async fn diverging_from(root: &Path, commit: &str) -> Result<Uncommitted, GitError> {
138    super::refuse_flag_like(commit)?;
139    let view = StagedView::of(root).await?;
140    let recorded_args = [
141        "--no-optional-locks",
142        "diff",
143        "--cached",
144        "--name-only",
145        "-z",
146        "--ignore-submodules=none",
147        "--no-relative",
148        "--no-ext-diff",
149        "--no-textconv",
150        commit,
151        "--",
152    ];
153    let (worktree, recorded) = tokio::join!(
154        uncommitted_paths(root),
155        spawn_git_bytes(root, &recorded_args, view.env())
156    );
157    let mut uncommitted = worktree?;
158    uncommitted
159        .differing
160        .extend(paths_from(listed(&recorded?).collect(), &view.prefix));
161    uncommitted.differing.sort();
162    uncommitted.differing.dedup();
163    Ok(uncommitted)
164}
165
166/// Tracked files whose index entries tell git not to compare them
167/// (`assume-unchanged`, `skip-worktree`) and whose working-tree state differs
168/// from the committing index all the same. `git diff` trusts the flags and
169/// reports neither, so each flagged regular file on disk is hashed and
170/// compared with its index blob, a flagged link's text is compared with its
171/// blob, a flagged submodule's checkout with its recorded commit
172/// (`submodule_differs`), and anything else in its place differs. One missing from the working
173/// tree differs when assumed unchanged; a `skip-worktree` one missing is a
174/// sparse checkout's ordinary state and is index-only.
175async fn flagged_differences(
176    root: &Path,
177    view: &StagedView,
178    listing: &[u8],
179) -> Result<Uncommitted, GitError> {
180    const LINK: &[u8] = b"120000";
181    const GITLINK: &[u8] = b"160000";
182    let mut flagged = Uncommitted::default();
183    let mut files: Vec<(PathBuf, &[u8])> = Vec::new();
184    for entry in index_entries(listing).filter(IndexEntry::flagged) {
185        let named = from_prefix(&path_from_bytes(entry.name.to_vec()), &view.prefix);
186        if entry.mode == GITLINK {
187            if submodule_differs(&root.join(&named), entry.blob).await? {
188                flagged.differing.push(named);
189            }
190            continue;
191        }
192        match std::fs::symlink_metadata(root.join(&named)) {
193            Ok(meta) if meta.is_file() && entry.mode != LINK => files.push((named, entry.blob)),
194            Ok(meta) if meta.file_type().is_symlink() && entry.mode == LINK => {
195                // A link's blob is its target text.
196                let oid = String::from_utf8_lossy(entry.blob);
197                let text = spawn_git_bytes(root, &["cat-file", "blob", &oid], view.env()).await?;
198                if !std::fs::read_link(root.join(&named))
199                    .is_ok_and(|target| target == path_from_bytes(text))
200                {
201                    flagged.differing.push(named);
202                }
203            }
204            // Anything else in a flagged file's place is not the blob the
205            // index holds, and hash-object would refuse a directory.
206            Ok(_) => flagged.differing.push(named),
207            Err(_) if entry.skip_worktree() => flagged.index_only.push(named),
208            Err(_) => flagged.differing.push(named),
209        }
210    }
211    // A long list is hashed a batch at a time, within any system's argument limit.
212    for batch in files.chunks(256) {
213        let mut args: Vec<std::ffi::OsString> = vec!["hash-object".into(), "--".into()];
214        args.extend(
215            batch
216                .iter()
217                .map(|(named, _)| root.join(named).into_os_string()),
218        );
219        let hashes = spawn_git_bytes(root, &args, view.env()).await?;
220        let mut hashes = hashes.split(|&byte| byte == b'\n');
221        for (named, blob) in batch {
222            if hashes.next() != Some(*blob) {
223                flagged.differing.push(named.clone());
224            }
225        }
226    }
227    Ok(flagged)
228}
229
230/// Whether a flagged submodule's checkout differs from the commit its index
231/// entry records: another commit checked out, changes of its own, or
232/// something other than a directory in its place. A submodule not checked
233/// out, or never initialized, holds nothing a tool can read.
234async fn submodule_differs(path: &Path, commit: &[u8]) -> Result<bool, GitError> {
235    match std::fs::symlink_metadata(path) {
236        Err(_) => return Ok(false),
237        Ok(meta) if !meta.is_dir() => return Ok(true),
238        Ok(_) => {}
239    }
240    if !path.join(".git").exists() {
241        return Ok(false);
242    }
243    let head = spawn_git_bytes(path, &["rev-parse", "HEAD"], GitEnv::Scrubbed).await?;
244    if head.trim_ascii() != commit {
245        return Ok(true);
246    }
247    let changes = spawn_git_bytes(
248        path,
249        &[
250            "--no-optional-locks",
251            "status",
252            "--porcelain",
253            "-z",
254            "--ignore-submodules=none",
255        ],
256        GitEnv::Scrubbed,
257    )
258    .await?;
259    Ok(!changes.is_empty())
260}
261
262/// Tracked regular files a clean filter (a `filter` attribute) rewrites whose
263/// working-tree bytes differ from what checking out their index blob writes.
264/// `git diff` compares the filtered content and reports none of them, while a
265/// linter reads the file as it is: a filter that strips a suppression comment
266/// commits a file the linter never saw. Git LFS is left alone: it round-trips
267/// losslessly and is content-addressed, so an LFS file `git diff` reports
268/// unchanged already holds its blob's content, and smudging every asset again
269/// would stream them all on each commit.
270async fn filtered_differences(
271    root: &Path,
272    view: &StagedView,
273    listing: &[u8],
274) -> Result<Vec<PathBuf>, GitError> {
275    let regular: Vec<IndexEntry<'_>> = index_entries(listing)
276        .filter(|entry| entry.mode == b"100644" || entry.mode == b"100755")
277        .collect();
278    if regular.is_empty() {
279        return Ok(Vec::new());
280    }
281    let named: Vec<PathBuf> = regular
282        .iter()
283        .map(|entry| from_prefix(&path_from_bytes(entry.name.to_vec()), &view.prefix))
284        .collect();
285    let mut input = Vec::new();
286    for path in &named {
287        input.extend_from_slice(path.as_os_str().as_encoded_bytes());
288        input.push(0);
289    }
290    let attributes = spawn_git_bytes_with_input(
291        root,
292        &["check-attr", "--stdin", "-z", "filter"],
293        view.env(),
294        &input,
295    )
296    .await?;
297    // `path NUL filter NUL value NUL`, one record per path in the order given.
298    let values = attributes.split(|&byte| byte == 0).skip(2).step_by(3);
299    let mut differing = Vec::new();
300    for ((entry, path), value) in regular.iter().zip(named).zip(values) {
301        if matches!(value, b"unspecified" | b"unset" | b"set" | b"lfs") {
302            continue;
303        }
304        // A file absent from the working tree is `git diff`'s to report.
305        let Ok(raw) = std::fs::read(root.join(&path)) else {
306            continue;
307        };
308        let mut at = std::ffi::OsString::from("--path=");
309        at.push(&path);
310        let oid = std::ffi::OsString::from(String::from_utf8_lossy(entry.blob).as_ref());
311        let checkout = spawn_git_bytes(
312            root,
313            &[
314                std::ffi::OsString::from("cat-file"),
315                "--filters".into(),
316                at,
317                oid,
318            ],
319            view.env(),
320        )
321        .await;
322        if checkout.is_ok_and(|content| content == raw) {
323            continue;
324        }
325        differing.push(path);
326    }
327    Ok(differing)
328}
329
330/// One `ls-files --stage -v` record.
331struct IndexEntry<'a> {
332    /// A lowercase tag is assumed unchanged, and `S` or `s` is skip-worktree.
333    tag: u8,
334    mode: &'a [u8],
335    blob: &'a [u8],
336    name: &'a [u8],
337}
338
339impl IndexEntry<'_> {
340    /// Whether git is told not to compare this entry with the working tree.
341    fn flagged(&self) -> bool {
342        self.tag.is_ascii_lowercase() || self.tag == b'S'
343    }
344
345    fn skip_worktree(&self) -> bool {
346        self.tag.eq_ignore_ascii_case(&b'S')
347    }
348}
349
350/// The records of an `ls-files --stage -v -z` listing.
351fn index_entries(listing: &[u8]) -> impl Iterator<Item = IndexEntry<'_>> {
352    listing.split(|&byte| byte == 0).filter_map(|record| {
353        let tab = record.iter().position(|&byte| byte == b'\t')?;
354        let mut fields = record[..tab].split(|&byte| byte == b' ');
355        Some(IndexEntry {
356            tag: *fields.next()?.first()?,
357            mode: fields.next()?,
358            blob: fields.next()?,
359            name: &record[tab + 1..],
360        })
361    })
362}
363
364/// The names in a NUL-separated git listing.
365fn listed(output: &[u8]) -> impl Iterator<Item = PathBuf> + '_ {
366    output
367        .split(|&byte| byte == 0)
368        .filter(|name| !name.is_empty())
369        .map(|name| path_from_bytes(name.to_vec()))
370}