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}