use std::path::{Path, PathBuf};
use anyhow::{Context, bail};
use dashmap::mapref::entry::Entry;
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,
})
}
#[derive(Debug, Clone, Default)]
#[must_use]
pub struct WorkingTreeGitInfo {
pub is_inside: bool,
pub root: Option<PathBuf>,
pub git_dir: Option<PathBuf>,
pub current_branch: Option<Option<String>>,
pub head_sha: Option<String>,
}
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 prewarm_info(&self) -> anyhow::Result<WorkingTreeGitInfo> {
if let Some(is_inside) = self
.repo
.cache
.prewarm_is_inside
.get(&self.path)
.map(|e| *e)
{
if !is_inside {
return Ok(WorkingTreeGitInfo::default());
}
return Ok(WorkingTreeGitInfo {
is_inside: true,
root: self
.repo
.cache
.worktree_roots
.get(&self.path)
.map(|e| e.clone()),
git_dir: self.repo.cache.git_dirs.get(&self.path).map(|e| e.clone()),
current_branch: self
.repo
.cache
.current_branches
.get(&self.path)
.map(|e| e.clone()),
head_sha: self
.repo
.cache
.head_shas
.get(&self.path)
.and_then(|e| e.clone()),
});
}
let output = self.run_command_output(&[
"rev-parse",
"--is-inside-work-tree",
"--show-toplevel",
"--git-dir",
"HEAD",
"--symbolic-full-name",
"HEAD",
])?;
let stdout = String::from_utf8_lossy(&output.stdout);
let mut lines = stdout.lines();
let is_inside = lines.next().is_some_and(|s| s.trim() == "true");
if !is_inside {
self.repo
.cache
.prewarm_is_inside
.entry(self.path.clone())
.or_insert(false);
return Ok(WorkingTreeGitInfo::default());
}
let root = lines.next().map(|raw| {
let root = PathBuf::from(raw.trim());
let root = canonicalize(&root).unwrap_or_else(|_| self.path.clone());
self.repo
.cache
.worktree_roots
.entry(self.path.clone())
.or_insert_with(|| root.clone());
root
});
let git_dir = lines.next().and_then(|raw| {
let path = PathBuf::from(raw.trim());
let absolute = if path.is_relative() {
self.path.join(&path)
} else {
path
};
let resolved = canonicalize(&absolute).ok()?;
self.repo
.cache
.git_dirs
.entry(self.path.clone())
.or_insert_with(|| resolved.clone());
Some(resolved)
});
let (head_sha, current_branch) = if output.status.success() {
let sha = lines.next().and_then(|raw| {
let sha = (!raw.trim().is_empty()).then(|| raw.trim().to_owned());
self.repo
.cache
.head_shas
.entry(self.path.clone())
.or_insert_with(|| sha.clone());
sha
});
let branch = lines.next().map(|raw| {
let branch = raw.trim().strip_prefix("refs/heads/").map(str::to_owned);
self.repo
.cache
.current_branches
.entry(self.path.clone())
.or_insert_with(|| branch.clone());
branch
});
(sha, branch)
} else {
(None, None)
};
self.repo
.cache
.prewarm_is_inside
.entry(self.path.clone())
.or_insert(true);
Ok(WorkingTreeGitInfo {
is_inside: true,
root,
git_dir,
current_branch,
head_sha,
})
}
pub fn branch(&self) -> anyhow::Result<Option<String>> {
match self.repo.cache.current_branches.entry(self.path.clone()) {
Entry::Occupied(e) => Ok(e.get().clone()),
Entry::Vacant(e) => {
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()),
};
Ok(e.insert(result).clone())
}
}
}
pub fn head_sha(&self) -> anyhow::Result<Option<String>> {
match self.repo.cache.head_shas.entry(self.path.clone()) {
Entry::Occupied(e) => Ok(e.get().clone()),
Entry::Vacant(e) => {
let sha = self
.run_command(&["rev-parse", "HEAD"])
.ok()
.map(|s| s.trim().to_owned())
.filter(|s| !s.is_empty());
Ok(e.insert(sha).clone())
}
}
}
pub fn status_porcelain_cached(&self) -> anyhow::Result<String> {
match self.repo.cache.status_porcelain.entry(self.path.clone()) {
Entry::Occupied(e) => Ok(e.get().clone()),
Entry::Vacant(e) => {
let stdout = self.run_command(&["--no-optional-locks", "status", "--porcelain"])?;
Ok(e.insert(stdout).clone())
}
}
}
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> {
match self.repo.cache.worktree_roots.entry(self.path.clone()) {
Entry::Occupied(e) => Ok(e.get().clone()),
Entry::Vacant(e) => {
let root = 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());
Ok(e.insert(root).clone())
}
}
}
pub fn git_dir(&self) -> anyhow::Result<PathBuf> {
match self.repo.cache.git_dirs.entry(self.path.clone()) {
Entry::Occupied(e) => Ok(e.get().clone()),
Entry::Vacant(e) => {
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
};
let resolved =
canonicalize(&absolute_path).context("Failed to resolve git directory")?;
Ok(e.insert(resolved).clone())
}
}
}
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;
use crate::git::Repository;
use crate::testing::TestRepo;
#[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)"
));
}
#[test]
fn prewarm_info_populates_every_field_on_a_branch() {
let test = TestRepo::with_initial_commit();
let repo = Repository::at(test.root_path()).unwrap();
let wt = repo.worktree_at(test.root_path());
let info = wt.prewarm_info().unwrap();
let head_sha = wt.head_sha().unwrap().expect("HEAD resolved after commit");
assert!(info.is_inside);
assert_eq!(info.root.as_deref(), Some(wt.root().unwrap().as_path()));
assert_eq!(
info.git_dir.as_deref(),
Some(wt.git_dir().unwrap().as_path())
);
assert_eq!(info.current_branch, Some(Some("main".to_string())));
assert_eq!(info.head_sha.as_deref(), Some(head_sha.as_str()));
assert_eq!(wt.head_sha().unwrap().as_deref(), Some(head_sha.as_str()));
}
#[test]
fn prewarm_info_second_call_returns_cached_snapshot() {
let test = TestRepo::with_initial_commit();
let repo = Repository::at(test.root_path()).unwrap();
let wt = repo.worktree_at(test.root_path());
let first = wt.prewarm_info().unwrap();
let sentinel_root = std::path::PathBuf::from("/nonexistent/sentinel");
repo.cache
.worktree_roots
.insert(wt.path().to_path_buf(), sentinel_root.clone());
let second = wt.prewarm_info().unwrap();
assert_eq!(second.root.as_deref(), Some(sentinel_root.as_path()));
assert_eq!(second.git_dir, first.git_dir);
assert_eq!(second.current_branch, first.current_branch);
assert_eq!(second.head_sha, first.head_sha);
}
#[test]
fn prewarm_info_short_circuit_ignores_stale_root_cache_outside_work_tree() {
let tmp = tempfile::tempdir().unwrap();
let outside = tmp.path().to_path_buf();
let test = TestRepo::with_initial_commit();
let repo = Repository::at(test.root_path()).unwrap();
repo.cache
.worktree_roots
.insert(outside.clone(), outside.clone());
let wt = repo.worktree_at(&outside);
let info = wt.prewarm_info().unwrap();
assert!(
!info.is_inside,
"batch must run and set is_inside=false regardless of stale root cache"
);
assert!(info.root.is_none());
}
#[test]
fn prewarm_info_leaves_head_fields_unresolved_on_unborn_branch() {
let test = TestRepo::new();
let repo = Repository::at(test.root_path()).unwrap();
let wt = repo.worktree_at(test.root_path());
let info = wt.prewarm_info().unwrap();
assert!(info.is_inside);
assert!(info.root.is_some(), "toplevel lands even on unborn HEAD");
assert!(info.git_dir.is_some(), "git-dir lands even on unborn HEAD");
assert!(
info.current_branch.is_none(),
"batch failed, branch cache left to `symbolic-ref` fallback"
);
assert!(info.head_sha.is_none(), "no commits yet => no SHA");
assert_eq!(wt.branch().unwrap().as_deref(), Some("main"));
assert!(wt.head_sha().unwrap().is_none());
}
}