#![deny(missing_docs)]
use std::collections::BTreeSet;
use std::io;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::string::FromUtf8Error;
use haz_domain::path::{CanonicalPath, ParseAbsoluteError};
use snafu::{ResultExt, Snafu};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GitFilter {
Changed,
Staged,
ChangedSince(String),
}
#[derive(Debug, Snafu)]
pub enum GitChangesetError {
#[snafu(display("failed to invoke `git`: {source}"))]
GitInvocationFailed {
source: io::Error,
},
#[snafu(display("workspace root `{workspace_root}` is not inside a git repository: {stderr}"))]
NotAGitRepo {
workspace_root: String,
stderr: String,
},
#[snafu(display("`--git-changed-since {rev}` failed: {stderr}"))]
UnresolvableRev {
rev: String,
stderr: String,
},
#[snafu(display("`git {command}` failed: {stderr}"))]
GitCommandFailed {
command: String,
stderr: String,
},
#[snafu(display("git emitted a non-UTF-8 path: {source}"))]
NonUtf8Path {
source: FromUtf8Error,
},
#[snafu(display("failed to canonicalise workspace root `{}`: {source}", path.display()))]
CanonicaliseWorkspaceRoot {
source: io::Error,
path: PathBuf,
},
#[snafu(display("git emitted a path that is not a valid workspace-absolute path: {input}"))]
InvalidWorkspacePath {
input: String,
source: ParseAbsoluteError,
},
}
pub fn workspace_changeset(
filter: &GitFilter,
workspace_root: &Path,
) -> Result<BTreeSet<CanonicalPath>, GitChangesetError> {
let ws_canonical =
std::fs::canonicalize(workspace_root).context(CanonicaliseWorkspaceRootSnafu {
path: workspace_root.to_path_buf(),
})?;
let repo_root = git_repo_root(&ws_canonical)?;
let mut diff_args: Vec<&str> = Vec::new();
let mut include_untracked = false;
match filter {
GitFilter::Changed => {
diff_args.extend(["diff", "--name-only", "-z", "HEAD", "--"]);
include_untracked = true;
}
GitFilter::Staged => {
diff_args.extend(["diff", "--staged", "--name-only", "-z", "--"]);
}
GitFilter::ChangedSince(rev) => {
diff_args.extend(["diff", "--name-only", "-z", rev.as_str(), "--"]);
include_untracked = true;
}
}
let diff_output = run_git(&ws_canonical, &diff_args)?;
let diff_paths = match filter {
GitFilter::ChangedSince(rev) if !diff_output.status.success() => {
return Err(GitChangesetError::UnresolvableRev {
rev: rev.clone(),
stderr: stderr_string(&diff_output),
});
}
_ if !diff_output.status.success() => {
return Err(GitChangesetError::GitCommandFailed {
command: diff_args.join(" "),
stderr: stderr_string(&diff_output),
});
}
_ => parse_nul_separated(&diff_output.stdout)?,
};
let mut all_paths: Vec<String> = diff_paths;
if include_untracked {
let ls_args = ["ls-files", "--others", "--exclude-standard", "-z"];
let ls_output = run_git(&ws_canonical, &ls_args)?;
if !ls_output.status.success() {
return Err(GitChangesetError::GitCommandFailed {
command: ls_args.join(" "),
stderr: stderr_string(&ls_output),
});
}
all_paths.extend(parse_nul_separated(&ls_output.stdout)?);
}
let mut out: BTreeSet<CanonicalPath> = BTreeSet::new();
for relative in all_paths {
let absolute = repo_root.join(&relative);
let Ok(workspace_relative) = absolute.strip_prefix(&ws_canonical) else {
continue;
};
if workspace_relative.as_os_str().is_empty() {
continue;
}
let Some(rel_str) = workspace_relative.to_str() else {
continue;
};
let canonical_input = format!("/{rel_str}");
let canonical = CanonicalPath::parse_workspace_absolute(&canonical_input).context(
InvalidWorkspacePathSnafu {
input: canonical_input.clone(),
},
)?;
out.insert(canonical);
}
Ok(out)
}
fn git_repo_root(workspace_root: &Path) -> Result<PathBuf, GitChangesetError> {
let output = run_git(workspace_root, &["rev-parse", "--show-toplevel"])?;
if !output.status.success() {
return Err(GitChangesetError::NotAGitRepo {
workspace_root: workspace_root.display().to_string(),
stderr: stderr_string(&output),
});
}
let stdout = String::from_utf8(output.stdout)
.map_err(|source| GitChangesetError::NonUtf8Path { source })?;
Ok(PathBuf::from(stdout.trim_end_matches('\n')))
}
fn run_git(cwd: &Path, args: &[&str]) -> Result<Output, GitChangesetError> {
Command::new("git")
.arg("-C")
.arg(cwd)
.args(args)
.output()
.context(GitInvocationFailedSnafu)
}
fn stderr_string(output: &Output) -> String {
String::from_utf8_lossy(&output.stderr).trim().to_owned()
}
fn parse_nul_separated(bytes: &[u8]) -> Result<Vec<String>, GitChangesetError> {
let mut out = Vec::new();
for segment in bytes.split(|b| *b == 0) {
if segment.is_empty() {
continue;
}
out.push(
String::from_utf8(segment.to_vec())
.map_err(|source| GitChangesetError::NonUtf8Path { source })?,
);
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::{GitChangesetError, GitFilter, workspace_changeset};
use std::fs;
use std::path::Path;
use std::process::Command;
fn git(cwd: &Path, args: &[&str]) {
let output = Command::new("git")
.arg("-C")
.arg(cwd)
.args(args)
.output()
.expect("git binary must be available on PATH for these tests");
assert!(
output.status.success(),
"git {} failed:\nstdout: {}\nstderr: {}",
args.join(" "),
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
}
fn init_repo(root: &Path) {
git(root, &["init", "--quiet", "--initial-branch=main"]);
git(root, &["config", "user.email", "test@example.invalid"]);
git(root, &["config", "user.name", "Test"]);
git(root, &["config", "commit.gpgsign", "false"]);
}
fn write(path: &Path, contents: &str) {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(path, contents).unwrap();
}
fn collect_strings(
set: &std::collections::BTreeSet<haz_domain::path::CanonicalPath>,
) -> Vec<String> {
set.iter().map(ToString::to_string).collect()
}
#[test]
fn not_a_repo_surfaces_typed_error() {
let tempdir = tempfile::TempDir::new().unwrap();
let err = workspace_changeset(&GitFilter::Changed, tempdir.path()).unwrap_err();
match err {
GitChangesetError::NotAGitRepo { .. } => {}
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn exec_027_git_changed_lists_unstaged_modification() {
let tempdir = tempfile::TempDir::new().unwrap();
let root = tempdir.path();
init_repo(root);
write(&root.join("a.txt"), "v1\n");
git(root, &["add", "."]);
git(root, &["commit", "--quiet", "-m", "init"]);
write(&root.join("a.txt"), "v2\n");
let set = workspace_changeset(&GitFilter::Changed, root).unwrap();
assert_eq!(collect_strings(&set), vec!["/a.txt"]);
}
#[test]
fn exec_027_git_changed_lists_staged_modification() {
let tempdir = tempfile::TempDir::new().unwrap();
let root = tempdir.path();
init_repo(root);
write(&root.join("a.txt"), "v1\n");
git(root, &["add", "."]);
git(root, &["commit", "--quiet", "-m", "init"]);
write(&root.join("a.txt"), "v2\n");
git(root, &["add", "a.txt"]);
let set = workspace_changeset(&GitFilter::Changed, root).unwrap();
assert_eq!(collect_strings(&set), vec!["/a.txt"]);
}
#[test]
fn exec_027_git_changed_includes_untracked() {
let tempdir = tempfile::TempDir::new().unwrap();
let root = tempdir.path();
init_repo(root);
write(&root.join("seed.txt"), "x\n");
git(root, &["add", "."]);
git(root, &["commit", "--quiet", "-m", "init"]);
write(&root.join("new.txt"), "y\n");
let set = workspace_changeset(&GitFilter::Changed, root).unwrap();
assert_eq!(collect_strings(&set), vec!["/new.txt"]);
}
#[test]
fn git_staged_excludes_unstaged_and_untracked() {
let tempdir = tempfile::TempDir::new().unwrap();
let root = tempdir.path();
init_repo(root);
write(&root.join("a.txt"), "v1\n");
write(&root.join("b.txt"), "v1\n");
git(root, &["add", "."]);
git(root, &["commit", "--quiet", "-m", "init"]);
write(&root.join("a.txt"), "v2\n");
git(root, &["add", "a.txt"]);
write(&root.join("b.txt"), "v2\n");
write(&root.join("u.txt"), "u\n");
let set = workspace_changeset(&GitFilter::Staged, root).unwrap();
assert_eq!(collect_strings(&set), vec!["/a.txt"]);
}
#[test]
fn git_changed_since_lists_files_since_rev() {
let tempdir = tempfile::TempDir::new().unwrap();
let root = tempdir.path();
init_repo(root);
write(&root.join("a.txt"), "v1\n");
git(root, &["add", "."]);
git(root, &["commit", "--quiet", "-m", "init"]);
git(root, &["tag", "base"]);
write(&root.join("a.txt"), "v2\n");
git(root, &["add", "a.txt"]);
git(root, &["commit", "--quiet", "-m", "tick"]);
write(&root.join("b.txt"), "b\n");
let filter = GitFilter::ChangedSince("base".to_owned());
let set = workspace_changeset(&filter, root).unwrap();
assert_eq!(collect_strings(&set), vec!["/a.txt", "/b.txt"]);
}
#[test]
fn git_changed_since_unresolvable_rev_errors() {
let tempdir = tempfile::TempDir::new().unwrap();
let root = tempdir.path();
init_repo(root);
write(&root.join("a.txt"), "v1\n");
git(root, &["add", "."]);
git(root, &["commit", "--quiet", "-m", "init"]);
let filter = GitFilter::ChangedSince("does-not-exist".to_owned());
let err = workspace_changeset(&filter, root).unwrap_err();
match err {
GitChangesetError::UnresolvableRev { rev, .. } => {
assert_eq!(rev, "does-not-exist");
}
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn workspace_inside_repo_subdir_returns_workspace_relative_paths() {
let tempdir = tempfile::TempDir::new().unwrap();
let repo_root = tempdir.path();
let workspace_root = repo_root.join("ws");
fs::create_dir_all(&workspace_root).unwrap();
init_repo(repo_root);
write(&workspace_root.join("a.txt"), "v1\n");
write(&repo_root.join("outside.txt"), "z\n");
git(repo_root, &["add", "."]);
git(repo_root, &["commit", "--quiet", "-m", "init"]);
write(&workspace_root.join("a.txt"), "v2\n");
write(&repo_root.join("outside.txt"), "y\n");
let set = workspace_changeset(&GitFilter::Changed, &workspace_root).unwrap();
assert_eq!(collect_strings(&set), vec!["/a.txt"]);
}
}