1#![deny(missing_docs)]
14
15use std::collections::BTreeSet;
16use std::io;
17use std::path::{Path, PathBuf};
18use std::process::{Command, Output};
19use std::string::FromUtf8Error;
20
21use haz_domain::path::{CanonicalPath, ParseAbsoluteError};
22use snafu::{ResultExt, Snafu};
23
24#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum GitFilter {
33 Changed,
36 Staged,
39 ChangedSince(String),
43}
44
45#[derive(Debug, Snafu)]
53pub enum GitChangesetError {
54 #[snafu(display("failed to invoke `git`: {source}"))]
56 GitInvocationFailed {
57 source: io::Error,
59 },
60
61 #[snafu(display("workspace root `{workspace_root}` is not inside a git repository: {stderr}"))]
64 NotAGitRepo {
65 workspace_root: String,
67 stderr: String,
69 },
70
71 #[snafu(display("`--git-changed-since {rev}` failed: {stderr}"))]
73 UnresolvableRev {
74 rev: String,
76 stderr: String,
78 },
79
80 #[snafu(display("`git {command}` failed: {stderr}"))]
82 GitCommandFailed {
83 command: String,
85 stderr: String,
87 },
88
89 #[snafu(display("git emitted a non-UTF-8 path: {source}"))]
93 NonUtf8Path {
94 source: FromUtf8Error,
96 },
97
98 #[snafu(display("failed to canonicalise workspace root `{}`: {source}", path.display()))]
101 CanonicaliseWorkspaceRoot {
102 source: io::Error,
104 path: PathBuf,
106 },
107
108 #[snafu(display("git emitted a path that is not a valid workspace-absolute path: {input}"))]
114 InvalidWorkspacePath {
115 input: String,
117 source: ParseAbsoluteError,
119 },
120}
121
122pub fn workspace_changeset(
138 filter: &GitFilter,
139 workspace_root: &Path,
140) -> Result<BTreeSet<CanonicalPath>, GitChangesetError> {
141 let ws_canonical =
142 std::fs::canonicalize(workspace_root).context(CanonicaliseWorkspaceRootSnafu {
143 path: workspace_root.to_path_buf(),
144 })?;
145 let repo_root = git_repo_root(&ws_canonical)?;
146
147 let mut diff_args: Vec<&str> = Vec::new();
148 let mut include_untracked = false;
149 match filter {
150 GitFilter::Changed => {
151 diff_args.extend(["diff", "--name-only", "-z", "HEAD", "--"]);
152 include_untracked = true;
153 }
154 GitFilter::Staged => {
155 diff_args.extend(["diff", "--staged", "--name-only", "-z", "--"]);
156 }
157 GitFilter::ChangedSince(rev) => {
158 diff_args.extend(["diff", "--name-only", "-z", rev.as_str(), "--"]);
159 include_untracked = true;
160 }
161 }
162
163 let diff_output = run_git(&ws_canonical, &diff_args)?;
164 let diff_paths = match filter {
165 GitFilter::ChangedSince(rev) if !diff_output.status.success() => {
166 return Err(GitChangesetError::UnresolvableRev {
167 rev: rev.clone(),
168 stderr: stderr_string(&diff_output),
169 });
170 }
171 _ if !diff_output.status.success() => {
172 return Err(GitChangesetError::GitCommandFailed {
173 command: diff_args.join(" "),
174 stderr: stderr_string(&diff_output),
175 });
176 }
177 _ => parse_nul_separated(&diff_output.stdout)?,
178 };
179
180 let mut all_paths: Vec<String> = diff_paths;
181 if include_untracked {
182 let ls_args = ["ls-files", "--others", "--exclude-standard", "-z"];
183 let ls_output = run_git(&ws_canonical, &ls_args)?;
184 if !ls_output.status.success() {
185 return Err(GitChangesetError::GitCommandFailed {
186 command: ls_args.join(" "),
187 stderr: stderr_string(&ls_output),
188 });
189 }
190 all_paths.extend(parse_nul_separated(&ls_output.stdout)?);
191 }
192
193 let mut out: BTreeSet<CanonicalPath> = BTreeSet::new();
194 for relative in all_paths {
195 let absolute = repo_root.join(&relative);
196 let Ok(workspace_relative) = absolute.strip_prefix(&ws_canonical) else {
197 continue;
199 };
200 if workspace_relative.as_os_str().is_empty() {
201 continue;
206 }
207 let Some(rel_str) = workspace_relative.to_str() else {
208 continue;
214 };
215 let canonical_input = format!("/{rel_str}");
216 let canonical = CanonicalPath::parse_workspace_absolute(&canonical_input).context(
217 InvalidWorkspacePathSnafu {
218 input: canonical_input.clone(),
219 },
220 )?;
221 out.insert(canonical);
222 }
223 Ok(out)
224}
225
226fn git_repo_root(workspace_root: &Path) -> Result<PathBuf, GitChangesetError> {
232 let output = run_git(workspace_root, &["rev-parse", "--show-toplevel"])?;
233 if !output.status.success() {
234 return Err(GitChangesetError::NotAGitRepo {
235 workspace_root: workspace_root.display().to_string(),
236 stderr: stderr_string(&output),
237 });
238 }
239 let stdout = String::from_utf8(output.stdout)
240 .map_err(|source| GitChangesetError::NonUtf8Path { source })?;
241 Ok(PathBuf::from(stdout.trim_end_matches('\n')))
242}
243
244fn run_git(cwd: &Path, args: &[&str]) -> Result<Output, GitChangesetError> {
245 Command::new("git")
246 .arg("-C")
247 .arg(cwd)
248 .args(args)
249 .output()
250 .context(GitInvocationFailedSnafu)
251}
252
253fn stderr_string(output: &Output) -> String {
254 String::from_utf8_lossy(&output.stderr).trim().to_owned()
255}
256
257fn parse_nul_separated(bytes: &[u8]) -> Result<Vec<String>, GitChangesetError> {
263 let mut out = Vec::new();
264 for segment in bytes.split(|b| *b == 0) {
265 if segment.is_empty() {
266 continue;
267 }
268 out.push(
269 String::from_utf8(segment.to_vec())
270 .map_err(|source| GitChangesetError::NonUtf8Path { source })?,
271 );
272 }
273 Ok(out)
274}
275
276#[cfg(test)]
277mod tests {
278 use super::{GitChangesetError, GitFilter, workspace_changeset};
279 use std::fs;
280 use std::path::Path;
281 use std::process::Command;
282
283 fn git(cwd: &Path, args: &[&str]) {
287 let output = Command::new("git")
288 .arg("-C")
289 .arg(cwd)
290 .args(args)
291 .output()
292 .expect("git binary must be available on PATH for these tests");
293 assert!(
294 output.status.success(),
295 "git {} failed:\nstdout: {}\nstderr: {}",
296 args.join(" "),
297 String::from_utf8_lossy(&output.stdout),
298 String::from_utf8_lossy(&output.stderr),
299 );
300 }
301
302 fn init_repo(root: &Path) {
306 git(root, &["init", "--quiet", "--initial-branch=main"]);
307 git(root, &["config", "user.email", "test@example.invalid"]);
308 git(root, &["config", "user.name", "Test"]);
309 git(root, &["config", "commit.gpgsign", "false"]);
310 }
311
312 fn write(path: &Path, contents: &str) {
313 if let Some(parent) = path.parent() {
314 fs::create_dir_all(parent).unwrap();
315 }
316 fs::write(path, contents).unwrap();
317 }
318
319 fn collect_strings(
324 set: &std::collections::BTreeSet<haz_domain::path::CanonicalPath>,
325 ) -> Vec<String> {
326 set.iter().map(ToString::to_string).collect()
327 }
328
329 #[test]
330 fn not_a_repo_surfaces_typed_error() {
331 let tempdir = tempfile::TempDir::new().unwrap();
332 let err = workspace_changeset(&GitFilter::Changed, tempdir.path()).unwrap_err();
333 match err {
334 GitChangesetError::NotAGitRepo { .. } => {}
335 other => panic!("unexpected error: {other:?}"),
336 }
337 }
338
339 #[test]
340 fn exec_027_git_changed_lists_unstaged_modification() {
341 let tempdir = tempfile::TempDir::new().unwrap();
342 let root = tempdir.path();
343 init_repo(root);
344 write(&root.join("a.txt"), "v1\n");
345 git(root, &["add", "."]);
346 git(root, &["commit", "--quiet", "-m", "init"]);
347 write(&root.join("a.txt"), "v2\n");
348
349 let set = workspace_changeset(&GitFilter::Changed, root).unwrap();
350 assert_eq!(collect_strings(&set), vec!["/a.txt"]);
351 }
352
353 #[test]
354 fn exec_027_git_changed_lists_staged_modification() {
355 let tempdir = tempfile::TempDir::new().unwrap();
356 let root = tempdir.path();
357 init_repo(root);
358 write(&root.join("a.txt"), "v1\n");
359 git(root, &["add", "."]);
360 git(root, &["commit", "--quiet", "-m", "init"]);
361 write(&root.join("a.txt"), "v2\n");
362 git(root, &["add", "a.txt"]);
363
364 let set = workspace_changeset(&GitFilter::Changed, root).unwrap();
365 assert_eq!(collect_strings(&set), vec!["/a.txt"]);
366 }
367
368 #[test]
369 fn exec_027_git_changed_includes_untracked() {
370 let tempdir = tempfile::TempDir::new().unwrap();
371 let root = tempdir.path();
372 init_repo(root);
373 write(&root.join("seed.txt"), "x\n");
374 git(root, &["add", "."]);
375 git(root, &["commit", "--quiet", "-m", "init"]);
376 write(&root.join("new.txt"), "y\n");
377
378 let set = workspace_changeset(&GitFilter::Changed, root).unwrap();
379 assert_eq!(collect_strings(&set), vec!["/new.txt"]);
380 }
381
382 #[test]
383 fn git_staged_excludes_unstaged_and_untracked() {
384 let tempdir = tempfile::TempDir::new().unwrap();
385 let root = tempdir.path();
386 init_repo(root);
387 write(&root.join("a.txt"), "v1\n");
388 write(&root.join("b.txt"), "v1\n");
389 git(root, &["add", "."]);
390 git(root, &["commit", "--quiet", "-m", "init"]);
391
392 write(&root.join("a.txt"), "v2\n");
394 git(root, &["add", "a.txt"]);
395 write(&root.join("b.txt"), "v2\n");
396 write(&root.join("u.txt"), "u\n");
397
398 let set = workspace_changeset(&GitFilter::Staged, root).unwrap();
399 assert_eq!(collect_strings(&set), vec!["/a.txt"]);
400 }
401
402 #[test]
403 fn git_changed_since_lists_files_since_rev() {
404 let tempdir = tempfile::TempDir::new().unwrap();
405 let root = tempdir.path();
406 init_repo(root);
407 write(&root.join("a.txt"), "v1\n");
408 git(root, &["add", "."]);
409 git(root, &["commit", "--quiet", "-m", "init"]);
410 git(root, &["tag", "base"]);
411 write(&root.join("a.txt"), "v2\n");
412 git(root, &["add", "a.txt"]);
413 git(root, &["commit", "--quiet", "-m", "tick"]);
414 write(&root.join("b.txt"), "b\n");
415
416 let filter = GitFilter::ChangedSince("base".to_owned());
417 let set = workspace_changeset(&filter, root).unwrap();
418 assert_eq!(collect_strings(&set), vec!["/a.txt", "/b.txt"]);
420 }
421
422 #[test]
423 fn git_changed_since_unresolvable_rev_errors() {
424 let tempdir = tempfile::TempDir::new().unwrap();
425 let root = tempdir.path();
426 init_repo(root);
427 write(&root.join("a.txt"), "v1\n");
428 git(root, &["add", "."]);
429 git(root, &["commit", "--quiet", "-m", "init"]);
430
431 let filter = GitFilter::ChangedSince("does-not-exist".to_owned());
432 let err = workspace_changeset(&filter, root).unwrap_err();
433 match err {
434 GitChangesetError::UnresolvableRev { rev, .. } => {
435 assert_eq!(rev, "does-not-exist");
436 }
437 other => panic!("unexpected error: {other:?}"),
438 }
439 }
440
441 #[test]
442 fn workspace_inside_repo_subdir_returns_workspace_relative_paths() {
443 let tempdir = tempfile::TempDir::new().unwrap();
447 let repo_root = tempdir.path();
448 let workspace_root = repo_root.join("ws");
449 fs::create_dir_all(&workspace_root).unwrap();
450 init_repo(repo_root);
451 write(&workspace_root.join("a.txt"), "v1\n");
452 write(&repo_root.join("outside.txt"), "z\n");
453 git(repo_root, &["add", "."]);
454 git(repo_root, &["commit", "--quiet", "-m", "init"]);
455 write(&workspace_root.join("a.txt"), "v2\n");
456 write(&repo_root.join("outside.txt"), "y\n");
459
460 let set = workspace_changeset(&GitFilter::Changed, &workspace_root).unwrap();
461 assert_eq!(collect_strings(&set), vec!["/a.txt"]);
462 }
463}