1use std::path::Path;
20use std::process::Command;
21
22use thiserror::Error;
23
24use crate::location::FilePath;
25
26#[derive(Debug, Clone, PartialEq, Eq, Error)]
28pub enum ChangeError {
29 #[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 detail: String,
37 },
38
39 #[error("git could not list changed files: {detail}")]
41 Refused {
42 detail: String,
44 },
45}
46
47pub 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
62pub 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
75fn untracked(root: &Path) -> Result<Vec<String>, ChangeError> {
77 run_git(
78 root,
79 &["ls-files", "--others", "--exclude-standard", "--", "."],
80 )
81}
82
83fn 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
104fn 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 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 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 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 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 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}