use std::path::{Path, PathBuf};
use std::process::Command;
use klasp_core::CLAUDE_PROJECT_DIR_ENV;
pub fn find_repo_root_from_cwd() -> Option<PathBuf> {
if let Ok(dir) = std::env::var(CLAUDE_PROJECT_DIR_ENV) {
let candidate = PathBuf::from(dir);
if candidate.is_dir() {
return Some(candidate);
}
}
if let Ok(output) = Command::new("git")
.args(["rev-parse", "--show-toplevel"])
.output()
{
if output.status.success() {
let raw = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !raw.is_empty() {
let candidate = PathBuf::from(raw);
if candidate.is_dir() {
return Some(candidate);
}
}
}
}
std::env::current_dir().ok()
}
pub fn compute_base_ref(cwd: &Path) -> String {
const CANDIDATES: &[&str] = &["@{upstream}", "origin/main", "origin/master"];
for candidate in CANDIDATES {
if let Some(sha) = git_merge_base(cwd, candidate, "HEAD") {
return sha;
}
}
"HEAD~1".to_string()
}
fn git_merge_base(cwd: &Path, a: &str, b: &str) -> Option<String> {
let output = Command::new("git")
.args(["merge-base", a, b])
.current_dir(cwd)
.output()
.ok()?;
if !output.status.success() {
return None;
}
let raw = String::from_utf8_lossy(&output.stdout).trim().to_string();
if raw.is_empty() {
None
} else {
Some(raw)
}
}
pub fn staged_files(repo_root: &Path) -> Vec<PathBuf> {
let output = match Command::new("git")
.args([
"-c",
"core.quotePath=false",
"diff",
"--staged",
"-z",
"--name-only",
"--diff-filter=ACMRT",
])
.current_dir(repo_root)
.output()
{
Ok(o) => o,
Err(_) => return Vec::new(),
};
if !output.status.success() {
return Vec::new();
}
output
.stdout
.split(|&b| b == 0)
.filter(|chunk| !chunk.is_empty())
.map(|chunk| repo_root.join(String::from_utf8_lossy(chunk).as_ref()))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use std::process::Command;
use tempfile::TempDir;
fn init_repo_with_commits(dir: &Path, commits: usize) {
run(dir, &["init", "--initial-branch=main"]);
run(dir, &["config", "user.email", "klasp-test@example.com"]);
run(dir, &["config", "user.name", "klasp-test"]);
run(dir, &["config", "commit.gpgsign", "false"]);
for i in 0..commits {
std::fs::write(dir.join(format!("f{i}.txt")), format!("commit {i}"))
.expect("write fixture file");
run(dir, &["add", "."]);
run(dir, &["commit", "-m", &format!("c{i}")]);
}
}
fn run(cwd: &Path, args: &[&str]) {
let status = Command::new("git")
.args(args)
.current_dir(cwd)
.status()
.expect("spawn git");
assert!(status.success(), "git {args:?} failed");
}
#[test]
fn compute_base_ref_falls_back_to_head_tilde_one_without_remote() {
let tmp = TempDir::new().unwrap();
init_repo_with_commits(tmp.path(), 2);
assert_eq!(compute_base_ref(tmp.path()), "HEAD~1");
}
#[test]
fn staged_files_returns_verbatim_path_for_non_ascii_filename() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path();
init_repo_with_commits(repo, 0);
let filename = "café.ts";
std::fs::write(repo.join(filename), b"export {}").expect("write non-ASCII file");
run(repo, &["add", filename]);
let files = staged_files(repo);
assert_eq!(files.len(), 1, "expected exactly one staged file");
let got = &files[0];
let expected = repo.join(filename);
assert_eq!(
got, &expected,
"staged_files() must return the real path, not a quoted/escaped one"
);
assert!(
got.exists(),
"returned path must exist on disk: {}",
got.display()
);
}
#[test]
fn compute_base_ref_uses_upstream_when_available() {
let upstream_tmp = TempDir::new().unwrap();
let local_tmp = TempDir::new().unwrap();
init_repo_with_commits(upstream_tmp.path(), 1);
run(
local_tmp.path(),
&[
"clone",
upstream_tmp.path().to_str().unwrap(),
local_tmp.path().to_str().unwrap(),
],
);
run(
local_tmp.path(),
&["config", "user.email", "klasp-test@example.com"],
);
run(local_tmp.path(), &["config", "user.name", "klasp-test"]);
run(local_tmp.path(), &["config", "commit.gpgsign", "false"]);
let expected = String::from_utf8_lossy(
&Command::new("git")
.args(["rev-parse", "origin/main"])
.current_dir(local_tmp.path())
.output()
.expect("rev-parse origin/main")
.stdout,
)
.trim()
.to_string();
std::fs::write(local_tmp.path().join("local.txt"), "local").unwrap();
run(local_tmp.path(), &["add", "."]);
run(local_tmp.path(), &["commit", "-m", "local divergence"]);
let got = compute_base_ref(local_tmp.path());
assert_eq!(got, expected, "merge-base @{{u}} should match origin/main");
}
}