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)]
1701mod tests {
1702    use std::fs;
1703    #[cfg(unix)]
1704    use std::os::unix::fs::PermissionsExt;
1705    use std::path::Path;
1706    use std::process::{Command, Output};
1707
1708    use mockall::Sequence;
1709    use mockall::predicate::function;
1710    use tempfile::tempdir;
1711
1712    use super::*;
1713    use crate::repo::MockAsyncGitCommandRunner;
1714
1715    /// Builds captured asynchronous git output for command-runner tests.
1716    fn async_git_output(
1717        exit_code: i32,
1718        stdout: impl Into<Vec<u8>>,
1719        stderr: impl Into<Vec<u8>>,
1720    ) -> AsyncGitCommandOutput {
1721        AsyncGitCommandOutput {
1722            exit_code: Some(exit_code),
1723            stderr: stderr.into(),
1724            stdout: stdout.into(),
1725        }
1726    }
1727
1728    /// Runs `git` in `repo_path` and asserts the command succeeds.
1729    fn run_git_command(repo_path: &Path, args: &[&str]) {
1730        let output = git_command_output(repo_path, args);
1731
1732        assert!(
1733            output.status.success(),
1734            "git command {:?} failed: {}",
1735            args,
1736            String::from_utf8_lossy(&output.stderr)
1737        );
1738    }
1739
1740    /// Runs `git` in `repo_path` and returns the captured command output.
1741    fn git_command_output(repo_path: &Path, args: &[&str]) -> Output {
1742        Command::new("git")
1743            .args(args)
1744            .current_dir(repo_path)
1745            .output()
1746            .expect("failed to run git command")
1747    }
1748
1749    /// Runs `git` in `repo_path`, asserts success, and returns trimmed stdout.
1750    fn git_command_stdout(repo_path: &Path, args: &[&str]) -> String {
1751        let output = git_command_output(repo_path, args);
1752
1753        assert!(
1754            output.status.success(),
1755            "git command {:?} failed: {}",
1756            args,
1757            String::from_utf8_lossy(&output.stderr)
1758        );
1759
1760        String::from_utf8(output.stdout)
1761            .expect("git stdout should be valid utf-8")
1762            .trim()
1763            .to_string()
1764    }
1765
1766    /// Creates a committed repository rooted at `repo_path`.
1767    fn setup_test_git_repo(repo_path: &Path) {
1768        run_git_command(repo_path, &["init", "-b", "main"]);
1769        run_git_command(repo_path, &["config", "user.name", "Test User"]);
1770        run_git_command(repo_path, &["config", "user.email", "test@example.com"]);
1771        fs::write(repo_path.join("README.md"), "base\n").expect("failed to write base file");
1772        run_git_command(repo_path, &["add", "README.md"]);
1773        run_git_command(repo_path, &["commit", "-m", "Initial commit"]);
1774    }
1775
1776    #[tokio::test]
1777    async fn delete_branch_removes_branch_from_isolated_repository() {
1778        // Arrange
1779        let temp_dir = tempdir().expect("failed to create temp dir");
1780        setup_test_git_repo(temp_dir.path());
1781        run_git_command(temp_dir.path(), &["branch", "review/topic"]);
1782
1783        // Act
1784        delete_branch(temp_dir.path().to_path_buf(), "review/topic".to_string())
1785            .await
1786            .expect("branch deletion should succeed");
1787
1788        // Assert
1789        let branch_lookup = git_command_output(
1790            temp_dir.path(),
1791            &["show-ref", "--verify", "--quiet", "refs/heads/review/topic"],
1792        );
1793        assert!(!branch_lookup.status.success());
1794    }
1795
1796    #[tokio::test]
1797    async fn diff_preserves_staged_changes_and_includes_untracked_files() {
1798        // Arrange
1799        let temp_dir = tempdir().expect("failed to create temp dir");
1800        setup_test_git_repo(temp_dir.path());
1801        fs::write(temp_dir.path().join("README.md"), "staged change\n")
1802            .expect("failed to write staged change");
1803        run_git_command(temp_dir.path(), &["add", "README.md"]);
1804        fs::write(
1805            temp_dir.path().join("README.md"),
1806            "staged change\nunstaged change\n",
1807        )
1808        .expect("failed to write unstaged change");
1809        fs::write(temp_dir.path().join("new.txt"), "untracked change\n")
1810            .expect("failed to write untracked file");
1811        let cached_diff_before = git_command_output(temp_dir.path(), &["diff", "--cached"]).stdout;
1812        let status_before = git_command_output(
1813            temp_dir.path(),
1814            &["status", "--porcelain=v1", "--untracked-files=all"],
1815        )
1816        .stdout;
1817
1818        // Act
1819        let result = diff(temp_dir.path().to_path_buf(), "main".to_string()).await;
1820        let changed_files =
1821            diff_changed_files(temp_dir.path().to_path_buf(), "main".to_string()).await;
1822
1823        // Assert
1824        let diff_output = result.expect("diff should succeed");
1825        let cached_diff_after = git_command_output(temp_dir.path(), &["diff", "--cached"]).stdout;
1826        let status_after = git_command_output(
1827            temp_dir.path(),
1828            &["status", "--porcelain=v1", "--untracked-files=all"],
1829        )
1830        .stdout;
1831        assert!(diff_output.contains("staged change"));
1832        assert!(diff_output.contains("unstaged change"));
1833        assert!(diff_output.contains("untracked change"));
1834        assert_eq!(
1835            changed_files.expect("changed files should load"),
1836            vec!["README.md".to_string(), "new.txt".to_string()]
1837        );
1838        assert_eq!(cached_diff_after, cached_diff_before);
1839        assert_eq!(status_after, status_before);
1840    }
1841
1842    #[tokio::test]
1843    async fn diff_reports_repository_unavailable_outside_git_repository() {
1844        // Arrange
1845        let temp_dir = tempdir().expect("failed to create temp dir");
1846
1847        // Act
1848        let result = diff(temp_dir.path().to_path_buf(), "main".to_string()).await;
1849
1850        // Assert
1851        assert!(matches!(
1852            result,
1853            Err(GitError::RepositoryUnavailable { detail })
1854                if detail.to_ascii_lowercase().contains("not a git repository")
1855        ));
1856    }
1857
1858    #[test]
1859    fn diff_reports_repository_unavailable_when_removed_after_index_resolution() {
1860        // Arrange
1861        let temp_dir = tempdir().expect("failed to create temp dir");
1862        let preserved_index_dir = tempdir().expect("failed to create preserved index dir");
1863        setup_test_git_repo(temp_dir.path());
1864        let index_path =
1865            resolve_diff_index_path(temp_dir.path()).expect("index path should resolve");
1866        let index_path = PathBuf::from(index_path.trim());
1867        let index_path = temp_dir.path().join(index_path);
1868        let preserved_index_path = preserved_index_dir.path().join("index");
1869        fs::copy(index_path, &preserved_index_path).expect("index copy should succeed");
1870        fs::remove_dir_all(temp_dir.path()).expect("worktree removal should succeed");
1871
1872        // Act
1873        let result = diff_output_after_index_resolution(
1874            temp_dir.path(),
1875            "main",
1876            false,
1877            &preserved_index_path,
1878        );
1879
1880        // Assert
1881        assert!(matches!(
1882            result,
1883            Err(GitError::RepositoryUnavailable { detail })
1884                if detail.contains("git add -A --intent-to-add")
1885        ));
1886    }
1887
1888    #[tokio::test]
1889    async fn diff_preserves_invalid_base_reference_error() {
1890        // Arrange
1891        let temp_dir = tempdir().expect("failed to create temp dir");
1892        setup_test_git_repo(temp_dir.path());
1893
1894        // Act
1895        let result = diff(
1896            temp_dir.path().to_path_buf(),
1897            "missing-base-reference".to_string(),
1898        )
1899        .await;
1900
1901        // Assert
1902        assert!(matches!(
1903            result,
1904            Err(GitError::CommandFailed { command, stderr })
1905                if command == "git diff missing-base-reference"
1906                    && stderr.contains("Git diff failed")
1907        ));
1908    }
1909
1910    #[test]
1911    fn diff_repository_error_classification_preserves_unrelated_failures() {
1912        // Arrange
1913        let temp_dir = tempdir().expect("failed to create temp dir");
1914        setup_test_git_repo(temp_dir.path());
1915        let error = GitError::CommandFailed {
1916            command: "git rev-parse --git-path index".to_string(),
1917            stderr: "fatal: ambiguous argument".to_string(),
1918        };
1919
1920        // Act
1921        let classified = classify_diff_repository_error(temp_dir.path(), error);
1922
1923        // Assert
1924        assert!(matches!(
1925            classified,
1926            GitError::CommandFailed { command, stderr }
1927                if command == "git rev-parse --git-path index"
1928                    && stderr == "fatal: ambiguous argument"
1929        ));
1930    }
1931
1932    #[test]
1933    fn diff_repository_error_classification_ignores_localized_diagnostic() {
1934        // Arrange
1935        let temp_dir = tempdir().expect("failed to create temp dir");
1936        let error = GitError::CommandFailed {
1937            command: "git rev-parse --git-path index".to_string(),
1938            stderr: "fatal: kein Git-Repository".to_string(),
1939        };
1940
1941        // Act
1942        let classified = classify_diff_repository_error(temp_dir.path(), error);
1943
1944        // Assert
1945        assert!(matches!(
1946            classified,
1947            GitError::RepositoryUnavailable { detail }
1948                if detail == "git rev-parse --git-path index: fatal: kein Git-Repository"
1949        ));
1950    }
1951
1952    #[test]
1953    fn diff_repository_probe_preserves_spawn_failure() {
1954        // Arrange
1955        let probe = Err(GitError::CommandFailed {
1956            command: "git rev-parse --git-dir".to_string(),
1957            stderr: "git executable unavailable".to_string(),
1958        });
1959
1960        // Act
1961        let unavailable = diff_repository_probe_is_unavailable(probe);
1962
1963        // Assert
1964        assert!(!unavailable);
1965    }
1966
1967    #[test]
1968    fn diff_repository_error_classification_types_missing_directory() {
1969        // Arrange
1970        let temp_dir = tempdir().expect("failed to create temp dir");
1971        let missing_path = temp_dir.path().join("removed-worktree");
1972        let error = GitError::Io(std::io::Error::new(
1973            std::io::ErrorKind::NotFound,
1974            "worktree removed",
1975        ));
1976
1977        // Act
1978        let classified = classify_diff_repository_error(&missing_path, error);
1979
1980        // Assert
1981        assert!(matches!(
1982            classified,
1983            GitError::RepositoryUnavailable { detail } if detail == "worktree removed"
1984        ));
1985    }
1986
1987    #[tokio::test]
1988    async fn read_worktree_file_returns_text_for_safe_nested_path() {
1989        // Arrange
1990        let temp_dir = tempdir().expect("failed to create temp dir");
1991        let docs_dir = temp_dir.path().join("docs");
1992        fs::create_dir(&docs_dir).expect("failed to create docs directory");
1993        fs::write(docs_dir.join("README.md"), "# Preview\n")
1994            .expect("failed to write markdown file");
1995
1996        // Act
1997        let result =
1998            read_worktree_file(temp_dir.path().to_path_buf(), "docs/README.md".to_string()).await;
1999
2000        // Assert
2001        assert_eq!(
2002            result.expect("worktree read should succeed"),
2003            WorktreeFileContent::Text("# Preview\n".to_string())
2004        );
2005    }
2006
2007    #[tokio::test]
2008    async fn read_worktree_file_classifies_missing_binary_and_oversize_files() {
2009        // Arrange
2010        let temp_dir = tempdir().expect("failed to create temp dir");
2011        fs::write(temp_dir.path().join("binary.md"), [0xff, 0xfe])
2012            .expect("failed to write binary file");
2013        fs::write(
2014            temp_dir.path().join("large.md"),
2015            vec![b'a'; MAX_WORKTREE_FILE_BYTE_COUNT + 1],
2016        )
2017        .expect("failed to write oversize file");
2018
2019        // Act
2020        let missing =
2021            read_worktree_file(temp_dir.path().to_path_buf(), "missing.md".to_string()).await;
2022        let binary =
2023            read_worktree_file(temp_dir.path().to_path_buf(), "binary.md".to_string()).await;
2024        let too_large =
2025            read_worktree_file(temp_dir.path().to_path_buf(), "large.md".to_string()).await;
2026
2027        // Assert
2028        assert_eq!(
2029            missing.expect("missing read should succeed"),
2030            WorktreeFileContent::Missing
2031        );
2032        assert_eq!(
2033            binary.expect("binary read should succeed"),
2034            WorktreeFileContent::Binary
2035        );
2036        assert_eq!(
2037            too_large.expect("oversize read should succeed"),
2038            WorktreeFileContent::TooLarge
2039        );
2040    }
2041
2042    #[tokio::test]
2043    async fn read_worktree_file_rejects_unsafe_relative_paths() {
2044        // Arrange
2045        let temp_dir = tempdir().expect("failed to create temp dir");
2046        let absolute_path = temp_dir.path().join("README.md");
2047
2048        // Act
2049        let empty = read_worktree_file(temp_dir.path().to_path_buf(), String::new()).await;
2050        let parent =
2051            read_worktree_file(temp_dir.path().to_path_buf(), "../README.md".to_string()).await;
2052        let absolute = read_worktree_file(
2053            temp_dir.path().to_path_buf(),
2054            absolute_path.to_string_lossy().into_owned(),
2055        )
2056        .await;
2057
2058        // Assert
2059        for result in [empty, parent, absolute] {
2060            assert!(
2061                matches!(result, Err(GitError::OutputParse(message)) if message.contains("Unsafe worktree file path"))
2062            );
2063        }
2064    }
2065
2066    #[cfg(unix)]
2067    #[tokio::test]
2068    async fn read_worktree_file_rejects_symlinks_outside_repository() {
2069        // Arrange
2070        let temp_dir = tempdir().expect("failed to create temp dir");
2071        let outside_dir = tempdir().expect("failed to create outside temp dir");
2072        let outside_file = outside_dir.path().join("outside.md");
2073        fs::write(&outside_file, "outside").expect("failed to write outside file");
2074        std::os::unix::fs::symlink(&outside_file, temp_dir.path().join("link.md"))
2075            .expect("failed to create outside symlink");
2076
2077        // Act
2078        let result = read_worktree_file(temp_dir.path().to_path_buf(), "link.md".to_string()).await;
2079
2080        // Assert
2081        assert!(
2082            matches!(result, Err(GitError::OutputParse(message)) if message.contains("resolves outside repository"))
2083        );
2084    }
2085
2086    #[cfg(unix)]
2087    #[tokio::test]
2088    async fn read_worktree_file_maps_non_missing_path_resolution_errors() {
2089        // Arrange
2090        let temp_dir = tempdir().expect("failed to create temp dir");
2091        std::os::unix::fs::symlink("loop.md", temp_dir.path().join("loop.md"))
2092            .expect("failed to create symlink loop");
2093
2094        // Act
2095        let result = read_worktree_file(temp_dir.path().to_path_buf(), "loop.md".to_string()).await;
2096
2097        // Assert
2098        assert!(matches!(result, Err(GitError::Io(_))));
2099    }
2100
2101    #[test]
2102    fn copy_git_index_to_temp_maps_path_create_and_copy_failures() {
2103        // Arrange
2104        let temp_dir = tempdir().expect("failed to create temp dir");
2105        let path_without_parent = Path::new("/");
2106        let missing_parent_index = temp_dir.path().join("missing-parent").join("index");
2107        let missing_index = temp_dir.path().join("missing-index");
2108
2109        // Act
2110        let parent_error = copy_git_index_to_temp(path_without_parent);
2111        let create_error = copy_git_index_to_temp(&missing_parent_index);
2112        let copy_error = copy_git_index_to_temp(&missing_index);
2113
2114        // Assert
2115        assert!(matches!(parent_error, Err(GitError::OutputParse(_))));
2116        assert!(matches!(
2117            create_error,
2118            Err(GitError::CommandFailed { ref command, .. })
2119                if command == "create temporary git index"
2120        ));
2121        assert!(matches!(
2122            copy_error,
2123            Err(GitError::CommandFailed { ref command, .. }) if command == "copy git index"
2124        ));
2125    }
2126
2127    #[test]
2128    fn run_git_command_with_index_sync_maps_process_and_command_failures() {
2129        // Arrange
2130        let temp_dir = tempdir().expect("failed to create temp dir");
2131        let index_path = temp_dir.path().join("index");
2132        let missing_repo_path = temp_dir.path().join("missing-repository");
2133        fs::write(&index_path, []).expect("failed to create temporary index");
2134
2135        // Act
2136        let process_error = run_git_command_with_index_sync(
2137            &missing_repo_path,
2138            &["status"],
2139            &index_path,
2140            "Expected process failure",
2141        );
2142        let command_error = run_git_command_with_index_sync(
2143            temp_dir.path(),
2144            &["definitely-not-a-git-command"],
2145            &index_path,
2146            "Expected git failure",
2147        );
2148
2149        // Assert
2150        assert!(matches!(
2151            process_error,
2152            Err(GitError::CommandFailed { ref command, .. }) if command == "git status"
2153        ));
2154        assert!(matches!(
2155            command_error,
2156            Err(GitError::CommandFailed {
2157                ref command,
2158                ref stderr,
2159            }) if command == "git definitely-not-a-git-command"
2160                && stderr.starts_with("Expected git failure:")
2161        ));
2162    }
2163
2164    #[cfg(unix)]
2165    fn write_executable_pre_commit_hook(hook_path: &Path) {
2166        write_executable_hook(hook_path, "#!/bin/sh\nexit 0\n");
2167    }
2168
2169    #[cfg(unix)]
2170    fn write_executable_hook(hook_path: &Path, contents: &str) {
2171        fs::create_dir_all(
2172            hook_path
2173                .parent()
2174                .expect("pre-commit hook should have a parent directory"),
2175        )
2176        .expect("failed to create hooks directory");
2177        fs::write(hook_path, contents).expect("failed to write Git hook");
2178        let mut permissions = fs::metadata(hook_path)
2179            .expect("failed to read Git hook metadata")
2180            .permissions();
2181        permissions.set_mode(0o750);
2182        fs::set_permissions(hook_path, permissions).expect("failed to make Git hook executable");
2183    }
2184
2185    #[test]
2186    fn ensure_pre_commit_hook_ready_allows_repositories_without_configuration() {
2187        // Arrange
2188        let temp_dir = tempdir().expect("failed to create temp dir");
2189        setup_test_git_repo(temp_dir.path());
2190
2191        // Act
2192        let result = ensure_pre_commit_hook_ready(temp_dir.path());
2193
2194        // Assert
2195        assert!(result.is_ok());
2196    }
2197
2198    #[test]
2199    fn ensure_pre_commit_hook_ready_rejects_missing_hook() {
2200        // Arrange
2201        let temp_dir = tempdir().expect("failed to create temp dir");
2202        setup_test_git_repo(temp_dir.path());
2203        fs::write(
2204            temp_dir.path().join(".pre-commit-config.yaml"),
2205            "repos: []\n",
2206        )
2207        .expect("failed to write pre-commit configuration");
2208
2209        // Act
2210        let result = ensure_pre_commit_hook_ready(temp_dir.path());
2211
2212        // Assert
2213        assert!(matches!(
2214            result,
2215            Err(GitError::PreCommitHookMissing { ref config_file })
2216                if config_file == ".pre-commit-config.yaml"
2217        ));
2218    }
2219
2220    #[cfg(unix)]
2221    #[test]
2222    fn ensure_pre_commit_hook_ready_accepts_default_executable_hook() {
2223        // Arrange
2224        let temp_dir = tempdir().expect("failed to create temp dir");
2225        setup_test_git_repo(temp_dir.path());
2226        fs::write(
2227            temp_dir.path().join(".pre-commit-config.yaml"),
2228            "repos: []\n",
2229        )
2230        .expect("failed to write pre-commit configuration");
2231        let hook_path = temp_dir.path().join(git_command_stdout(
2232            temp_dir.path(),
2233            &["rev-parse", "--git-path", "hooks/pre-commit"],
2234        ));
2235        write_executable_pre_commit_hook(&hook_path);
2236
2237        // Act
2238        let result = ensure_pre_commit_hook_ready(temp_dir.path());
2239
2240        // Assert
2241        assert!(result.is_ok());
2242    }
2243
2244    #[cfg(unix)]
2245    #[test]
2246    fn ensure_pre_commit_hook_ready_accepts_custom_executable_hook() {
2247        // Arrange
2248        let temp_dir = tempdir().expect("failed to create temp dir");
2249        setup_test_git_repo(temp_dir.path());
2250        fs::write(
2251            temp_dir.path().join(".pre-commit-config.yaml"),
2252            "repos: []\n",
2253        )
2254        .expect("failed to write pre-commit configuration");
2255        run_git_command(
2256            temp_dir.path(),
2257            &["config", "core.hooksPath", ".custom-hooks"],
2258        );
2259        write_executable_pre_commit_hook(&temp_dir.path().join(".custom-hooks").join("pre-commit"));
2260
2261        // Act
2262        let result = ensure_pre_commit_hook_ready(temp_dir.path());
2263
2264        // Assert
2265        assert!(result.is_ok());
2266    }
2267
2268    #[cfg(unix)]
2269    #[test]
2270    fn ensure_pre_commit_hook_ready_rejects_hook_inaccessible_to_owner() {
2271        // Arrange
2272        let temp_dir = tempdir().expect("failed to create temp dir");
2273        setup_test_git_repo(temp_dir.path());
2274        fs::write(
2275            temp_dir.path().join(".pre-commit-config.yaml"),
2276            "repos: []\n",
2277        )
2278        .expect("failed to write pre-commit configuration");
2279        let hook_path = temp_dir.path().join(git_command_stdout(
2280            temp_dir.path(),
2281            &["rev-parse", "--git-path", "hooks/pre-commit"],
2282        ));
2283        fs::write(&hook_path, "#!/bin/sh\nexit 0\n").expect("failed to write pre-commit hook");
2284        fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o010))
2285            .expect("failed to set mismatched execute permissions");
2286
2287        // Act
2288        let result = ensure_pre_commit_hook_ready(temp_dir.path());
2289
2290        // Assert
2291        assert!(matches!(result, Err(GitError::PreCommitHookMissing { .. })));
2292    }
2293
2294    #[tokio::test]
2295    async fn run_pre_commit_hook_accepts_missing_hook() {
2296        // Arrange
2297        let temp_dir = tempdir().expect("failed to create temp dir");
2298        setup_test_git_repo(temp_dir.path());
2299
2300        // Act
2301        let result = run_pre_commit_hook(temp_dir.path().to_path_buf()).await;
2302
2303        // Assert
2304        assert!(result.is_ok());
2305    }
2306
2307    #[cfg(unix)]
2308    #[tokio::test]
2309    async fn run_pre_commit_hook_uses_effective_custom_hook() {
2310        // Arrange
2311        let temp_dir = tempdir().expect("failed to create temp dir");
2312        setup_test_git_repo(temp_dir.path());
2313        run_git_command(
2314            temp_dir.path(),
2315            &["config", "core.hooksPath", ".custom-hooks"],
2316        );
2317        write_executable_hook(
2318            &temp_dir.path().join(".custom-hooks").join("pre-commit"),
2319            "#!/bin/sh\nprintf 'ran\\n' > pre-commit-ran\n",
2320        );
2321
2322        // Act
2323        let result = run_pre_commit_hook(temp_dir.path().to_path_buf()).await;
2324
2325        // Assert
2326        assert!(result.is_ok());
2327        assert_eq!(
2328            fs::read_to_string(temp_dir.path().join("pre-commit-ran"))
2329                .expect("pre-commit marker should exist"),
2330            "ran\n"
2331        );
2332    }
2333
2334    #[cfg(unix)]
2335    #[tokio::test]
2336    async fn run_pre_commit_hook_returns_hook_failure_output() {
2337        // Arrange
2338        let temp_dir = tempdir().expect("failed to create temp dir");
2339        setup_test_git_repo(temp_dir.path());
2340        let hook_path = temp_dir.path().join(git_command_stdout(
2341            temp_dir.path(),
2342            &["rev-parse", "--git-path", "hooks/pre-commit"],
2343        ));
2344        write_executable_hook(
2345            &hook_path,
2346            "#!/bin/sh\nprintf 'resolved conflict rejected\\n' >&2\nexit 1\n",
2347        );
2348
2349        // Act
2350        let result = run_pre_commit_hook(temp_dir.path().to_path_buf()).await;
2351
2352        // Assert
2353        assert!(matches!(
2354            result,
2355            Err(GitError::CommandFailed {
2356                ref command,
2357                ref stderr,
2358            }) if command == "git hook run pre-commit"
2359                && stderr.contains("resolved conflict rejected")
2360        ));
2361    }
2362
2363    #[test]
2364    fn pre_commit_hook_result_preserves_command_launch_error() {
2365        // Arrange
2366        let command_error = GitError::CommandFailed {
2367            command: "git hook run pre-commit".to_string(),
2368            stderr: "git executable unavailable".to_string(),
2369        };
2370
2371        // Act
2372        let result = pre_commit_hook_result(Err(command_error));
2373
2374        // Assert
2375        assert!(matches!(
2376            result,
2377            Err(GitError::CommandFailed { ref stderr, .. })
2378                if stderr == "git executable unavailable"
2379        ));
2380    }
2381
2382    #[tokio::test]
2383    async fn commit_all_allows_configured_validation_without_hook() {
2384        // Arrange
2385        let temp_dir = tempdir().expect("failed to create temp dir");
2386        setup_test_git_repo(temp_dir.path());
2387        fs::write(
2388            temp_dir.path().join(".pre-commit-config.yaml"),
2389            "repos: []\n",
2390        )
2391        .expect("failed to write pre-commit configuration");
2392        fs::write(temp_dir.path().join("README.md"), "changed\n")
2393            .expect("failed to write worktree change");
2394
2395        // Act
2396        let result = commit_all(temp_dir.path().to_path_buf(), "Change README".to_string()).await;
2397
2398        // Assert
2399        assert!(result.is_ok());
2400        assert_eq!(
2401            git_command_stdout(temp_dir.path(), &["log", "-1", "--pretty=%s"]),
2402            "Change README"
2403        );
2404    }
2405
2406    #[tokio::test]
2407    async fn current_branch_name_returns_error_for_detached_head() {
2408        // Arrange
2409        let temp_dir = tempdir().expect("failed to create temp dir");
2410        setup_test_git_repo(temp_dir.path());
2411        run_git_command(temp_dir.path(), &["checkout", "--detach"]);
2412        let command_runner = ProcessAsyncGitCommandRunner;
2413
2414        // Act
2415        let result = current_branch_name(temp_dir.path(), &command_runner).await;
2416
2417        // Assert
2418        let error = result.expect_err("detached HEAD should fail");
2419        assert!(error.to_string().contains("detached HEAD"));
2420    }
2421
2422    #[tokio::test]
2423    async fn current_branch_remote_name_returns_none_when_remote_is_not_configured() {
2424        // Arrange
2425        let temp_dir = tempdir().expect("failed to create temp dir");
2426        setup_test_git_repo(temp_dir.path());
2427        let command_runner = ProcessAsyncGitCommandRunner;
2428
2429        // Act
2430        let remote_name = current_branch_remote_name(temp_dir.path(), &command_runner)
2431            .await
2432            .expect("missing branch remote should not be a command failure");
2433
2434        // Assert
2435        assert_eq!(remote_name, None);
2436    }
2437
2438    #[tokio::test]
2439    async fn current_branch_remote_name_returns_configured_non_origin_remote() {
2440        // Arrange
2441        let temp_dir = tempdir().expect("failed to create temp dir");
2442        setup_test_git_repo(temp_dir.path());
2443        run_git_command(
2444            temp_dir.path(),
2445            &["config", "branch.main.remote", "review-remote"],
2446        );
2447        let command_runner = ProcessAsyncGitCommandRunner;
2448
2449        // Act
2450        let remote_name = current_branch_remote_name(temp_dir.path(), &command_runner)
2451            .await
2452            .expect("configured branch remote should resolve");
2453
2454        // Assert
2455        assert_eq!(remote_name, Some("review-remote".to_string()));
2456    }
2457
2458    #[test]
2459    fn parse_current_branch_remote_output_preserves_fatal_config_error() {
2460        // Arrange
2461        let output = AsyncGitCommandOutput {
2462            exit_code: Some(128),
2463            stderr: b"fatal: bad config line".to_vec(),
2464            stdout: Vec::new(),
2465        };
2466
2467        // Act
2468        let error = parse_current_branch_remote_output(&output, "branch.main.remote")
2469            .expect_err("malformed config should remain an error");
2470
2471        // Assert
2472        assert!(matches!(
2473            error,
2474            GitError::CommandFailed { command, stderr }
2475                if command == "git config --get branch.main.remote"
2476                    && stderr.contains("Failed to resolve current branch remote")
2477        ));
2478    }
2479
2480    #[tokio::test]
2481    async fn primary_upstream_reference_uses_first_non_empty_line() {
2482        // Arrange
2483        let temp_dir = tempdir().expect("failed to create temp dir");
2484        let remote_dir = tempdir().expect("failed to create remote temp dir");
2485        setup_test_git_repo(temp_dir.path());
2486        run_git_command(remote_dir.path(), &["init", "--bare"]);
2487        let remote_path = remote_dir.path().to_string_lossy().to_string();
2488        run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
2489        run_git_command(temp_dir.path(), &["push", "-u", "origin", "main"]);
2490        run_git_command(
2491            temp_dir.path(),
2492            &[
2493                "config",
2494                "--replace-all",
2495                "branch.main.merge",
2496                "refs/heads/main",
2497            ],
2498        );
2499        run_git_command(
2500            temp_dir.path(),
2501            &["config", "--add", "branch.main.merge", "refs/heads/feature"],
2502        );
2503        let command_runner = ProcessAsyncGitCommandRunner;
2504
2505        // Act
2506        let upstream_reference = primary_upstream_reference(temp_dir.path(), &command_runner)
2507            .await
2508            .expect("failed to resolve upstream");
2509
2510        // Assert
2511        assert_eq!(upstream_reference, "origin/main");
2512    }
2513
2514    #[tokio::test]
2515    async fn pull_rebase_retries_index_lock_through_async_runner() {
2516        // Arrange
2517        let repo_path = PathBuf::from("test-repo");
2518        let mut command_runner = MockAsyncGitCommandRunner::new();
2519        let mut sequence = Sequence::new();
2520        command_runner
2521            .expect_run()
2522            .with(function(|command: &AsyncGitCommand| {
2523                command.arguments == ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]
2524            }))
2525            .times(1)
2526            .in_sequence(&mut sequence)
2527            .return_once(|_| {
2528                Box::pin(async { Ok(async_git_output(0, "origin/main\n", Vec::new())) })
2529            });
2530        for output in [
2531            async_git_output(
2532                128,
2533                Vec::new(),
2534                "fatal: Unable to create '.git/index.lock': File exists.",
2535            ),
2536            async_git_output(0, Vec::new(), Vec::new()),
2537        ] {
2538            command_runner
2539                .expect_run()
2540                .with(function(|command: &AsyncGitCommand| {
2541                    command.arguments == ["pull", "--rebase", "origin", "main"]
2542                        && command.environment
2543                            == [
2544                                ("GIT_EDITOR".to_string(), ":".to_string()),
2545                                ("GIT_SEQUENCE_EDITOR".to_string(), ":".to_string()),
2546                            ]
2547                }))
2548                .times(1)
2549                .in_sequence(&mut sequence)
2550                .return_once(move |_| Box::pin(async move { Ok(output) }));
2551        }
2552
2553        // Act
2554        let result = pull_rebase_with_runner(repo_path, &command_runner, Duration::ZERO).await;
2555
2556        // Assert
2557        assert!(matches!(result, Ok(PullRebaseResult::Completed)));
2558    }
2559
2560    #[tokio::test]
2561    async fn pull_rebase_preserves_non_conflict_command_failure() {
2562        // Arrange
2563        let repo_path = PathBuf::from("test-repo");
2564        let mut command_runner = MockAsyncGitCommandRunner::new();
2565        let mut sequence = Sequence::new();
2566        command_runner
2567            .expect_run()
2568            .with(function(|command: &AsyncGitCommand| {
2569                command.arguments == ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]
2570            }))
2571            .times(1)
2572            .in_sequence(&mut sequence)
2573            .return_once(|_| {
2574                Box::pin(async { Ok(async_git_output(0, "origin/main\n", Vec::new())) })
2575            });
2576        command_runner
2577            .expect_run()
2578            .with(function(|command: &AsyncGitCommand| {
2579                command.arguments == ["pull", "--rebase", "origin", "main"]
2580            }))
2581            .times(1)
2582            .in_sequence(&mut sequence)
2583            .return_once(|_| {
2584                Box::pin(async { Ok(async_git_output(128, Vec::new(), "fatal: transport failed")) })
2585            });
2586
2587        // Act
2588        let error = pull_rebase_with_runner(repo_path, &command_runner, Duration::ZERO)
2589            .await
2590            .expect_err("non-conflict pull failure should remain an error");
2591
2592        // Assert
2593        assert!(matches!(
2594            error,
2595            GitError::CommandFailed { command, stderr }
2596                if command == "git pull --rebase" && stderr == "fatal: transport failed"
2597        ));
2598    }
2599
2600    #[tokio::test]
2601    async fn pull_rebase_rejects_local_upstream_without_configured_remote() {
2602        // Arrange
2603        let repo_path = PathBuf::from("test-repo");
2604        let mut command_runner = MockAsyncGitCommandRunner::new();
2605        let mut sequence = Sequence::new();
2606        let expectations = [
2607            (
2608                vec!["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
2609                async_git_output(0, "main\n", Vec::new()),
2610            ),
2611            (
2612                vec!["rev-parse", "--abbrev-ref", "HEAD"],
2613                async_git_output(0, "main\n", Vec::new()),
2614            ),
2615            (
2616                vec!["config", "--get", "branch.main.remote"],
2617                async_git_output(1, Vec::new(), Vec::new()),
2618            ),
2619        ];
2620        for (arguments, output) in expectations {
2621            let arguments = arguments
2622                .into_iter()
2623                .map(str::to_string)
2624                .collect::<Vec<_>>();
2625            command_runner
2626                .expect_run()
2627                .with(function(move |command: &AsyncGitCommand| {
2628                    command.arguments == arguments
2629                }))
2630                .times(1)
2631                .in_sequence(&mut sequence)
2632                .return_once(move |_| Box::pin(async move { Ok(output) }));
2633        }
2634
2635        // Act
2636        let error = pull_rebase_with_runner(repo_path, &command_runner, Duration::ZERO)
2637            .await
2638            .expect_err("local upstream without a remote should fail");
2639
2640        // Assert
2641        assert!(matches!(
2642            error,
2643            GitError::OutputParse(message)
2644                if message == "Failed to resolve current branch remote: not configured"
2645        ));
2646    }
2647
2648    #[tokio::test]
2649    async fn pull_rebase_returns_last_index_lock_failure_after_retry_exhaustion() {
2650        // Arrange
2651        let repo_path = PathBuf::from("test-repo");
2652        let mut command_runner = MockAsyncGitCommandRunner::new();
2653        let mut sequence = Sequence::new();
2654        command_runner
2655            .expect_run()
2656            .with(function(|command: &AsyncGitCommand| {
2657                command.arguments == ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]
2658            }))
2659            .times(1)
2660            .in_sequence(&mut sequence)
2661            .return_once(|_| {
2662                Box::pin(async { Ok(async_git_output(0, "origin/main\n", Vec::new())) })
2663            });
2664        command_runner
2665            .expect_run()
2666            .with(function(|command: &AsyncGitCommand| {
2667                command.arguments == ["pull", "--rebase", "origin", "main"]
2668            }))
2669            .times(GIT_INDEX_LOCK_RETRY_ATTEMPTS)
2670            .in_sequence(&mut sequence)
2671            .returning(|_| {
2672                Box::pin(async {
2673                    Ok(async_git_output(
2674                        128,
2675                        Vec::new(),
2676                        "fatal: Unable to create '.git/index.lock': File exists.",
2677                    ))
2678                })
2679            });
2680
2681        // Act
2682        let error = pull_rebase_with_runner(repo_path, &command_runner, Duration::ZERO)
2683            .await
2684            .expect_err("exhausted index-lock retries should return the last failure");
2685
2686        // Assert
2687        assert!(matches!(
2688            error,
2689            GitError::CommandFailed { command, stderr }
2690                if command == "git pull --rebase" && stderr.contains("index.lock")
2691        ));
2692    }
2693
2694    #[tokio::test]
2695    async fn remote_branch_lookup_uses_origin_fallback_through_async_runner() {
2696        // Arrange
2697        let repo_path = PathBuf::from("test-repo");
2698        let mut command_runner = MockAsyncGitCommandRunner::new();
2699        let mut sequence = Sequence::new();
2700        let expectations = [
2701            (
2702                vec!["rev-parse", "--abbrev-ref", "HEAD"],
2703                async_git_output(0, "main\n", Vec::new()),
2704            ),
2705            (
2706                vec!["config", "--get", "branch.main.remote"],
2707                async_git_output(1, Vec::new(), Vec::new()),
2708            ),
2709            (
2710                vec!["ls-remote", "--heads", "origin", "review/topic"],
2711                async_git_output(0, "abc123\trefs/heads/review/topic\n", Vec::new()),
2712            ),
2713        ];
2714        for (arguments, output) in expectations {
2715            let arguments = arguments
2716                .into_iter()
2717                .map(str::to_string)
2718                .collect::<Vec<_>>();
2719            command_runner
2720                .expect_run()
2721                .with(function(move |command: &AsyncGitCommand| {
2722                    command.arguments == arguments
2723                }))
2724                .times(1)
2725                .in_sequence(&mut sequence)
2726                .return_once(move |_| Box::pin(async move { Ok(output) }));
2727        }
2728
2729        // Act
2730        let exists = remote_branch_exists_with_runner(
2731            repo_path,
2732            "review/topic".to_string(),
2733            &command_runner,
2734        )
2735        .await
2736        .expect("remote branch lookup should succeed");
2737
2738        // Assert
2739        assert!(exists);
2740    }
2741
2742    #[tokio::test]
2743    async fn new_remote_branch_push_requires_missing_remote_ref() {
2744        // Arrange
2745        let repo_path = PathBuf::from("test-repo");
2746        let mut command_runner = MockAsyncGitCommandRunner::new();
2747        let mut sequence = Sequence::new();
2748        let expectations = [
2749            (
2750                vec!["rev-parse", "--abbrev-ref", "HEAD"],
2751                async_git_output(0, "main\n", Vec::new()),
2752            ),
2753            (
2754                vec!["config", "--get", "branch.main.remote"],
2755                async_git_output(1, Vec::new(), Vec::new()),
2756            ),
2757            (
2758                vec![
2759                    "push",
2760                    "--force-with-lease=refs/heads/review/topic:",
2761                    "--set-upstream",
2762                    "origin",
2763                    "HEAD:review/topic",
2764                ],
2765                async_git_output(0, Vec::new(), Vec::new()),
2766            ),
2767        ];
2768        for (arguments, output) in expectations {
2769            let arguments = arguments
2770                .into_iter()
2771                .map(str::to_string)
2772                .collect::<Vec<_>>();
2773            command_runner
2774                .expect_run()
2775                .with(function(move |command: &AsyncGitCommand| {
2776                    command.arguments == arguments
2777                }))
2778                .times(1)
2779                .in_sequence(&mut sequence)
2780                .return_once(move |_| Box::pin(async move { Ok(output) }));
2781        }
2782
2783        // Act
2784        let upstream_reference = push_current_branch_to_new_remote_branch_with_runner(
2785            repo_path,
2786            "review/topic".to_string(),
2787            &command_runner,
2788        )
2789        .await
2790        .expect("new remote branch push should succeed");
2791
2792        // Assert
2793        assert_eq!(upstream_reference, "origin/review/topic");
2794    }
2795
2796    #[tokio::test]
2797    async fn remote_branch_lookup_checks_isolated_local_remote() {
2798        // Arrange
2799        let temp_dir = tempdir().expect("failed to create temp dir");
2800        let remote_dir = tempdir().expect("failed to create remote temp dir");
2801        setup_test_git_repo(temp_dir.path());
2802        run_git_command(remote_dir.path(), &["init", "--bare"]);
2803        let remote_path = remote_dir.path().to_string_lossy().to_string();
2804        run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
2805        run_git_command(temp_dir.path(), &["push", "-u", "origin", "main"]);
2806
2807        // Act
2808        let exists = remote_branch_exists(temp_dir.path().to_path_buf(), "main".to_string())
2809            .await
2810            .expect("local remote branch lookup should succeed");
2811
2812        // Assert
2813        assert!(exists);
2814    }
2815
2816    #[tokio::test]
2817    async fn push_without_upstream_reuses_configured_remote() {
2818        // Arrange
2819        let repo_path = PathBuf::from("test-repo");
2820        let mut command_runner = MockAsyncGitCommandRunner::new();
2821        let mut sequence = Sequence::new();
2822        let expectations = [
2823            (
2824                vec!["push", "--force-with-lease"],
2825                async_git_output(128, Vec::new(), "fatal: no upstream branch"),
2826            ),
2827            (
2828                vec!["rev-parse", "--abbrev-ref", "HEAD"],
2829                async_git_output(0, "main\n", Vec::new()),
2830            ),
2831            (
2832                vec!["config", "--get", "branch.main.remote"],
2833                async_git_output(0, "review-remote\n", Vec::new()),
2834            ),
2835            (
2836                vec![
2837                    "push",
2838                    "--force-with-lease",
2839                    "--set-upstream",
2840                    "review-remote",
2841                    "HEAD",
2842                ],
2843                async_git_output(0, Vec::new(), Vec::new()),
2844            ),
2845            (
2846                vec!["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
2847                async_git_output(0, "review-remote/main\n", Vec::new()),
2848            ),
2849        ];
2850        for (arguments, output) in expectations {
2851            let arguments = arguments
2852                .into_iter()
2853                .map(str::to_string)
2854                .collect::<Vec<_>>();
2855            command_runner
2856                .expect_run()
2857                .with(function(move |command: &AsyncGitCommand| {
2858                    command.arguments == arguments
2859                }))
2860                .times(1)
2861                .in_sequence(&mut sequence)
2862                .return_once(move |_| Box::pin(async move { Ok(output) }));
2863        }
2864
2865        // Act
2866        let upstream_reference = push_current_branch_with_runner(repo_path, &command_runner)
2867            .await
2868            .expect("configured remote push should succeed");
2869
2870        // Assert
2871        assert_eq!(upstream_reference, "review-remote/main");
2872    }
2873
2874    #[test]
2875    fn parse_branch_tracking_statuses_reads_repo_wide_branch_snapshot() {
2876        // Arrange
2877        let output = "\
2878main\torigin/main\tbehind 2\nwt/1234abcd\torigin/wt/1234abcd\tahead 3, behind \
2879                      1\nfeature/local\t\t\nfeature/gone\torigin/feature/gone\tgone\n";
2880
2881        // Act
2882        let branch_tracking_statuses = parse_branch_tracking_statuses(output);
2883
2884        // Assert
2885        assert_eq!(branch_tracking_statuses.get("main"), Some(&Some((0, 2))));
2886        assert_eq!(
2887            branch_tracking_statuses.get("wt/1234abcd"),
2888            Some(&Some((3, 1)))
2889        );
2890        assert_eq!(branch_tracking_statuses.get("feature/local"), Some(&None));
2891        assert_eq!(branch_tracking_statuses.get("feature/gone"), Some(&None));
2892    }
2893
2894    #[tokio::test]
2895    async fn pull_rebase_returns_conflict_detail_for_conflicting_remote_change() {
2896        // Arrange
2897        let temp_dir = tempdir().expect("failed to create temp dir");
2898        let remote_dir = tempdir().expect("failed to create remote temp dir");
2899        let contributor_dir = tempdir().expect("failed to create contributor temp dir");
2900        let contributor_clone_path = contributor_dir.path().join("clone");
2901        setup_test_git_repo(temp_dir.path());
2902        run_git_command(remote_dir.path(), &["init", "--bare"]);
2903        let remote_path = remote_dir.path().to_string_lossy().to_string();
2904        let contributor_clone_path_text = contributor_clone_path.to_string_lossy().to_string();
2905        run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
2906        run_git_command(temp_dir.path(), &["push", "-u", "origin", "main"]);
2907        fs::write(temp_dir.path().join("README.md"), "local change\n")
2908            .expect("failed to write local change");
2909        run_git_command(temp_dir.path(), &["add", "README.md"]);
2910        run_git_command(temp_dir.path(), &["commit", "-m", "Local change"]);
2911        run_git_command(
2912            contributor_dir.path(),
2913            &["clone", &remote_path, &contributor_clone_path_text],
2914        );
2915        run_git_command(
2916            &contributor_clone_path,
2917            &["config", "user.name", "Contributor User"],
2918        );
2919        run_git_command(
2920            &contributor_clone_path,
2921            &["config", "user.email", "contributor@example.com"],
2922        );
2923        run_git_command(
2924            &contributor_clone_path,
2925            &["checkout", "-B", "main", "origin/main"],
2926        );
2927        fs::write(contributor_clone_path.join("README.md"), "remote change\n")
2928            .expect("failed to write remote change");
2929        run_git_command(&contributor_clone_path, &["add", "README.md"]);
2930        run_git_command(&contributor_clone_path, &["commit", "-m", "Remote change"]);
2931        run_git_command(&contributor_clone_path, &["push", "origin", "main"]);
2932
2933        // Act
2934        let result = pull_rebase(temp_dir.path().to_path_buf()).await;
2935
2936        // Assert
2937        assert!(matches!(
2938            result,
2939            Ok(PullRebaseResult::Conflict { ref detail })
2940                if {
2941                    let normalized_detail = detail.to_ascii_lowercase();
2942
2943                    (normalized_detail.contains("conflict")
2944                        || normalized_detail.contains("could not apply"))
2945                        && !detail.is_empty()
2946                }
2947        ));
2948    }
2949
2950    #[tokio::test]
2951    async fn push_current_branch_returns_rejected_error_for_non_fast_forward_push() {
2952        // Arrange
2953        let temp_dir = tempdir().expect("failed to create temp dir");
2954        let remote_dir = tempdir().expect("failed to create remote temp dir");
2955        let contributor_dir = tempdir().expect("failed to create contributor temp dir");
2956        let contributor_clone_path = contributor_dir.path().join("clone");
2957        setup_test_git_repo(temp_dir.path());
2958        run_git_command(remote_dir.path(), &["init", "--bare"]);
2959        let remote_path = remote_dir.path().to_string_lossy().to_string();
2960        let contributor_clone_path_text = contributor_clone_path.to_string_lossy().to_string();
2961        run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
2962        run_git_command(temp_dir.path(), &["push", "-u", "origin", "main"]);
2963        run_git_command(
2964            contributor_dir.path(),
2965            &["clone", &remote_path, &contributor_clone_path_text],
2966        );
2967        run_git_command(
2968            &contributor_clone_path,
2969            &["config", "user.name", "Contributor User"],
2970        );
2971        run_git_command(
2972            &contributor_clone_path,
2973            &["config", "user.email", "contributor@example.com"],
2974        );
2975        run_git_command(
2976            &contributor_clone_path,
2977            &["checkout", "-B", "main", "origin/main"],
2978        );
2979        fs::write(contributor_clone_path.join("remote.txt"), "remote change")
2980            .expect("failed to write remote file");
2981        run_git_command(&contributor_clone_path, &["add", "remote.txt"]);
2982        run_git_command(&contributor_clone_path, &["commit", "-m", "Remote change"]);
2983        run_git_command(&contributor_clone_path, &["push", "origin", "main"]);
2984        fs::write(temp_dir.path().join("local.txt"), "local change")
2985            .expect("failed to write local file");
2986        run_git_command(temp_dir.path(), &["add", "local.txt"]);
2987        run_git_command(temp_dir.path(), &["commit", "-m", "Local change"]);
2988
2989        // Act
2990        let result = push_current_branch(temp_dir.path().to_path_buf()).await;
2991
2992        // Assert
2993        let error = result
2994            .expect_err("non-fast-forward push should fail")
2995            .to_string();
2996        assert!(error.contains("git push"));
2997        assert!(
2998            error.contains("stale info")
2999                || error.contains("rejected")
3000                || error.contains("fetch first")
3001        );
3002    }
3003
3004    #[tokio::test]
3005    async fn push_current_branch_force_with_lease_updates_rewritten_history() {
3006        // Arrange
3007        let temp_dir = tempdir().expect("failed to create temp dir");
3008        let remote_dir = tempdir().expect("failed to create remote temp dir");
3009        setup_test_git_repo(temp_dir.path());
3010        run_git_command(remote_dir.path(), &["init", "--bare"]);
3011        let remote_path = remote_dir.path().to_string_lossy().to_string();
3012        run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
3013        run_git_command(temp_dir.path(), &["push", "-u", "origin", "main"]);
3014        fs::write(
3015            temp_dir.path().join("README.md"),
3016            "first published version\n",
3017        )
3018        .expect("failed to write first version");
3019        run_git_command(temp_dir.path(), &["add", "README.md"]);
3020        run_git_command(temp_dir.path(), &["commit", "-m", "Publish branch change"]);
3021        push_current_branch(temp_dir.path().to_path_buf())
3022            .await
3023            .expect("initial push should succeed");
3024        fs::write(
3025            temp_dir.path().join("README.md"),
3026            "rewritten published version\n",
3027        )
3028        .expect("failed to rewrite published version");
3029        run_git_command(temp_dir.path(), &["add", "README.md"]);
3030        run_git_command(
3031            temp_dir.path(),
3032            &["commit", "--amend", "-m", "Rewrite published branch change"],
3033        );
3034
3035        // Act
3036        let upstream_reference = push_current_branch(temp_dir.path().to_path_buf())
3037            .await
3038            .expect("force-with-lease push should update rewritten history");
3039        let local_head = git_command_stdout(temp_dir.path(), &["rev-parse", "HEAD"]);
3040        let remote_head = git_command_stdout(remote_dir.path(), &["rev-parse", "refs/heads/main"]);
3041
3042        // Assert
3043        assert_eq!(upstream_reference, "origin/main");
3044        assert_eq!(local_head, remote_head);
3045    }
3046
3047    #[tokio::test]
3048    async fn push_current_branch_to_remote_branch_returns_custom_upstream_reference() {
3049        // Arrange
3050        let temp_dir = tempdir().expect("failed to create temp dir");
3051        let remote_dir = tempdir().expect("failed to create remote temp dir");
3052        setup_test_git_repo(temp_dir.path());
3053        run_git_command(remote_dir.path(), &["init", "--bare"]);
3054        let remote_path = remote_dir.path().to_string_lossy().to_string();
3055        run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
3056
3057        // Act
3058        let upstream_reference = push_current_branch_to_remote_branch(
3059            temp_dir.path().to_path_buf(),
3060            "review/custom-branch".to_string(),
3061        )
3062        .await
3063        .expect("failed to push current branch to custom remote branch");
3064
3065        // Assert
3066        assert_eq!(upstream_reference, "origin/review/custom-branch");
3067    }
3068
3069    #[tokio::test]
3070    async fn new_remote_branch_push_rejects_existing_remote_branch() {
3071        // Arrange
3072        let temp_dir = tempdir().expect("failed to create temp dir");
3073        let remote_dir = tempdir().expect("failed to create remote temp dir");
3074        setup_test_git_repo(temp_dir.path());
3075        run_git_command(remote_dir.path(), &["init", "--bare"]);
3076        let remote_path = remote_dir.path().to_string_lossy().to_string();
3077        run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
3078        run_git_command(
3079            temp_dir.path(),
3080            &["push", "origin", "HEAD:review/existing-branch"],
3081        );
3082        let remote_head_before = git_command_stdout(
3083            remote_dir.path(),
3084            &["rev-parse", "refs/heads/review/existing-branch"],
3085        );
3086        fs::write(temp_dir.path().join("new.txt"), "new local review\n")
3087            .expect("failed to write new local review file");
3088        run_git_command(temp_dir.path(), &["add", "new.txt"]);
3089        run_git_command(temp_dir.path(), &["commit", "-m", "New local review"]);
3090
3091        // Act
3092        let result = push_current_branch_to_new_remote_branch(
3093            temp_dir.path().to_path_buf(),
3094            "review/existing-branch".to_string(),
3095        )
3096        .await;
3097        let remote_head_after = git_command_stdout(
3098            remote_dir.path(),
3099            &["rev-parse", "refs/heads/review/existing-branch"],
3100        );
3101
3102        // Assert
3103        let error = result.expect_err("existing remote branch should be rejected");
3104        assert!(error.to_string().contains("stale info"));
3105        assert_eq!(remote_head_before, remote_head_after);
3106    }
3107
3108    #[tokio::test]
3109    async fn new_remote_branch_push_ignores_stale_remote_tracking_ref() {
3110        // Arrange
3111        let temp_dir = tempdir().expect("failed to create temp dir");
3112        let remote_dir = tempdir().expect("failed to create remote temp dir");
3113        setup_test_git_repo(temp_dir.path());
3114        run_git_command(remote_dir.path(), &["init", "--bare"]);
3115        let remote_path = remote_dir.path().to_string_lossy().to_string();
3116        run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
3117        run_git_command(temp_dir.path(), &["checkout", "-b", "previous-review"]);
3118        fs::write(temp_dir.path().join("previous.txt"), "previous review\n")
3119            .expect("failed to write previous review file");
3120        run_git_command(temp_dir.path(), &["add", "previous.txt"]);
3121        run_git_command(temp_dir.path(), &["commit", "-m", "Previous review"]);
3122        let previous_head = git_command_stdout(temp_dir.path(), &["rev-parse", "HEAD"]);
3123        run_git_command(
3124            temp_dir.path(),
3125            &[
3126                "push",
3127                "--set-upstream",
3128                "origin",
3129                "HEAD:review/deleted-branch",
3130            ],
3131        );
3132        run_git_command(
3133            temp_dir.path(),
3134            &["push", "origin", ":review/deleted-branch"],
3135        );
3136        run_git_command(
3137            temp_dir.path(),
3138            &[
3139                "update-ref",
3140                "refs/remotes/origin/review/deleted-branch",
3141                &previous_head,
3142            ],
3143        );
3144        run_git_command(temp_dir.path(), &["checkout", "main"]);
3145        fs::write(
3146            temp_dir.path().join("replacement.txt"),
3147            "replacement review\n",
3148        )
3149        .expect("failed to write replacement review file");
3150        run_git_command(temp_dir.path(), &["add", "replacement.txt"]);
3151        run_git_command(temp_dir.path(), &["commit", "-m", "Replacement review"]);
3152
3153        // Act
3154        let upstream_reference = push_current_branch_to_new_remote_branch(
3155            temp_dir.path().to_path_buf(),
3156            "review/deleted-branch".to_string(),
3157        )
3158        .await
3159        .expect("new branch push should ignore the stale remote-tracking ref");
3160        let local_head = git_command_stdout(temp_dir.path(), &["rev-parse", "HEAD"]);
3161        let remote_head = git_command_stdout(
3162            remote_dir.path(),
3163            &["rev-parse", "refs/heads/review/deleted-branch"],
3164        );
3165
3166        // Assert
3167        assert_eq!(upstream_reference, "origin/review/deleted-branch");
3168        assert_eq!(local_head, remote_head);
3169    }
3170
3171    #[tokio::test]
3172    async fn current_upstream_reference_returns_origin_main() {
3173        // Arrange
3174        let temp_dir = tempdir().expect("failed to create temp dir");
3175        let remote_dir = tempdir().expect("failed to create remote temp dir");
3176        setup_test_git_repo(temp_dir.path());
3177        run_git_command(remote_dir.path(), &["init", "--bare"]);
3178        let remote_path = remote_dir.path().to_string_lossy().to_string();
3179        run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
3180        run_git_command(temp_dir.path(), &["push", "-u", "origin", "main"]);
3181
3182        // Act
3183        let upstream_reference = current_upstream_reference(temp_dir.path().to_path_buf())
3184            .await
3185            .expect("failed to resolve upstream reference");
3186
3187        // Assert
3188        assert_eq!(upstream_reference, "origin/main");
3189    }
3190
3191    #[tokio::test]
3192    async fn get_ref_ahead_behind_returns_counts_between_two_local_branches() {
3193        // Arrange
3194        let temp_dir = tempdir().expect("failed to create temp dir");
3195        setup_test_git_repo(temp_dir.path());
3196        run_git_command(temp_dir.path(), &["checkout", "-b", "wt/1234abcd"]);
3197        fs::write(temp_dir.path().join("session.txt"), "session change\n")
3198            .expect("failed to write session file");
3199        run_git_command(temp_dir.path(), &["add", "session.txt"]);
3200        run_git_command(temp_dir.path(), &["commit", "-m", "Session change"]);
3201        run_git_command(temp_dir.path(), &["checkout", "main"]);
3202        fs::write(temp_dir.path().join("main.txt"), "main change\n")
3203            .expect("failed to write main file");
3204        run_git_command(temp_dir.path(), &["add", "main.txt"]);
3205        run_git_command(temp_dir.path(), &["commit", "-m", "Main change"]);
3206
3207        // Act
3208        let status = get_ref_ahead_behind(
3209            temp_dir.path().to_path_buf(),
3210            "wt/1234abcd".to_string(),
3211            "main".to_string(),
3212        )
3213        .await
3214        .expect("failed to compare branch refs");
3215
3216        // Assert
3217        assert_eq!(status, (1, 1));
3218    }
3219
3220    #[tokio::test]
3221    async fn branch_tracking_statuses_returns_repo_wide_branch_counts() {
3222        // Arrange
3223        let temp_dir = tempdir().expect("failed to create temp dir");
3224        let remote_dir = tempdir().expect("failed to create remote temp dir");
3225        let contributor_dir = tempdir().expect("failed to create contributor temp dir");
3226        let contributor_clone_path = contributor_dir.path().join("clone");
3227        setup_test_git_repo(temp_dir.path());
3228        run_git_command(remote_dir.path(), &["init", "--bare"]);
3229        let remote_path = remote_dir.path().to_string_lossy().to_string();
3230        let contributor_clone_path_text = contributor_clone_path.to_string_lossy().to_string();
3231        run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
3232        run_git_command(temp_dir.path(), &["push", "-u", "origin", "main"]);
3233        run_git_command(
3234            contributor_dir.path(),
3235            &["clone", &remote_path, &contributor_clone_path_text],
3236        );
3237        run_git_command(
3238            &contributor_clone_path,
3239            &["config", "user.name", "Contributor User"],
3240        );
3241        run_git_command(
3242            &contributor_clone_path,
3243            &["config", "user.email", "contributor@example.com"],
3244        );
3245        run_git_command(
3246            &contributor_clone_path,
3247            &["checkout", "-B", "main", "origin/main"],
3248        );
3249        fs::write(contributor_clone_path.join("remote.txt"), "remote change")
3250            .expect("failed to write remote file");
3251        run_git_command(&contributor_clone_path, &["add", "remote.txt"]);
3252        run_git_command(&contributor_clone_path, &["commit", "-m", "Remote change"]);
3253        run_git_command(&contributor_clone_path, &["push", "origin", "main"]);
3254        run_git_command(temp_dir.path(), &["checkout", "-b", "wt/1234abcd"]);
3255        fs::write(temp_dir.path().join("session.txt"), "session change\n")
3256            .expect("failed to write session file");
3257        run_git_command(temp_dir.path(), &["add", "session.txt"]);
3258        run_git_command(temp_dir.path(), &["commit", "-m", "Session change"]);
3259        run_git_command(temp_dir.path(), &["push", "-u", "origin", "wt/1234abcd"]);
3260        fs::write(
3261            temp_dir.path().join("session.txt"),
3262            "session change\nmore local\n",
3263        )
3264        .expect("failed to extend session file");
3265        run_git_command(temp_dir.path(), &["add", "session.txt"]);
3266        run_git_command(temp_dir.path(), &["commit", "-m", "More session work"]);
3267        run_git_command(temp_dir.path(), &["fetch"]);
3268
3269        // Act
3270        let branch_tracking_statuses = branch_tracking_statuses(temp_dir.path().to_path_buf())
3271            .await
3272            .expect("failed to read branch tracking statuses");
3273
3274        // Assert
3275        assert_eq!(branch_tracking_statuses.get("main"), Some(&Some((0, 1))));
3276        assert_eq!(
3277            branch_tracking_statuses.get("wt/1234abcd"),
3278            Some(&Some((1, 0)))
3279        );
3280    }
3281
3282    #[tokio::test]
3283    /// Verifies that amending a session commit whose staged result is identical
3284    /// to the base branch (i.e., all changes were reverted) surfaces the
3285    /// canonical "Nothing to commit" sentinel rather than triggering the assist
3286    /// retry loop with the raw git "allow-empty" error.
3287    async fn test_empty_amend_resets_session_commit_and_returns_no_changes() {
3288        // Arrange
3289        let temp_dir = tempdir().expect("failed to create temp dir");
3290        setup_test_git_repo(temp_dir.path());
3291        run_git_command(temp_dir.path(), &["checkout", "-b", "session-branch"]);
3292        fs::write(temp_dir.path().join("session.txt"), "session work\n")
3293            .expect("failed to write session file");
3294        run_git_command(temp_dir.path(), &["add", "session.txt"]);
3295        run_git_command(temp_dir.path(), &["commit", "-m", "Session commit"]);
3296        fs::remove_file(temp_dir.path().join("session.txt"))
3297            .expect("failed to remove session file");
3298
3299        // Act - the worktree is dirty (session.txt removed) but amending HEAD
3300        // would produce a tree identical to the base branch, making the amend
3301        // result an empty commit.
3302        let result = commit_all_preserving_single_commit(
3303            temp_dir.path().to_path_buf(),
3304            "main".to_string(),
3305            "Session commit".to_string(),
3306            SingleCommitMessageStrategy::Replace,
3307        )
3308        .await;
3309
3310        // Assert
3311        let error = result.expect_err("amend-would-be-empty should fail");
3312        let commit_count = git_command_stdout(temp_dir.path(), &["rev-list", "--count", "HEAD"]);
3313        let head_message = git_command_stdout(temp_dir.path(), &["log", "-1", "--pretty=%B"]);
3314        let status = git_command_stdout(temp_dir.path(), &["status", "--porcelain"]);
3315
3316        assert!(
3317            error.to_string().contains("Nothing to commit"),
3318            "expected 'Nothing to commit' sentinel but got: {error}"
3319        );
3320        assert_eq!(commit_count, "1");
3321        assert_eq!(head_message, "Initial commit");
3322        assert_eq!(status, "");
3323    }
3324}