use std::{
collections::{BTreeMap, BTreeSet},
io::Write,
path::{Path, PathBuf},
process::{Command, Stdio},
};
use sha2::{Digest, Sha256};
use crate::backend::{
error::{Error, Result},
extensions::gitoxide,
};
pub fn get_cwd_git_dir() -> Result<String> {
let cwd_raw = std::env::current_dir()
.map_err(|err| Error::Invalid(format!("failed to read current directory: {err}")))?;
let cwd = std::fs::canonicalize(&cwd_raw).map_err(|err| {
Error::Invalid(format!(
"failed to canonicalize current directory '{}': {err}",
cwd_raw.display()
))
})?;
let ceilings: Vec<PathBuf> = std::env::var_os("GIT_CEILING_DIRECTORIES")
.map(|raw| {
std::env::split_paths(&raw)
.map(|p| std::fs::canonicalize(&p).unwrap_or(p))
.collect()
})
.unwrap_or_default();
let mut cursor = Some(cwd.as_path());
let mut found: Option<PathBuf> = None;
while let Some(dir) = cursor {
if dir != cwd && ceilings.iter().any(|c| c == dir) {
break;
}
if dir.file_name().is_some_and(|name| name == ".git") || dir.join(".git").exists() {
found = Some(dir.to_path_buf());
break;
}
if ceilings.iter().any(|c| c == dir) {
break;
}
cursor = dir.parent();
}
let Some(repo_root) = found else {
return Err(Error::Invalid(format!(
"not a git repository (or any of the parent directories): '{}'",
cwd.display()
)));
};
let (_, _, normalized_git_dir) = resolve_git_paths(&repo_root.to_string_lossy())?;
Ok(normalized_git_dir)
}
pub fn resolve_git_paths(git_dir_or_worktree: &str) -> Result<(PathBuf, PathBuf, String)> {
let trimmed = git_dir_or_worktree.trim();
if trimmed.is_empty() {
return Err(Error::Invalid("git_dir must not be empty".to_owned()));
}
let input_path = Path::new(trimmed);
let canonical = std::fs::canonicalize(input_path).map_err(|err| {
Error::Invalid(format!(
"failed to resolve git_dir '{}': {err}",
input_path.display()
))
})?;
let (worktree_git_dir, worktree_dir) =
if canonical.file_name().is_some_and(|name| name == ".git") {
let worktree = canonical
.parent()
.ok_or_else(|| Error::Invalid("git_dir has no parent worktree".to_owned()))?
.to_path_buf();
let resolved_git = if canonical.is_file() {
resolve_gitdir_file(&canonical)?
} else {
canonical
};
(resolved_git, worktree)
} else if canonical.join(".git").exists() {
let dot_git = canonical.join(".git");
let resolved_git = if dot_git.is_file() {
resolve_gitdir_file(&dot_git)?
} else {
dot_git
};
(resolved_git, canonical)
} else if canonical.join("gitdir").is_file() && canonical.join("commondir").is_file() {
let wt_git_file = std::fs::read_to_string(canonical.join("gitdir")).map_err(|err| {
Error::Invalid(format!(
"failed to read worktree gitdir '{}': {err}",
canonical.display()
))
})?;
let wt_dot_git = Path::new(wt_git_file.trim());
let wt_dir = wt_dot_git
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| canonical.clone());
(canonical, wt_dir)
} else {
(canonical.clone(), canonical)
};
let common_git_dir = resolve_common_git_dir(&worktree_git_dir)?;
let normalized_git_dir = common_git_dir.to_string_lossy().into_owned();
Ok((worktree_git_dir, worktree_dir, normalized_git_dir))
}
pub fn compute_repo_slug(git_dir: &str) -> Result<String> {
let (_, worktree_dir, normalized_git_dir) = resolve_git_paths(git_dir)?;
let common_path = Path::new(&normalized_git_dir);
let repo_root = if common_path.file_name().is_some_and(|name| name == ".git") {
common_path.parent().unwrap_or(&worktree_dir)
} else {
common_path
};
let repo_name = repo_root
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("repo")
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
ch
} else {
'-'
}
})
.collect::<String>();
let mut hasher = Sha256::new();
hasher.update(normalized_git_dir.as_bytes());
let digest = hasher.finalize();
let short_hash = digest[..6]
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>();
Ok(format!("{repo_name}-{short_hash}"))
}
pub fn stage_paths(git_dir_or_worktree: &str, paths: &[String]) -> Result<Vec<String>> {
if paths.is_empty() {
return Err(Error::Invalid("at least one path is required".to_owned()));
}
let (_, worktree_dir, _) = resolve_git_paths(git_dir_or_worktree)?;
let expanded = gitoxide::collect_worktree_paths(&worktree_dir, paths)?;
let output = Command::new("git")
.arg("-C")
.arg(&worktree_dir)
.arg("add")
.arg("--")
.args(&expanded)
.output()
.map_err(|err| Error::Git(format!("failed to invoke git add: {err}")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(Error::Git(format!("git add failed: {}", stderr.trim())));
}
Ok(expanded)
}
pub fn create_commit(git_dir_or_worktree: &str, message: Option<&str>) -> Result<(String, String)> {
let ctx = gitoxide::get_repo_context(git_dir_or_worktree)?;
let (_, worktree_dir, _) = resolve_git_paths(git_dir_or_worktree)?;
let staged_check = Command::new("git")
.arg("-C")
.arg(&worktree_dir)
.args(["diff", "--cached", "--quiet"])
.status()
.map_err(|err| Error::Git(format!("failed to check staged index: {err}")))?;
if staged_check.success() {
let add_all = Command::new("git")
.arg("-C")
.arg(&worktree_dir)
.args(["add", "-A"])
.output()
.map_err(|err| Error::Git(format!("failed to stage worktree files: {err}")))?;
if !add_all.status.success() {
let stderr = String::from_utf8_lossy(&add_all.stderr);
return Err(Error::Git(format!("git add -A failed: {}", stderr.trim())));
}
}
let commit_message = message
.map(str::trim)
.filter(|msg| !msg.is_empty())
.unwrap_or("Update working branch");
let mut cmd = Command::new("git");
cmd.arg("-C")
.arg(&worktree_dir)
.args(["-c", &format!("user.name={}", ctx.user_name)])
.args(["-c", &format!("user.email={}", ctx.user_email)])
.args(["commit", "--allow-empty", "--cleanup=verbatim", "-F", "-"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if std::env::var("LGTM_GITHUB_TEST_MODE").as_deref() == Ok("1") {
let parent_ts = Command::new("git")
.arg("-C")
.arg(&worktree_dir)
.args(["log", "-1", "--format=%ct"])
.output()
.ok()
.and_then(|out| String::from_utf8(out.stdout).ok())
.and_then(|s| s.trim().parse::<i64>().ok())
.unwrap_or(1_700_000_000);
let deterministic_date = format!("@{} +0000", parent_ts + 1);
cmd.env("GIT_AUTHOR_DATE", &deterministic_date)
.env("GIT_COMMITTER_DATE", &deterministic_date);
}
let mut child = cmd
.spawn()
.map_err(|err| Error::Git(format!("failed to invoke git commit: {err}")))?;
if let Some(mut stdin) = child.stdin.take() {
stdin
.write_all(commit_message.as_bytes())
.map_err(|err| Error::Git(format!("failed to write commit message: {err}")))?;
}
let output = child
.wait_with_output()
.map_err(|err| Error::Git(format!("failed to wait on git commit: {err}")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(Error::Git(format!("git commit failed: {}", stderr.trim())));
}
let rev_parse = Command::new("git")
.arg("-C")
.arg(&worktree_dir)
.args(["rev-parse", "HEAD"])
.output()
.map_err(|err| Error::Git(format!("failed to resolve HEAD after commit: {err}")))?;
let sha = String::from_utf8_lossy(&rev_parse.stdout).trim().to_owned();
Ok((sha, ctx.branch))
}
pub fn push_branch(git_dir_or_worktree: &str, branch: &str) -> Result<(String, String)> {
let ctx = gitoxide::get_repo_context(git_dir_or_worktree)?;
let repository_url = ctx.repository_url.ok_or_else(|| {
Error::Invalid("working repository has no 'origin' remote configured".to_owned())
})?;
let (_, worktree_dir, _) = resolve_git_paths(git_dir_or_worktree)?;
let output = Command::new("git")
.arg("-C")
.arg(&worktree_dir)
.arg("-c")
.arg("credential.helper=!gh auth git-credential")
.arg("push")
.arg("origin")
.arg(branch)
.output()
.map_err(|err| Error::Git(format!("failed to invoke git push: {err}")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(Error::Git(format!(
"git push origin {branch} failed: {}",
stderr.trim()
)));
}
Ok((ctx.head_sha, repository_url))
}
pub fn is_local_remote(git_dir_or_worktree: &str) -> Result<bool> {
let (_, worktree_dir, _) = resolve_git_paths(git_dir_or_worktree)?;
let output = Command::new("git")
.arg("-C")
.arg(&worktree_dir)
.args(["config", "--get", "remote.origin.url"])
.output()
.map_err(|err| Error::Git(format!("failed to read remote.origin.url: {err}")))?;
if !output.status.success() {
return Ok(false);
}
let remote_raw = String::from_utf8_lossy(&output.stdout).trim().to_owned();
let clean = remote_raw.strip_prefix("file://").unwrap_or(&remote_raw);
Ok(Path::new(clean).exists())
}
pub fn set_reference(git_dir_or_worktree: &str, ref_name: &str, commit_sha: &str) -> Result<()> {
let (_, worktree_dir, _) = resolve_git_paths(git_dir_or_worktree)?;
let output = Command::new("git")
.arg("-C")
.arg(&worktree_dir)
.args(["update-ref", ref_name, commit_sha.trim()])
.output()
.map_err(|err| Error::Git(format!("failed to invoke git update-ref: {err}")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(Error::Git(format!(
"git update-ref {ref_name} failed: {}",
stderr.trim()
)));
}
Ok(())
}
pub fn get_changed_files(
git_dir_or_worktree: &str,
old_sha: &str,
new_sha: &str,
) -> Result<Vec<String>> {
if old_sha.trim() == new_sha.trim() {
return Ok(Vec::new());
}
let (_, worktree_dir, _) = resolve_git_paths(git_dir_or_worktree)?;
let output = Command::new("git")
.arg("-C")
.arg(&worktree_dir)
.args(["diff", "--name-only", old_sha.trim(), new_sha.trim()])
.output()
.map_err(|err| Error::Git(format!("failed to invoke git diff --name-only: {err}")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(Error::Git(format!(
"git diff --name-only failed: {}",
stderr.trim()
)));
}
let mut files: BTreeSet<String> = BTreeSet::new();
for line in String::from_utf8_lossy(&output.stdout).lines() {
let trimmed = line.trim();
if !trimmed.is_empty() {
files.insert(trimmed.to_owned());
}
}
Ok(files.into_iter().collect())
}
pub fn get_file_at_commit(
git_dir_or_worktree: &str,
commit_sha: &str,
file_path: &str,
) -> Result<Option<String>> {
let file_path = file_path.trim();
if file_path.is_empty() {
return Err(Error::Invalid("file_path must not be empty".to_owned()));
}
let (_, worktree_dir, _) = resolve_git_paths(git_dir_or_worktree)?;
let spec = format!("{}:{file_path}", commit_sha.trim());
let output = Command::new("git")
.arg("-C")
.arg(&worktree_dir)
.args(["show", &spec])
.output()
.map_err(|err| Error::Git(format!("failed to invoke git show {spec}: {err}")))?;
if !output.status.success() {
return Ok(None);
}
Ok(Some(String::from_utf8_lossy(&output.stdout).into_owned()))
}
pub fn get_commit_blobs_for_paths(
git_dir_or_worktree: &str,
commit_sha: &str,
paths: &BTreeSet<String>,
) -> Result<BTreeMap<String, String>> {
let mut blobs = BTreeMap::new();
for path in paths {
if let Some(content) = get_file_at_commit(git_dir_or_worktree, commit_sha, path)? {
blobs.insert(path.clone(), content);
}
}
Ok(blobs)
}
fn resolve_common_git_dir(worktree_git_dir: &Path) -> Result<PathBuf> {
let commondir_file = worktree_git_dir.join("commondir");
if commondir_file.is_file() {
let raw = std::fs::read_to_string(&commondir_file).map_err(|err| {
Error::Invalid(format!(
"failed to read commondir file '{}': {err}",
commondir_file.display()
))
})?;
let rel = raw.trim();
let target = if Path::new(rel).is_absolute() {
PathBuf::from(rel)
} else {
worktree_git_dir.join(rel)
};
return std::fs::canonicalize(&target).map_err(|err| {
Error::Invalid(format!(
"failed to canonicalize commondir target '{}': {err}",
target.display()
))
});
}
Ok(worktree_git_dir.to_path_buf())
}
fn resolve_gitdir_file(dot_git_file: &Path) -> Result<PathBuf> {
let contents = std::fs::read_to_string(dot_git_file).map_err(|err| {
Error::Invalid(format!(
"failed to read .git file '{}': {err}",
dot_git_file.display()
))
})?;
let raw_target = contents
.trim()
.strip_prefix("gitdir:")
.ok_or_else(|| Error::Invalid(format!("malformed .git file '{}'", dot_git_file.display())))?
.trim();
let target_path = Path::new(raw_target);
let resolved = if target_path.is_absolute() {
target_path.to_path_buf()
} else {
dot_git_file
.parent()
.unwrap_or_else(|| Path::new("."))
.join(target_path)
};
std::fs::canonicalize(&resolved).map_err(|err| {
Error::Invalid(format!(
"failed to canonicalize gitdir target '{}': {err}",
resolved.display()
))
})
}