use std::collections::HashMap;
use std::io::Read;
use std::path::{Component, Path, PathBuf};
use std::process::Output;
use std::time::Duration;
#[cfg(unix)]
use rustix::fs::{self as rustix_fs, Access};
use tokio::task::spawn_blocking;
use tokio::time;
use super::error::GitError;
use super::rebase::{
GIT_INDEX_LOCK_RETRY_ATTEMPTS, GIT_INDEX_LOCK_RETRY_DELAY, is_git_index_lock_error,
is_rebase_conflict, run_git_command_with_index_lock_retry,
};
use super::repo::{
AsyncGitCommand, AsyncGitCommandOutput, AsyncGitCommandRunner, ProcessAsyncGitCommandRunner,
command_output_detail, run_git_command, run_git_command_output_sync,
run_git_command_output_with_env_sync, run_git_command_sync, run_git_command_with_runner,
};
pub type BranchTrackingMap = HashMap<String, Option<(u32, u32)>>;
const COMMIT_ALL_HOOK_RETRY_ATTEMPTS: usize = 5;
const MAX_WORKTREE_FILE_BYTE_COUNT: usize = 1024 * 1024;
const PRE_COMMIT_CONFIG_FILES: [&str; 2] = [".pre-commit-config.yaml", ".pre-commit-config.yml"];
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum WorktreeFileContent {
Text(String),
Missing,
Binary,
TooLarge,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SingleCommitMessageStrategy {
Replace,
Reuse,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum PullRebaseResult {
Completed,
Conflict {
detail: String,
},
}
pub(crate) async fn commit_all(repo_path: PathBuf, commit_message: String) -> Result<(), GitError> {
commit_all_with_retry(
repo_path,
commit_message,
SingleCommitMessageStrategy::Replace,
false,
)
.await
}
pub(crate) async fn commit_all_preserving_single_commit(
repo_path: PathBuf,
base_branch: String,
commit_message: String,
message_strategy: SingleCommitMessageStrategy,
) -> Result<(), GitError> {
let amend_existing_commit = has_commits_since(repo_path.clone(), base_branch).await?;
commit_all_with_retry(
repo_path,
commit_message,
message_strategy,
amend_existing_commit,
)
.await
}
pub(crate) async fn stage_all(repo_path: PathBuf) -> Result<(), GitError> {
spawn_blocking(move || stage_all_sync(&repo_path)).await?
}
pub(crate) async fn check_pre_commit_hook_ready(repo_path: PathBuf) -> Result<(), GitError> {
spawn_blocking(move || ensure_pre_commit_hook_ready(&repo_path)).await?
}
pub(crate) async fn run_pre_commit_hook(repo_path: PathBuf) -> Result<(), GitError> {
spawn_blocking(move || {
pre_commit_hook_result(run_git_command_output_sync(
&repo_path,
&["hook", "run", "--ignore-missing", "pre-commit"],
))
})
.await?
}
fn pre_commit_hook_result(output: Result<Output, GitError>) -> Result<(), GitError> {
let output = output?;
if output.status.success() {
return Ok(());
}
Err(GitError::CommandFailed {
command: "git hook run pre-commit".to_string(),
stderr: command_output_detail(&output.stdout, &output.stderr),
})
}
pub(crate) async fn head_short_hash(repo_path: PathBuf) -> Result<String, GitError> {
let hash = run_git_command(
repo_path,
vec![
"rev-parse".to_string(),
"--short".to_string(),
"HEAD".to_string(),
],
"Failed to resolve HEAD hash".to_string(),
)
.await?;
let hash = hash.trim().to_string();
if hash.is_empty() {
return Err(GitError::OutputParse(
"Failed to resolve HEAD hash: empty output".to_string(),
));
}
Ok(hash)
}
pub(crate) async fn head_hash(repo_path: PathBuf) -> Result<String, GitError> {
let hash = run_git_command(
repo_path,
vec!["rev-parse".to_string(), "HEAD".to_string()],
"Failed to resolve HEAD hash".to_string(),
)
.await?;
let hash = hash.trim().to_string();
if hash.is_empty() {
return Err(GitError::OutputParse(
"Failed to resolve HEAD hash: empty output".to_string(),
));
}
Ok(hash)
}
pub(crate) async fn ref_hash(repo_path: PathBuf, reference: String) -> Result<String, GitError> {
let hash = run_git_command(
repo_path,
vec![
"rev-parse".to_string(),
"--verify".to_string(),
format!("{reference}^{{commit}}"),
],
format!("Failed to resolve `{reference}` hash"),
)
.await?;
let hash = hash.trim().to_string();
if hash.is_empty() {
return Err(GitError::OutputParse(format!(
"Failed to resolve `{reference}` hash: empty output"
)));
}
Ok(hash)
}
pub(crate) async fn head_commit_message(repo_path: PathBuf) -> Result<Option<String>, GitError> {
spawn_blocking(move || head_commit_message_sync(&repo_path)).await?
}
pub(crate) async fn delete_branch(repo_path: PathBuf, branch_name: String) -> Result<(), GitError> {
run_git_command(
repo_path,
vec!["branch".to_string(), "-D".to_string(), branch_name],
"Git branch deletion failed".to_string(),
)
.await?;
Ok(())
}
pub(crate) async fn diff(repo_path: PathBuf, base_branch: String) -> Result<String, GitError> {
diff_output(repo_path, base_branch, false).await
}
pub(crate) async fn diff_changed_files(
repo_path: PathBuf,
base_branch: String,
) -> Result<Vec<String>, GitError> {
let output = diff_output(repo_path, base_branch, true).await?;
Ok(output
.lines()
.map(str::trim)
.filter(|path| !path.is_empty())
.map(str::to_string)
.collect())
}
async fn diff_output(
repo_path: PathBuf,
base_branch: String,
name_only: bool,
) -> Result<String, GitError> {
spawn_blocking(move || -> Result<String, GitError> {
let index_path = resolve_diff_index_path(&repo_path)?;
let index_path = PathBuf::from(index_path.trim());
let index_path = if index_path.is_absolute() {
index_path
} else {
repo_path.join(index_path)
};
diff_output_after_index_resolution(&repo_path, &base_branch, name_only, &index_path)
})
.await?
}
fn diff_output_after_index_resolution(
repo_path: &Path,
base_branch: &str,
name_only: bool,
index_path: &Path,
) -> Result<String, GitError> {
let result = (|| -> Result<String, GitError> {
let temporary_index = copy_git_index_to_temp(index_path)?;
run_git_command_with_index_sync(
repo_path,
&["add", "-A", "--intent-to-add"],
&temporary_index,
"Git add --intent-to-add failed",
)?;
let merge_base_output =
run_git_command_output_sync(repo_path, &["merge-base", "HEAD", base_branch])?;
let diff_target = if merge_base_output.status.success() {
resolve_diff_target(
repo_path,
base_branch,
String::from_utf8_lossy(&merge_base_output.stdout).trim(),
)?
} else {
base_branch.to_string()
};
let args = if name_only {
vec!["diff", "--name-only", diff_target.as_str()]
} else {
vec!["diff", diff_target.as_str()]
};
run_git_command_with_index_sync(repo_path, &args, &temporary_index, "Git diff failed")
})();
result.map_err(|error| classify_diff_repository_error(repo_path, error))
}
fn resolve_diff_index_path(repo_path: &Path) -> Result<String, GitError> {
run_git_command_sync(
repo_path,
&["rev-parse", "--git-path", "index"],
"Git index path resolution failed",
)
.map_err(|error| classify_diff_repository_error(repo_path, error))
}
fn classify_diff_repository_error(repo_path: &Path, error: GitError) -> GitError {
if !diff_repository_is_unavailable(repo_path) {
return error;
}
GitError::RepositoryUnavailable {
detail: error.to_string(),
}
}
fn diff_repository_is_unavailable(repo_path: &Path) -> bool {
if !repo_path.is_dir() {
return true;
}
diff_repository_probe_is_unavailable(run_git_command_output_sync(
repo_path,
&["rev-parse", "--git-dir"],
))
}
fn diff_repository_probe_is_unavailable(probe: Result<Output, GitError>) -> bool {
match probe {
Ok(output) => !output.status.success(),
Err(_) => false,
}
}
pub(crate) async fn read_worktree_file(
repo_path: PathBuf,
relative_path: String,
) -> Result<WorktreeFileContent, GitError> {
spawn_blocking(move || read_worktree_file_sync(&repo_path, &relative_path)).await?
}
fn read_worktree_file_sync(
repo_path: &Path,
relative_path: &str,
) -> Result<WorktreeFileContent, GitError> {
let relative_file_path = Path::new(relative_path);
if relative_path.is_empty()
|| relative_file_path
.components()
.any(|component| !matches!(component, Component::Normal(_)))
{
return Err(GitError::OutputParse(format!(
"Unsafe worktree file path: {relative_path}"
)));
}
let canonical_repo_path = std::fs::canonicalize(repo_path)?;
let candidate_path = repo_path.join(relative_file_path);
let canonical_file_path = match std::fs::canonicalize(candidate_path) {
Ok(path) => path,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Ok(WorktreeFileContent::Missing);
}
Err(error) => return Err(error.into()),
};
if !canonical_file_path.starts_with(canonical_repo_path) {
return Err(GitError::OutputParse(format!(
"Worktree file resolves outside repository: {relative_path}"
)));
}
let file = std::fs::File::open(canonical_file_path)?;
let mut bytes = Vec::with_capacity(MAX_WORKTREE_FILE_BYTE_COUNT.min(8192));
file.take((MAX_WORKTREE_FILE_BYTE_COUNT as u64).saturating_add(1))
.read_to_end(&mut bytes)?;
Ok(worktree_file_content(bytes))
}
fn worktree_file_content(bytes: Vec<u8>) -> WorktreeFileContent {
if bytes.len() > MAX_WORKTREE_FILE_BYTE_COUNT {
return WorktreeFileContent::TooLarge;
}
match String::from_utf8(bytes) {
Ok(content) => WorktreeFileContent::Text(content),
Err(_) => WorktreeFileContent::Binary,
}
}
fn copy_git_index_to_temp(index_path: &Path) -> Result<tempfile::TempPath, GitError> {
let index_parent = index_path.parent().ok_or_else(|| {
GitError::OutputParse(format!(
"Git index path has no parent: {}",
index_path.display()
))
})?;
let temporary_index =
tempfile::NamedTempFile::new_in(index_parent).map_err(|error| GitError::CommandFailed {
command: "create temporary git index".to_string(),
stderr: error.to_string(),
})?;
std::fs::copy(index_path, temporary_index.path()).map_err(|error| GitError::CommandFailed {
command: "copy git index".to_string(),
stderr: error.to_string(),
})?;
Ok(temporary_index.into_temp_path())
}
fn run_git_command_with_index_sync(
repo_path: &Path,
args: &[&str],
index_path: &Path,
error_context: &str,
) -> Result<String, GitError> {
let output = run_git_command_output_with_env_sync(
repo_path,
args,
&[("GIT_INDEX_FILE", index_path.as_os_str())],
)?;
if !output.status.success() {
return Err(GitError::CommandFailed {
command: format!("git {}", args.join(" ")),
stderr: format!(
"{error_context}: {}",
command_output_detail(&output.stdout, &output.stderr)
),
});
}
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
pub(crate) async fn is_worktree_clean(repo_path: PathBuf) -> Result<bool, GitError> {
let status_output = worktree_status(repo_path).await?;
Ok(status_output.trim().is_empty())
}
pub(crate) async fn worktree_status(repo_path: PathBuf) -> Result<String, GitError> {
run_git_command(
repo_path,
vec![
"status".to_string(),
"--porcelain=v1".to_string(),
"--untracked-files=all".to_string(),
],
"Git status --porcelain=v1 failed".to_string(),
)
.await
}
pub(crate) async fn tracked_worktree_status(repo_path: PathBuf) -> Result<String, GitError> {
run_git_command(
repo_path,
vec![
"status".to_string(),
"--porcelain=v1".to_string(),
"--untracked-files=no".to_string(),
],
"Git tracked status --porcelain=v1 failed".to_string(),
)
.await
}
pub(crate) async fn pull_rebase(repo_path: PathBuf) -> Result<PullRebaseResult, GitError> {
let command_runner = ProcessAsyncGitCommandRunner;
pull_rebase_with_runner(repo_path, &command_runner, GIT_INDEX_LOCK_RETRY_DELAY).await
}
async fn pull_rebase_with_runner(
repo_path: PathBuf,
command_runner: &dyn AsyncGitCommandRunner,
retry_delay: Duration,
) -> Result<PullRebaseResult, GitError> {
let pull_arguments = pull_rebase_arguments(&repo_path, command_runner).await?;
let command = AsyncGitCommand::new(repo_path, pull_arguments).with_environment(vec![
("GIT_EDITOR".to_string(), ":".to_string()),
("GIT_SEQUENCE_EDITOR".to_string(), ":".to_string()),
]);
let output =
run_async_git_command_with_index_lock_retry(command, command_runner, retry_delay).await?;
if output.success() {
return Ok(PullRebaseResult::Completed);
}
let detail = command_output_detail(&output.stdout, &output.stderr);
if is_rebase_conflict(&detail) {
return Ok(PullRebaseResult::Conflict { detail });
}
Err(GitError::CommandFailed {
command: "git pull --rebase".to_string(),
stderr: detail,
})
}
async fn pull_rebase_arguments(
repo_path: &Path,
command_runner: &dyn AsyncGitCommandRunner,
) -> Result<Vec<String>, GitError> {
let upstream_reference = primary_upstream_reference(repo_path, command_runner).await?;
if let Some((remote_name, branch_name)) = upstream_reference.split_once('/') {
return Ok(vec![
"pull".to_string(),
"--rebase".to_string(),
remote_name.to_string(),
branch_name.to_string(),
]);
}
let remote_name = current_branch_remote_name(repo_path, command_runner)
.await?
.ok_or_else(|| {
GitError::OutputParse(
"Failed to resolve current branch remote: not configured".to_string(),
)
})?;
Ok(vec![
"pull".to_string(),
"--rebase".to_string(),
remote_name,
upstream_reference,
])
}
async fn primary_upstream_reference(
repo_path: &Path,
command_runner: &dyn AsyncGitCommandRunner,
) -> Result<String, GitError> {
let upstream_reference = upstream_reference_name(repo_path, command_runner).await?;
let Some(primary_reference) = upstream_reference
.lines()
.map(str::trim)
.find(|line| !line.is_empty())
else {
return Err(GitError::OutputParse(
"Failed to resolve upstream branch: empty output".to_string(),
));
};
Ok(primary_reference.to_string())
}
async fn upstream_reference_name(
repo_path: &Path,
command_runner: &dyn AsyncGitCommandRunner,
) -> Result<String, GitError> {
let upstream_reference = run_git_command_with_runner(
AsyncGitCommand::new(
repo_path.to_path_buf(),
vec![
"rev-parse".to_string(),
"--abbrev-ref".to_string(),
"--symbolic-full-name".to_string(),
"@{u}".to_string(),
],
),
"Failed to resolve upstream branch",
command_runner,
)
.await?;
let upstream_reference = upstream_reference.trim().to_string();
if upstream_reference.is_empty() {
return Err(GitError::OutputParse(
"Failed to resolve upstream branch: empty output".to_string(),
));
}
Ok(upstream_reference)
}
async fn current_branch_remote_name(
repo_path: &Path,
command_runner: &dyn AsyncGitCommandRunner,
) -> Result<Option<String>, GitError> {
let current_branch_name = current_branch_name(repo_path, command_runner).await?;
let remote_config_key = format!("branch.{current_branch_name}.remote");
let output = command_runner
.run(AsyncGitCommand::new(
repo_path.to_path_buf(),
vec![
"config".to_string(),
"--get".to_string(),
remote_config_key.clone(),
],
))
.await?;
parse_current_branch_remote_output(&output, &remote_config_key)
}
fn parse_current_branch_remote_output(
output: &AsyncGitCommandOutput,
remote_config_key: &str,
) -> Result<Option<String>, GitError> {
if output.exit_code == Some(1) {
return Ok(None);
}
if !output.success() {
let detail = command_output_detail(&output.stdout, &output.stderr);
return Err(GitError::CommandFailed {
command: format!("git config --get {remote_config_key}"),
stderr: format!(
"Failed to resolve current branch remote `{remote_config_key}`: {detail}"
),
});
}
let remote_name = String::from_utf8_lossy(&output.stdout).trim().to_string();
if remote_name.is_empty() {
return Err(GitError::OutputParse(format!(
"Failed to resolve current branch remote `{remote_config_key}`: empty output"
)));
}
Ok(Some(remote_name))
}
async fn current_branch_name(
repo_path: &Path,
command_runner: &dyn AsyncGitCommandRunner,
) -> Result<String, GitError> {
let branch_name = run_git_command_with_runner(
AsyncGitCommand::new(
repo_path.to_path_buf(),
vec![
"rev-parse".to_string(),
"--abbrev-ref".to_string(),
"HEAD".to_string(),
],
),
"Failed to resolve current branch name",
command_runner,
)
.await?;
let branch_name = branch_name.trim().to_string();
if branch_name.is_empty() {
return Err(GitError::OutputParse(
"Failed to resolve current branch name: empty output".to_string(),
));
}
if branch_name == "HEAD" {
return Err(GitError::OutputParse(
"Failed to resolve current branch name: detached HEAD".to_string(),
));
}
Ok(branch_name)
}
async fn run_async_git_command_with_index_lock_retry(
command: AsyncGitCommand,
command_runner: &dyn AsyncGitCommandRunner,
retry_delay: Duration,
) -> Result<AsyncGitCommandOutput, GitError> {
let mut attempt_count = 0;
loop {
attempt_count += 1;
let output = command_runner.run(command.clone()).await?;
if output.success() {
return Ok(output);
}
let detail = command_output_detail(&output.stdout, &output.stderr);
let is_last_attempt = attempt_count == GIT_INDEX_LOCK_RETRY_ATTEMPTS;
if !is_git_index_lock_error(&detail) || is_last_attempt {
return Ok(output);
}
time::sleep(retry_delay).await;
}
}
pub(crate) async fn push_current_branch(repo_path: PathBuf) -> Result<String, GitError> {
let command_runner = ProcessAsyncGitCommandRunner;
push_current_branch_with_runner(repo_path, &command_runner).await
}
async fn push_current_branch_with_runner(
repo_path: PathBuf,
command_runner: &dyn AsyncGitCommandRunner,
) -> Result<String, GitError> {
let push_command = AsyncGitCommand::new(
repo_path.clone(),
vec!["push".to_string(), "--force-with-lease".to_string()],
);
let push_output = command_runner.run(push_command).await?;
if push_output.success() {
return primary_upstream_reference(&repo_path, command_runner).await;
}
let push_detail = command_output_detail(&push_output.stdout, &push_output.stderr);
if !is_no_upstream_error(&push_detail) {
return Err(GitError::CommandFailed {
command: "git push --force-with-lease".to_string(),
stderr: push_detail,
});
}
let remote_name = current_branch_remote_name(&repo_path, command_runner)
.await?
.unwrap_or_else(|| "origin".to_string());
run_git_command_with_runner(
AsyncGitCommand::new(
repo_path.clone(),
vec![
"push".to_string(),
"--force-with-lease".to_string(),
"--set-upstream".to_string(),
remote_name,
"HEAD".to_string(),
],
),
"Git push failed",
command_runner,
)
.await?;
primary_upstream_reference(&repo_path, command_runner).await
}
pub(crate) async fn push_current_branch_to_remote_branch(
repo_path: PathBuf,
remote_branch_name: String,
) -> Result<String, GitError> {
let command_runner = ProcessAsyncGitCommandRunner;
push_current_branch_to_remote_branch_with_runner(repo_path, remote_branch_name, &command_runner)
.await
}
pub(crate) async fn push_current_branch_to_new_remote_branch(
repo_path: PathBuf,
remote_branch_name: String,
) -> Result<String, GitError> {
let command_runner = ProcessAsyncGitCommandRunner;
push_current_branch_to_new_remote_branch_with_runner(
repo_path,
remote_branch_name,
&command_runner,
)
.await
}
pub(crate) async fn remote_branch_exists(
repo_path: PathBuf,
remote_branch_name: String,
) -> Result<bool, GitError> {
let command_runner = ProcessAsyncGitCommandRunner;
remote_branch_exists_with_runner(repo_path, remote_branch_name, &command_runner).await
}
async fn push_current_branch_to_remote_branch_with_runner(
repo_path: PathBuf,
remote_branch_name: String,
command_runner: &dyn AsyncGitCommandRunner,
) -> Result<String, GitError> {
let remote_name = current_branch_remote_name(&repo_path, command_runner)
.await?
.unwrap_or_else(|| "origin".to_string());
let push_refspec = format!("HEAD:{remote_branch_name}");
let arguments = vec![
"push".to_string(),
"--force-with-lease".to_string(),
"--set-upstream".to_string(),
remote_name.clone(),
push_refspec,
];
run_git_command_with_runner(
AsyncGitCommand::new(repo_path, arguments),
"Git push failed",
command_runner,
)
.await?;
Ok(format!("{remote_name}/{remote_branch_name}"))
}
async fn push_current_branch_to_new_remote_branch_with_runner(
repo_path: PathBuf,
remote_branch_name: String,
command_runner: &dyn AsyncGitCommandRunner,
) -> Result<String, GitError> {
let remote_name = current_branch_remote_name(&repo_path, command_runner)
.await?
.unwrap_or_else(|| "origin".to_string());
let remote_ref = format!("refs/heads/{remote_branch_name}");
let lease_argument = format!("--force-with-lease={remote_ref}:");
let push_refspec = format!("HEAD:{remote_branch_name}");
let arguments = vec![
"push".to_string(),
lease_argument,
"--set-upstream".to_string(),
remote_name.clone(),
push_refspec,
];
run_git_command_with_runner(
AsyncGitCommand::new(repo_path, arguments),
"Git push failed",
command_runner,
)
.await?;
Ok(format!("{remote_name}/{remote_branch_name}"))
}
async fn remote_branch_exists_with_runner(
repo_path: PathBuf,
remote_branch_name: String,
command_runner: &dyn AsyncGitCommandRunner,
) -> Result<bool, GitError> {
let remote_name = current_branch_remote_name(&repo_path, command_runner)
.await?
.unwrap_or_else(|| "origin".to_string());
let arguments = vec![
"ls-remote".to_string(),
"--heads".to_string(),
remote_name,
remote_branch_name,
];
let stdout = run_git_command_with_runner(
AsyncGitCommand::new(repo_path, arguments),
"Git ls-remote failed",
command_runner,
)
.await?;
Ok(!stdout.trim().is_empty())
}
pub(crate) async fn current_upstream_reference(repo_path: PathBuf) -> Result<String, GitError> {
let command_runner = ProcessAsyncGitCommandRunner;
primary_upstream_reference(&repo_path, &command_runner).await
}
pub(crate) async fn fetch_remote(repo_path: PathBuf) -> Result<(), GitError> {
run_git_command(
repo_path,
vec!["fetch".to_string()],
"Git fetch failed".to_string(),
)
.await?;
Ok(())
}
pub(crate) async fn get_ahead_behind(repo_path: PathBuf) -> Result<(u32, u32), GitError> {
get_ref_ahead_behind(repo_path, "HEAD".to_string(), "@{u}".to_string()).await
}
pub(crate) async fn get_ref_ahead_behind(
repo_path: PathBuf,
left_ref: String,
right_ref: String,
) -> Result<(u32, u32), GitError> {
let rev_list_output = run_git_command(
repo_path,
vec![
"rev-list".to_string(),
"--left-right".to_string(),
"--count".to_string(),
format!("{left_ref}...{right_ref}"),
],
"Git rev-list failed".to_string(),
)
.await?;
parse_ahead_behind_counts(&rev_list_output)
}
fn parse_ahead_behind_counts(rev_list_output: &str) -> Result<(u32, u32), GitError> {
let parts: Vec<&str> = rev_list_output.split_whitespace().collect();
if parts.len() >= 2 {
let ahead = parts[0].parse().unwrap_or(0);
let behind = parts[1].parse().unwrap_or(0);
return Ok((ahead, behind));
}
Err(GitError::OutputParse(
"Unexpected output format from git rev-list".to_string(),
))
}
pub(crate) async fn branch_tracking_statuses(
repo_path: PathBuf,
) -> Result<BranchTrackingMap, GitError> {
let git_output = run_git_command(
repo_path,
vec![
"for-each-ref".to_string(),
"--format=%(refname:short)\t%(upstream:short)\t%(upstream:track,nobracket)".to_string(),
"refs/heads".to_string(),
],
"Git for-each-ref failed".to_string(),
)
.await?;
Ok(parse_branch_tracking_statuses(&git_output))
}
pub(crate) async fn list_upstream_commit_titles(
repo_path: PathBuf,
) -> Result<Vec<String>, GitError> {
let git_output = run_git_command(
repo_path,
vec![
"log".to_string(),
"--reverse".to_string(),
"--pretty=%s".to_string(),
"HEAD..@{u}".to_string(),
],
"Git log failed".to_string(),
)
.await?;
Ok(parse_commit_titles(&git_output))
}
pub(crate) async fn list_local_commit_titles(repo_path: PathBuf) -> Result<Vec<String>, GitError> {
let git_output = run_git_command(
repo_path,
vec![
"log".to_string(),
"--reverse".to_string(),
"--pretty=%s".to_string(),
"@{u}..HEAD".to_string(),
],
"Git log failed".to_string(),
)
.await?;
Ok(parse_commit_titles(&git_output))
}
pub(crate) async fn has_commits_since(
repo_path: PathBuf,
base_branch: String,
) -> Result<bool, GitError> {
spawn_blocking(move || -> Result<bool, GitError> {
let rev_list_output = run_git_command_sync(
&repo_path,
&["rev-list", "--count", &format!("{base_branch}..HEAD")],
"Failed to count commits since base branch",
)?;
let commit_count = rev_list_output.trim().parse::<u32>().map_err(|error| {
GitError::OutputParse(format!(
"Failed to parse commit count since base branch `{base_branch}`: {error}"
))
})?;
Ok(commit_count > 0)
})
.await?
}
fn parse_commit_titles(output: &str) -> Vec<String> {
output
.lines()
.map(str::trim)
.filter(|title| !title.is_empty())
.map(ToString::to_string)
.collect()
}
fn parse_branch_tracking_statuses(output: &str) -> BranchTrackingMap {
let mut branch_tracking_statuses = HashMap::new();
for line in output
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
{
let mut parts = line.splitn(3, '\t');
let Some(branch_name) = parts
.next()
.map(str::trim)
.filter(|value| !value.is_empty())
else {
continue;
};
let upstream_ref = parts.next().map(str::trim).unwrap_or_default();
let track = parts.next().map(str::trim).unwrap_or_default();
let status = if upstream_ref.is_empty() {
None
} else {
parse_branch_tracking_counts(track)
};
branch_tracking_statuses.insert(branch_name.to_string(), status);
}
branch_tracking_statuses
}
fn parse_branch_tracking_counts(track: &str) -> Option<(u32, u32)> {
let normalized_track = track.trim();
if normalized_track.is_empty() || normalized_track == "gone" {
return None;
}
let mut ahead = 0;
let mut behind = 0;
for part in normalized_track.split(',').map(str::trim) {
if let Some(count) = part.strip_prefix("ahead ") {
ahead = count.parse().ok()?;
} else if let Some(count) = part.strip_prefix("behind ") {
behind = count.parse().ok()?;
}
}
Some((ahead, behind))
}
fn resolve_diff_target(
repo_path: &Path,
base_branch: &str,
merge_base: &str,
) -> Result<String, GitError> {
let cherry_output = run_git_command_output_sync(repo_path, &["cherry", base_branch, "HEAD"])?;
if !cherry_output.status.success() {
return Ok(merge_base.to_string());
}
let cherry_stdout = String::from_utf8_lossy(&cherry_output.stdout);
let Some(last_leading_applied_commit) = last_leading_applied_commit(&cherry_stdout) else {
return Ok(merge_base.to_string());
};
Ok(last_leading_applied_commit.to_string())
}
fn last_leading_applied_commit(cherry_output: &str) -> Option<&str> {
let mut last_applied_commit = None;
for line in cherry_output.lines() {
let trimmed_line = line.trim();
if trimmed_line.is_empty() {
continue;
}
let mut parts = trimmed_line.split_whitespace();
let marker = parts.next()?;
let commit_hash = parts.next()?;
if marker == "-" {
last_applied_commit = Some(commit_hash);
continue;
}
if marker == "+" {
break;
}
break;
}
last_applied_commit
}
async fn commit_all_with_retry(
repo_path: PathBuf,
commit_message: String,
message_strategy: SingleCommitMessageStrategy,
amend_existing_commit: bool,
) -> Result<(), GitError> {
spawn_blocking(move || {
stage_all_sync(&repo_path)?;
for _ in 0..COMMIT_ALL_HOOK_RETRY_ATTEMPTS {
let output = run_commit_command(
&repo_path,
&commit_message,
message_strategy,
amend_existing_commit,
)?;
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
if is_nothing_to_commit_output(&stdout, &stderr) {
return Err(nothing_to_commit_error());
}
if amend_existing_commit && is_empty_amend_output(&stdout, &stderr) {
reset_empty_amend_sync(&repo_path)?;
return Err(nothing_to_commit_error());
}
if is_hook_modified_error(&stdout, &stderr) {
stage_all_sync(&repo_path)?;
continue;
}
let detail = command_output_detail(&output.stdout, &output.stderr);
return Err(GitError::CommandFailed {
command: "git commit".to_string(),
stderr: detail,
});
}
Err(GitError::CommandFailed {
command: "git commit".to_string(),
stderr: format!(
"Failed to commit: commit hooks kept modifying files after \
{COMMIT_ALL_HOOK_RETRY_ATTEMPTS} attempts"
),
})
})
.await?
}
fn ensure_pre_commit_hook_ready(repo_path: &Path) -> Result<(), GitError> {
let Some(config_file) = PRE_COMMIT_CONFIG_FILES
.iter()
.find(|config_file| repo_path.join(config_file).is_file())
else {
return Ok(());
};
let hook_path = resolve_pre_commit_hook_path(repo_path)?;
if is_executable_hook(&hook_path) {
return Ok(());
}
Err(GitError::PreCommitHookMissing {
config_file: (*config_file).to_string(),
})
}
fn resolve_pre_commit_hook_path(repo_path: &Path) -> Result<PathBuf, GitError> {
let hooks_path_output =
run_git_command_output_sync(repo_path, &["config", "--path", "--get", "core.hooksPath"])?;
let hooks_path = if hooks_path_output.status.success() {
PathBuf::from(String::from_utf8_lossy(&hooks_path_output.stdout).trim())
} else if hooks_path_output.status.code() == Some(1) {
let default_hook_path = run_git_command_sync(
repo_path,
&["rev-parse", "--git-path", "hooks/pre-commit"],
"Failed to resolve Git pre-commit hook path",
)?;
return Ok(resolve_repo_path(
repo_path,
PathBuf::from(default_hook_path.trim()),
));
} else {
return Err(GitError::CommandFailed {
command: "git config --path --get core.hooksPath".to_string(),
stderr: command_output_detail(&hooks_path_output.stdout, &hooks_path_output.stderr),
});
};
Ok(resolve_repo_path(repo_path, hooks_path).join("pre-commit"))
}
fn resolve_repo_path(repo_path: &Path, path: PathBuf) -> PathBuf {
if path.is_absolute() {
return path;
}
repo_path.join(path)
}
#[cfg(unix)]
fn is_executable_hook(hook_path: &Path) -> bool {
hook_path.is_file() && rustix_fs::access(hook_path, Access::EXEC_OK).is_ok()
}
#[cfg(not(unix))]
fn is_executable_hook(hook_path: &Path) -> bool {
hook_path.is_file()
}
fn nothing_to_commit_error() -> GitError {
GitError::CommandFailed {
command: "git commit".to_string(),
stderr: "Nothing to commit: no changes detected".to_string(),
}
}
fn is_nothing_to_commit_output(stdout: &str, stderr: &str) -> bool {
let combined = format!("{stdout}\n{stderr}").to_ascii_lowercase();
combined.contains("nothing to commit")
}
fn is_empty_amend_output(stdout: &str, stderr: &str) -> bool {
let combined = format!("{stdout}\n{stderr}").to_ascii_lowercase();
let normalized = combined.split_whitespace().collect::<Vec<_>>().join(" ");
normalized.contains("would make it empty") && normalized.contains("allow-empty")
}
fn reset_empty_amend_sync(repo_path: &Path) -> Result<(), GitError> {
run_git_command_sync(
repo_path,
&["reset", "HEAD^"],
"Git reset after empty amend failed",
)?;
Ok(())
}
fn stage_all_sync(repo_path: &Path) -> Result<(), GitError> {
let output = run_git_command_with_index_lock_retry(repo_path, &["add", "-A"], &[])?;
if !output.status.success() {
let detail = command_output_detail(&output.stdout, &output.stderr);
return Err(GitError::CommandFailed {
command: "git add -A".to_string(),
stderr: format!("Failed to stage changes: {detail}"),
});
}
Ok(())
}
fn head_commit_message_sync(repo_path: &Path) -> Result<Option<String>, GitError> {
if !has_head_commit_sync(repo_path)? {
return Ok(None);
}
let output = run_git_command_sync(
repo_path,
&["log", "-1", "--pretty=%B"],
"Failed to read HEAD commit message",
)?;
Ok(Some(output.trim().to_string()))
}
fn has_head_commit_sync(repo_path: &Path) -> Result<bool, GitError> {
let output = run_git_command_output_sync(repo_path, &["rev-parse", "--verify", "HEAD"])?;
if output.status.success() {
return Ok(true);
}
let detail = command_output_detail(&output.stdout, &output.stderr);
let normalized_detail = detail.to_ascii_lowercase();
if normalized_detail.contains("needed a single revision")
|| normalized_detail.contains("unknown revision")
|| normalized_detail.contains("does not have any commits yet")
{
return Ok(false);
}
Err(GitError::CommandFailed {
command: "git rev-parse --verify HEAD".to_string(),
stderr: detail,
})
}
fn run_commit_command(
repo_path: &Path,
commit_message: &str,
message_strategy: SingleCommitMessageStrategy,
amend_existing_commit: bool,
) -> Result<Output, GitError> {
let mut args = vec!["commit"];
if amend_existing_commit {
args.push("--amend");
match message_strategy {
SingleCommitMessageStrategy::Replace => {
args.push("-m");
args.push(commit_message);
}
SingleCommitMessageStrategy::Reuse => {
args.push("--no-edit");
}
}
} else {
args.push("-m");
args.push(commit_message);
}
run_git_command_with_index_lock_retry(repo_path, &args, &[])
}
fn is_hook_modified_error(stdout: &str, stderr: &str) -> bool {
let combined = format!(
"{stdout}
{stderr}"
)
.to_ascii_lowercase();
combined.contains("files were modified by this hook")
}
pub(super) fn is_no_upstream_error(detail: &str) -> bool {
let normalized_detail = detail.to_ascii_lowercase();
normalized_detail.contains("has no upstream branch")
|| normalized_detail.contains("no upstream branch")
|| normalized_detail.contains("set-upstream")
}
#[cfg(test)]
#[path = "sync_test.rs"]
mod tests;