Skip to main content

lanekeep_core/
changed.rs

1//! Selecting files from git, for `--since` and `--staged`.
2//!
3//! These are the incremental entry points from `docs/architecture.md` ยง8.4. They answer
4//! "what did I touch", which is a different question from "what is stale" โ€” the cache
5//! answers the second, by content hash, and would still do the right thing without these.
6//! What they save is reading and hashing every file in the corpus to find that out.
7//!
8//! # Shelling out to git
9//!
10//! Rather than linking a git implementation. The user's `git` already agrees with their
11//! configuration โ€” worktrees, submodules, `core.excludesFile`, a `.gitattributes` that
12//! affects nothing here but might later โ€” and a second implementation would agree until it
13//! did not. It is also one dependency instead of a large one.
14//!
15//! A failure is reported rather than swallowed. If someone asks for `--since main` and
16//! there is no `main`, checking everything instead would be a surprising amount of work
17//! done silently, and checking nothing would look like a clean run.
18
19use std::path::Path;
20use std::process::Command;
21
22use thiserror::Error;
23
24use crate::location::FilePath;
25
26/// Why a selection could not be made.
27#[derive(Debug, Clone, PartialEq, Eq, Error)]
28pub enum ChangeError {
29    /// `git` could not be run at all.
30    #[error(
31        "cannot run git: {detail}\n  \
32         --since and --staged ask git which files changed, so they need it on PATH"
33    )]
34    Unavailable {
35        /// What the operating system said.
36        detail: String,
37    },
38
39    /// `git` ran and refused.
40    #[error("git could not list changed files: {detail}")]
41    Refused {
42        /// What git wrote to stderr, trimmed.
43        detail: String,
44    },
45}
46
47/// Files changed against a git ref, including untracked ones.
48///
49/// Untracked files are included because a file you just created is a file you just changed,
50/// and a pre-commit check that ignored new files would miss the most likely place for a new
51/// violation.
52///
53/// # Errors
54///
55/// [`ChangeError`] if git is missing or the ref does not resolve.
56pub fn since(root: &Path, reference: &str) -> Result<Vec<FilePath>, ChangeError> {
57    let mut paths = run_git(root, &["diff", "--name-only", "--relative", reference])?;
58    paths.extend(untracked(root)?);
59    Ok(normalize(root, paths))
60}
61
62/// Files staged in the index.
63///
64/// The pre-commit default: exactly what is about to be committed, which is not the same as
65/// what is in the working tree.
66///
67/// # Errors
68///
69/// As [`since`].
70pub fn staged(root: &Path) -> Result<Vec<FilePath>, ChangeError> {
71    let paths = run_git(root, &["diff", "--cached", "--name-only", "--relative"])?;
72    Ok(normalize(root, paths))
73}
74
75/// Files git knows nothing about yet, respecting ignore rules.
76fn untracked(root: &Path) -> Result<Vec<String>, ChangeError> {
77    run_git(
78        root,
79        &["ls-files", "--others", "--exclude-standard", "--", "."],
80    )
81}
82
83/// Drop what no longer exists, canonicalize separators, sort, and dedupe.
84///
85/// A rename or a delete shows up in `git diff` as a path that is not there any more.
86/// Checking it would be an error about a missing file for something the user did on
87/// purpose.
88///
89/// Sorting matters for the same reason it matters everywhere else here: two runs over the
90/// same working tree must produce the same list, and git's output order is not something to
91/// depend on.
92fn normalize(root: &Path, paths: Vec<String>) -> Vec<FilePath> {
93    let mut selected: Vec<FilePath> = paths
94        .into_iter()
95        .filter(|path| !path.is_empty())
96        .filter(|path| root.join(path).is_file())
97        .map(|path| FilePath::new(&path))
98        .collect();
99    selected.sort();
100    selected.dedup();
101    selected
102}
103
104/// Run git in the project root and split its output into lines.
105fn run_git(root: &Path, args: &[&str]) -> Result<Vec<String>, ChangeError> {
106    let output = Command::new("git")
107        .arg("-C")
108        .arg(root)
109        .args(args)
110        .output()
111        .map_err(|e| ChangeError::Unavailable {
112            detail: e.to_string(),
113        })?;
114
115    if !output.status.success() {
116        return Err(ChangeError::Refused {
117            detail: String::from_utf8_lossy(&output.stderr).trim().to_owned(),
118        });
119    }
120
121    Ok(String::from_utf8_lossy(&output.stdout)
122        .lines()
123        .map(str::to_owned)
124        .collect())
125}
126
127#[cfg(test)]
128mod tests {
129    use std::path::PathBuf;
130
131    use super::*;
132
133    struct Repo {
134        dir: PathBuf,
135    }
136
137    impl Repo {
138        fn new(name: &str) -> Self {
139            let dir = std::env::temp_dir()
140                .join(format!("lanekeep-changed-{name}-{}", std::process::id()));
141            let _ = std::fs::remove_dir_all(&dir);
142            std::fs::create_dir_all(&dir).expect("creates dir");
143            let repo = Self { dir };
144
145            repo.git(&["init", "--quiet"]);
146            repo.git(&["config", "user.email", "test@example.com"]);
147            repo.git(&["config", "user.name", "Test"]);
148            repo.git(&["config", "commit.gpgsign", "false"]);
149            repo
150        }
151
152        fn git(&self, args: &[&str]) -> String {
153            let output = Command::new("git")
154                .arg("-C")
155                .arg(&self.dir)
156                .args(args)
157                .output()
158                .expect("runs git");
159            assert!(
160                output.status.success(),
161                "git {args:?} failed: {}",
162                String::from_utf8_lossy(&output.stderr)
163            );
164            String::from_utf8_lossy(&output.stdout).into_owned()
165        }
166
167        fn write(&self, path: &str, contents: &str) {
168            let full = self.dir.join(path);
169            if let Some(parent) = full.parent() {
170                std::fs::create_dir_all(parent).expect("creates parent");
171            }
172            std::fs::write(full, contents).expect("writes");
173        }
174
175        fn commit(&self, message: &str) {
176            self.git(&["add", "-A"]);
177            self.git(&["commit", "--quiet", "-m", message]);
178        }
179
180        fn names(paths: &[FilePath]) -> Vec<&str> {
181            paths.iter().map(FilePath::as_str).collect()
182        }
183    }
184
185    impl Drop for Repo {
186        fn drop(&mut self) {
187            let _ = std::fs::remove_dir_all(&self.dir);
188        }
189    }
190
191    #[test]
192    fn since_lists_files_changed_against_a_ref() {
193        let repo = Repo::new("since");
194        repo.write("a.ts", "const a = 1;\n");
195        repo.write("b.ts", "const b = 1;\n");
196        repo.commit("first");
197
198        repo.write("a.ts", "const a = 2;\n");
199        let changed = since(&repo.dir, "HEAD").expect("lists");
200        assert_eq!(Repo::names(&changed), vec!["a.ts"]);
201    }
202
203    #[test]
204    fn since_includes_untracked_files() {
205        // A file you just created is a file you just changed. A pre-commit check that
206        // ignored new files would miss the likeliest place for a new violation.
207        let repo = Repo::new("untracked");
208        repo.write("a.ts", "const a = 1;\n");
209        repo.commit("first");
210
211        repo.write("new.ts", "const n = 1;\n");
212        let changed = since(&repo.dir, "HEAD").expect("lists");
213        assert_eq!(Repo::names(&changed), vec!["new.ts"]);
214    }
215
216    #[test]
217    fn since_respects_gitignore() {
218        let repo = Repo::new("ignored");
219        repo.write(".gitignore", "ignored/\n");
220        repo.write("a.ts", "const a = 1;\n");
221        repo.commit("first");
222
223        repo.write("ignored/x.ts", "const x = 1;\n");
224        let changed = since(&repo.dir, "HEAD").expect("lists");
225        assert!(
226            Repo::names(&changed).is_empty(),
227            "an ignored file was selected: {:?}",
228            Repo::names(&changed)
229        );
230    }
231
232    #[test]
233    fn a_deleted_file_is_not_selected() {
234        // It shows in `git diff` as a path that is no longer there. Checking it would be an
235        // error about a missing file for something the user did on purpose.
236        let repo = Repo::new("deleted");
237        repo.write("a.ts", "const a = 1;\n");
238        repo.write("b.ts", "const b = 1;\n");
239        repo.commit("first");
240
241        std::fs::remove_file(repo.dir.join("b.ts")).expect("removes");
242        repo.write("a.ts", "const a = 2;\n");
243
244        let changed = since(&repo.dir, "HEAD").expect("lists");
245        assert_eq!(Repo::names(&changed), vec!["a.ts"]);
246    }
247
248    #[test]
249    fn a_directory_is_not_selected() {
250        let repo = Repo::new("directory");
251        repo.write("a.ts", "const a = 1;\n");
252        repo.commit("first");
253        std::fs::create_dir_all(repo.dir.join("subdir")).expect("creates");
254
255        let changed = since(&repo.dir, "HEAD").expect("lists");
256        assert!(Repo::names(&changed).is_empty());
257    }
258
259    #[test]
260    fn staged_lists_only_the_index() {
261        // Not the same as the working tree, which is the whole point for a pre-commit hook:
262        // what is about to be committed is what should be checked.
263        let repo = Repo::new("staged");
264        repo.write("a.ts", "const a = 1;\n");
265        repo.write("b.ts", "const b = 1;\n");
266        repo.commit("first");
267
268        repo.write("a.ts", "const a = 2;\n");
269        repo.write("b.ts", "const b = 2;\n");
270        repo.git(&["add", "a.ts"]);
271
272        let changed = staged(&repo.dir).expect("lists");
273        assert_eq!(Repo::names(&changed), vec!["a.ts"]);
274    }
275
276    #[test]
277    fn staged_is_empty_with_nothing_staged() {
278        let repo = Repo::new("staged-empty");
279        repo.write("a.ts", "const a = 1;\n");
280        repo.commit("first");
281        repo.write("a.ts", "const a = 2;\n");
282
283        assert!(staged(&repo.dir).expect("lists").is_empty());
284    }
285
286    #[test]
287    fn an_unknown_ref_is_an_error() {
288        // Checking everything instead would be a surprising amount of work done silently;
289        // checking nothing would look like a clean run.
290        let repo = Repo::new("bad-ref");
291        repo.write("a.ts", "const a = 1;\n");
292        repo.commit("first");
293
294        let error = since(&repo.dir, "no-such-ref").expect_err("refuses");
295        assert!(matches!(error, ChangeError::Refused { .. }), "{error:?}");
296    }
297
298    #[test]
299    fn results_are_sorted_and_deduplicated() {
300        // Two runs over the same working tree must produce the same list, and git's output
301        // order is not something to depend on.
302        let repo = Repo::new("sorted");
303        repo.write("z.ts", "const z = 1;\n");
304        repo.write("a.ts", "const a = 1;\n");
305        repo.write("m.ts", "const m = 1;\n");
306        repo.commit("first");
307
308        repo.write("z.ts", "const z = 2;\n");
309        repo.write("a.ts", "const a = 2;\n");
310        repo.write("m.ts", "const m = 2;\n");
311
312        let changed = since(&repo.dir, "HEAD").expect("lists");
313        assert_eq!(Repo::names(&changed), vec!["a.ts", "m.ts", "z.ts"]);
314    }
315
316    #[test]
317    fn a_nested_path_keeps_its_directory() {
318        let repo = Repo::new("nested");
319        repo.write("src/deep/a.ts", "const a = 1;\n");
320        repo.commit("first");
321        repo.write("src/deep/a.ts", "const a = 2;\n");
322
323        let changed = since(&repo.dir, "HEAD").expect("lists");
324        assert_eq!(Repo::names(&changed), vec!["src/deep/a.ts"]);
325    }
326
327    #[test]
328    fn outside_a_repository_is_an_error() {
329        let dir = std::env::temp_dir().join(format!("lanekeep-not-a-repo-{}", std::process::id()));
330        let _ = std::fs::remove_dir_all(&dir);
331        std::fs::create_dir_all(&dir).expect("creates dir");
332
333        let error = staged(&dir).expect_err("refuses");
334        assert!(matches!(error, ChangeError::Refused { .. }), "{error:?}");
335
336        let _ = std::fs::remove_dir_all(&dir);
337    }
338}