use std::collections::HashMap;
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::sync::{LazyLock, Mutex, PoisonError};
use anyhow::{Context as _, bail};
use crate::hidden_git;
static FOLDERS: LazyLock<Mutex<HashMap<PathBuf, PathBuf>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
pub(crate) fn know_folder(folder: &Path, hidden: &Path) {
FOLDERS
.lock()
.unwrap_or_else(PoisonError::into_inner)
.insert(folder.to_owned(), hidden.to_owned());
}
fn hidden_dir_of(folder: &Path) -> Option<PathBuf> {
FOLDERS
.lock()
.unwrap_or_else(PoisonError::into_inner)
.get(folder)
.cloned()
}
pub(crate) fn command(dir: &Path) -> Command {
let mut command = Command::new("git");
command.arg("-C").arg(dir);
let Some(hidden) = hidden_dir_of(dir).filter(|_| !dir.join(".git").exists()) else {
return command;
};
if hidden_git::is_on(&hidden) {
command
.arg(flag_with_path("--git-dir=", &hidden))
.arg(flag_with_path("--work-tree=", dir));
} else if let Some(parent) = dir.parent() {
command.env("GIT_CEILING_DIRECTORIES", parent);
}
command
}
fn flag_with_path(flag: &str, path: &Path) -> OsString {
let mut arg = OsString::from(flag);
arg.push(path);
arg
}
fn run(repo: &Path, args: &[&str]) -> anyhow::Result<Output> {
command(repo)
.args(args)
.output()
.with_context(|| format!("running git {}", args.join(" ")))
}
pub(crate) fn git(repo: &Path, args: &[&str]) -> anyhow::Result<String> {
let output = run(repo, args)?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("git {} failed: {}", args.join(" "), stderr.trim());
}
let mut text = String::from_utf8_lossy(&output.stdout).into_owned();
if text.ends_with('\n') {
text.pop();
}
Ok(text)
}
pub(crate) fn git_ok(repo: &Path, args: &[&str]) -> anyhow::Result<bool> {
Ok(run(repo, args)?.status.success())
}
pub(crate) fn has_git(dir: &Path) -> bool {
git_ok(dir, &["rev-parse", "--git-dir"]).unwrap_or(false)
}
pub(crate) fn toplevel(dir: &Path) -> anyhow::Result<PathBuf> {
let root = git(dir, &["rev-parse", "--show-toplevel"])
.with_context(|| format!("{} isn't inside a git repository", dir.display()))?;
Ok(PathBuf::from(root))
}
pub(crate) fn project_root(dir: &Path) -> PathBuf {
match toplevel(dir) {
Ok(top) if !git_ok(dir, &["check-ignore", "--quiet", "."]).unwrap_or(false) => top,
_ => dir.to_owned(),
}
}
pub(crate) fn has_commits(repo: &Path) -> anyhow::Result<bool> {
git_ok(repo, &["rev-parse", "--verify", "--quiet", "HEAD^{commit}"])
}
pub(crate) fn head(repo: &Path) -> anyhow::Result<String> {
git(repo, &["rev-parse", "HEAD"])
}
pub(crate) fn uncommitted_paths(repo: &Path) -> anyhow::Result<Vec<String>> {
let output = git(repo, &["status", "--porcelain=v1", "-z"])?;
let mut paths = Vec::new();
let mut fields = output.split('\0').filter(|field| !field.is_empty());
while let Some(entry) = fields.next() {
let Some(path) = entry.get(3..) else {
continue;
};
paths.push(path.to_owned());
if matches!(entry.as_bytes().first(), Some(b'R' | b'C')) {
fields.next();
}
}
Ok(paths)
}
pub(crate) fn tracks(repo: &Path, path: &Path) -> anyhow::Result<bool> {
let path = path.to_string_lossy();
git_ok(repo, &["ls-files", "--error-unmatch", "--", &path])
}
pub(crate) fn copy_index(from: &Path, to: &Path) -> std::io::Result<()> {
let modified = std::fs::metadata(from)?.modified()?;
std::fs::copy(from, to)?;
std::fs::File::options()
.write(true)
.open(to)?
.set_modified(modified)
}
#[cfg(test)]
mod tests {
use super::*;
use argonauts_testkit::TempRepo;
#[test]
fn a_copied_index_still_sees_a_change_made_in_its_own_tick() {
let repo = TempRepo::new().unwrap();
let file = repo.root().join("app.py");
let yours = repo.root().join(".git/index");
let tick = std::time::SystemTime::now() - std::time::Duration::from_secs(60);
let date = |path: &Path| {
let opened = std::fs::File::options().write(true).open(path).unwrap();
opened.set_modified(tick).unwrap();
};
repo.write("app.py", "print(1)\n").unwrap();
date(&file);
repo.commit_all("app").unwrap();
date(&yours);
repo.write("app.py", "print(2)\n").unwrap();
date(&file);
let copy = repo.scratch().join("index-copy");
copy_index(&yours, ©).unwrap();
let changed = command(repo.root())
.args(["-c", "core.trustctime=false", "diff-files", "--name-only"])
.env("GIT_INDEX_FILE", ©)
.output()
.unwrap();
assert_eq!(String::from_utf8_lossy(&changed.stdout), "app.py\n");
}
#[test]
fn lists_uncommitted_paths_and_tracked_files() {
let repo = TempRepo::new().unwrap();
repo.write("notes.txt", "draft\n").unwrap();
let paths = uncommitted_paths(repo.root()).unwrap();
assert_eq!(paths, vec!["notes.txt".to_owned()]);
assert!(!tracks(repo.root(), Path::new("notes.txt")).unwrap());
repo.commit_all("notes").unwrap();
assert!(tracks(repo.root(), Path::new("notes.txt")).unwrap());
assert_eq!(head(repo.root()).unwrap(), repo.head().unwrap());
}
}