use std::path::Path;
use super::cmd::{GitError, git};
pub fn config_value(dir: &Path, key: &str) -> Result<String, GitError> {
git(dir, &["config", key])
}
pub fn head_oid(dir: &Path) -> Result<String, GitError> {
git(dir, &["rev-parse", "HEAD"])
}
pub fn current_branch(dir: &Path) -> Result<String, GitError> {
git(dir, &["rev-parse", "--abbrev-ref", "HEAD"])
}
pub fn resolve_rev(dir: &Path, rev: &str) -> Result<String, GitError> {
git(dir, &["rev-parse", "--verify", rev])
}
pub fn detect_host(dir: &Path) -> standard_changelog::RepoHost {
match git(dir, &["remote", "get-url", "origin"]) {
Ok(url) => standard_changelog::detect_host(&url),
Err(_) => standard_changelog::RepoHost::Unknown,
}
}
pub fn staged_diff(dir: &Path) -> Result<String, GitError> {
git(dir, &["diff", "--staged"])
}
pub fn short_status(dir: &Path) -> Result<String, GitError> {
git(dir, &["status", "--short"])
}
pub fn staged_files(dir: &Path) -> Result<Vec<String>, GitError> {
let output = git(dir, &["diff", "--cached", "--name-only"])?;
Ok(output
.lines()
.map(str::to_owned)
.filter(|s| !s.is_empty())
.collect())
}
pub fn walk_commits(
dir: &Path,
from: &str,
until: Option<&str>,
) -> Result<Vec<(String, String)>, GitError> {
let range = match until {
Some(u) => format!("{u}..{from}"),
None => from.to_string(),
};
let output = git(
dir,
&["log", "--format=%H%x00%B%x00", "--topo-order", &range, "--"],
)?;
Ok(parse_nul_delimited_log(&output))
}
pub fn walk_range(dir: &Path, range: &str) -> Result<Vec<(String, String)>, GitError> {
let output = git(
dir,
&["log", "--format=%H%x00%B%x00", "--topo-order", range, "--"],
)?;
Ok(parse_nul_delimited_log(&output))
}
pub fn walk_commits_for_path(
dir: &Path,
from: &str,
until: Option<&str>,
paths: &[&str],
) -> Result<Vec<(String, String)>, GitError> {
let range = match until {
Some(u) => format!("{u}..{from}"),
None => from.to_string(),
};
let mut args = vec!["log", "--format=%H%x00%B%x00", "--topo-order", &range, "--"];
args.extend(paths);
let output = git(dir, &args)?;
Ok(parse_nul_delimited_log(&output))
}
fn parse_nul_delimited_log(output: &str) -> Vec<(String, String)> {
let mut commits = Vec::new();
let parts: Vec<&str> = output.split('\0').collect();
let mut i = 0;
while i + 1 < parts.len() {
let sha_part = parts[i].trim();
let msg_part = parts[i + 1].trim();
if !sha_part.is_empty() && sha_part.len() >= 40 {
let sha = if let Some(pos) = sha_part.rfind('\n') {
&sha_part[pos + 1..]
} else {
sha_part
};
if sha.len() >= 40 {
commits.push((sha.to_string(), msg_part.to_string()));
}
}
i += 2;
}
commits
}
pub fn commit_date(dir: &Path, rev: &str) -> Result<String, GitError> {
let output = git(dir, &["log", "-1", "--format=%ai", rev, "--"])?;
if output.len() < 10 {
return Err(GitError {
message: format!("unexpected date format: '{output}'"),
});
}
Ok(output[..10].to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicI64, Ordering};
static COMMIT_TIME: AtomicI64 = AtomicI64::new(1_700_000_000);
fn next_timestamp() -> String {
let ts = COMMIT_TIME.fetch_add(1, Ordering::SeqCst);
format!("{ts} +0000")
}
fn init_repo(dir: &Path) {
std::process::Command::new("git")
.current_dir(dir)
.args(["init"])
.output()
.unwrap();
std::process::Command::new("git")
.current_dir(dir)
.args(["config", "user.name", "Test"])
.output()
.unwrap();
std::process::Command::new("git")
.current_dir(dir)
.args(["config", "user.email", "test@test.com"])
.output()
.unwrap();
}
fn commit(dir: &Path, message: &str) -> String {
let ts = next_timestamp();
let filename = format!("file-{}.txt", &ts[..10]);
std::fs::write(dir.join(&filename), message).unwrap();
std::process::Command::new("git")
.current_dir(dir)
.args(["add", &filename])
.output()
.unwrap();
let output = std::process::Command::new("git")
.current_dir(dir)
.args(["commit", "-m", message])
.env("GIT_COMMITTER_DATE", &ts)
.env("GIT_AUTHOR_DATE", &ts)
.output()
.unwrap();
assert!(
output.status.success(),
"git commit failed: {}",
String::from_utf8_lossy(&output.stderr)
);
git(dir, &["rev-parse", "HEAD"]).unwrap()
}
fn commit_in_path(dir: &Path, subdir: &str, message: &str) -> String {
let ts = next_timestamp();
let full_dir = dir.join(subdir);
std::fs::create_dir_all(&full_dir).unwrap();
let filename = format!("{subdir}/file-{}.txt", &ts[..10]);
std::fs::write(dir.join(&filename), message).unwrap();
std::process::Command::new("git")
.current_dir(dir)
.args(["add", &filename])
.output()
.unwrap();
let output = std::process::Command::new("git")
.current_dir(dir)
.args(["commit", "-m", message])
.env("GIT_COMMITTER_DATE", &ts)
.env("GIT_AUTHOR_DATE", &ts)
.output()
.unwrap();
assert!(
output.status.success(),
"git commit failed: {}",
String::from_utf8_lossy(&output.stderr)
);
git(dir, &["rev-parse", "HEAD"]).unwrap()
}
#[test]
fn walk_commits_returns_topological_order() {
let dir = tempfile::tempdir().unwrap();
init_repo(dir.path());
commit(dir.path(), "chore: init");
let start = git(dir.path(), &["rev-parse", "HEAD"]).unwrap();
commit(dir.path(), "feat: A");
commit(dir.path(), "feat: B");
let head = git(dir.path(), &["rev-parse", "HEAD"]).unwrap();
let commits = walk_commits(dir.path(), &head, Some(&start)).unwrap();
assert_eq!(commits.len(), 2);
assert_eq!(commits[0].1, "feat: B");
assert_eq!(commits[1].1, "feat: A");
}
#[test]
fn walk_commits_for_path_filters_by_directory() {
let dir = tempfile::tempdir().unwrap();
init_repo(dir.path());
let base = commit(dir.path(), "chore: init");
commit_in_path(dir.path(), "crates/core", "feat: core feature");
commit_in_path(dir.path(), "crates/cli", "feat: cli feature");
commit_in_path(dir.path(), "crates/core", "fix: core fix");
let head = head_oid(dir.path()).unwrap();
let core_commits =
walk_commits_for_path(dir.path(), &head, Some(&base), &["crates/core"]).unwrap();
assert_eq!(core_commits.len(), 2);
assert_eq!(core_commits[0].1, "fix: core fix");
assert_eq!(core_commits[1].1, "feat: core feature");
let cli_commits =
walk_commits_for_path(dir.path(), &head, Some(&base), &["crates/cli"]).unwrap();
assert_eq!(cli_commits.len(), 1);
assert_eq!(cli_commits[0].1, "feat: cli feature");
}
#[test]
fn walk_commits_for_path_multi_package_commit_appears_in_both() {
let dir = tempfile::tempdir().unwrap();
init_repo(dir.path());
let base = commit(dir.path(), "chore: init");
let ts = next_timestamp();
std::fs::create_dir_all(dir.path().join("crates/core")).unwrap();
std::fs::create_dir_all(dir.path().join("crates/cli")).unwrap();
std::fs::write(dir.path().join("crates/core/shared.txt"), "shared").unwrap();
std::fs::write(dir.path().join("crates/cli/shared.txt"), "shared").unwrap();
std::process::Command::new("git")
.current_dir(dir.path())
.args(["add", "."])
.output()
.unwrap();
std::process::Command::new("git")
.current_dir(dir.path())
.args(["commit", "-m", "feat: shared change"])
.env("GIT_COMMITTER_DATE", &ts)
.env("GIT_AUTHOR_DATE", &ts)
.output()
.unwrap();
let head = head_oid(dir.path()).unwrap();
let core_commits =
walk_commits_for_path(dir.path(), &head, Some(&base), &["crates/core"]).unwrap();
let cli_commits =
walk_commits_for_path(dir.path(), &head, Some(&base), &["crates/cli"]).unwrap();
assert_eq!(core_commits.len(), 1);
assert_eq!(cli_commits.len(), 1);
assert_eq!(core_commits[0].1, "feat: shared change");
assert_eq!(cli_commits[0].1, "feat: shared change");
}
#[test]
fn walk_commits_for_path_empty_when_no_matching_commits() {
let dir = tempfile::tempdir().unwrap();
init_repo(dir.path());
let base = commit(dir.path(), "chore: init");
commit_in_path(dir.path(), "crates/core", "feat: core only");
let head = head_oid(dir.path()).unwrap();
let commits =
walk_commits_for_path(dir.path(), &head, Some(&base), &["crates/cli"]).unwrap();
assert!(commits.is_empty());
}
#[test]
fn walk_commits_for_path_without_until() {
let dir = tempfile::tempdir().unwrap();
init_repo(dir.path());
commit_in_path(dir.path(), "crates/core", "feat: core feature");
commit_in_path(dir.path(), "crates/cli", "feat: cli feature");
let head = head_oid(dir.path()).unwrap();
let core_commits =
walk_commits_for_path(dir.path(), &head, None, &["crates/core"]).unwrap();
assert_eq!(core_commits.len(), 1);
assert_eq!(core_commits[0].1, "feat: core feature");
}
#[test]
fn walk_commits_for_path_includes_merged_branch_commits() {
let dir = tempfile::tempdir().unwrap();
init_repo(dir.path());
let base = commit(dir.path(), "chore: init");
std::process::Command::new("git")
.current_dir(dir.path())
.args(["checkout", "-b", "feature"])
.output()
.unwrap();
let ts = next_timestamp();
std::fs::create_dir_all(dir.path().join("crates/core")).unwrap();
std::fs::write(dir.path().join("crates/core/f.txt"), "x").unwrap();
std::process::Command::new("git")
.current_dir(dir.path())
.args(["add", "crates/core/f.txt"])
.output()
.unwrap();
std::process::Command::new("git")
.current_dir(dir.path())
.args(["commit", "-m", "feat: core feature on branch"])
.env("GIT_COMMITTER_DATE", &ts)
.env("GIT_AUTHOR_DATE", &ts)
.output()
.unwrap();
std::process::Command::new("git")
.current_dir(dir.path())
.args(["checkout", "-"])
.output()
.unwrap();
let ts2 = next_timestamp();
std::process::Command::new("git")
.current_dir(dir.path())
.args([
"merge",
"--no-ff",
"feature",
"-m",
"Merge branch 'feature'",
])
.env("GIT_COMMITTER_DATE", &ts2)
.env("GIT_AUTHOR_DATE", &ts2)
.output()
.unwrap();
let head = head_oid(dir.path()).unwrap();
let commits =
walk_commits_for_path(dir.path(), &head, Some(&base), &["crates/core"]).unwrap();
assert_eq!(
commits.len(),
1,
"branch commit must be visible after merge"
);
assert_eq!(commits[0].1, "feat: core feature on branch");
}
}