use std::path::{Path, PathBuf};
use anyhow::Context;
use dashmap::mapref::entry::Entry;
use crate::shell_exec::Cmd;
use dunce::canonicalize;
use super::{GitError, LineDiff, Repository};
use crate::git::CommandError;
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 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() {
return Err(CommandError::from_failed_output("git", args, &output).into());
}
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(root) = super::WORKTREE_ROOTS.get(&self.path).map(|e| e.clone()) {
return Ok(WorkingTreeGitInfo {
is_inside: true,
root: Some(root),
git_dir: super::GIT_DIRS.get(&self.path).map(|e| e.clone()),
current_branch: super::CURRENT_BRANCHES.get(&self.path).map(|e| e.clone()),
});
}
let output = self.run_command_output(&[
"rev-parse",
"--is-inside-work-tree",
"--show-toplevel",
"--git-dir",
"--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 {
return Ok(WorkingTreeGitInfo::default());
}
let raw_toplevel = lines.next().unwrap_or("").trim();
let canonical = canonicalize(PathBuf::from(raw_toplevel)).unwrap_or(self.path.clone());
super::WORKTREE_ROOTS
.entry(self.path.clone())
.or_insert_with(|| canonical.clone());
let root = Some(canonical);
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()?;
super::GIT_DIRS
.entry(self.path.clone())
.or_insert_with(|| resolved.clone());
Some(resolved)
});
let current_branch = if output.status.success() {
lines.next().map(|raw| {
let branch = raw.trim().strip_prefix("refs/heads/").map(str::to_owned);
super::CURRENT_BRANCHES
.entry(self.path.clone())
.or_insert_with(|| branch.clone());
branch
})
} else {
None
};
Ok(WorkingTreeGitInfo {
is_inside: true,
root,
git_dir,
current_branch,
})
}
pub fn branch(&self) -> anyhow::Result<Option<String>> {
match super::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>> {
Ok(self
.run_command(&["rev-parse", "HEAD"])
.ok()
.map(|s| s.trim().to_owned())
.filter(|s| !s.is_empty()))
}
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 dirty_files(&self) -> anyhow::Result<Vec<String>> {
let stdout = self.run_command(&["status", "--porcelain"])?;
Ok(stdout.lines().map(str::to_owned).collect())
}
pub fn root(&self) -> anyhow::Result<PathBuf> {
match super::WORKTREE_ROOTS.entry(self.path.clone()) {
Entry::Occupied(e) => Ok(e.get().clone()),
Entry::Vacant(e) => match self
.run_command(&["rev-parse", "--show-toplevel"])
.ok()
.map(|s| PathBuf::from(s.trim()))
.and_then(|p| canonicalize(&p).ok())
{
Some(root) => Ok(e.insert(root).clone()),
None => Ok(self.path.clone()),
},
}
}
pub fn git_dir(&self) -> anyhow::Result<PathBuf> {
match super::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<()> {
let dirty_files = self.dirty_files()?;
if !dirty_files.is_empty() {
return Err(GitError::UncommittedChanges {
action: Some(action.into()),
branch: branch.map(String::from),
force_hint,
dirty_files,
}
.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 ref_name = format!("refs/wt-backup/{}", branch);
self.run_command(&[
"update-ref",
"--create-reflog",
"-m",
message,
&ref_name,
&backup_sha,
])
.context("Failed to create backup ref")?;
self.repo().short_sha(&backup_sha)
}
}
#[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();
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())));
}
#[test]
fn head_sha_tracks_head_movement() {
let test = TestRepo::with_initial_commit();
let repo = Repository::at(test.root_path()).unwrap();
let wt = repo.worktree_at(test.root_path());
let before = wt.head_sha().unwrap().expect("HEAD resolved after init");
std::fs::write(test.root_path().join("file.txt"), "second").unwrap();
test.run_git(&["add", "."]);
test.run_git(&["commit", "-m", "second"]);
let after = wt
.head_sha()
.unwrap()
.expect("HEAD still resolved after second commit");
assert_ne!(before, after, "head_sha must reflect the new commit");
}
#[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");
super::super::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);
}
#[test]
fn root_fallback_outside_work_tree_does_not_pollute_cache() {
let tmp = tempfile::tempdir().unwrap();
let test = TestRepo::with_initial_commit();
let repo = Repository::at(test.root_path()).unwrap();
let wt = repo.worktree_at(tmp.path());
let fallback = wt.root().expect("root() returns fallback, never errors");
assert_eq!(fallback, wt.path());
assert!(
!super::super::WORKTREE_ROOTS.contains_key(wt.path()),
"fallback must not populate the cache"
);
let info = wt.prewarm_info().unwrap();
assert!(!info.is_inside);
assert!(info.root.is_none());
}
#[test]
fn create_safety_backup_distinguishes_slash_and_dash_branches() {
let test = TestRepo::with_initial_commit();
let repo = Repository::at(test.root_path()).unwrap();
let wt = repo.worktree_at(test.root_path());
for branch in ["a/b", "a-b"] {
test.run_git(&["switch", "-c", branch]);
std::fs::write(test.root_path().join("file.txt"), branch).unwrap();
wt.create_safety_backup(&format!("{branch} (squash)"))
.unwrap();
test.run_git(&["checkout", "--", "file.txt"]);
test.run_git(&["switch", "main"]);
}
let refs = test.git_output(&["for-each-ref", "--format=%(refname)", "refs/wt-backup/"]);
let mut listed: Vec<&str> = refs.lines().collect();
listed.sort();
assert_eq!(
listed,
vec!["refs/wt-backup/a-b", "refs/wt-backup/a/b"],
"expected distinct backup refs for `a/b` and `a-b`, got: {refs}"
);
}
#[test]
fn prewarm_at_populates_global_caches_for_a_fresh_repository() {
let test = TestRepo::with_initial_commit();
Repository::prewarm_at(test.root_path());
let repo = Repository::at(test.root_path()).unwrap();
let wt = repo.worktree_at(test.root_path());
let sentinel = std::path::PathBuf::from("/nonexistent/prewarm-at-sentinel");
super::super::WORKTREE_ROOTS.insert(wt.path().to_path_buf(), sentinel.clone());
let info = wt.prewarm_info().unwrap();
assert_eq!(info.root.as_deref(), Some(sentinel.as_path()));
}
#[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_eq!(wt.branch().unwrap().as_deref(), Some("main"));
assert!(wt.head_sha().unwrap().is_none());
}
}