Skip to main content

haz_vcs/
lib.rs

1//! Version-control system integration for `haz`.
2//!
3//! Currently exposes the git-changeset enumeration required by
4//! `haz run`'s `--git-changed` / `--git-staged` /
5//! `--git-changed-since <REV>` flags (`EXEC-026`..`EXEC-029`).
6//! The implementation shells out to the host `git` binary; no
7//! libgit2 or pure-Rust git dependency is taken in v1.
8//!
9//! Future extensions (other revision-control backends, alternate
10//! changeset queries) MAY land here so consumers depend on one
11//! VCS crate rather than several.
12
13#![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/// Git-changeset filter selected on the `haz run` invocation.
25///
26/// The three variants correspond to `EXEC-027`, `EXEC-028`, and
27/// `EXEC-029`. The enum makes the intra-family mutex
28/// (`EXEC-026`) representable: a single optional value is the
29/// type-level guarantee that callers never see two filters at
30/// once.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum GitFilter {
33    /// `--git-changed`: working tree vs `HEAD`, untracked
34    /// included (`EXEC-027`).
35    Changed,
36    /// `--git-staged`: index vs `HEAD`, tracked only
37    /// (`EXEC-028`).
38    Staged,
39    /// `--git-changed-since <REV>`: working tree vs the named
40    /// revision, untracked included (`EXEC-029`). The string is
41    /// the raw user input; `git rev-parse` validates it later.
42    ChangedSince(String),
43}
44
45/// Error returned by [`workspace_changeset`].
46///
47/// Each variant maps to a single failure mode the user can act
48/// on: git binary missing, workspace not inside a git repo,
49/// unresolvable `<REV>`, diff invocation failure, non-UTF-8 path
50/// bytes in the output, or workspace-root canonicalisation
51/// failure.
52#[derive(Debug, Snafu)]
53pub enum GitChangesetError {
54    /// `git` could not be invoked (typically: not on PATH).
55    #[snafu(display("failed to invoke `git`: {source}"))]
56    GitInvocationFailed {
57        /// The underlying IO error.
58        source: io::Error,
59    },
60
61    /// Workspace root is not inside a git repository, surfaced by
62    /// a non-zero `rev-parse --show-toplevel` exit (`EXEC-026`).
63    #[snafu(display("workspace root `{workspace_root}` is not inside a git repository: {stderr}"))]
64    NotAGitRepo {
65        /// The workspace root that was probed.
66        workspace_root: String,
67        /// Trimmed `git` stderr.
68        stderr: String,
69    },
70
71    /// `<REV>` did not resolve to a commit (`EXEC-029`).
72    #[snafu(display("`--git-changed-since {rev}` failed: {stderr}"))]
73    UnresolvableRev {
74        /// The user-supplied revision string.
75        rev: String,
76        /// Trimmed `git` stderr.
77        stderr: String,
78    },
79
80    /// A `git diff` or `git ls-files` invocation returned non-zero.
81    #[snafu(display("`git {command}` failed: {stderr}"))]
82    GitCommandFailed {
83        /// The argv tail used (for diagnostic context).
84        command: String,
85        /// Trimmed `git` stderr.
86        stderr: String,
87    },
88
89    /// A path in `git`'s output failed UTF-8 decoding. The
90    /// [`CanonicalPath`] type the workspace consumes requires
91    /// `str`, so byte-only paths cannot be carried forward.
92    #[snafu(display("git emitted a non-UTF-8 path: {source}"))]
93    NonUtf8Path {
94        /// The underlying decoding error.
95        source: FromUtf8Error,
96    },
97
98    /// The workspace root could not be canonicalised (typically:
99    /// the directory was deleted under the process).
100    #[snafu(display("failed to canonicalise workspace root `{}`: {source}", path.display()))]
101    CanonicaliseWorkspaceRoot {
102        /// The underlying IO error.
103        source: io::Error,
104        /// The path that failed to canonicalise.
105        path: PathBuf,
106    },
107
108    /// A changeset path inside the workspace failed
109    /// [`CanonicalPath::parse_workspace_absolute`] (e.g., contains
110    /// a `..` segment or a bidi control). Such paths cannot
111    /// participate in input matching, so surfacing the error is
112    /// preferable to silently dropping them.
113    #[snafu(display("git emitted a path that is not a valid workspace-absolute path: {input}"))]
114    InvalidWorkspacePath {
115        /// The offending input.
116        input: String,
117        /// The underlying parse error.
118        source: ParseAbsoluteError,
119    },
120}
121
122/// Compute the workspace-normalised changeset for the active
123/// [`GitFilter`].
124///
125/// `workspace_root` MUST exist on the host filesystem (the
126/// function canonicalises it via [`std::fs::canonicalize`] to
127/// align with the symlink-resolved paths `git` emits).
128///
129/// The returned set contains every changeset path whose canonical
130/// host location is inside the canonical workspace root,
131/// expressed as a [`CanonicalPath`] (`PATH-004`). Paths outside
132/// the workspace root are silently dropped (`EXEC-026`).
133///
134/// # Errors
135///
136/// See [`GitChangesetError`].
137pub 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            // Path outside the workspace root: drop per EXEC-026.
198            continue;
199        };
200        if workspace_relative.as_os_str().is_empty() {
201            // The repo-root output included the workspace root
202            // itself (vanishingly unlikely with diff/ls-files, but
203            // explicitly guarded so the empty-segment case can't
204            // produce a malformed CanonicalPath).
205            continue;
206        }
207        let Some(rel_str) = workspace_relative.to_str() else {
208            // Non-UTF-8 path that nevertheless decoded earlier
209            // (raw bytes did UTF-8 cleanly, but the resulting
210            // OsStr cannot round-trip). On Unix this is
211            // unreachable: `to_str` on a UTF-8-decoded `OsString`
212            // succeeds.
213            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
226/// Resolve the git-worktree root that encloses `workspace_root`.
227///
228/// `git -C <workspace_root> rev-parse --show-toplevel` returns the
229/// absolute path of the worktree root, or fails with a non-zero
230/// exit when the path is not inside any git repository.
231fn 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
257/// Split `bytes` on `\0` and return each entry as a UTF-8 string.
258///
259/// `git`'s `-z` mode emits entries separated by `\0`; the trailing
260/// separator after the last entry produces an empty tail that is
261/// filtered out.
262fn 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    /// Run `git` with `args` inside `cwd`, asserting success. The
284    /// test fixtures use this to script tracked, staged, and
285    /// untracked states.
286    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    /// Initialise a git repo at `root` with a minimal identity so
303    /// `git commit` succeeds in CI environments that lack global
304    /// config.
305    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    /// Render the changeset as a `Vec<String>` for deterministic
320    /// test assertions. `BTreeSet<CanonicalPath>` iterates in the
321    /// canonical lexicographic order on segments, so the order
322    /// here matches the test expectations directly.
323    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        // Stage a.txt; leave b.txt unstaged; create untracked u.txt.
393        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        // a.txt (committed since base) plus b.txt (untracked).
419        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        // Git repo at /repo; workspace at /repo/ws. The diff
444        // output is repo-relative (`ws/a.txt`); the function MUST
445        // strip the workspace prefix and emit `/a.txt`.
446        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        // Also modify a file OUTSIDE the workspace; the function
457        // MUST drop it.
458        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}