use std::{
borrow::Cow,
ffi::{OsStr, OsString},
fmt::Write as _,
io,
path::{Path, PathBuf},
process::{Command, ExitStatus, Output},
str::Utf8Error,
};
use snafu::{OptionExt as _, ResultExt as _, Snafu, ensure};
use super::VcsRepository;
use crate::{
ModifyGuardError, error,
repository::{FileChange, RepositoryChanges},
util::{self, WorktreeRelativePath},
vcs::VcsBackend,
};
pub(super) const BACKEND: GitCliBackend = GitCliBackend;
#[derive(Debug)]
pub(super) struct GitCliBackend;
#[derive(Debug, Snafu)]
#[non_exhaustive]
pub enum GitCliBackendError {
#[snafu(display(
"failed to execute git command: {}{}",
program.display(),
args.iter().fold(String::new(), |mut output, arg| {
let _ = write!(&mut output, " {}", arg.display());
output
})
))]
GitCommand {
source: io::Error,
program: OsString,
args: Vec<OsString>,
},
#[snafu(display("git command returned non-zero exit status: {status}"))]
GitExitStatus {
status: ExitStatus,
},
#[snafu(display("failed to convert git command output to UTF-8"))]
InvalidUtf8 {
source: Utf8Error,
},
#[snafu(display("invalid status entry in git status output: {entry:?}"))]
InvalidGitStatus {
entry: Vec<u8>,
},
#[snafu(display("invalid rev-parse output: {output:?}"))]
InvalidRevParse {
output: Vec<u8>,
},
#[snafu(display("path has no parent directory: {}", git_dir.display()))]
NoGitDirParent {
git_dir: PathBuf,
},
#[snafu(display("no listed worktree matched git dir: {}", git_dir.display()))]
NoWorktreeForGitDir {
git_dir: PathBuf,
},
}
impl From<GitCliBackendError> for ModifyGuardError {
#[inline]
fn from(source: GitCliBackendError) -> Self {
Self::Backend {
source: source.into(),
}
}
}
impl VcsBackend for GitCliBackend {
fn discover(
&self,
mut path: &Path,
) -> Result<Option<Box<dyn VcsRepository>>, ModifyGuardError> {
util::ensure_path_exists(path)?;
#[expect(
clippy::unwrap_used,
reason = "path is guaranteed to have a parent because it exists and is a file"
)]
if path.is_file() {
path = path.parent().unwrap();
}
let Some(is_bare) = repo_is_bare(path)? else {
return Ok(None);
};
ensure!(!is_bare, error::RepositoryWithoutWorktreeSnafu { path });
let worktree = if repo_is_inside_git_dir(path)? {
let git_dir = repo_absolute_git_dir(path)?;
repo_worktree_from_git_dir(&git_dir)?
} else {
repo_toplevel(path)?
};
Ok(Some(Box::new(GitCliRepository { worktree })))
}
fn open(&self, path: &Path) -> Result<Option<Box<dyn VcsRepository>>, ModifyGuardError> {
util::ensure_path_is_directory(path)?;
let Some(is_bare) = repo_is_bare(path)? else {
return Ok(None);
};
if is_bare {
let git_dir = repo_absolute_git_dir(path)?;
if is_same_path(&git_dir, path) {
return Err(error::RepositoryWithoutWorktreeSnafu { path }.build());
}
return Ok(None);
}
if repo_is_inside_git_dir(path)? {
let git_dir = repo_absolute_git_dir(path)?;
if is_same_path(&git_dir, path) {
let worktree = repo_worktree_from_git_dir(&git_dir)?;
return Ok(Some(Box::new(GitCliRepository { worktree })));
}
return Ok(None);
}
let prefix = run_git(["rev-parse", "--show-prefix"], path)?;
let prefix = parse_stdout_as_path(&prefix)?;
if !prefix.as_os_str().is_empty() {
return Ok(None);
}
let worktree = repo_toplevel(path)?;
Ok(Some(Box::new(GitCliRepository { worktree })))
}
}
#[derive(Debug)]
struct GitCliRepository {
worktree: PathBuf,
}
impl VcsRepository for GitCliRepository {
fn worktree(&self) -> &Path {
&self.worktree
}
fn repository_changes(&self) -> Result<Option<RepositoryChanges>, ModifyGuardError> {
let file_changes = self.collect_changes(None)?;
Ok(RepositoryChanges::new(file_changes))
}
fn path_changes(&self, wt_path: &Path) -> Result<Option<RepositoryChanges>, ModifyGuardError> {
let wt_path = WorktreeRelativePath::from_wt_path(&self.worktree, wt_path)?;
let file_changes = self.collect_changes(Some(&wt_path))?;
Ok(RepositoryChanges::new(file_changes))
}
fn file_change(&self, wt_path: &Path) -> Result<Option<FileChange>, ModifyGuardError> {
let wt_path = WorktreeRelativePath::from_wt_path(&self.worktree, wt_path)?;
match &wt_path {
WorktreeRelativePath::Existing(wt_path) => {
let fs_path = self.worktree.join(wt_path);
util::ensure_path_is_file(&fs_path)?;
}
WorktreeRelativePath::Missing(_) => {}
}
let file_changes = self.collect_changes(Some(&wt_path))?;
match file_changes.as_slice() {
[] => Ok(None),
[change] if change.wt_path() == wt_path.as_path() => Ok(Some(change.clone())),
[..] => Err(error::AmbiguousFilePathSnafu { wt_path }.build()),
}
}
}
impl GitCliRepository {
fn collect_changes(
&self,
wt_path: Option<&WorktreeRelativePath>,
) -> Result<Vec<FileChange>, ModifyGuardError> {
let pathspec = wt_path
.as_ref()
.filter(|wt_path| !wt_path.is_empty())
.map(|wt_path| literal_pathspec(wt_path.as_path()));
let args = [
"status",
"--porcelain=v1",
"-z",
"--no-renames",
"--no-ignored",
"--untracked-files=all",
]
.into_iter()
.map(|s| Cow::Borrowed(OsStr::new(s)))
.chain(pathspec.map(Cow::Owned));
let statuses = run_git(args, &self.worktree)?;
let statuses = parse_stdout_as_bytes(&statuses);
let statuses = parse_git_status(statuses)?;
let statuses = statuses
.into_iter()
.filter_map(StatusEntry::build)
.peekable()
.collect::<Vec<_>>();
if statuses.is_empty()
&& let Some(WorktreeRelativePath::Missing(wt_path)) = &wt_path
{
return Err(error::PathNotFoundSnafu { path: wt_path }.build());
}
Ok(statuses)
}
}
const REPO_CONTEXT_ENV_VARS: &[&str] = &[
"GIT_DIR",
"GIT_WORK_TREE",
"GIT_INDEX_FILE",
"GIT_COMMON_DIR",
];
fn literal_pathspec(path: &Path) -> OsString {
let mut pattern = OsString::from(":(top,literal)");
pattern.push(path.as_os_str());
pattern
}
fn git_command(current_dir: &Path) -> Command {
let mut cmd = Command::new("git");
for env_var in REPO_CONTEXT_ENV_VARS {
cmd.env_remove(env_var);
}
cmd.current_dir(current_dir);
cmd
}
fn run_git_without_status_check<I, S>(
args: I,
current_dir: &Path,
) -> Result<Output, GitCliBackendError>
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
let mut cmd = git_command(current_dir);
cmd.args(args);
let output = cmd.output().with_context(|_| GitCommandSnafu {
program: cmd.get_program(),
args: cmd.get_args().map(ToOwned::to_owned).collect::<Vec<_>>(),
})?;
Ok(output)
}
fn run_git<I, S>(args: I, current_dir: &Path) -> Result<Output, GitCliBackendError>
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
let output = run_git_without_status_check(args, current_dir)?;
ensure!(
output.status.success(),
GitExitStatusSnafu {
status: output.status
}
);
Ok(output)
}
fn trim_trailing_newline(bytes: &[u8]) -> &[u8] {
bytes.strip_suffix(b"\n").unwrap_or(bytes)
}
fn parse_stdout_as_bytes(output: &Output) -> &[u8] {
trim_trailing_newline(&output.stdout)
}
fn parse_stdout_as_os_str(output: &Output) -> Result<&OsStr, GitCliBackendError> {
let s = parse_stdout_as_bytes(output);
util::bytes_to_os_str(s).context(InvalidUtf8Snafu)
}
fn parse_stdout_as_path(output: &Output) -> Result<&Path, GitCliBackendError> {
let path = parse_stdout_as_os_str(output)?;
Ok(Path::new(path))
}
fn parse_stdout_as_bool(output: &Output) -> Result<bool, GitCliBackendError> {
let s = parse_stdout_as_bytes(output);
match s {
b"true" => Ok(true),
b"false" => Ok(false),
bytes => Err(InvalidRevParseSnafu { output: bytes }.build()),
}
}
fn repo_is_bare(path: &Path) -> Result<Option<bool>, GitCliBackendError> {
let output = run_git_without_status_check(["rev-parse", "--is-bare-repository"], path)?;
if !output.status.success() {
return Ok(None);
}
let is_bare = parse_stdout_as_bool(&output)?;
Ok(Some(is_bare))
}
fn repo_is_inside_git_dir(path: &Path) -> Result<bool, GitCliBackendError> {
let output = run_git(["rev-parse", "--is-inside-git-dir"], path)?;
parse_stdout_as_bool(&output)
}
fn repo_absolute_git_dir(path: &Path) -> Result<PathBuf, GitCliBackendError> {
let output = run_git(["rev-parse", "--absolute-git-dir"], path)?;
Ok(parse_stdout_as_path(&output)?.to_path_buf())
}
fn repo_toplevel(path: &Path) -> Result<PathBuf, GitCliBackendError> {
let output = run_git(["rev-parse", "--show-toplevel"], path)?;
Ok(parse_stdout_as_path(&output)?.to_path_buf())
}
fn run_git_for_git_dir<I, S>(git_dir: &Path, args: I) -> Result<Output, GitCliBackendError>
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
let query_dir = git_dir.parent().context(NoGitDirParentSnafu {
git_dir: git_dir.to_path_buf(),
})?;
let mut git_dir_arg = OsString::from("--git-dir=");
git_dir_arg.push(git_dir.as_os_str());
let args = std::iter::once(Cow::Owned(git_dir_arg)).chain(
args.into_iter()
.map(|arg| Cow::Owned(arg.as_ref().to_os_string())),
);
run_git(args, query_dir)
}
fn repo_common_git_dir(git_dir: &Path) -> Result<PathBuf, GitCliBackendError> {
let output = run_git_for_git_dir(
git_dir,
[OsStr::new("rev-parse"), OsStr::new("--git-common-dir")],
)?;
let common_dir = parse_stdout_as_path(&output)?;
if common_dir.is_absolute() {
return Ok(common_dir.to_path_buf());
}
let query_dir = git_dir.parent().context(NoGitDirParentSnafu {
git_dir: git_dir.to_path_buf(),
})?;
Ok(query_dir.join(common_dir))
}
fn parse_worktree_list_porcelain(output: &[u8]) -> Result<Vec<PathBuf>, GitCliBackendError> {
let mut worktrees = vec![];
for field in output.split(|&byte| byte == b'\0') {
let Some(path) = field.strip_prefix(b"worktree ") else {
continue;
};
let path = util::bytes_to_os_str(path).context(InvalidUtf8Snafu)?;
worktrees.push(PathBuf::from(path));
}
Ok(worktrees)
}
fn repo_worktree_from_git_dir(git_dir: &Path) -> Result<PathBuf, GitCliBackendError> {
let common_git_dir = repo_common_git_dir(git_dir)?;
let worktrees = run_git_for_git_dir(
&common_git_dir,
[
OsStr::new("worktree"),
OsStr::new("list"),
OsStr::new("--porcelain"),
OsStr::new("-z"),
],
)?;
let worktrees = parse_worktree_list_porcelain(parse_stdout_as_bytes(&worktrees))?;
for worktree in worktrees {
let candidate_git_dir = repo_absolute_git_dir(&worktree)?;
if is_same_path(&candidate_git_dir, git_dir) {
return Ok(worktree);
}
}
NoWorktreeForGitDirSnafu {
git_dir: git_dir.to_path_buf(),
}
.fail()
}
fn is_same_path(path1: &Path, path2: &Path) -> bool {
if path1.components() == path2.components() {
return true;
}
match (path1.canonicalize(), path2.canonicalize()) {
(Ok(canon1), Ok(canon2)) => canon1 == canon2,
_ => false,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ChangeKind {
Unmodified,
Modified,
TypeChanged,
Added,
Deleted,
Renamed,
Copied,
UpdatedButUnmerged,
Untracked,
}
impl ChangeKind {
fn from_byte(c: u8) -> Option<Self> {
match c {
b' ' => Some(Self::Unmodified),
b'M' => Some(Self::Modified),
b'T' => Some(Self::TypeChanged),
b'A' => Some(Self::Added),
b'D' => Some(Self::Deleted),
b'R' => Some(Self::Renamed),
b'C' => Some(Self::Copied),
b'U' => Some(Self::UpdatedButUnmerged),
b'?' => Some(Self::Untracked),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct StatusEntry<'a> {
index: ChangeKind,
worktree: ChangeKind,
wt_path: &'a OsStr,
}
fn parse_git_status(status: &[u8]) -> Result<Vec<StatusEntry<'_>>, GitCliBackendError> {
let mut changes = vec![];
for entry in status.split(|c| *c == b'\0') {
if entry.is_empty() {
continue;
}
let mut cs = entry.iter();
let index = cs
.next()
.copied()
.and_then(ChangeKind::from_byte)
.context(InvalidGitStatusSnafu { entry })?;
let worktree = cs
.next()
.copied()
.and_then(ChangeKind::from_byte)
.context(InvalidGitStatusSnafu { entry })?;
let space = cs
.next()
.copied()
.context(InvalidGitStatusSnafu { entry })?;
ensure!(space == b' ', InvalidGitStatusSnafu { entry });
let Some(wt_path) = util::bytes_to_os_str(cs.as_slice()).ok() else {
continue;
};
let entry = StatusEntry {
index,
worktree,
wt_path,
};
changes.push(entry);
}
Ok(changes)
}
impl StatusEntry<'_> {
fn build(self) -> Option<FileChange> {
let StatusEntry {
index,
worktree,
wt_path,
} = self;
let (dirty, staged) = if index == ChangeKind::Untracked || worktree == ChangeKind::Untracked
{
(true, false)
} else {
(
worktree != ChangeKind::Unmodified,
index != ChangeKind::Unmodified,
)
};
(dirty || staged).then(|| FileChange {
wt_path: PathBuf::from(wt_path),
dirty,
staged,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_git_status_returns_file_changes() {
use ChangeKind::*;
let status = b" clean.txt\0M staged.txt\0";
let changes = parse_git_status(status).unwrap();
assert_eq!(
changes,
[
StatusEntry {
index: Unmodified,
worktree: Unmodified,
wt_path: OsStr::new("clean.txt")
},
StatusEntry {
index: Modified,
worktree: Unmodified,
wt_path: OsStr::new("staged.txt")
},
]
);
}
}