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 .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 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 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 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 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 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}