use gix::{
ObjectId,
bstr::BString,
prelude::ObjectIdExt,
progress::Discard,
status::{Item as StatusItem, UntrackedFiles},
worktree::IndexPersistedOrInMemory,
};
use std::{
fmt::Write as _,
io,
path::{Path, PathBuf},
};
use super::ToolError;
use zlob::{ZlobFlags, ZlobPattern};
mod commit;
mod diff;
mod log;
mod push;
mod show;
mod stage;
mod status;
pub(crate) use commit::GitCommit;
pub use commit::{GitCommitArgs, execute_git_commit_tool};
pub(crate) use diff::{GitDiff, append_fenced_diff};
pub use diff::{GitDiffArgs, execute_git_diff_tool};
pub(crate) use log::GitLog;
pub use log::{GitLogArgs, execute_git_log_tool};
pub(crate) use push::GitPush;
pub use push::{GitPushArgs, execute_git_push_tool};
pub(crate) use show::GitShow;
pub use show::{GitShowArgs, execute_git_show_tool};
pub(crate) use stage::GitAdd;
pub use stage::{GitAddArgs, execute_git_add_tool};
pub(crate) use status::GitStatus;
pub use status::{GitRepoArgs, execute_git_status_tool};
pub(crate) fn open_repo(
repo_path: Option<&str>,
working_dir: Option<&std::path::Path>,
) -> Result<gix::Repository, ToolError> {
let path = repo_path.unwrap_or(".").trim();
let path = if path.is_empty() { "." } else { path };
let resolved = super::resolve_path(path, working_dir);
gix::discover(&resolved).map_err(|error| {
ToolError::Other(format!(
"failed to open git repository from {}: {error}",
resolved.display()
))
})
}
pub(crate) fn repo_work_dir(repo: &gix::Repository) -> &Path {
repo.workdir().unwrap_or_else(|| repo.git_dir())
}
pub(crate) fn repo_work_dir_display(repo: &gix::Repository) -> String {
repo_work_dir(repo).display().to_string()
}
pub(crate) fn describe_head(repo: &gix::Repository) -> Result<String, ToolError> {
if let Some(name) = repo.head_name().map_err(io::Error::other)? {
return Ok(name.shorten().to_string());
}
match repo.head_id() {
Ok(id) => Ok(format!("detached at {}", shorten_id(repo, id.detach())?)),
Err(_) => Ok("unborn HEAD".to_string()),
}
}
pub(crate) fn shorten_id(repo: &gix::Repository, id: ObjectId) -> Result<String, ToolError> {
Ok(id
.attach(repo)
.shorten()
.map_err(io::Error::other)?
.to_string())
}
pub(crate) fn path_from_bytes(path: &[u8]) -> String {
String::from_utf8_lossy(path).into_owned()
}
pub(crate) fn sort_and_dedup(lines: &mut Vec<String>) {
lines.sort();
lines.dedup();
}
pub(crate) fn write_section(out: &mut String, title: &str, lines: &[String]) {
let _ = writeln!(out, "{title}:");
if lines.is_empty() {
let _ = writeln!(out, " (none)");
return;
}
for line in lines {
let _ = writeln!(out, " {line}");
}
}
pub(crate) fn yes_no(value: bool) -> &'static str {
if value { "yes" } else { "no" }
}
pub(crate) fn append_command_output(out: &mut String, label: &str, content: &str) {
if content.is_empty() {
return;
}
let _ = writeln!(out);
let _ = writeln!(out, "{label}:");
let _ = writeln!(out, "{content}");
}
pub(crate) fn run_git_command(
repo: &gix::Repository,
args: &[String],
) -> Result<std::process::Output, ToolError> {
std::process::Command::new("git")
.args(args)
.current_dir(repo_work_dir(repo))
.output()
.map_err(|error| ToolError::Other(format!("failed to run git {}: {error}", args.join(" "))))
}
pub(crate) fn normalize_nonempty_argument<'a>(
value: &'a str,
name: &str,
) -> Result<&'a str, ToolError> {
let value = value.trim();
if value.is_empty() {
Err(ToolError::Other(format!("{name} must not be empty")))
} else {
Ok(value)
}
}
pub(crate) fn current_branch_name(repo: &gix::Repository) -> Result<String, ToolError> {
repo.head_name()
.map_err(io::Error::other)?
.map(|name| name.shorten().to_string())
.ok_or_else(|| {
ToolError::Other("branch must be provided when HEAD is detached".to_string())
})
}
pub(crate) fn load_mutable_index(repo: &gix::Repository) -> Result<gix::index::File, ToolError> {
match repo
.index_or_load_from_head_or_empty()
.map_err(io::Error::other)?
{
IndexPersistedOrInMemory::Persisted(index) => Ok((**index).clone()),
IndexPersistedOrInMemory::InMemory(index) => Ok(index),
}
}
pub(crate) fn collect_cached_diff_lines(
repo: &gix::Repository,
pathspec: &[String],
) -> Result<Vec<String>, ToolError> {
let iter = repo
.status(Discard)
.map_err(io::Error::other)?
.untracked_files(UntrackedFiles::None)
.into_iter(Vec::<BString>::new())
.map_err(io::Error::other)?;
let mut lines = Vec::new();
for item in iter {
let item = item.map_err(io::Error::other)?;
if let StatusItem::TreeIndex(change) = item {
let path = path_from_bytes(change.location().as_ref());
if pathspec_matches(pathspec, &path) {
lines.push(format_tree_index_change(&change));
}
}
}
Ok(lines)
}
pub(crate) fn pathspec_patterns(pathspec: &[String]) -> Vec<BString> {
pathspec
.iter()
.map(|spec| BString::from(spec.as_str()))
.collect()
}
pub(crate) fn pathspec_matches(pathspec: &[String], path: &str) -> bool {
if pathspec.is_empty() {
return true;
}
pathspec.iter().any(|spec| {
let spec = spec.trim();
!spec.is_empty()
&& (path == spec
|| path.starts_with(spec.strip_suffix('/').unwrap_or(spec))
|| simple_glob_matches(spec, path))
})
}
pub(crate) fn simple_glob_matches(pattern: &str, text: &str) -> bool {
if !zlob::has_wildcards(pattern, ZlobFlags::RECOMMENDED) {
return false;
}
ZlobPattern::compile(pattern, ZlobFlags::RECOMMENDED)
.map(|p| p.matches_default(text))
.unwrap_or(false)
}
pub(crate) fn resolve_pathspec_prefix(
repo: &gix::Repository,
repo_path: Option<&str>,
working_dir: Option<&Path>,
) -> Result<Option<String>, ToolError> {
let Some(workdir) = repo.workdir() else {
return Ok(None);
};
let workdir = workdir
.canonicalize()
.unwrap_or_else(|_| workdir.to_path_buf());
let working_dir = match working_dir {
Some(wd) => Some(wd.canonicalize().map_err(|e| {
ToolError::Other(format!(
"failed to canonicalize working directory '{}': {e}",
wd.display()
))
})?),
None => None,
};
let base: PathBuf = match repo_path {
Some(rp) => {
let trimmed = rp.trim();
if trimmed.is_empty() || trimmed == "." {
return Ok(None);
}
let candidate = Path::new(trimmed);
if candidate.is_absolute() {
candidate.to_path_buf()
} else if let Some(wd) = working_dir {
wd.join(candidate)
} else {
candidate.to_path_buf()
}
}
None => match working_dir {
Some(wd) => wd,
None => return Ok(None),
},
};
let base = base.canonicalize().unwrap_or(base);
let Ok(prefix) = base.strip_prefix(workdir) else {
return Ok(None);
};
if prefix.as_os_str().is_empty() {
return Ok(None);
}
Ok(Some(
prefix
.components()
.map(|component| component.as_os_str().to_string_lossy())
.collect::<Vec<_>>()
.join("/"),
))
}
pub(crate) fn filter_repo_root_pathspecs(pathspec: Vec<String>) -> Vec<String> {
pathspec
.into_iter()
.filter(|spec| spec != "." && spec != "./")
.collect()
}
pub(crate) fn format_tree_index_change(change: &gix::diff::index::Change) -> String {
use gix::diff::index::ChangeRef;
match change {
ChangeRef::Addition { location, .. } => format!("A {}", path_from_bytes(location.as_ref())),
ChangeRef::Deletion { location, .. } => format!("D {}", path_from_bytes(location.as_ref())),
ChangeRef::Modification {
location,
previous_entry_mode,
entry_mode,
..
} => {
let prefix = if previous_entry_mode != entry_mode {
"T"
} else {
"M"
};
format!("{prefix} {}", path_from_bytes(location.as_ref()))
}
ChangeRef::Rewrite {
source_location,
location,
copy,
..
} => {
let from = path_from_bytes(source_location.as_ref());
let to = path_from_bytes(location.as_ref());
if *copy {
format!("C {from} -> {to}")
} else {
format!("R {from} -> {to}")
}
}
}
}
pub(crate) fn format_index_worktree_change(change: &gix::status::index_worktree::Item) -> String {
use gix::status::index_worktree::Item;
match change {
Item::Modification { .. } => match change.summary() {
Some(summary) => format!(
"{} {}",
worktree_summary_code(summary),
path_from_bytes(change.rela_path().as_ref())
),
None => format!("M {}", path_from_bytes(change.rela_path().as_ref())),
},
Item::DirectoryContents { entry, .. } => {
let path = path_from_bytes(entry.rela_path.as_ref());
if matches!(entry.status, gix::dir::entry::Status::Untracked) {
format!("?? {path}")
} else {
format!("DIR {path}")
}
}
Item::Rewrite { source, copy, .. } => {
let from = path_from_bytes(source.rela_path().as_ref());
let to = path_from_bytes(change.rela_path().as_ref());
if *copy {
format!("C {from} -> {to}")
} else {
format!("R {from} -> {to}")
}
}
}
}
pub(crate) fn worktree_summary_code(
summary: gix::status::index_worktree::iter::Summary,
) -> &'static str {
use gix::status::index_worktree::iter::Summary;
match summary {
Summary::Added => "A",
Summary::Removed => "D",
Summary::Modified => "M",
Summary::Copied => "C",
Summary::Renamed => "R",
Summary::TypeChange => "T",
Summary::Conflict => "U",
Summary::IntentToAdd => "I",
}
}
#[cfg(test)]
mod tests {
use super::*;
fn init_repo_with_subdir(sub_path: &str) -> (tempfile::TempDir, PathBuf, gix::Repository) {
let tmp = tempfile::tempdir().unwrap();
let repo_root = tmp.path();
std::process::Command::new("git")
.args(["init", "-q"])
.current_dir(repo_root)
.output()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.email", "test@test"])
.current_dir(repo_root)
.output()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.name", "test"])
.current_dir(repo_root)
.output()
.unwrap();
let sub = repo_root.join(sub_path);
std::fs::create_dir_all(&sub).unwrap();
let repo = gix::discover(repo_root).unwrap();
(tmp, sub, repo)
}
#[test]
fn test_resolve_pathspec_prefix_none_when_working_dir_is_repo_root() {
let (_tmp, _sub, repo) = init_repo_with_subdir("sub");
let workdir = repo.workdir().unwrap().to_path_buf();
let result = resolve_pathspec_prefix(&repo, None, Some(&workdir)).unwrap();
assert_eq!(result, None);
}
#[test]
fn test_resolve_pathspec_prefix_returns_subdir_prefix() {
let (_tmp, sub, repo) = init_repo_with_subdir("sub");
let result = resolve_pathspec_prefix(&repo, None, Some(&sub)).unwrap();
assert_eq!(result.as_deref(), Some("sub"));
}
#[test]
fn test_resolve_pathspec_prefix_nested_subdir() {
let (_tmp, sub, repo) = init_repo_with_subdir("a/b/c");
let result = resolve_pathspec_prefix(&repo, None, Some(&sub)).unwrap();
assert_eq!(result.as_deref(), Some("a/b/c"));
}
#[test]
fn test_resolve_pathspec_prefix_none_when_no_working_dir() {
let (_tmp, _sub, repo) = init_repo_with_subdir("sub");
let result = resolve_pathspec_prefix(&repo, None, None).unwrap();
assert_eq!(result, None);
}
#[test]
fn test_resolve_pathspec_prefix_with_explicit_repo_path() {
let (_tmp, _sub, repo) = init_repo_with_subdir("sub");
let workdir = repo.workdir().unwrap().to_path_buf();
let result = resolve_pathspec_prefix(&repo, Some("sub"), Some(&workdir)).unwrap();
assert_eq!(result.as_deref(), Some("sub"));
}
#[test]
fn test_resolve_pathspec_prefix_empty_repo_path_returns_none() {
let (_tmp, _sub, repo) = init_repo_with_subdir("sub");
let workdir = repo.workdir().unwrap().to_path_buf();
let result = resolve_pathspec_prefix(&repo, Some(""), Some(&workdir)).unwrap();
assert_eq!(result, None);
}
#[test]
fn test_resolve_pathspec_prefix_dot_repo_path_returns_none() {
let (_tmp, _sub, repo) = init_repo_with_subdir("sub");
let workdir = repo.workdir().unwrap().to_path_buf();
let result = resolve_pathspec_prefix(&repo, Some("."), Some(&workdir)).unwrap();
assert_eq!(result, None);
}
#[test]
fn test_resolve_pathspec_prefix_absolute_repo_path() {
let (_tmp, sub, repo) = init_repo_with_subdir("sub");
let result = resolve_pathspec_prefix(&repo, Some(sub.to_str().unwrap()), None).unwrap();
assert_eq!(result.as_deref(), Some("sub"));
}
#[test]
fn test_filter_repo_root_pathspecs_removes_dot() {
let result = filter_repo_root_pathspecs(vec![".".into()]);
assert!(result.is_empty());
}
#[test]
fn test_filter_repo_root_pathspecs_removes_dot_slash() {
let result = filter_repo_root_pathspecs(vec!["./".into()]);
assert!(result.is_empty());
}
#[test]
fn test_filter_repo_root_pathspecs_preserves_other() {
let result = filter_repo_root_pathspecs(vec!["src/".into(), "Cargo.toml".into()]);
assert_eq!(result, vec!["src/", "Cargo.toml"]);
}
#[test]
fn test_filter_repo_root_pathspecs_mixed() {
let result = filter_repo_root_pathspecs(vec![".".into(), "src/".into(), "./".into()]);
assert_eq!(result, vec!["src/"]);
}
#[test]
fn test_filter_repo_root_pathspecs_empty() {
let result = filter_repo_root_pathspecs(vec![]);
assert!(result.is_empty());
}
#[test]
fn test_filter_repo_root_pathspecs_keeps_dot_prefix() {
let result = filter_repo_root_pathspecs(vec!["./foo.rs".into()]);
assert_eq!(result, vec!["./foo.rs"]);
}
#[test]
fn test_filter_repo_root_pathspecs_keeps_subdir_dot() {
let result = filter_repo_root_pathspecs(vec!["./bar/.gitkeep".into()]);
assert_eq!(result, vec!["./bar/.gitkeep"]);
}
#[test]
fn test_resolve_pathspec_prefix_bare_repo_no_workdir_returns_none() {
let tmp = tempfile::tempdir().unwrap();
let repo_root = tmp.path();
std::process::Command::new("git")
.args(["init", "-q", "--bare"])
.current_dir(repo_root)
.output()
.unwrap();
let repo = gix::discover(repo_root).unwrap();
assert!(repo.workdir().is_none());
let result = resolve_pathspec_prefix(&repo, None, None).unwrap();
assert_eq!(result, None);
}
}