diff --git a/Cargo.lock b/Cargo.lock index 369687a..d55320d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -273,6 +273,7 @@ dependencies = [ "reqwest", "serde", "serde_json", + "tempfile", "toml", ] @@ -511,6 +512,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1910,6 +1917,19 @@ dependencies = [ "libc", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "termtree" version = "0.5.1" diff --git a/Cargo.toml b/Cargo.toml index 2052364..fe2867b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,7 @@ toml = "^1.0" [dev-dependencies] assert_cmd = "^2.1.1" predicates = "^3.1.3" +tempfile = "^3.10" [profile.release] opt-level = 3 diff --git a/src/git.rs b/src/git.rs index 20f3879..5a3dbc2 100644 --- a/src/git.rs +++ b/src/git.rs @@ -91,10 +91,20 @@ pub fn git_output(args: &[&str]) -> Result { .with_context(|| format!("failed to run git {:?}", args))?; if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let stderr = stderr.trim(); + if stderr.is_empty() { + return Err(anyhow!( + "git {:?} exited with status {:?}", + args, + output.status.code() + )); + } return Err(anyhow!( - "git {:?} exited with status {:?}", + "git {:?} exited with status {:?}: {}", args, - output.status.code() + output.status.code(), + stderr )); } @@ -137,8 +147,14 @@ pub fn staged_files() -> Result> { } /// Get per-file staged diff. +/// +/// `staged_files` returns paths relative to the repo root, but a bare pathspec +/// is resolved by git relative to the current working directory. Anchor it +/// with the `:/` top-level magic pathspec so this still works when commitbot +/// is invoked from a subdirectory of the repo. pub fn staged_diff_for_file(path: &str) -> Result { - let diff = git_output(&["diff", "--cached", "--", path])?; + let pathspec = format!(":/{path}"); + let diff = git_output(&["diff", "--cached", "--", &pathspec])?; Ok(diff) } diff --git a/tests/git.rs b/tests/git.rs index 2589c68..9f41680 100644 --- a/tests/git.rs +++ b/tests/git.rs @@ -1,7 +1,41 @@ use commitbot::git::{ find_first_pr_number, format_pr_commit_appendix_with_remote, parse_remote_repo, - short_commit_hash, split_diff_by_file, PrItem, PrSummaryMode, + short_commit_hash, split_diff_by_file, staged_diff_for_file, staged_files, PrItem, + PrSummaryMode, }; +use std::process::Command; + +/// Set up a throwaway git repo with a staged change in a nested file, and +/// return its tempdir handle plus the nested directory's path. +fn repo_with_staged_nested_file() -> (tempfile::TempDir, std::path::PathBuf) { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path(); + + let run = |args: &[&str]| { + let status = Command::new("git") + .args(args) + .current_dir(root) + .status() + .expect("run git"); + assert!(status.success(), "git {:?} failed", args); + }; + + run(&["init", "-q"]); + run(&["config", "user.email", "test@example.com"]); + run(&["config", "user.name", "Test"]); + + let nested_dir = root.join("app").join("Models"); + std::fs::create_dir_all(&nested_dir).expect("mkdir"); + let file = nested_dir.join("OrderItem.php"); + std::fs::write(&file, "original\n").expect("write"); + run(&["add", "."]); + run(&["commit", "-q", "-m", "initial"]); + + std::fs::write(&file, "original\nchanged\n").expect("rewrite"); + run(&["add", "."]); + + (dir, nested_dir) +} #[test] fn parses_github_ssh_remote() { @@ -136,6 +170,29 @@ fn short_commit_hash_short_input() { assert_eq!(result, "abc"); } +#[test] +fn staged_diff_for_file_works_from_subdirectory() { + let (_dir, nested_dir) = repo_with_staged_nested_file(); + + let original_cwd = std::env::current_dir().expect("current dir"); + std::env::set_current_dir(&nested_dir).expect("chdir into nested dir"); + + let result = (|| { + let files = staged_files()?; + assert_eq!(files, vec!["app/Models/OrderItem.php".to_string()]); + + let diff = staged_diff_for_file(&files[0])?; + assert!( + diff.contains("+changed"), + "expected diff to contain the staged change, got: {diff:?}" + ); + anyhow::Ok(()) + })(); + + std::env::set_current_dir(original_cwd).expect("restore cwd"); + result.expect("staged diff lookup from subdirectory"); +} + #[test] fn pr_summary_mode_as_str() { assert_eq!(PrSummaryMode::ByCommits.as_str(), "commits");