Skip to main content

ag_git/
sync.rs

1use std::collections::HashMap;
2use std::io::Read;
3use std::path::{Component, Path, PathBuf};
4use std::process::Output;
5use std::time::Duration;
6
7#[cfg(unix)]
8use rustix::fs::{self as rustix_fs, Access};
9use tokio::task::spawn_blocking;
10use tokio::time;
11
12use super::error::GitError;
13use super::rebase::{
14    GIT_INDEX_LOCK_RETRY_ATTEMPTS, GIT_INDEX_LOCK_RETRY_DELAY, is_git_index_lock_error,
15    is_rebase_conflict, run_git_command_with_index_lock_retry,
16};
17use super::repo::{
18    AsyncGitCommand, AsyncGitCommandOutput, AsyncGitCommandRunner, ProcessAsyncGitCommandRunner,
19    command_output_detail, run_git_command, run_git_command_output_sync,
20    run_git_command_output_with_env_sync, run_git_command_sync, run_git_command_with_runner,
21};
22
23/// Map of local branch names to their ahead/behind counts relative to their
24/// tracked upstream branch. `None` indicates no upstream or a gone upstream.
25pub type BranchTrackingMap = HashMap<String, Option<(u32, u32)>>;
26
27const COMMIT_ALL_HOOK_RETRY_ATTEMPTS: usize = 5;
28const MAX_WORKTREE_FILE_BYTE_COUNT: usize = 1024 * 1024;
29const PRE_COMMIT_CONFIG_FILES: [&str; 2] = [".pre-commit-config.yaml", ".pre-commit-config.yml"];
30
31/// Bounded content returned when reading a worktree file for presentation.
32#[derive(Clone, Debug, Eq, PartialEq)]
33pub enum WorktreeFileContent {
34    /// The file contains valid UTF-8 text within the preview byte limit.
35    Text(String),
36    /// The file does not exist in the current worktree.
37    Missing,
38    /// The file is not valid UTF-8 text.
39    Binary,
40    /// The file exceeds the preview byte limit.
41    TooLarge,
42}
43
44/// Controls how single-commit session branches treat the commit message when
45/// amending `HEAD`.
46#[derive(Clone, Copy, Debug, Eq, PartialEq)]
47pub enum SingleCommitMessageStrategy {
48    /// Replaces the existing `HEAD` message with the newly generated message.
49    Replace,
50    /// Keeps the current `HEAD` message while amending file content only.
51    Reuse,
52}
53
54/// Result of attempting `git pull --rebase`.
55#[derive(Clone, Debug, Eq, PartialEq)]
56pub enum PullRebaseResult {
57    /// Pull and rebase completed successfully.
58    Completed,
59    /// Pull stopped because of merge conflicts.
60    Conflict {
61        /// Git diagnostic describing the conflict state.
62        detail: String,
63    },
64}
65
66/// Stages all changes and commits them with the given message.
67///
68/// # Arguments
69/// * `repo_path` - Path to the git repository or worktree
70/// * `commit_message` - Message for the commit
71/// # Returns
72/// Ok(()) on success.
73///
74/// # Errors
75/// Returns a [`GitError`] if staging or committing changes fails.
76pub(crate) async fn commit_all(repo_path: PathBuf, commit_message: String) -> Result<(), GitError> {
77    commit_all_with_retry(
78        repo_path,
79        commit_message,
80        SingleCommitMessageStrategy::Replace,
81        false,
82    )
83    .await
84}
85
86/// Stages all changes and keeps a single commit for the provided message.
87///
88/// Creates a new commit when `HEAD` has no commits beyond `base_branch`.
89/// Otherwise, amends `HEAD` so the branch keeps one evolving session commit.
90///
91/// # Arguments
92/// * `repo_path` - Path to the git repository or worktree
93/// * `base_branch` - Branch used to detect whether a session commit already
94///   exists on `HEAD`
95/// * `commit_message` - Message that identifies the session commit
96/// * `message_strategy` - Whether amends replace or reuse the existing `HEAD`
97///   message
98/// # Returns
99/// Ok(()) on success.
100///
101/// # Errors
102/// Returns a [`GitError`] if staging, commit lookup, or committing changes
103/// fails.
104pub(crate) async fn commit_all_preserving_single_commit(
105    repo_path: PathBuf,
106    base_branch: String,
107    commit_message: String,
108    message_strategy: SingleCommitMessageStrategy,
109) -> Result<(), GitError> {
110    let amend_existing_commit = has_commits_since(repo_path.clone(), base_branch).await?;
111
112    commit_all_with_retry(
113        repo_path,
114        commit_message,
115        message_strategy,
116        amend_existing_commit,
117    )
118    .await
119}
120
121/// Stages all changes in the repository or worktree.
122///
123/// # Arguments
124/// * `repo_path` - Path to the git repository or worktree
125///
126/// # Returns
127/// Ok(()) on success.
128///
129/// # Errors
130/// Returns a [`GitError`] if `git add -A` fails.
131pub(crate) async fn stage_all(repo_path: PathBuf) -> Result<(), GitError> {
132    spawn_blocking(move || stage_all_sync(&repo_path)).await?
133}
134
135/// Verifies that configured pre-commit validation has an executable Git hook.
136///
137/// # Errors
138/// Returns [`GitError::PreCommitHookMissing`] when a supported configuration
139/// exists without an executable hook, or a command error when the effective
140/// hook path cannot be resolved.
141pub(crate) async fn check_pre_commit_hook_ready(repo_path: PathBuf) -> Result<(), GitError> {
142    spawn_blocking(move || ensure_pre_commit_hook_ready(&repo_path)).await?
143}
144
145/// Runs the effective Git `pre-commit` hook against the current index.
146///
147/// Missing hooks are accepted so repositories without configured validation
148/// retain Git's normal commit behavior. Hook failures are returned with their
149/// captured output.
150///
151/// # Errors
152/// Returns a [`GitError`] when Git cannot run the hook or the hook rejects the
153/// staged changes.
154pub(crate) async fn run_pre_commit_hook(repo_path: PathBuf) -> Result<(), GitError> {
155    spawn_blocking(move || {
156        pre_commit_hook_result(run_git_command_output_sync(
157            &repo_path,
158            &["hook", "run", "--ignore-missing", "pre-commit"],
159        ))
160    })
161    .await?
162}
163
164/// Interprets the output of an effective pre-commit hook invocation.
165fn pre_commit_hook_result(output: Result<Output, GitError>) -> Result<(), GitError> {
166    let output = output?;
167    if output.status.success() {
168        return Ok(());
169    }
170
171    Err(GitError::CommandFailed {
172        command: "git hook run pre-commit".to_string(),
173        stderr: command_output_detail(&output.stdout, &output.stderr),
174    })
175}
176
177/// Returns the short hash of the current `HEAD` commit.
178///
179/// # Arguments
180/// * `repo_path` - Path to the git repository or worktree
181///
182/// # Returns
183/// The short commit hash as a string.
184///
185/// # Errors
186/// Returns a [`GitError`] if resolving `HEAD` fails.
187pub(crate) async fn head_short_hash(repo_path: PathBuf) -> Result<String, GitError> {
188    let hash = run_git_command(
189        repo_path,
190        vec![
191            "rev-parse".to_string(),
192            "--short".to_string(),
193            "HEAD".to_string(),
194        ],
195        "Failed to resolve HEAD hash".to_string(),
196    )
197    .await?;
198    let hash = hash.trim().to_string();
199    if hash.is_empty() {
200        return Err(GitError::OutputParse(
201            "Failed to resolve HEAD hash: empty output".to_string(),
202        ));
203    }
204
205    Ok(hash)
206}
207
208/// Returns the full hash of the current `HEAD` commit.
209///
210/// # Arguments
211/// * `repo_path` - Path to the git repository or worktree
212///
213/// # Returns
214/// The full commit hash as a string.
215///
216/// # Errors
217/// Returns a [`GitError`] if resolving `HEAD` fails.
218pub(crate) async fn head_hash(repo_path: PathBuf) -> Result<String, GitError> {
219    let hash = run_git_command(
220        repo_path,
221        vec!["rev-parse".to_string(), "HEAD".to_string()],
222        "Failed to resolve HEAD hash".to_string(),
223    )
224    .await?;
225    let hash = hash.trim().to_string();
226    if hash.is_empty() {
227        return Err(GitError::OutputParse(
228            "Failed to resolve HEAD hash: empty output".to_string(),
229        ));
230    }
231
232    Ok(hash)
233}
234
235/// Returns the full commit hash for a git reference.
236///
237/// # Arguments
238/// * `repo_path` - Path to the git repository or worktree.
239/// * `reference` - Branch, tag, or commit-ish to resolve.
240///
241/// # Returns
242/// The full commit hash as a string.
243///
244/// # Errors
245/// Returns a [`GitError`] if the reference cannot be resolved to a commit.
246pub(crate) async fn ref_hash(repo_path: PathBuf, reference: String) -> Result<String, GitError> {
247    let hash = run_git_command(
248        repo_path,
249        vec![
250            "rev-parse".to_string(),
251            "--verify".to_string(),
252            format!("{reference}^{{commit}}"),
253        ],
254        format!("Failed to resolve `{reference}` hash"),
255    )
256    .await?;
257    let hash = hash.trim().to_string();
258    if hash.is_empty() {
259        return Err(GitError::OutputParse(format!(
260            "Failed to resolve `{reference}` hash: empty output"
261        )));
262    }
263
264    Ok(hash)
265}
266
267/// Returns the full `HEAD` commit message, or `None` when no commits exist.
268///
269/// # Errors
270/// Returns a [`GitError`] if `HEAD` cannot be inspected.
271pub(crate) async fn head_commit_message(repo_path: PathBuf) -> Result<Option<String>, GitError> {
272    spawn_blocking(move || head_commit_message_sync(&repo_path)).await?
273}
274
275/// Deletes a git branch.
276///
277/// Uses -D to force deletion even if not merged.
278///
279/// # Arguments
280/// * `repo_path` - Path to the git repository root
281/// * `branch_name` - Name of the branch to delete
282///
283/// # Returns
284/// Ok(()) on success.
285///
286/// # Errors
287/// Returns a [`GitError`] if the branch delete command fails or exceeds its
288/// runtime bound.
289pub(crate) async fn delete_branch(repo_path: PathBuf, branch_name: String) -> Result<(), GitError> {
290    run_git_command(
291        repo_path,
292        vec!["branch".to_string(), "-D".to_string(), branch_name],
293        "Git branch deletion failed".to_string(),
294    )
295    .await?;
296
297    Ok(())
298}
299
300/// Returns the output of `git diff` for the given repository path, showing
301/// all changes (committed and uncommitted) relative to the base branch.
302///
303/// Copies the repository index into a temporary index and uses
304/// `git add --intent-to-add` there to make untracked files visible, then
305/// finds the merge-base between `HEAD` and `base_branch` to diff against the
306/// fork point. To avoid re-showing squash-merged/cherry-picked session commits
307/// on non-rebased branches, this also checks `git cherry` and, when applicable,
308/// diffs from the last leading commit already applied to `base_branch`.
309/// The real repository index is never modified.
310///
311/// # Arguments
312/// * `repo_path` - Path to the git repository or worktree
313/// * `base_branch` - Branch to diff against (e.g., `main`)
314///
315/// # Returns
316/// The diff output as a string.
317///
318/// # Errors
319/// Returns a [`GitError`] if preparing the temporary index or generating the
320/// diff fails.
321pub(crate) async fn diff(repo_path: PathBuf, base_branch: String) -> Result<String, GitError> {
322    diff_output(repo_path, base_branch, false).await
323}
324
325/// Returns repository-relative changed paths using the same isolated index and
326/// fork-point semantics as [`diff`].
327pub(crate) async fn diff_changed_files(
328    repo_path: PathBuf,
329    base_branch: String,
330) -> Result<Vec<String>, GitError> {
331    let output = diff_output(repo_path, base_branch, true).await?;
332
333    Ok(output
334        .lines()
335        .map(str::trim)
336        .filter(|path| !path.is_empty())
337        .map(str::to_string)
338        .collect())
339}
340
341/// Generates either a patch or name-only output without mutating the real
342/// repository index.
343async fn diff_output(
344    repo_path: PathBuf,
345    base_branch: String,
346    name_only: bool,
347) -> Result<String, GitError> {
348    spawn_blocking(move || -> Result<String, GitError> {
349        let index_path = resolve_diff_index_path(&repo_path)?;
350        let index_path = PathBuf::from(index_path.trim());
351        let index_path = if index_path.is_absolute() {
352            index_path
353        } else {
354            repo_path.join(index_path)
355        };
356
357        diff_output_after_index_resolution(&repo_path, &base_branch, name_only, &index_path)
358    })
359    .await?
360}
361
362/// Generates diff output after the real index path has been resolved.
363fn diff_output_after_index_resolution(
364    repo_path: &Path,
365    base_branch: &str,
366    name_only: bool,
367    index_path: &Path,
368) -> Result<String, GitError> {
369    let result = (|| -> Result<String, GitError> {
370        let temporary_index = copy_git_index_to_temp(index_path)?;
371
372        run_git_command_with_index_sync(
373            repo_path,
374            &["add", "-A", "--intent-to-add"],
375            &temporary_index,
376            "Git add --intent-to-add failed",
377        )?;
378
379        let merge_base_output =
380            run_git_command_output_sync(repo_path, &["merge-base", "HEAD", base_branch])?;
381
382        let diff_target = if merge_base_output.status.success() {
383            resolve_diff_target(
384                repo_path,
385                base_branch,
386                String::from_utf8_lossy(&merge_base_output.stdout).trim(),
387            )?
388        } else {
389            base_branch.to_string()
390        };
391
392        let args = if name_only {
393            vec!["diff", "--name-only", diff_target.as_str()]
394        } else {
395            vec!["diff", diff_target.as_str()]
396        };
397
398        run_git_command_with_index_sync(repo_path, &args, &temporary_index, "Git diff failed")
399    })();
400
401    result.map_err(|error| classify_diff_repository_error(repo_path, error))
402}
403
404/// Resolves the real index path and classifies a reclaimed repository.
405fn resolve_diff_index_path(repo_path: &Path) -> Result<String, GitError> {
406    run_git_command_sync(
407        repo_path,
408        &["rev-parse", "--git-path", "index"],
409        "Git index path resolution failed",
410    )
411    .map_err(|error| classify_diff_repository_error(repo_path, error))
412}
413
414/// Preserves ordinary Git failures while typing unavailable repository paths.
415fn classify_diff_repository_error(repo_path: &Path, error: GitError) -> GitError {
416    if !diff_repository_is_unavailable(repo_path) {
417        return error;
418    }
419
420    GitError::RepositoryUnavailable {
421        detail: error.to_string(),
422    }
423}
424
425/// Probes repository discovery without interpreting localized Git output.
426fn diff_repository_is_unavailable(repo_path: &Path) -> bool {
427    if !repo_path.is_dir() {
428        return true;
429    }
430
431    diff_repository_probe_is_unavailable(run_git_command_output_sync(
432        repo_path,
433        &["rev-parse", "--git-dir"],
434    ))
435}
436
437/// Treats only a completed, unsuccessful discovery probe as unavailable.
438fn diff_repository_probe_is_unavailable(probe: Result<Output, GitError>) -> bool {
439    match probe {
440        Ok(output) => !output.status.success(),
441        Err(_) => false,
442    }
443}
444
445/// Reads one repository-relative worktree file with a fixed memory bound.
446///
447/// The path must contain only normal relative components. Canonical path
448/// validation also rejects symlinks that resolve outside `repo_path`.
449///
450/// # Errors
451/// Returns a [`GitError`] when the path is unsafe, repository path resolution
452/// fails, or the selected file cannot be read.
453pub(crate) async fn read_worktree_file(
454    repo_path: PathBuf,
455    relative_path: String,
456) -> Result<WorktreeFileContent, GitError> {
457    spawn_blocking(move || read_worktree_file_sync(&repo_path, &relative_path)).await?
458}
459
460/// Performs the bounded worktree read on a blocking worker thread.
461fn read_worktree_file_sync(
462    repo_path: &Path,
463    relative_path: &str,
464) -> Result<WorktreeFileContent, GitError> {
465    let relative_file_path = Path::new(relative_path);
466    if relative_path.is_empty()
467        || relative_file_path
468            .components()
469            .any(|component| !matches!(component, Component::Normal(_)))
470    {
471        return Err(GitError::OutputParse(format!(
472            "Unsafe worktree file path: {relative_path}"
473        )));
474    }
475
476    let canonical_repo_path = std::fs::canonicalize(repo_path)?;
477    let candidate_path = repo_path.join(relative_file_path);
478    let canonical_file_path = match std::fs::canonicalize(candidate_path) {
479        Ok(path) => path,
480        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
481            return Ok(WorktreeFileContent::Missing);
482        }
483        Err(error) => return Err(error.into()),
484    };
485    if !canonical_file_path.starts_with(canonical_repo_path) {
486        return Err(GitError::OutputParse(format!(
487            "Worktree file resolves outside repository: {relative_path}"
488        )));
489    }
490
491    let file = std::fs::File::open(canonical_file_path)?;
492    let mut bytes = Vec::with_capacity(MAX_WORKTREE_FILE_BYTE_COUNT.min(8192));
493    file.take((MAX_WORKTREE_FILE_BYTE_COUNT as u64).saturating_add(1))
494        .read_to_end(&mut bytes)?;
495
496    Ok(worktree_file_content(bytes))
497}
498
499/// Classifies bytes read through the bounded worktree-file reader.
500fn worktree_file_content(bytes: Vec<u8>) -> WorktreeFileContent {
501    if bytes.len() > MAX_WORKTREE_FILE_BYTE_COUNT {
502        return WorktreeFileContent::TooLarge;
503    }
504
505    match String::from_utf8(bytes) {
506        Ok(content) => WorktreeFileContent::Text(content),
507        Err(_) => WorktreeFileContent::Binary,
508    }
509}
510
511/// Copies one repository index beside its source and returns the temporary
512/// path used by isolated read-only diff commands.
513fn copy_git_index_to_temp(index_path: &Path) -> Result<tempfile::TempPath, GitError> {
514    let index_parent = index_path.parent().ok_or_else(|| {
515        GitError::OutputParse(format!(
516            "Git index path has no parent: {}",
517            index_path.display()
518        ))
519    })?;
520    let temporary_index =
521        tempfile::NamedTempFile::new_in(index_parent).map_err(|error| GitError::CommandFailed {
522            command: "create temporary git index".to_string(),
523            stderr: error.to_string(),
524        })?;
525    std::fs::copy(index_path, temporary_index.path()).map_err(|error| GitError::CommandFailed {
526        command: "copy git index".to_string(),
527        stderr: error.to_string(),
528    })?;
529
530    Ok(temporary_index.into_temp_path())
531}
532
533/// Runs one git command against a temporary index without touching the real
534/// index.
535fn run_git_command_with_index_sync(
536    repo_path: &Path,
537    args: &[&str],
538    index_path: &Path,
539    error_context: &str,
540) -> Result<String, GitError> {
541    let output = run_git_command_output_with_env_sync(
542        repo_path,
543        args,
544        &[("GIT_INDEX_FILE", index_path.as_os_str())],
545    )?;
546    if !output.status.success() {
547        return Err(GitError::CommandFailed {
548            command: format!("git {}", args.join(" ")),
549            stderr: format!(
550                "{error_context}: {}",
551                command_output_detail(&output.stdout, &output.stderr)
552            ),
553        });
554    }
555
556    Ok(String::from_utf8_lossy(&output.stdout).into_owned())
557}
558
559/// Returns whether a repository or worktree has no uncommitted changes.
560///
561/// # Arguments
562/// * `repo_path` - Path to the git repository or worktree
563///
564/// # Returns
565/// `true` when `git status --porcelain` is empty, `false` otherwise.
566///
567/// # Errors
568/// Returns a [`GitError`] if `git status --porcelain` cannot be executed.
569pub(crate) async fn is_worktree_clean(repo_path: PathBuf) -> Result<bool, GitError> {
570    let status_output = worktree_status(repo_path).await?;
571
572    Ok(status_output.trim().is_empty())
573}
574
575/// Returns a stable porcelain status snapshot for a repository or worktree.
576///
577/// The snapshot includes untracked files so cleanup and review workflows can
578/// detect all local filesystem changes in the worktree.
579///
580/// # Arguments
581/// * `repo_path` - Path to the git repository or worktree
582///
583/// # Returns
584/// Raw `git status --porcelain=v1 --untracked-files=all` stdout.
585///
586/// # Errors
587/// Returns a [`GitError`] if the status command cannot be executed.
588pub(crate) async fn worktree_status(repo_path: PathBuf) -> Result<String, GitError> {
589    run_git_command(
590        repo_path,
591        vec![
592            "status".to_string(),
593            "--porcelain=v1".to_string(),
594            "--untracked-files=all".to_string(),
595        ],
596        "Git status --porcelain=v1 failed".to_string(),
597    )
598    .await
599}
600
601/// Returns a stable porcelain status snapshot for tracked worktree files only.
602///
603/// This omits untracked files so session isolation checks can ignore unrelated
604/// editor or build artifacts while still catching modifications, deletions, and
605/// staged changes to tracked files in the main checkout.
606///
607/// # Arguments
608/// * `repo_path` - Path to the git repository or worktree
609///
610/// # Returns
611/// Raw `git status --porcelain=v1 --untracked-files=no` stdout.
612///
613/// # Errors
614/// Returns a [`GitError`] if the status command cannot be executed.
615pub(crate) async fn tracked_worktree_status(repo_path: PathBuf) -> Result<String, GitError> {
616    run_git_command(
617        repo_path,
618        vec![
619            "status".to_string(),
620            "--porcelain=v1".to_string(),
621            "--untracked-files=no".to_string(),
622        ],
623        "Git tracked status --porcelain=v1 failed".to_string(),
624    )
625    .await
626}
627
628/// Runs `git pull --rebase` and returns conflict outcome when applicable.
629///
630/// When an upstream branch can be resolved, this uses an explicit
631/// `git pull --rebase <remote> <branch>` target to avoid ambiguous rebase
632/// failures caused by multiple configured merge branches.
633///
634/// # Arguments
635/// * `repo_path` - Path to the git repository or worktree
636///
637/// # Returns
638/// A [`PullRebaseResult`] describing whether pull/rebase completed or stopped
639/// on conflicts.
640///
641/// # Errors
642/// Returns a [`GitError`] for non-conflict pull/rebase failures.
643pub(crate) async fn pull_rebase(repo_path: PathBuf) -> Result<PullRebaseResult, GitError> {
644    let command_runner = ProcessAsyncGitCommandRunner;
645
646    pull_rebase_with_runner(repo_path, &command_runner, GIT_INDEX_LOCK_RETRY_DELAY).await
647}
648
649/// Runs pull/rebase through an injected asynchronous command boundary.
650async fn pull_rebase_with_runner(
651    repo_path: PathBuf,
652    command_runner: &dyn AsyncGitCommandRunner,
653    retry_delay: Duration,
654) -> Result<PullRebaseResult, GitError> {
655    let pull_arguments = pull_rebase_arguments(&repo_path, command_runner).await?;
656    let command = AsyncGitCommand::new(repo_path, pull_arguments).with_environment(vec![
657        ("GIT_EDITOR".to_string(), ":".to_string()),
658        ("GIT_SEQUENCE_EDITOR".to_string(), ":".to_string()),
659    ]);
660    let output =
661        run_async_git_command_with_index_lock_retry(command, command_runner, retry_delay).await?;
662
663    if output.success() {
664        return Ok(PullRebaseResult::Completed);
665    }
666
667    let detail = command_output_detail(&output.stdout, &output.stderr);
668    if is_rebase_conflict(&detail) {
669        return Ok(PullRebaseResult::Conflict { detail });
670    }
671
672    Err(GitError::CommandFailed {
673        command: "git pull --rebase".to_string(),
674        stderr: detail,
675    })
676}
677
678/// Builds pull arguments that target a single upstream branch when available.
679///
680/// Resolves an explicit `<remote> <branch>` pull target for both remote and
681/// local upstreams so git does not need to infer one from branch config.
682async fn pull_rebase_arguments(
683    repo_path: &Path,
684    command_runner: &dyn AsyncGitCommandRunner,
685) -> Result<Vec<String>, GitError> {
686    let upstream_reference = primary_upstream_reference(repo_path, command_runner).await?;
687
688    if let Some((remote_name, branch_name)) = upstream_reference.split_once('/') {
689        return Ok(vec![
690            "pull".to_string(),
691            "--rebase".to_string(),
692            remote_name.to_string(),
693            branch_name.to_string(),
694        ]);
695    }
696
697    let remote_name = current_branch_remote_name(repo_path, command_runner)
698        .await?
699        .ok_or_else(|| {
700            GitError::OutputParse(
701                "Failed to resolve current branch remote: not configured".to_string(),
702            )
703        })?;
704
705    Ok(vec![
706        "pull".to_string(),
707        "--rebase".to_string(),
708        remote_name,
709        upstream_reference,
710    ])
711}
712
713/// Returns the first upstream reference reported for `HEAD`.
714///
715/// Git can return multiple lines when multiple merge targets are configured.
716/// Pulling with rebase needs one concrete target, so this selects the first
717/// non-empty line.
718async fn primary_upstream_reference(
719    repo_path: &Path,
720    command_runner: &dyn AsyncGitCommandRunner,
721) -> Result<String, GitError> {
722    let upstream_reference = upstream_reference_name(repo_path, command_runner).await?;
723    let Some(primary_reference) = upstream_reference
724        .lines()
725        .map(str::trim)
726        .find(|line| !line.is_empty())
727    else {
728        return Err(GitError::OutputParse(
729            "Failed to resolve upstream branch: empty output".to_string(),
730        ));
731    };
732
733    Ok(primary_reference.to_string())
734}
735
736/// Returns the full upstream reference for `HEAD` (for example, `origin/main`).
737async fn upstream_reference_name(
738    repo_path: &Path,
739    command_runner: &dyn AsyncGitCommandRunner,
740) -> Result<String, GitError> {
741    let upstream_reference = run_git_command_with_runner(
742        AsyncGitCommand::new(
743            repo_path.to_path_buf(),
744            vec![
745                "rev-parse".to_string(),
746                "--abbrev-ref".to_string(),
747                "--symbolic-full-name".to_string(),
748                "@{u}".to_string(),
749            ],
750        ),
751        "Failed to resolve upstream branch",
752        command_runner,
753    )
754    .await?;
755    let upstream_reference = upstream_reference.trim().to_string();
756    if upstream_reference.is_empty() {
757        return Err(GitError::OutputParse(
758            "Failed to resolve upstream branch: empty output".to_string(),
759        ));
760    }
761
762    Ok(upstream_reference)
763}
764
765/// Returns the configured remote name for the current local branch.
766///
767/// This is used when the upstream short name omits a remote prefix (for
768/// example, `main` with `branch.<name>.remote=.`).
769async fn current_branch_remote_name(
770    repo_path: &Path,
771    command_runner: &dyn AsyncGitCommandRunner,
772) -> Result<Option<String>, GitError> {
773    let current_branch_name = current_branch_name(repo_path, command_runner).await?;
774    let remote_config_key = format!("branch.{current_branch_name}.remote");
775    let output = command_runner
776        .run(AsyncGitCommand::new(
777            repo_path.to_path_buf(),
778            vec![
779                "config".to_string(),
780                "--get".to_string(),
781                remote_config_key.clone(),
782            ],
783        ))
784        .await?;
785
786    parse_current_branch_remote_output(&output, &remote_config_key)
787}
788
789/// Parses `git config --get` output for one current-branch remote.
790fn parse_current_branch_remote_output(
791    output: &AsyncGitCommandOutput,
792    remote_config_key: &str,
793) -> Result<Option<String>, GitError> {
794    if output.exit_code == Some(1) {
795        return Ok(None);
796    }
797    if !output.success() {
798        let detail = command_output_detail(&output.stdout, &output.stderr);
799
800        return Err(GitError::CommandFailed {
801            command: format!("git config --get {remote_config_key}"),
802            stderr: format!(
803                "Failed to resolve current branch remote `{remote_config_key}`: {detail}"
804            ),
805        });
806    }
807
808    let remote_name = String::from_utf8_lossy(&output.stdout).trim().to_string();
809    if remote_name.is_empty() {
810        return Err(GitError::OutputParse(format!(
811            "Failed to resolve current branch remote `{remote_config_key}`: empty output"
812        )));
813    }
814
815    Ok(Some(remote_name))
816}
817
818/// Returns the current local branch name for `HEAD`.
819async fn current_branch_name(
820    repo_path: &Path,
821    command_runner: &dyn AsyncGitCommandRunner,
822) -> Result<String, GitError> {
823    let branch_name = run_git_command_with_runner(
824        AsyncGitCommand::new(
825            repo_path.to_path_buf(),
826            vec![
827                "rev-parse".to_string(),
828                "--abbrev-ref".to_string(),
829                "HEAD".to_string(),
830            ],
831        ),
832        "Failed to resolve current branch name",
833        command_runner,
834    )
835    .await?;
836    let branch_name = branch_name.trim().to_string();
837    if branch_name.is_empty() {
838        return Err(GitError::OutputParse(
839            "Failed to resolve current branch name: empty output".to_string(),
840        ));
841    }
842
843    if branch_name == "HEAD" {
844        return Err(GitError::OutputParse(
845            "Failed to resolve current branch name: detached HEAD".to_string(),
846        ));
847    }
848
849    Ok(branch_name)
850}
851
852/// Runs one asynchronous git command and retries transient index lock
853/// contention without blocking a Tokio worker.
854async fn run_async_git_command_with_index_lock_retry(
855    command: AsyncGitCommand,
856    command_runner: &dyn AsyncGitCommandRunner,
857    retry_delay: Duration,
858) -> Result<AsyncGitCommandOutput, GitError> {
859    let mut attempt_count = 0;
860    loop {
861        attempt_count += 1;
862        let output = command_runner.run(command.clone()).await?;
863        if output.success() {
864            return Ok(output);
865        }
866
867        let detail = command_output_detail(&output.stdout, &output.stderr);
868        let is_last_attempt = attempt_count == GIT_INDEX_LOCK_RETRY_ATTEMPTS;
869        if !is_git_index_lock_error(&detail) || is_last_attempt {
870            return Ok(output);
871        }
872
873        time::sleep(retry_delay).await;
874    }
875}
876
877/// Pushes the current branch to its upstream remote with
878/// `--force-with-lease`.
879///
880/// Falls back to `git push --force-with-lease --set-upstream origin HEAD`
881/// when no upstream branch is configured, then returns the resolved upstream
882/// reference.
883///
884/// # Arguments
885/// * `repo_path` - Path to the git repository or worktree
886///
887/// # Returns
888/// The upstream reference on success.
889///
890/// # Errors
891/// Returns a [`GitError`] if `git push` fails or upstream tracking cannot be
892/// resolved afterwards.
893pub(crate) async fn push_current_branch(repo_path: PathBuf) -> Result<String, GitError> {
894    let command_runner = ProcessAsyncGitCommandRunner;
895
896    push_current_branch_with_runner(repo_path, &command_runner).await
897}
898
899/// Pushes the current branch through an injected asynchronous command
900/// boundary.
901async fn push_current_branch_with_runner(
902    repo_path: PathBuf,
903    command_runner: &dyn AsyncGitCommandRunner,
904) -> Result<String, GitError> {
905    let push_command = AsyncGitCommand::new(
906        repo_path.clone(),
907        vec!["push".to_string(), "--force-with-lease".to_string()],
908    );
909    let push_output = command_runner.run(push_command).await?;
910
911    if push_output.success() {
912        return primary_upstream_reference(&repo_path, command_runner).await;
913    }
914
915    let push_detail = command_output_detail(&push_output.stdout, &push_output.stderr);
916    if !is_no_upstream_error(&push_detail) {
917        return Err(GitError::CommandFailed {
918            command: "git push --force-with-lease".to_string(),
919            stderr: push_detail,
920        });
921    }
922
923    let remote_name = current_branch_remote_name(&repo_path, command_runner)
924        .await?
925        .unwrap_or_else(|| "origin".to_string());
926    run_git_command_with_runner(
927        AsyncGitCommand::new(
928            repo_path.clone(),
929            vec![
930                "push".to_string(),
931                "--force-with-lease".to_string(),
932                "--set-upstream".to_string(),
933                remote_name,
934                "HEAD".to_string(),
935            ],
936        ),
937        "Git push failed",
938        command_runner,
939    )
940    .await?;
941
942    primary_upstream_reference(&repo_path, command_runner).await
943}
944
945/// Pushes the current branch to one explicit remote branch name with
946/// `--force-with-lease` and returns the resulting upstream reference.
947///
948/// When the current branch already tracks a remote, that remote name is
949/// reused. Otherwise this falls back to `origin`.
950///
951/// # Arguments
952/// * `repo_path` - Path to the git repository or worktree
953/// * `remote_branch_name` - Target branch name to create or update on the
954///   remote
955///
956/// # Returns
957/// The upstream reference on success, for example `origin/feature/review`.
958///
959/// # Errors
960/// Returns a [`GitError`] if `git push` fails.
961pub(crate) async fn push_current_branch_to_remote_branch(
962    repo_path: PathBuf,
963    remote_branch_name: String,
964) -> Result<String, GitError> {
965    let command_runner = ProcessAsyncGitCommandRunner;
966
967    push_current_branch_to_remote_branch_with_runner(repo_path, remote_branch_name, &command_runner)
968        .await
969}
970
971/// Pushes the current branch to one explicit remote branch name while
972/// requiring that the remote ref does not exist.
973///
974/// The explicit empty lease ignores stale local remote-tracking refs left
975/// behind after the remote branch was deleted, while still refusing to
976/// overwrite a branch created concurrently.
977///
978/// # Arguments
979/// * `repo_path` - Path to the git repository or worktree
980/// * `remote_branch_name` - Target branch name to create or update on the
981///   remote
982///
983/// # Returns
984/// The upstream reference on success, for example `origin/feature/review`.
985///
986/// # Errors
987/// Returns a [`GitError`] if the remote branch exists or `git push` fails.
988pub(crate) async fn push_current_branch_to_new_remote_branch(
989    repo_path: PathBuf,
990    remote_branch_name: String,
991) -> Result<String, GitError> {
992    let command_runner = ProcessAsyncGitCommandRunner;
993
994    push_current_branch_to_new_remote_branch_with_runner(
995        repo_path,
996        remote_branch_name,
997        &command_runner,
998    )
999    .await
1000}
1001
1002/// Checks whether a branch already exists on the remote.
1003///
1004/// Resolves the remote name from the current branch config, falling back
1005/// to `origin`, then runs `git ls-remote --heads <remote> <branch>`.
1006/// Returns `true` when the remote reports at least one matching ref.
1007///
1008/// # Arguments
1009/// * `repo_path` - Path to the git repository or worktree
1010/// * `remote_branch_name` - Branch name to look up on the remote
1011///
1012/// # Errors
1013/// Returns a [`GitError`] if the `git ls-remote` command fails.
1014pub(crate) async fn remote_branch_exists(
1015    repo_path: PathBuf,
1016    remote_branch_name: String,
1017) -> Result<bool, GitError> {
1018    let command_runner = ProcessAsyncGitCommandRunner;
1019
1020    remote_branch_exists_with_runner(repo_path, remote_branch_name, &command_runner).await
1021}
1022
1023/// Pushes one explicit branch through an injected asynchronous command
1024/// boundary.
1025async fn push_current_branch_to_remote_branch_with_runner(
1026    repo_path: PathBuf,
1027    remote_branch_name: String,
1028    command_runner: &dyn AsyncGitCommandRunner,
1029) -> Result<String, GitError> {
1030    let remote_name = current_branch_remote_name(&repo_path, command_runner)
1031        .await?
1032        .unwrap_or_else(|| "origin".to_string());
1033    let push_refspec = format!("HEAD:{remote_branch_name}");
1034    let arguments = vec![
1035        "push".to_string(),
1036        "--force-with-lease".to_string(),
1037        "--set-upstream".to_string(),
1038        remote_name.clone(),
1039        push_refspec,
1040    ];
1041    run_git_command_with_runner(
1042        AsyncGitCommand::new(repo_path, arguments),
1043        "Git push failed",
1044        command_runner,
1045    )
1046    .await?;
1047
1048    Ok(format!("{remote_name}/{remote_branch_name}"))
1049}
1050
1051/// Pushes one explicit branch while requiring its remote ref to be absent.
1052async fn push_current_branch_to_new_remote_branch_with_runner(
1053    repo_path: PathBuf,
1054    remote_branch_name: String,
1055    command_runner: &dyn AsyncGitCommandRunner,
1056) -> Result<String, GitError> {
1057    let remote_name = current_branch_remote_name(&repo_path, command_runner)
1058        .await?
1059        .unwrap_or_else(|| "origin".to_string());
1060    let remote_ref = format!("refs/heads/{remote_branch_name}");
1061    let lease_argument = format!("--force-with-lease={remote_ref}:");
1062    let push_refspec = format!("HEAD:{remote_branch_name}");
1063    let arguments = vec![
1064        "push".to_string(),
1065        lease_argument,
1066        "--set-upstream".to_string(),
1067        remote_name.clone(),
1068        push_refspec,
1069    ];
1070    run_git_command_with_runner(
1071        AsyncGitCommand::new(repo_path, arguments),
1072        "Git push failed",
1073        command_runner,
1074    )
1075    .await?;
1076
1077    Ok(format!("{remote_name}/{remote_branch_name}"))
1078}
1079
1080/// Checks a remote branch through an injected asynchronous command boundary.
1081async fn remote_branch_exists_with_runner(
1082    repo_path: PathBuf,
1083    remote_branch_name: String,
1084    command_runner: &dyn AsyncGitCommandRunner,
1085) -> Result<bool, GitError> {
1086    let remote_name = current_branch_remote_name(&repo_path, command_runner)
1087        .await?
1088        .unwrap_or_else(|| "origin".to_string());
1089    let arguments = vec![
1090        "ls-remote".to_string(),
1091        "--heads".to_string(),
1092        remote_name,
1093        remote_branch_name,
1094    ];
1095    let stdout = run_git_command_with_runner(
1096        AsyncGitCommand::new(repo_path, arguments),
1097        "Git ls-remote failed",
1098        command_runner,
1099    )
1100    .await?;
1101
1102    Ok(!stdout.trim().is_empty())
1103}
1104
1105/// Returns the current upstream reference for `HEAD`.
1106///
1107/// # Arguments
1108/// * `repo_path` - Path to the git repository or worktree
1109///
1110/// # Returns
1111/// The configured upstream reference, for example `origin/main`.
1112///
1113/// # Errors
1114/// Returns a [`GitError`] when upstream tracking information cannot be
1115/// resolved.
1116pub(crate) async fn current_upstream_reference(repo_path: PathBuf) -> Result<String, GitError> {
1117    let command_runner = ProcessAsyncGitCommandRunner;
1118
1119    primary_upstream_reference(&repo_path, &command_runner).await
1120}
1121
1122/// Fetches from the configured remote.
1123///
1124/// # Arguments
1125/// * `repo_path` - Path to the git repository root
1126///
1127/// # Returns
1128/// Ok(()) on success.
1129///
1130/// # Errors
1131/// Returns a [`GitError`] if `git fetch` cannot be executed successfully.
1132pub(crate) async fn fetch_remote(repo_path: PathBuf) -> Result<(), GitError> {
1133    run_git_command(
1134        repo_path,
1135        vec!["fetch".to_string()],
1136        "Git fetch failed".to_string(),
1137    )
1138    .await?;
1139
1140    Ok(())
1141}
1142
1143/// Returns the number of commits ahead and behind the upstream branch.
1144///
1145/// # Arguments
1146/// * `repo_path` - Path to the git repository root
1147///
1148/// # Returns
1149/// Ok((ahead, behind)) on success.
1150///
1151/// # Errors
1152/// Returns a [`GitError`] if `git rev-list` fails or returns unexpected
1153/// output.
1154pub(crate) async fn get_ahead_behind(repo_path: PathBuf) -> Result<(u32, u32), GitError> {
1155    get_ref_ahead_behind(repo_path, "HEAD".to_string(), "@{u}".to_string()).await
1156}
1157
1158/// Returns the number of commits `left_ref` is ahead of and behind `right_ref`.
1159///
1160/// The returned tuple is `(ahead, behind)`, where `ahead` counts commits
1161/// reachable from `left_ref` but not `right_ref`, and `behind` counts commits
1162/// reachable from `right_ref` but not `left_ref`.
1163///
1164/// # Errors
1165/// Returns a [`GitError`] if `git rev-list` fails or returns unexpected
1166/// output.
1167pub(crate) async fn get_ref_ahead_behind(
1168    repo_path: PathBuf,
1169    left_ref: String,
1170    right_ref: String,
1171) -> Result<(u32, u32), GitError> {
1172    let rev_list_output = run_git_command(
1173        repo_path,
1174        vec![
1175            "rev-list".to_string(),
1176            "--left-right".to_string(),
1177            "--count".to_string(),
1178            format!("{left_ref}...{right_ref}"),
1179        ],
1180        "Git rev-list failed".to_string(),
1181    )
1182    .await?;
1183
1184    parse_ahead_behind_counts(&rev_list_output)
1185}
1186
1187/// Parses one `git rev-list --left-right --count` output into `(ahead,
1188/// behind)`.
1189fn parse_ahead_behind_counts(rev_list_output: &str) -> Result<(u32, u32), GitError> {
1190    let parts: Vec<&str> = rev_list_output.split_whitespace().collect();
1191    if parts.len() >= 2 {
1192        let ahead = parts[0].parse().unwrap_or(0);
1193        let behind = parts[1].parse().unwrap_or(0);
1194
1195        return Ok((ahead, behind));
1196    }
1197
1198    Err(GitError::OutputParse(
1199        "Unexpected output format from git rev-list".to_string(),
1200    ))
1201}
1202
1203/// Returns ahead/behind snapshots for every local branch in `repo_path`.
1204///
1205/// The returned map is keyed by local branch name. Branches without an
1206/// upstream, with a gone upstream, or without ahead/behind markers map to
1207/// `None`.
1208///
1209/// # Errors
1210/// Returns a [`GitError`] if `git for-each-ref` fails.
1211pub(crate) async fn branch_tracking_statuses(
1212    repo_path: PathBuf,
1213) -> Result<BranchTrackingMap, GitError> {
1214    let git_output = run_git_command(
1215        repo_path,
1216        vec![
1217            "for-each-ref".to_string(),
1218            "--format=%(refname:short)\t%(upstream:short)\t%(upstream:track,nobracket)".to_string(),
1219            "refs/heads".to_string(),
1220        ],
1221        "Git for-each-ref failed".to_string(),
1222    )
1223    .await?;
1224
1225    Ok(parse_branch_tracking_statuses(&git_output))
1226}
1227
1228/// Returns upstream commit subjects that are not yet in local `HEAD`.
1229///
1230/// The returned order is oldest to newest to match pull application order.
1231///
1232/// # Arguments
1233/// * `repo_path` - Path to the git repository root
1234///
1235/// # Errors
1236/// Returns a [`GitError`] when `git log` fails or upstream tracking refs are
1237/// unavailable.
1238pub(crate) async fn list_upstream_commit_titles(
1239    repo_path: PathBuf,
1240) -> Result<Vec<String>, GitError> {
1241    let git_output = run_git_command(
1242        repo_path,
1243        vec![
1244            "log".to_string(),
1245            "--reverse".to_string(),
1246            "--pretty=%s".to_string(),
1247            "HEAD..@{u}".to_string(),
1248        ],
1249        "Git log failed".to_string(),
1250    )
1251    .await?;
1252
1253    Ok(parse_commit_titles(&git_output))
1254}
1255
1256/// Returns local commit subjects that are not yet present in upstream.
1257///
1258/// The returned order is oldest to newest to match push application order.
1259///
1260/// # Arguments
1261/// * `repo_path` - Path to the git repository root
1262///
1263/// # Errors
1264/// Returns a [`GitError`] when `git log` fails or upstream tracking refs are
1265/// unavailable.
1266pub(crate) async fn list_local_commit_titles(repo_path: PathBuf) -> Result<Vec<String>, GitError> {
1267    let git_output = run_git_command(
1268        repo_path,
1269        vec![
1270            "log".to_string(),
1271            "--reverse".to_string(),
1272            "--pretty=%s".to_string(),
1273            "@{u}..HEAD".to_string(),
1274        ],
1275        "Git log failed".to_string(),
1276    )
1277    .await?;
1278
1279    Ok(parse_commit_titles(&git_output))
1280}
1281
1282/// Returns whether `HEAD` contains commits that are not reachable from
1283/// `base_branch`.
1284///
1285/// # Errors
1286/// Returns a [`GitError`] if commit ancestry cannot be queried.
1287pub(crate) async fn has_commits_since(
1288    repo_path: PathBuf,
1289    base_branch: String,
1290) -> Result<bool, GitError> {
1291    spawn_blocking(move || -> Result<bool, GitError> {
1292        let rev_list_output = run_git_command_sync(
1293            &repo_path,
1294            &["rev-list", "--count", &format!("{base_branch}..HEAD")],
1295            "Failed to count commits since base branch",
1296        )?;
1297        let commit_count = rev_list_output.trim().parse::<u32>().map_err(|error| {
1298            GitError::OutputParse(format!(
1299                "Failed to parse commit count since base branch `{base_branch}`: {error}"
1300            ))
1301        })?;
1302
1303        Ok(commit_count > 0)
1304    })
1305    .await?
1306}
1307
1308/// Parses newline-delimited commit subjects from `git log` output.
1309fn parse_commit_titles(output: &str) -> Vec<String> {
1310    output
1311        .lines()
1312        .map(str::trim)
1313        .filter(|title| !title.is_empty())
1314        .map(ToString::to_string)
1315        .collect()
1316}
1317
1318/// Parses repo-wide branch tracking information from `git for-each-ref`.
1319fn parse_branch_tracking_statuses(output: &str) -> BranchTrackingMap {
1320    let mut branch_tracking_statuses = HashMap::new();
1321
1322    for line in output
1323        .lines()
1324        .map(str::trim)
1325        .filter(|line| !line.is_empty())
1326    {
1327        let mut parts = line.splitn(3, '\t');
1328        let Some(branch_name) = parts
1329            .next()
1330            .map(str::trim)
1331            .filter(|value| !value.is_empty())
1332        else {
1333            continue;
1334        };
1335        let upstream_ref = parts.next().map(str::trim).unwrap_or_default();
1336        let track = parts.next().map(str::trim).unwrap_or_default();
1337
1338        let status = if upstream_ref.is_empty() {
1339            None
1340        } else {
1341            parse_branch_tracking_counts(track)
1342        };
1343        branch_tracking_statuses.insert(branch_name.to_string(), status);
1344    }
1345
1346    branch_tracking_statuses
1347}
1348
1349/// Parses one `%(upstream:track,nobracket)` marker into ahead/behind counts.
1350fn parse_branch_tracking_counts(track: &str) -> Option<(u32, u32)> {
1351    let normalized_track = track.trim();
1352    if normalized_track.is_empty() || normalized_track == "gone" {
1353        return None;
1354    }
1355
1356    let mut ahead = 0;
1357    let mut behind = 0;
1358
1359    for part in normalized_track.split(',').map(str::trim) {
1360        if let Some(count) = part.strip_prefix("ahead ") {
1361            ahead = count.parse().ok()?;
1362        } else if let Some(count) = part.strip_prefix("behind ") {
1363            behind = count.parse().ok()?;
1364        }
1365    }
1366
1367    Some((ahead, behind))
1368}
1369
1370/// Resolves the commit/tree to use as the `git diff` "before" side.
1371///
1372/// Starts from the merge-base fallback and, when `git cherry` reports leading
1373/// commits already applied to `base_branch`, advances the baseline to the last
1374/// such commit so squash-merged session changes are not shown again.
1375fn resolve_diff_target(
1376    repo_path: &Path,
1377    base_branch: &str,
1378    merge_base: &str,
1379) -> Result<String, GitError> {
1380    let cherry_output = run_git_command_output_sync(repo_path, &["cherry", base_branch, "HEAD"])?;
1381    if !cherry_output.status.success() {
1382        return Ok(merge_base.to_string());
1383    }
1384
1385    let cherry_stdout = String::from_utf8_lossy(&cherry_output.stdout);
1386    let Some(last_leading_applied_commit) = last_leading_applied_commit(&cherry_stdout) else {
1387        return Ok(merge_base.to_string());
1388    };
1389
1390    Ok(last_leading_applied_commit.to_string())
1391}
1392
1393/// Returns the last leading commit from `git cherry` marked as already applied.
1394///
1395/// `git cherry` prefixes commits with `-` when an equivalent patch exists in
1396/// the upstream branch and `+` when it does not. This helper only consumes the
1397/// initial contiguous `-` block and stops at the first `+` to avoid dropping
1398/// non-merged changes.
1399fn last_leading_applied_commit(cherry_output: &str) -> Option<&str> {
1400    let mut last_applied_commit = None;
1401
1402    for line in cherry_output.lines() {
1403        let trimmed_line = line.trim();
1404        if trimmed_line.is_empty() {
1405            continue;
1406        }
1407
1408        let mut parts = trimmed_line.split_whitespace();
1409        let marker = parts.next()?;
1410        let commit_hash = parts.next()?;
1411
1412        if marker == "-" {
1413            last_applied_commit = Some(commit_hash);
1414
1415            continue;
1416        }
1417
1418        if marker == "+" {
1419            break;
1420        }
1421
1422        break;
1423    }
1424
1425    last_applied_commit
1426}
1427
1428/// Stages all changes and commits or amends with retry behavior for hook
1429/// rewrites.
1430///
1431/// If an amend would make `HEAD` empty, the staged tree has reverted the
1432/// session commit back to its parent. In that case the helper drops the now
1433/// empty session commit and reports the standard no-changes sentinel so the
1434/// app can skip model-assisted commit recovery.
1435async fn commit_all_with_retry(
1436    repo_path: PathBuf,
1437    commit_message: String,
1438    message_strategy: SingleCommitMessageStrategy,
1439    amend_existing_commit: bool,
1440) -> Result<(), GitError> {
1441    spawn_blocking(move || {
1442        stage_all_sync(&repo_path)?;
1443
1444        for _ in 0..COMMIT_ALL_HOOK_RETRY_ATTEMPTS {
1445            let output = run_commit_command(
1446                &repo_path,
1447                &commit_message,
1448                message_strategy,
1449                amend_existing_commit,
1450            )?;
1451
1452            if output.status.success() {
1453                return Ok(());
1454            }
1455
1456            let stderr = String::from_utf8_lossy(&output.stderr);
1457            let stdout = String::from_utf8_lossy(&output.stdout);
1458            if is_nothing_to_commit_output(&stdout, &stderr) {
1459                return Err(nothing_to_commit_error());
1460            }
1461
1462            if amend_existing_commit && is_empty_amend_output(&stdout, &stderr) {
1463                reset_empty_amend_sync(&repo_path)?;
1464
1465                return Err(nothing_to_commit_error());
1466            }
1467
1468            if is_hook_modified_error(&stdout, &stderr) {
1469                stage_all_sync(&repo_path)?;
1470
1471                continue;
1472            }
1473
1474            let detail = command_output_detail(&output.stdout, &output.stderr);
1475
1476            return Err(GitError::CommandFailed {
1477                command: "git commit".to_string(),
1478                stderr: detail,
1479            });
1480        }
1481
1482        Err(GitError::CommandFailed {
1483            command: "git commit".to_string(),
1484            stderr: format!(
1485                "Failed to commit: commit hooks kept modifying files after \
1486                 {COMMIT_ALL_HOOK_RETRY_ATTEMPTS} attempts"
1487            ),
1488        })
1489    })
1490    .await?
1491}
1492
1493/// Ensures repositories declaring pre-commit validation have an executable
1494/// hook.
1495fn ensure_pre_commit_hook_ready(repo_path: &Path) -> Result<(), GitError> {
1496    let Some(config_file) = PRE_COMMIT_CONFIG_FILES
1497        .iter()
1498        .find(|config_file| repo_path.join(config_file).is_file())
1499    else {
1500        return Ok(());
1501    };
1502    let hook_path = resolve_pre_commit_hook_path(repo_path)?;
1503
1504    if is_executable_hook(&hook_path) {
1505        return Ok(());
1506    }
1507
1508    Err(GitError::PreCommitHookMissing {
1509        config_file: (*config_file).to_string(),
1510    })
1511}
1512
1513/// Resolves the pre-commit hook using `core.hooksPath` or Git's default path.
1514fn resolve_pre_commit_hook_path(repo_path: &Path) -> Result<PathBuf, GitError> {
1515    let hooks_path_output =
1516        run_git_command_output_sync(repo_path, &["config", "--path", "--get", "core.hooksPath"])?;
1517    let hooks_path = if hooks_path_output.status.success() {
1518        PathBuf::from(String::from_utf8_lossy(&hooks_path_output.stdout).trim())
1519    } else if hooks_path_output.status.code() == Some(1) {
1520        let default_hook_path = run_git_command_sync(
1521            repo_path,
1522            &["rev-parse", "--git-path", "hooks/pre-commit"],
1523            "Failed to resolve Git pre-commit hook path",
1524        )?;
1525
1526        return Ok(resolve_repo_path(
1527            repo_path,
1528            PathBuf::from(default_hook_path.trim()),
1529        ));
1530    } else {
1531        return Err(GitError::CommandFailed {
1532            command: "git config --path --get core.hooksPath".to_string(),
1533            stderr: command_output_detail(&hooks_path_output.stdout, &hooks_path_output.stderr),
1534        });
1535    };
1536
1537    Ok(resolve_repo_path(repo_path, hooks_path).join("pre-commit"))
1538}
1539
1540fn resolve_repo_path(repo_path: &Path, path: PathBuf) -> PathBuf {
1541    if path.is_absolute() {
1542        return path;
1543    }
1544
1545    repo_path.join(path)
1546}
1547
1548#[cfg(unix)]
1549fn is_executable_hook(hook_path: &Path) -> bool {
1550    hook_path.is_file() && rustix_fs::access(hook_path, Access::EXEC_OK).is_ok()
1551}
1552
1553#[cfg(not(unix))]
1554fn is_executable_hook(hook_path: &Path) -> bool {
1555    hook_path.is_file()
1556}
1557
1558/// Returns the canonical git no-changes error used by app auto-commit flows.
1559fn nothing_to_commit_error() -> GitError {
1560    GitError::CommandFailed {
1561        command: "git commit".to_string(),
1562        stderr: "Nothing to commit: no changes detected".to_string(),
1563    }
1564}
1565
1566/// Returns whether commit output reports that there was no staged work to
1567/// commit.
1568fn is_nothing_to_commit_output(stdout: &str, stderr: &str) -> bool {
1569    let combined = format!("{stdout}\n{stderr}").to_ascii_lowercase();
1570
1571    combined.contains("nothing to commit")
1572}
1573
1574/// Returns whether commit output reports that amending `HEAD` would remove the
1575/// session commit entirely.
1576fn is_empty_amend_output(stdout: &str, stderr: &str) -> bool {
1577    let combined = format!("{stdout}\n{stderr}").to_ascii_lowercase();
1578    let normalized = combined.split_whitespace().collect::<Vec<_>>().join(" ");
1579
1580    normalized.contains("would make it empty") && normalized.contains("allow-empty")
1581}
1582
1583/// Drops an amended session commit whose resulting tree would match its
1584/// parent, leaving the worktree at the reverted state.
1585fn reset_empty_amend_sync(repo_path: &Path) -> Result<(), GitError> {
1586    run_git_command_sync(
1587        repo_path,
1588        &["reset", "HEAD^"],
1589        "Git reset after empty amend failed",
1590    )?;
1591
1592    Ok(())
1593}
1594
1595/// Stages all changed files in the repository.
1596///
1597/// Uses shared git retry behavior for transient `index.lock` contention.
1598fn stage_all_sync(repo_path: &Path) -> Result<(), GitError> {
1599    let output = run_git_command_with_index_lock_retry(repo_path, &["add", "-A"], &[])?;
1600
1601    if !output.status.success() {
1602        let detail = command_output_detail(&output.stdout, &output.stderr);
1603
1604        return Err(GitError::CommandFailed {
1605            command: "git add -A".to_string(),
1606            stderr: format!("Failed to stage changes: {detail}"),
1607        });
1608    }
1609
1610    Ok(())
1611}
1612
1613/// Returns the full `HEAD` commit message, or `None` when no commits exist.
1614fn head_commit_message_sync(repo_path: &Path) -> Result<Option<String>, GitError> {
1615    if !has_head_commit_sync(repo_path)? {
1616        return Ok(None);
1617    }
1618
1619    let output = run_git_command_sync(
1620        repo_path,
1621        &["log", "-1", "--pretty=%B"],
1622        "Failed to read HEAD commit message",
1623    )?;
1624
1625    Ok(Some(output.trim().to_string()))
1626}
1627
1628/// Returns whether `HEAD` resolves to an existing commit.
1629fn has_head_commit_sync(repo_path: &Path) -> Result<bool, GitError> {
1630    let output = run_git_command_output_sync(repo_path, &["rev-parse", "--verify", "HEAD"])?;
1631
1632    if output.status.success() {
1633        return Ok(true);
1634    }
1635
1636    let detail = command_output_detail(&output.stdout, &output.stderr);
1637    let normalized_detail = detail.to_ascii_lowercase();
1638    if normalized_detail.contains("needed a single revision")
1639        || normalized_detail.contains("unknown revision")
1640        || normalized_detail.contains("does not have any commits yet")
1641    {
1642        return Ok(false);
1643    }
1644
1645    Err(GitError::CommandFailed {
1646        command: "git rev-parse --verify HEAD".to_string(),
1647        stderr: detail,
1648    })
1649}
1650
1651/// Runs `git commit` with optional amend and hook settings.
1652///
1653/// Uses shared git retry behavior for transient `index.lock` contention.
1654fn run_commit_command(
1655    repo_path: &Path,
1656    commit_message: &str,
1657    message_strategy: SingleCommitMessageStrategy,
1658    amend_existing_commit: bool,
1659) -> Result<Output, GitError> {
1660    let mut args = vec!["commit"];
1661    if amend_existing_commit {
1662        args.push("--amend");
1663        match message_strategy {
1664            SingleCommitMessageStrategy::Replace => {
1665                args.push("-m");
1666                args.push(commit_message);
1667            }
1668            SingleCommitMessageStrategy::Reuse => {
1669                args.push("--no-edit");
1670            }
1671        }
1672    } else {
1673        args.push("-m");
1674        args.push(commit_message);
1675    }
1676
1677    run_git_command_with_index_lock_retry(repo_path, &args, &[])
1678}
1679
1680/// Returns whether commit output indicates hooks rewrote files.
1681fn is_hook_modified_error(stdout: &str, stderr: &str) -> bool {
1682    let combined = format!(
1683        "{stdout}
1684{stderr}"
1685    )
1686    .to_ascii_lowercase();
1687
1688    combined.contains("files were modified by this hook")
1689}
1690
1691/// Returns whether git push output indicates a missing upstream branch.
1692pub(super) fn is_no_upstream_error(detail: &str) -> bool {
1693    let normalized_detail = detail.to_ascii_lowercase();
1694
1695    normalized_detail.contains("has no upstream branch")
1696        || normalized_detail.contains("no upstream branch")
1697        || normalized_detail.contains("set-upstream")
1698}
1699
1700#[cfg(test)]
1701#[path = "sync_test.rs"]
1702mod tests;