use std::path::{Path, PathBuf};
use anyhow::{Context, bail};
use crate::shell_exec::Cmd;
use dunce::canonicalize;
use super::{GitError, LineDiff, Repository};
fn has_initialized_submodules_from_status(status: &str) -> bool {
status.lines().any(|line| match line.chars().next() {
Some('-') | None => false,
Some(_) => true,
})
}
pub fn path_to_logging_context(path: &Path) -> String {
if path.to_str() == Some(".") {
".".to_string()
} else {
path.file_name()
.and_then(|n| n.to_str())
.unwrap_or(".")
.to_string()
}
}
#[derive(Debug)]
#[must_use]
pub struct WorkingTree<'a> {
pub(super) repo: &'a Repository,
pub(super) path: PathBuf,
}
impl<'a> WorkingTree<'a> {
pub fn repo(&self) -> &Repository {
self.repo
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn run_command(&self, args: &[&str]) -> anyhow::Result<String> {
let output = self.run_command_output(args)?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stderr = stderr.replace('\r', "\n");
let stdout = String::from_utf8_lossy(&output.stdout);
let error_msg = [stderr.trim(), stdout.trim()]
.into_iter()
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("\n");
bail!("{}", error_msg);
}
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
Ok(stdout)
}
pub fn run_command_output(&self, args: &[&str]) -> anyhow::Result<std::process::Output> {
Cmd::new("git")
.args(args.iter().copied())
.current_dir(&self.path)
.context(path_to_logging_context(&self.path))
.run()
.with_context(|| format!("Failed to execute: git {}", args.join(" ")))
}
pub fn branch(&self) -> anyhow::Result<Option<String>> {
if let Some(cached) = self.repo.cache.current_branches.get(&self.path) {
return Ok(cached.clone());
}
let result = match self.run_command(&["rev-parse", "--symbolic-full-name", "HEAD"]) {
Ok(stdout) => stdout.trim().strip_prefix("refs/heads/").map(str::to_owned),
Err(_) => self
.run_command(&["symbolic-ref", "--short", "HEAD"])
.ok()
.map(|s| s.trim().to_owned()),
};
self.repo
.cache
.current_branches
.insert(self.path.clone(), result.clone());
Ok(result)
}
pub fn is_dirty(&self) -> anyhow::Result<bool> {
let stdout = self.run_command(&["status", "--porcelain"])?;
Ok(!stdout.trim().is_empty())
}
pub fn root(&self) -> anyhow::Result<PathBuf> {
Ok(self
.repo
.cache
.worktree_roots
.entry(self.path.clone())
.or_insert_with(|| {
self.run_command(&["rev-parse", "--show-toplevel"])
.ok()
.map(|s| PathBuf::from(s.trim()))
.and_then(|p| canonicalize(&p).ok())
.unwrap_or_else(|| self.path.clone())
})
.clone())
}
pub fn git_dir(&self) -> anyhow::Result<PathBuf> {
let stdout = self.run_command(&["rev-parse", "--git-dir"])?;
let path = PathBuf::from(stdout.trim());
let absolute_path = if path.is_relative() {
self.path.join(&path)
} else {
path
};
canonicalize(&absolute_path).context("Failed to resolve git directory")
}
pub fn is_rebasing(&self) -> anyhow::Result<bool> {
let git_dir = self.git_dir()?;
Ok(git_dir.join("rebase-merge").exists() || git_dir.join("rebase-apply").exists())
}
pub fn is_merging(&self) -> anyhow::Result<bool> {
let git_dir = self.git_dir()?;
Ok(git_dir.join("MERGE_HEAD").exists())
}
pub fn is_linked(&self) -> anyhow::Result<bool> {
let git_dir = self.git_dir()?;
let common_dir = self.repo.git_common_dir();
Ok(git_dir != common_dir)
}
pub fn ensure_clean(
&self,
action: &str,
branch: Option<&str>,
force_hint: bool,
) -> anyhow::Result<()> {
if self.is_dirty()? {
return Err(GitError::UncommittedChanges {
action: Some(action.into()),
branch: branch.map(String::from),
force_hint,
}
.into());
}
Ok(())
}
pub fn working_tree_diff_stats(&self) -> anyhow::Result<LineDiff> {
let stdout = self.run_command(&["diff", "--shortstat", "HEAD"])?;
Ok(LineDiff::from_shortstat(&stdout))
}
pub fn has_staged_changes(&self) -> anyhow::Result<bool> {
Ok(self
.run_command(&["diff", "--cached", "--quiet", "--exit-code"])
.is_err())
}
pub fn has_initialized_submodules(&self) -> anyhow::Result<bool> {
let output = self.run_command(&["submodule", "status", "--recursive"])?;
Ok(has_initialized_submodules_from_status(&output))
}
pub fn create_safety_backup(&self, message: &str) -> anyhow::Result<String> {
let backup_sha = self
.run_command(&["stash", "create", "--include-untracked"])?
.trim()
.to_string();
if backup_sha.is_empty() {
return Err(GitError::Other {
message: "git stash create returned empty SHA - no changes to backup".into(),
}
.into());
}
let stdout = self.run_command(&["rev-parse", "--symbolic-full-name", "HEAD"])?;
let branch = stdout
.trim()
.strip_prefix("refs/heads/")
.unwrap_or("HEAD")
.to_string();
let safe_branch = branch.replace('/', "-");
let ref_name = format!("refs/wt-backup/{}", safe_branch);
self.run_command(&[
"update-ref",
"--create-reflog",
"-m",
message,
&ref_name,
&backup_sha,
])
.context("Failed to create backup ref")?;
Ok(backup_sha[..7].to_string())
}
}
#[cfg(test)]
mod tests {
use super::has_initialized_submodules_from_status;
#[test]
fn submodule_status_empty_is_not_initialized() {
assert!(!has_initialized_submodules_from_status(""));
}
#[test]
fn submodule_status_dash_is_not_initialized() {
assert!(!has_initialized_submodules_from_status(
"-9c8b8ff2fe89b8f1c5b8e17cb60f0d0df47f71e0 submod"
));
}
#[test]
fn submodule_status_space_is_initialized() {
assert!(has_initialized_submodules_from_status(
" 9c8b8ff2fe89b8f1c5b8e17cb60f0d0df47f71e0 submod (heads/main)"
));
}
#[test]
fn submodule_status_plus_is_initialized() {
assert!(has_initialized_submodules_from_status(
"+9c8b8ff2fe89b8f1c5b8e17cb60f0d0df47f71e0 submod (heads/main)"
));
}
}