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        .env_remove("GIT_DIR")
111        .env_remove("GIT_WORK_TREE")
112        .env_remove("GIT_INDEX_FILE")
113        .output()
114        .map_err(|e| ChangeError::Unavailable {
115            detail: e.to_string(),
116        })?;
117
118    if !output.status.success() {
119        return Err(ChangeError::Refused {
120            detail: String::from_utf8_lossy(&output.stderr).trim().to_owned(),
121        });
122    }
123
124    Ok(String::from_utf8_lossy(&output.stdout)
125        .lines()
126        .map(str::to_owned)
127        .collect())
128}
129
130#[cfg(test)]
131mod tests {
132    use std::path::PathBuf;
133
134    use super::*;
135
136    struct Repo {
137        dir: PathBuf,
138    }
139
140    impl Repo {
141        fn new(name: &str) -> Self {
142            let dir = std::env::temp_dir()
143                .join(format!("lanekeep-changed-{name}-{}", std::process::id()));
144            let _ = std::fs::remove_dir_all(&dir);
145            std::fs::create_dir_all(&dir).expect("creates dir");
146            let repo = Self { dir };
147
148            repo.git(&["init", "--quiet"]);
149            repo.git(&["config", "user.email", "test@example.com"]);
150            repo.git(&["config", "user.name", "Test"]);
151            repo.git(&["config", "commit.gpgsign", "false"]);
152            repo
153        }
154
155        fn git(&self, args: &[&str]) -> String {
156            let output = Command::new("git")
157                .arg("-C")
158                .arg(&self.dir)
159                .args(args)
160                .env_remove("GIT_DIR")
161                .env_remove("GIT_WORK_TREE")
162                .env_remove("GIT_INDEX_FILE")
163                .output()
164                .expect("runs git");
165            assert!(
166                output.status.success(),
167                "git {args:?} failed: {}",
168                String::from_utf8_lossy(&output.stderr)
169            );
170            String::from_utf8_lossy(&output.stdout).into_owned()
171        }
172
173        fn write(&self, path: &str, contents: &str) {
174            let full = self.dir.join(path);
175            if let Some(parent) = full.parent() {
176                std::fs::create_dir_all(parent).expect("creates parent");
177            }
178            std::fs::write(full, contents).expect("writes");
179        }
180
181        fn commit(&self, message: &str) {
182            self.git(&["add", "-A"]);
183            self.git(&["commit", "--quiet", "-m", message]);
184        }
185
186        fn names(paths: &[FilePath]) -> Vec<&str> {
187            paths.iter().map(FilePath::as_str).collect()
188        }
189    }
190
191    impl Drop for Repo {
192        fn drop(&mut self) {
193            let _ = std::fs::remove_dir_all(&self.dir);
194        }
195    }
196
197    #[test]
198    fn since_lists_files_changed_against_a_ref() {
199        let repo = Repo::new("since");
200        repo.write("a.ts", "const a = 1;\n");
201        repo.write("b.ts", "const b = 1;\n");
202        repo.commit("first");
203
204        repo.write("a.ts", "const a = 2;\n");
205        let changed = since(&repo.dir, "HEAD").expect("lists");
206        assert_eq!(Repo::names(&changed), vec!["a.ts"]);
207    }
208
209    #[test]
210    fn since_includes_untracked_files() {
211        // A file you just created is a file you just changed. A pre-commit check that
212        // ignored new files would miss the likeliest place for a new violation.
213        let repo = Repo::new("untracked");
214        repo.write("a.ts", "const a = 1;\n");
215        repo.commit("first");
216
217        repo.write("new.ts", "const n = 1;\n");
218        let changed = since(&repo.dir, "HEAD").expect("lists");
219        assert_eq!(Repo::names(&changed), vec!["new.ts"]);
220    }
221
222    #[test]
223    fn since_respects_gitignore() {
224        let repo = Repo::new("ignored");
225        repo.write(".gitignore", "ignored/\n");
226        repo.write("a.ts", "const a = 1;\n");
227        repo.commit("first");
228
229        repo.write("ignored/x.ts", "const x = 1;\n");
230        let changed = since(&repo.dir, "HEAD").expect("lists");
231        assert!(
232            Repo::names(&changed).is_empty(),
233            "an ignored file was selected: {:?}",
234            Repo::names(&changed)
235        );
236    }
237
238    #[test]
239    fn a_deleted_file_is_not_selected() {
240        // It shows in `git diff` as a path that is no longer there. Checking it would be an
241        // error about a missing file for something the user did on purpose.
242        let repo = Repo::new("deleted");
243        repo.write("a.ts", "const a = 1;\n");
244        repo.write("b.ts", "const b = 1;\n");
245        repo.commit("first");
246
247        std::fs::remove_file(repo.dir.join("b.ts")).expect("removes");
248        repo.write("a.ts", "const a = 2;\n");
249
250        let changed = since(&repo.dir, "HEAD").expect("lists");
251        assert_eq!(Repo::names(&changed), vec!["a.ts"]);
252    }
253
254    #[test]
255    fn a_directory_is_not_selected() {
256        let repo = Repo::new("directory");
257        repo.write("a.ts", "const a = 1;\n");
258        repo.commit("first");
259        std::fs::create_dir_all(repo.dir.join("subdir")).expect("creates");
260
261        let changed = since(&repo.dir, "HEAD").expect("lists");
262        assert!(Repo::names(&changed).is_empty());
263    }
264
265    #[test]
266    fn staged_lists_only_the_index() {
267        // Not the same as the working tree, which is the whole point for a pre-commit hook:
268        // what is about to be committed is what should be checked.
269        let repo = Repo::new("staged");
270        repo.write("a.ts", "const a = 1;\n");
271        repo.write("b.ts", "const b = 1;\n");
272        repo.commit("first");
273
274        repo.write("a.ts", "const a = 2;\n");
275        repo.write("b.ts", "const b = 2;\n");
276        repo.git(&["add", "a.ts"]);
277
278        let changed = staged(&repo.dir).expect("lists");
279        assert_eq!(Repo::names(&changed), vec!["a.ts"]);
280    }
281
282    #[test]
283    fn staged_is_empty_with_nothing_staged() {
284        let repo = Repo::new("staged-empty");
285        repo.write("a.ts", "const a = 1;\n");
286        repo.commit("first");
287        repo.write("a.ts", "const a = 2;\n");
288
289        assert!(staged(&repo.dir).expect("lists").is_empty());
290    }
291
292    #[test]
293    fn an_unknown_ref_is_an_error() {
294        // Checking everything instead would be a surprising amount of work done silently;
295        // checking nothing would look like a clean run.
296        let repo = Repo::new("bad-ref");
297        repo.write("a.ts", "const a = 1;\n");
298        repo.commit("first");
299
300        let error = since(&repo.dir, "no-such-ref").expect_err("refuses");
301        assert!(matches!(error, ChangeError::Refused { .. }), "{error:?}");
302    }
303
304    #[test]
305    fn results_are_sorted_and_deduplicated() {
306        // Two runs over the same working tree must produce the same list, and git's output
307        // order is not something to depend on.
308        let repo = Repo::new("sorted");
309        repo.write("z.ts", "const z = 1;\n");
310        repo.write("a.ts", "const a = 1;\n");
311        repo.write("m.ts", "const m = 1;\n");
312        repo.commit("first");
313
314        repo.write("z.ts", "const z = 2;\n");
315        repo.write("a.ts", "const a = 2;\n");
316        repo.write("m.ts", "const m = 2;\n");
317
318        let changed = since(&repo.dir, "HEAD").expect("lists");
319        assert_eq!(Repo::names(&changed), vec!["a.ts", "m.ts", "z.ts"]);
320    }
321
322    #[test]
323    fn a_nested_path_keeps_its_directory() {
324        let repo = Repo::new("nested");
325        repo.write("src/deep/a.ts", "const a = 1;\n");
326        repo.commit("first");
327        repo.write("src/deep/a.ts", "const a = 2;\n");
328
329        let changed = since(&repo.dir, "HEAD").expect("lists");
330        assert_eq!(Repo::names(&changed), vec!["src/deep/a.ts"]);
331    }
332
333    #[test]
334    fn outside_a_repository_is_an_error() {
335        let dir = std::env::temp_dir().join(format!("lanekeep-not-a-repo-{}", std::process::id()));
336        let _ = std::fs::remove_dir_all(&dir);
337        std::fs::create_dir_all(&dir).expect("creates dir");
338
339        let error = staged(&dir).expect_err("refuses");
340        assert!(matches!(error, ChangeError::Refused { .. }), "{error:?}");
341
342        let _ = std::fs::remove_dir_all(&dir);
343    }
344}