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/// * `no_verify` - When `true`, skips configured git hooks such as
72///   `prek`-managed `pre-commit` and `commit-msg` hooks (`--no-verify`)
73///
74/// # Returns
75/// Ok(()) on success.
76///
77/// # Errors
78/// Returns a [`GitError`] if staging or committing changes fails.
79pub(crate) async fn commit_all(
80    repo_path: PathBuf,
81    commit_message: String,
82    no_verify: bool,
83) -> Result<(), GitError> {
84    commit_all_with_retry(
85        repo_path,
86        commit_message,
87        SingleCommitMessageStrategy::Replace,
88        no_verify,
89        false,
90    )
91    .await
92}
93
94/// Stages all changes and keeps a single commit for the provided message.
95///
96/// Creates a new commit when `HEAD` has no commits beyond `base_branch`.
97/// Otherwise, amends `HEAD` so the branch keeps one evolving session commit.
98///
99/// # Arguments
100/// * `repo_path` - Path to the git repository or worktree
101/// * `base_branch` - Branch used to detect whether a session commit already
102///   exists on `HEAD`
103/// * `commit_message` - Message that identifies the session commit
104/// * `message_strategy` - Whether amends replace or reuse the existing `HEAD`
105///   message
106/// * `no_verify` - When `true`, skips configured git hooks such as
107///   `prek`-managed `pre-commit` and `commit-msg` hooks (`--no-verify`)
108///
109/// # Returns
110/// Ok(()) on success.
111///
112/// # Errors
113/// Returns a [`GitError`] if staging, commit lookup, or committing changes
114/// fails.
115pub(crate) async fn commit_all_preserving_single_commit(
116    repo_path: PathBuf,
117    base_branch: String,
118    commit_message: String,
119    message_strategy: SingleCommitMessageStrategy,
120    no_verify: bool,
121) -> Result<(), GitError> {
122    let amend_existing_commit = has_commits_since(repo_path.clone(), base_branch).await?;
123
124    commit_all_with_retry(
125        repo_path,
126        commit_message,
127        message_strategy,
128        no_verify,
129        amend_existing_commit,
130    )
131    .await
132}
133
134/// Stages all changes in the repository or worktree.
135///
136/// # Arguments
137/// * `repo_path` - Path to the git repository or worktree
138///
139/// # Returns
140/// Ok(()) on success.
141///
142/// # Errors
143/// Returns a [`GitError`] if `git add -A` fails.
144pub(crate) async fn stage_all(repo_path: PathBuf) -> Result<(), GitError> {
145    spawn_blocking(move || stage_all_sync(&repo_path)).await?
146}
147
148/// Verifies that configured pre-commit validation has an executable Git hook.
149///
150/// # Errors
151/// Returns [`GitError::PreCommitHookMissing`] when a supported configuration
152/// exists without an executable hook, or a command error when the effective
153/// hook path cannot be resolved.
154pub(crate) async fn check_pre_commit_hook_ready(repo_path: PathBuf) -> Result<(), GitError> {
155    spawn_blocking(move || ensure_pre_commit_hook_ready(&repo_path)).await?
156}
157
158/// Returns the short hash of the current `HEAD` commit.
159///
160/// # Arguments
161/// * `repo_path` - Path to the git repository or worktree
162///
163/// # Returns
164/// The short commit hash as a string.
165///
166/// # Errors
167/// Returns a [`GitError`] if resolving `HEAD` fails.
168pub(crate) async fn head_short_hash(repo_path: PathBuf) -> Result<String, GitError> {
169    let hash = run_git_command(
170        repo_path,
171        vec![
172            "rev-parse".to_string(),
173            "--short".to_string(),
174            "HEAD".to_string(),
175        ],
176        "Failed to resolve HEAD hash".to_string(),
177    )
178    .await?;
179    let hash = hash.trim().to_string();
180    if hash.is_empty() {
181        return Err(GitError::OutputParse(
182            "Failed to resolve HEAD hash: empty output".to_string(),
183        ));
184    }
185
186    Ok(hash)
187}
188
189/// Returns the full hash of the current `HEAD` commit.
190///
191/// # Arguments
192/// * `repo_path` - Path to the git repository or worktree
193///
194/// # Returns
195/// The full commit hash as a string.
196///
197/// # Errors
198/// Returns a [`GitError`] if resolving `HEAD` fails.
199pub(crate) async fn head_hash(repo_path: PathBuf) -> Result<String, GitError> {
200    let hash = run_git_command(
201        repo_path,
202        vec!["rev-parse".to_string(), "HEAD".to_string()],
203        "Failed to resolve HEAD hash".to_string(),
204    )
205    .await?;
206    let hash = hash.trim().to_string();
207    if hash.is_empty() {
208        return Err(GitError::OutputParse(
209            "Failed to resolve HEAD hash: empty output".to_string(),
210        ));
211    }
212
213    Ok(hash)
214}
215
216/// Returns the full commit hash for a git reference.
217///
218/// # Arguments
219/// * `repo_path` - Path to the git repository or worktree.
220/// * `reference` - Branch, tag, or commit-ish to resolve.
221///
222/// # Returns
223/// The full commit hash as a string.
224///
225/// # Errors
226/// Returns a [`GitError`] if the reference cannot be resolved to a commit.
227pub(crate) async fn ref_hash(repo_path: PathBuf, reference: String) -> Result<String, GitError> {
228    let hash = run_git_command(
229        repo_path,
230        vec![
231            "rev-parse".to_string(),
232            "--verify".to_string(),
233            format!("{reference}^{{commit}}"),
234        ],
235        format!("Failed to resolve `{reference}` hash"),
236    )
237    .await?;
238    let hash = hash.trim().to_string();
239    if hash.is_empty() {
240        return Err(GitError::OutputParse(format!(
241            "Failed to resolve `{reference}` hash: empty output"
242        )));
243    }
244
245    Ok(hash)
246}
247
248/// Returns the full `HEAD` commit message, or `None` when no commits exist.
249///
250/// # Errors
251/// Returns a [`GitError`] if `HEAD` cannot be inspected.
252pub(crate) async fn head_commit_message(repo_path: PathBuf) -> Result<Option<String>, GitError> {
253    spawn_blocking(move || head_commit_message_sync(&repo_path)).await?
254}
255
256/// Deletes a git branch.
257///
258/// Uses -D to force deletion even if not merged.
259///
260/// # Arguments
261/// * `repo_path` - Path to the git repository root
262/// * `branch_name` - Name of the branch to delete
263///
264/// # Returns
265/// Ok(()) on success.
266///
267/// # Errors
268/// Returns a [`GitError`] if the branch delete command fails or exceeds its
269/// runtime bound.
270pub(crate) async fn delete_branch(repo_path: PathBuf, branch_name: String) -> Result<(), GitError> {
271    run_git_command(
272        repo_path,
273        vec!["branch".to_string(), "-D".to_string(), branch_name],
274        "Git branch deletion failed".to_string(),
275    )
276    .await?;
277
278    Ok(())
279}
280
281/// Returns the output of `git diff` for the given repository path, showing
282/// all changes (committed and uncommitted) relative to the base branch.
283///
284/// Copies the repository index into a temporary index and uses
285/// `git add --intent-to-add` there to make untracked files visible, then
286/// finds the merge-base between `HEAD` and `base_branch` to diff against the
287/// fork point. To avoid re-showing squash-merged/cherry-picked session commits
288/// on non-rebased branches, this also checks `git cherry` and, when applicable,
289/// diffs from the last leading commit already applied to `base_branch`.
290/// The real repository index is never modified.
291///
292/// # Arguments
293/// * `repo_path` - Path to the git repository or worktree
294/// * `base_branch` - Branch to diff against (e.g., `main`)
295///
296/// # Returns
297/// The diff output as a string.
298///
299/// # Errors
300/// Returns a [`GitError`] if preparing the temporary index or generating the
301/// diff fails.
302pub(crate) async fn diff(repo_path: PathBuf, base_branch: String) -> Result<String, GitError> {
303    diff_output(repo_path, base_branch, false).await
304}
305
306/// Returns repository-relative changed paths using the same isolated index and
307/// fork-point semantics as [`diff`].
308pub(crate) async fn diff_changed_files(
309    repo_path: PathBuf,
310    base_branch: String,
311) -> Result<Vec<String>, GitError> {
312    let output = diff_output(repo_path, base_branch, true).await?;
313
314    Ok(output
315        .lines()
316        .map(str::trim)
317        .filter(|path| !path.is_empty())
318        .map(str::to_string)
319        .collect())
320}
321
322/// Generates either a patch or name-only output without mutating the real
323/// repository index.
324async fn diff_output(
325    repo_path: PathBuf,
326    base_branch: String,
327    name_only: bool,
328) -> Result<String, GitError> {
329    spawn_blocking(move || -> Result<String, GitError> {
330        let index_path = run_git_command_sync(
331            &repo_path,
332            &["rev-parse", "--git-path", "index"],
333            "Git index path resolution failed",
334        )?;
335        let index_path = PathBuf::from(index_path.trim());
336        let index_path = if index_path.is_absolute() {
337            index_path
338        } else {
339            repo_path.join(index_path)
340        };
341        let temporary_index = copy_git_index_to_temp(&index_path)?;
342
343        run_git_command_with_index_sync(
344            &repo_path,
345            &["add", "-A", "--intent-to-add"],
346            &temporary_index,
347            "Git add --intent-to-add failed",
348        )?;
349
350        let merge_base_output =
351            run_git_command_output_sync(&repo_path, &["merge-base", "HEAD", &base_branch])?;
352
353        let diff_target = if merge_base_output.status.success() {
354            resolve_diff_target(
355                &repo_path,
356                &base_branch,
357                String::from_utf8_lossy(&merge_base_output.stdout).trim(),
358            )?
359        } else {
360            base_branch
361        };
362
363        let args = if name_only {
364            vec!["diff", "--name-only", diff_target.as_str()]
365        } else {
366            vec!["diff", diff_target.as_str()]
367        };
368
369        run_git_command_with_index_sync(&repo_path, &args, &temporary_index, "Git diff failed")
370    })
371    .await?
372}
373
374/// Reads one repository-relative worktree file with a fixed memory bound.
375///
376/// The path must contain only normal relative components. Canonical path
377/// validation also rejects symlinks that resolve outside `repo_path`.
378///
379/// # Errors
380/// Returns a [`GitError`] when the path is unsafe, repository path resolution
381/// fails, or the selected file cannot be read.
382pub(crate) async fn read_worktree_file(
383    repo_path: PathBuf,
384    relative_path: String,
385) -> Result<WorktreeFileContent, GitError> {
386    spawn_blocking(move || read_worktree_file_sync(&repo_path, &relative_path)).await?
387}
388
389/// Performs the bounded worktree read on a blocking worker thread.
390fn read_worktree_file_sync(
391    repo_path: &Path,
392    relative_path: &str,
393) -> Result<WorktreeFileContent, GitError> {
394    let relative_file_path = Path::new(relative_path);
395    if relative_path.is_empty()
396        || relative_file_path
397            .components()
398            .any(|component| !matches!(component, Component::Normal(_)))
399    {
400        return Err(GitError::OutputParse(format!(
401            "Unsafe worktree file path: {relative_path}"
402        )));
403    }
404
405    let canonical_repo_path = std::fs::canonicalize(repo_path)?;
406    let candidate_path = repo_path.join(relative_file_path);
407    let canonical_file_path = match std::fs::canonicalize(candidate_path) {
408        Ok(path) => path,
409        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
410            return Ok(WorktreeFileContent::Missing);
411        }
412        Err(error) => return Err(error.into()),
413    };
414    if !canonical_file_path.starts_with(canonical_repo_path) {
415        return Err(GitError::OutputParse(format!(
416            "Worktree file resolves outside repository: {relative_path}"
417        )));
418    }
419
420    let file = std::fs::File::open(canonical_file_path)?;
421    let mut bytes = Vec::with_capacity(MAX_WORKTREE_FILE_BYTE_COUNT.min(8192));
422    file.take((MAX_WORKTREE_FILE_BYTE_COUNT as u64).saturating_add(1))
423        .read_to_end(&mut bytes)?;
424
425    Ok(worktree_file_content(bytes))
426}
427
428/// Classifies bytes read through the bounded worktree-file reader.
429fn worktree_file_content(bytes: Vec<u8>) -> WorktreeFileContent {
430    if bytes.len() > MAX_WORKTREE_FILE_BYTE_COUNT {
431        return WorktreeFileContent::TooLarge;
432    }
433
434    match String::from_utf8(bytes) {
435        Ok(content) => WorktreeFileContent::Text(content),
436        Err(_) => WorktreeFileContent::Binary,
437    }
438}
439
440/// Copies one repository index beside its source and returns the temporary
441/// path used by isolated read-only diff commands.
442fn copy_git_index_to_temp(index_path: &Path) -> Result<tempfile::TempPath, GitError> {
443    let index_parent = index_path.parent().ok_or_else(|| {
444        GitError::OutputParse(format!(
445            "Git index path has no parent: {}",
446            index_path.display()
447        ))
448    })?;
449    let temporary_index =
450        tempfile::NamedTempFile::new_in(index_parent).map_err(|error| GitError::CommandFailed {
451            command: "create temporary git index".to_string(),
452            stderr: error.to_string(),
453        })?;
454    std::fs::copy(index_path, temporary_index.path()).map_err(|error| GitError::CommandFailed {
455        command: "copy git index".to_string(),
456        stderr: error.to_string(),
457    })?;
458
459    Ok(temporary_index.into_temp_path())
460}
461
462/// Runs one git command against a temporary index without touching the real
463/// index.
464fn run_git_command_with_index_sync(
465    repo_path: &Path,
466    args: &[&str],
467    index_path: &Path,
468    error_context: &str,
469) -> Result<String, GitError> {
470    let output = run_git_command_output_with_env_sync(
471        repo_path,
472        args,
473        &[("GIT_INDEX_FILE", index_path.as_os_str())],
474    )?;
475    if !output.status.success() {
476        return Err(GitError::CommandFailed {
477            command: format!("git {}", args.join(" ")),
478            stderr: format!(
479                "{error_context}: {}",
480                command_output_detail(&output.stdout, &output.stderr)
481            ),
482        });
483    }
484
485    Ok(String::from_utf8_lossy(&output.stdout).into_owned())
486}
487
488/// Returns whether a repository or worktree has no uncommitted changes.
489///
490/// # Arguments
491/// * `repo_path` - Path to the git repository or worktree
492///
493/// # Returns
494/// `true` when `git status --porcelain` is empty, `false` otherwise.
495///
496/// # Errors
497/// Returns a [`GitError`] if `git status --porcelain` cannot be executed.
498pub(crate) async fn is_worktree_clean(repo_path: PathBuf) -> Result<bool, GitError> {
499    let status_output = worktree_status(repo_path).await?;
500
501    Ok(status_output.trim().is_empty())
502}
503
504/// Returns a stable porcelain status snapshot for a repository or worktree.
505///
506/// The snapshot includes untracked files so cleanup and review workflows can
507/// detect all local filesystem changes in the worktree.
508///
509/// # Arguments
510/// * `repo_path` - Path to the git repository or worktree
511///
512/// # Returns
513/// Raw `git status --porcelain=v1 --untracked-files=all` stdout.
514///
515/// # Errors
516/// Returns a [`GitError`] if the status command cannot be executed.
517pub(crate) async fn worktree_status(repo_path: PathBuf) -> Result<String, GitError> {
518    run_git_command(
519        repo_path,
520        vec![
521            "status".to_string(),
522            "--porcelain=v1".to_string(),
523            "--untracked-files=all".to_string(),
524        ],
525        "Git status --porcelain=v1 failed".to_string(),
526    )
527    .await
528}
529
530/// Returns a stable porcelain status snapshot for tracked worktree files only.
531///
532/// This omits untracked files so session isolation checks can ignore unrelated
533/// editor or build artifacts while still catching modifications, deletions, and
534/// staged changes to tracked files in the main checkout.
535///
536/// # Arguments
537/// * `repo_path` - Path to the git repository or worktree
538///
539/// # Returns
540/// Raw `git status --porcelain=v1 --untracked-files=no` stdout.
541///
542/// # Errors
543/// Returns a [`GitError`] if the status command cannot be executed.
544pub(crate) async fn tracked_worktree_status(repo_path: PathBuf) -> Result<String, GitError> {
545    run_git_command(
546        repo_path,
547        vec![
548            "status".to_string(),
549            "--porcelain=v1".to_string(),
550            "--untracked-files=no".to_string(),
551        ],
552        "Git tracked status --porcelain=v1 failed".to_string(),
553    )
554    .await
555}
556
557/// Runs `git pull --rebase` and returns conflict outcome when applicable.
558///
559/// When an upstream branch can be resolved, this uses an explicit
560/// `git pull --rebase <remote> <branch>` target to avoid ambiguous rebase
561/// failures caused by multiple configured merge branches.
562///
563/// # Arguments
564/// * `repo_path` - Path to the git repository or worktree
565///
566/// # Returns
567/// A [`PullRebaseResult`] describing whether pull/rebase completed or stopped
568/// on conflicts.
569///
570/// # Errors
571/// Returns a [`GitError`] for non-conflict pull/rebase failures.
572pub(crate) async fn pull_rebase(repo_path: PathBuf) -> Result<PullRebaseResult, GitError> {
573    let command_runner = ProcessAsyncGitCommandRunner;
574
575    pull_rebase_with_runner(repo_path, &command_runner, GIT_INDEX_LOCK_RETRY_DELAY).await
576}
577
578/// Runs pull/rebase through an injected asynchronous command boundary.
579async fn pull_rebase_with_runner(
580    repo_path: PathBuf,
581    command_runner: &dyn AsyncGitCommandRunner,
582    retry_delay: Duration,
583) -> Result<PullRebaseResult, GitError> {
584    let pull_arguments = pull_rebase_arguments(&repo_path, command_runner).await?;
585    let command = AsyncGitCommand::new(repo_path, pull_arguments).with_environment(vec![
586        ("GIT_EDITOR".to_string(), ":".to_string()),
587        ("GIT_SEQUENCE_EDITOR".to_string(), ":".to_string()),
588    ]);
589    let output =
590        run_async_git_command_with_index_lock_retry(command, command_runner, retry_delay).await?;
591
592    if output.success() {
593        return Ok(PullRebaseResult::Completed);
594    }
595
596    let detail = command_output_detail(&output.stdout, &output.stderr);
597    if is_rebase_conflict(&detail) {
598        return Ok(PullRebaseResult::Conflict { detail });
599    }
600
601    Err(GitError::CommandFailed {
602        command: "git pull --rebase".to_string(),
603        stderr: detail,
604    })
605}
606
607/// Builds pull arguments that target a single upstream branch when available.
608///
609/// Resolves an explicit `<remote> <branch>` pull target for both remote and
610/// local upstreams so git does not need to infer one from branch config.
611async fn pull_rebase_arguments(
612    repo_path: &Path,
613    command_runner: &dyn AsyncGitCommandRunner,
614) -> Result<Vec<String>, GitError> {
615    let upstream_reference = primary_upstream_reference(repo_path, command_runner).await?;
616
617    if let Some((remote_name, branch_name)) = upstream_reference.split_once('/') {
618        return Ok(vec![
619            "pull".to_string(),
620            "--rebase".to_string(),
621            remote_name.to_string(),
622            branch_name.to_string(),
623        ]);
624    }
625
626    let remote_name = current_branch_remote_name(repo_path, command_runner)
627        .await?
628        .ok_or_else(|| {
629            GitError::OutputParse(
630                "Failed to resolve current branch remote: not configured".to_string(),
631            )
632        })?;
633
634    Ok(vec![
635        "pull".to_string(),
636        "--rebase".to_string(),
637        remote_name,
638        upstream_reference,
639    ])
640}
641
642/// Returns the first upstream reference reported for `HEAD`.
643///
644/// Git can return multiple lines when multiple merge targets are configured.
645/// Pulling with rebase needs one concrete target, so this selects the first
646/// non-empty line.
647async fn primary_upstream_reference(
648    repo_path: &Path,
649    command_runner: &dyn AsyncGitCommandRunner,
650) -> Result<String, GitError> {
651    let upstream_reference = upstream_reference_name(repo_path, command_runner).await?;
652    let Some(primary_reference) = upstream_reference
653        .lines()
654        .map(str::trim)
655        .find(|line| !line.is_empty())
656    else {
657        return Err(GitError::OutputParse(
658            "Failed to resolve upstream branch: empty output".to_string(),
659        ));
660    };
661
662    Ok(primary_reference.to_string())
663}
664
665/// Returns the full upstream reference for `HEAD` (for example, `origin/main`).
666async fn upstream_reference_name(
667    repo_path: &Path,
668    command_runner: &dyn AsyncGitCommandRunner,
669) -> Result<String, GitError> {
670    let upstream_reference = run_git_command_with_runner(
671        AsyncGitCommand::new(
672            repo_path.to_path_buf(),
673            vec![
674                "rev-parse".to_string(),
675                "--abbrev-ref".to_string(),
676                "--symbolic-full-name".to_string(),
677                "@{u}".to_string(),
678            ],
679        ),
680        "Failed to resolve upstream branch",
681        command_runner,
682    )
683    .await?;
684    let upstream_reference = upstream_reference.trim().to_string();
685    if upstream_reference.is_empty() {
686        return Err(GitError::OutputParse(
687            "Failed to resolve upstream branch: empty output".to_string(),
688        ));
689    }
690
691    Ok(upstream_reference)
692}
693
694/// Returns the configured remote name for the current local branch.
695///
696/// This is used when the upstream short name omits a remote prefix (for
697/// example, `main` with `branch.<name>.remote=.`).
698async fn current_branch_remote_name(
699    repo_path: &Path,
700    command_runner: &dyn AsyncGitCommandRunner,
701) -> Result<Option<String>, GitError> {
702    let current_branch_name = current_branch_name(repo_path, command_runner).await?;
703    let remote_config_key = format!("branch.{current_branch_name}.remote");
704    let output = command_runner
705        .run(AsyncGitCommand::new(
706            repo_path.to_path_buf(),
707            vec![
708                "config".to_string(),
709                "--get".to_string(),
710                remote_config_key.clone(),
711            ],
712        ))
713        .await?;
714
715    parse_current_branch_remote_output(&output, &remote_config_key)
716}
717
718/// Parses `git config --get` output for one current-branch remote.
719fn parse_current_branch_remote_output(
720    output: &AsyncGitCommandOutput,
721    remote_config_key: &str,
722) -> Result<Option<String>, GitError> {
723    if output.exit_code == Some(1) {
724        return Ok(None);
725    }
726    if !output.success() {
727        let detail = command_output_detail(&output.stdout, &output.stderr);
728
729        return Err(GitError::CommandFailed {
730            command: format!("git config --get {remote_config_key}"),
731            stderr: format!(
732                "Failed to resolve current branch remote `{remote_config_key}`: {detail}"
733            ),
734        });
735    }
736
737    let remote_name = String::from_utf8_lossy(&output.stdout).trim().to_string();
738    if remote_name.is_empty() {
739        return Err(GitError::OutputParse(format!(
740            "Failed to resolve current branch remote `{remote_config_key}`: empty output"
741        )));
742    }
743
744    Ok(Some(remote_name))
745}
746
747/// Returns the current local branch name for `HEAD`.
748async fn current_branch_name(
749    repo_path: &Path,
750    command_runner: &dyn AsyncGitCommandRunner,
751) -> Result<String, GitError> {
752    let branch_name = run_git_command_with_runner(
753        AsyncGitCommand::new(
754            repo_path.to_path_buf(),
755            vec![
756                "rev-parse".to_string(),
757                "--abbrev-ref".to_string(),
758                "HEAD".to_string(),
759            ],
760        ),
761        "Failed to resolve current branch name",
762        command_runner,
763    )
764    .await?;
765    let branch_name = branch_name.trim().to_string();
766    if branch_name.is_empty() {
767        return Err(GitError::OutputParse(
768            "Failed to resolve current branch name: empty output".to_string(),
769        ));
770    }
771
772    if branch_name == "HEAD" {
773        return Err(GitError::OutputParse(
774            "Failed to resolve current branch name: detached HEAD".to_string(),
775        ));
776    }
777
778    Ok(branch_name)
779}
780
781/// Runs one asynchronous git command and retries transient index lock
782/// contention without blocking a Tokio worker.
783async fn run_async_git_command_with_index_lock_retry(
784    command: AsyncGitCommand,
785    command_runner: &dyn AsyncGitCommandRunner,
786    retry_delay: Duration,
787) -> Result<AsyncGitCommandOutput, GitError> {
788    let mut attempt_count = 0;
789    loop {
790        attempt_count += 1;
791        let output = command_runner.run(command.clone()).await?;
792        if output.success() {
793            return Ok(output);
794        }
795
796        let detail = command_output_detail(&output.stdout, &output.stderr);
797        let is_last_attempt = attempt_count == GIT_INDEX_LOCK_RETRY_ATTEMPTS;
798        if !is_git_index_lock_error(&detail) || is_last_attempt {
799            return Ok(output);
800        }
801
802        time::sleep(retry_delay).await;
803    }
804}
805
806/// Pushes the current branch to its upstream remote with
807/// `--force-with-lease`.
808///
809/// Falls back to `git push --force-with-lease --set-upstream origin HEAD`
810/// when no upstream branch is configured, then returns the resolved upstream
811/// reference.
812///
813/// # Arguments
814/// * `repo_path` - Path to the git repository or worktree
815///
816/// # Returns
817/// The upstream reference on success.
818///
819/// # Errors
820/// Returns a [`GitError`] if `git push` fails or upstream tracking cannot be
821/// resolved afterwards.
822pub(crate) async fn push_current_branch(repo_path: PathBuf) -> Result<String, GitError> {
823    let command_runner = ProcessAsyncGitCommandRunner;
824
825    push_current_branch_with_runner(repo_path, &command_runner).await
826}
827
828/// Pushes the current branch through an injected asynchronous command
829/// boundary.
830async fn push_current_branch_with_runner(
831    repo_path: PathBuf,
832    command_runner: &dyn AsyncGitCommandRunner,
833) -> Result<String, GitError> {
834    let push_command = AsyncGitCommand::new(
835        repo_path.clone(),
836        vec!["push".to_string(), "--force-with-lease".to_string()],
837    );
838    let push_output = command_runner.run(push_command).await?;
839
840    if push_output.success() {
841        return primary_upstream_reference(&repo_path, command_runner).await;
842    }
843
844    let push_detail = command_output_detail(&push_output.stdout, &push_output.stderr);
845    if !is_no_upstream_error(&push_detail) {
846        return Err(GitError::CommandFailed {
847            command: "git push --force-with-lease".to_string(),
848            stderr: push_detail,
849        });
850    }
851
852    let remote_name = current_branch_remote_name(&repo_path, command_runner)
853        .await?
854        .unwrap_or_else(|| "origin".to_string());
855    run_git_command_with_runner(
856        AsyncGitCommand::new(
857            repo_path.clone(),
858            vec![
859                "push".to_string(),
860                "--force-with-lease".to_string(),
861                "--set-upstream".to_string(),
862                remote_name,
863                "HEAD".to_string(),
864            ],
865        ),
866        "Git push failed",
867        command_runner,
868    )
869    .await?;
870
871    primary_upstream_reference(&repo_path, command_runner).await
872}
873
874/// Checks whether a branch already exists on the remote.
875///
876/// Resolves the remote name from the current branch config, falling back
877/// to `origin`, then runs `git ls-remote --heads <remote> <branch>`.
878/// Returns `true` when the remote reports at least one matching ref.
879///
880/// # Arguments
881/// * `repo_path` - Path to the git repository or worktree
882/// * `remote_branch_name` - Branch name to look up on the remote
883///
884/// # Errors
885/// Returns a [`GitError`] if the `git ls-remote` command fails.
886pub(crate) async fn remote_branch_exists(
887    repo_path: PathBuf,
888    remote_branch_name: String,
889) -> Result<bool, GitError> {
890    let command_runner = ProcessAsyncGitCommandRunner;
891
892    remote_branch_exists_with_runner(repo_path, remote_branch_name, &command_runner).await
893}
894
895/// Checks a remote branch through an injected asynchronous command boundary.
896async fn remote_branch_exists_with_runner(
897    repo_path: PathBuf,
898    remote_branch_name: String,
899    command_runner: &dyn AsyncGitCommandRunner,
900) -> Result<bool, GitError> {
901    let remote_name = current_branch_remote_name(&repo_path, command_runner)
902        .await?
903        .unwrap_or_else(|| "origin".to_string());
904    let arguments = vec![
905        "ls-remote".to_string(),
906        "--heads".to_string(),
907        remote_name,
908        remote_branch_name,
909    ];
910    let stdout = run_git_command_with_runner(
911        AsyncGitCommand::new(repo_path, arguments),
912        "Git ls-remote failed",
913        command_runner,
914    )
915    .await?;
916
917    Ok(!stdout.trim().is_empty())
918}
919
920/// Pushes the current branch to one explicit remote branch name with
921/// `--force-with-lease` and returns the resulting upstream reference.
922///
923/// When the current branch already tracks a remote, that remote name is
924/// reused. Otherwise this falls back to `origin`.
925///
926/// # Arguments
927/// * `repo_path` - Path to the git repository or worktree
928/// * `remote_branch_name` - Target branch name to create or update on the
929///   remote
930///
931/// # Returns
932/// The upstream reference on success, for example `origin/feature/review`.
933///
934/// # Errors
935/// Returns a [`GitError`] if `git push` fails.
936pub(crate) async fn push_current_branch_to_remote_branch(
937    repo_path: PathBuf,
938    remote_branch_name: String,
939) -> Result<String, GitError> {
940    let command_runner = ProcessAsyncGitCommandRunner;
941
942    push_current_branch_to_remote_branch_with_runner(repo_path, remote_branch_name, &command_runner)
943        .await
944}
945
946/// Pushes one explicit branch through an injected asynchronous command
947/// boundary.
948async fn push_current_branch_to_remote_branch_with_runner(
949    repo_path: PathBuf,
950    remote_branch_name: String,
951    command_runner: &dyn AsyncGitCommandRunner,
952) -> Result<String, GitError> {
953    let remote_name = current_branch_remote_name(&repo_path, command_runner)
954        .await?
955        .unwrap_or_else(|| "origin".to_string());
956    let push_refspec = format!("HEAD:{remote_branch_name}");
957    let arguments = vec![
958        "push".to_string(),
959        "--force-with-lease".to_string(),
960        "--set-upstream".to_string(),
961        remote_name.clone(),
962        push_refspec,
963    ];
964    run_git_command_with_runner(
965        AsyncGitCommand::new(repo_path, arguments),
966        "Git push failed",
967        command_runner,
968    )
969    .await?;
970
971    Ok(format!("{remote_name}/{remote_branch_name}"))
972}
973
974/// Returns the current upstream reference for `HEAD`.
975///
976/// # Arguments
977/// * `repo_path` - Path to the git repository or worktree
978///
979/// # Returns
980/// The configured upstream reference, for example `origin/main`.
981///
982/// # Errors
983/// Returns a [`GitError`] when upstream tracking information cannot be
984/// resolved.
985pub(crate) async fn current_upstream_reference(repo_path: PathBuf) -> Result<String, GitError> {
986    let command_runner = ProcessAsyncGitCommandRunner;
987
988    primary_upstream_reference(&repo_path, &command_runner).await
989}
990
991/// Fetches from the configured remote.
992///
993/// # Arguments
994/// * `repo_path` - Path to the git repository root
995///
996/// # Returns
997/// Ok(()) on success.
998///
999/// # Errors
1000/// Returns a [`GitError`] if `git fetch` cannot be executed successfully.
1001pub(crate) async fn fetch_remote(repo_path: PathBuf) -> Result<(), GitError> {
1002    run_git_command(
1003        repo_path,
1004        vec!["fetch".to_string()],
1005        "Git fetch failed".to_string(),
1006    )
1007    .await?;
1008
1009    Ok(())
1010}
1011
1012/// Returns the number of commits ahead and behind the upstream branch.
1013///
1014/// # Arguments
1015/// * `repo_path` - Path to the git repository root
1016///
1017/// # Returns
1018/// Ok((ahead, behind)) on success.
1019///
1020/// # Errors
1021/// Returns a [`GitError`] if `git rev-list` fails or returns unexpected
1022/// output.
1023pub(crate) async fn get_ahead_behind(repo_path: PathBuf) -> Result<(u32, u32), GitError> {
1024    get_ref_ahead_behind(repo_path, "HEAD".to_string(), "@{u}".to_string()).await
1025}
1026
1027/// Returns the number of commits `left_ref` is ahead of and behind `right_ref`.
1028///
1029/// The returned tuple is `(ahead, behind)`, where `ahead` counts commits
1030/// reachable from `left_ref` but not `right_ref`, and `behind` counts commits
1031/// reachable from `right_ref` but not `left_ref`.
1032///
1033/// # Errors
1034/// Returns a [`GitError`] if `git rev-list` fails or returns unexpected
1035/// output.
1036pub(crate) async fn get_ref_ahead_behind(
1037    repo_path: PathBuf,
1038    left_ref: String,
1039    right_ref: String,
1040) -> Result<(u32, u32), GitError> {
1041    let rev_list_output = run_git_command(
1042        repo_path,
1043        vec![
1044            "rev-list".to_string(),
1045            "--left-right".to_string(),
1046            "--count".to_string(),
1047            format!("{left_ref}...{right_ref}"),
1048        ],
1049        "Git rev-list failed".to_string(),
1050    )
1051    .await?;
1052
1053    parse_ahead_behind_counts(&rev_list_output)
1054}
1055
1056/// Parses one `git rev-list --left-right --count` output into `(ahead,
1057/// behind)`.
1058fn parse_ahead_behind_counts(rev_list_output: &str) -> Result<(u32, u32), GitError> {
1059    let parts: Vec<&str> = rev_list_output.split_whitespace().collect();
1060    if parts.len() >= 2 {
1061        let ahead = parts[0].parse().unwrap_or(0);
1062        let behind = parts[1].parse().unwrap_or(0);
1063
1064        return Ok((ahead, behind));
1065    }
1066
1067    Err(GitError::OutputParse(
1068        "Unexpected output format from git rev-list".to_string(),
1069    ))
1070}
1071
1072/// Returns ahead/behind snapshots for every local branch in `repo_path`.
1073///
1074/// The returned map is keyed by local branch name. Branches without an
1075/// upstream, with a gone upstream, or without ahead/behind markers map to
1076/// `None`.
1077///
1078/// # Errors
1079/// Returns a [`GitError`] if `git for-each-ref` fails.
1080pub(crate) async fn branch_tracking_statuses(
1081    repo_path: PathBuf,
1082) -> Result<BranchTrackingMap, GitError> {
1083    let git_output = run_git_command(
1084        repo_path,
1085        vec![
1086            "for-each-ref".to_string(),
1087            "--format=%(refname:short)\t%(upstream:short)\t%(upstream:track,nobracket)".to_string(),
1088            "refs/heads".to_string(),
1089        ],
1090        "Git for-each-ref failed".to_string(),
1091    )
1092    .await?;
1093
1094    Ok(parse_branch_tracking_statuses(&git_output))
1095}
1096
1097/// Returns upstream commit subjects that are not yet in local `HEAD`.
1098///
1099/// The returned order is oldest to newest to match pull application order.
1100///
1101/// # Arguments
1102/// * `repo_path` - Path to the git repository root
1103///
1104/// # Errors
1105/// Returns a [`GitError`] when `git log` fails or upstream tracking refs are
1106/// unavailable.
1107pub(crate) async fn list_upstream_commit_titles(
1108    repo_path: PathBuf,
1109) -> Result<Vec<String>, GitError> {
1110    let git_output = run_git_command(
1111        repo_path,
1112        vec![
1113            "log".to_string(),
1114            "--reverse".to_string(),
1115            "--pretty=%s".to_string(),
1116            "HEAD..@{u}".to_string(),
1117        ],
1118        "Git log failed".to_string(),
1119    )
1120    .await?;
1121
1122    Ok(parse_commit_titles(&git_output))
1123}
1124
1125/// Returns local commit subjects that are not yet present in upstream.
1126///
1127/// The returned order is oldest to newest to match push application order.
1128///
1129/// # Arguments
1130/// * `repo_path` - Path to the git repository root
1131///
1132/// # Errors
1133/// Returns a [`GitError`] when `git log` fails or upstream tracking refs are
1134/// unavailable.
1135pub(crate) async fn list_local_commit_titles(repo_path: PathBuf) -> Result<Vec<String>, GitError> {
1136    let git_output = run_git_command(
1137        repo_path,
1138        vec![
1139            "log".to_string(),
1140            "--reverse".to_string(),
1141            "--pretty=%s".to_string(),
1142            "@{u}..HEAD".to_string(),
1143        ],
1144        "Git log failed".to_string(),
1145    )
1146    .await?;
1147
1148    Ok(parse_commit_titles(&git_output))
1149}
1150
1151/// Returns whether `HEAD` contains commits that are not reachable from
1152/// `base_branch`.
1153///
1154/// # Errors
1155/// Returns a [`GitError`] if commit ancestry cannot be queried.
1156pub(crate) async fn has_commits_since(
1157    repo_path: PathBuf,
1158    base_branch: String,
1159) -> Result<bool, GitError> {
1160    spawn_blocking(move || -> Result<bool, GitError> {
1161        let rev_list_output = run_git_command_sync(
1162            &repo_path,
1163            &["rev-list", "--count", &format!("{base_branch}..HEAD")],
1164            "Failed to count commits since base branch",
1165        )?;
1166        let commit_count = rev_list_output.trim().parse::<u32>().map_err(|error| {
1167            GitError::OutputParse(format!(
1168                "Failed to parse commit count since base branch `{base_branch}`: {error}"
1169            ))
1170        })?;
1171
1172        Ok(commit_count > 0)
1173    })
1174    .await?
1175}
1176
1177/// Parses newline-delimited commit subjects from `git log` output.
1178fn parse_commit_titles(output: &str) -> Vec<String> {
1179    output
1180        .lines()
1181        .map(str::trim)
1182        .filter(|title| !title.is_empty())
1183        .map(ToString::to_string)
1184        .collect()
1185}
1186
1187/// Parses repo-wide branch tracking information from `git for-each-ref`.
1188fn parse_branch_tracking_statuses(output: &str) -> BranchTrackingMap {
1189    let mut branch_tracking_statuses = HashMap::new();
1190
1191    for line in output
1192        .lines()
1193        .map(str::trim)
1194        .filter(|line| !line.is_empty())
1195    {
1196        let mut parts = line.splitn(3, '\t');
1197        let Some(branch_name) = parts
1198            .next()
1199            .map(str::trim)
1200            .filter(|value| !value.is_empty())
1201        else {
1202            continue;
1203        };
1204        let upstream_ref = parts.next().map(str::trim).unwrap_or_default();
1205        let track = parts.next().map(str::trim).unwrap_or_default();
1206
1207        let status = if upstream_ref.is_empty() {
1208            None
1209        } else {
1210            parse_branch_tracking_counts(track)
1211        };
1212        branch_tracking_statuses.insert(branch_name.to_string(), status);
1213    }
1214
1215    branch_tracking_statuses
1216}
1217
1218/// Parses one `%(upstream:track,nobracket)` marker into ahead/behind counts.
1219fn parse_branch_tracking_counts(track: &str) -> Option<(u32, u32)> {
1220    let normalized_track = track.trim();
1221    if normalized_track.is_empty() || normalized_track == "gone" {
1222        return None;
1223    }
1224
1225    let mut ahead = 0;
1226    let mut behind = 0;
1227
1228    for part in normalized_track.split(',').map(str::trim) {
1229        if let Some(count) = part.strip_prefix("ahead ") {
1230            ahead = count.parse().ok()?;
1231        } else if let Some(count) = part.strip_prefix("behind ") {
1232            behind = count.parse().ok()?;
1233        }
1234    }
1235
1236    Some((ahead, behind))
1237}
1238
1239/// Resolves the commit/tree to use as the `git diff` "before" side.
1240///
1241/// Starts from the merge-base fallback and, when `git cherry` reports leading
1242/// commits already applied to `base_branch`, advances the baseline to the last
1243/// such commit so squash-merged session changes are not shown again.
1244fn resolve_diff_target(
1245    repo_path: &Path,
1246    base_branch: &str,
1247    merge_base: &str,
1248) -> Result<String, GitError> {
1249    let cherry_output = run_git_command_output_sync(repo_path, &["cherry", base_branch, "HEAD"])?;
1250    if !cherry_output.status.success() {
1251        return Ok(merge_base.to_string());
1252    }
1253
1254    let cherry_stdout = String::from_utf8_lossy(&cherry_output.stdout);
1255    let Some(last_leading_applied_commit) = last_leading_applied_commit(&cherry_stdout) else {
1256        return Ok(merge_base.to_string());
1257    };
1258
1259    Ok(last_leading_applied_commit.to_string())
1260}
1261
1262/// Returns the last leading commit from `git cherry` marked as already applied.
1263///
1264/// `git cherry` prefixes commits with `-` when an equivalent patch exists in
1265/// the upstream branch and `+` when it does not. This helper only consumes the
1266/// initial contiguous `-` block and stops at the first `+` to avoid dropping
1267/// non-merged changes.
1268fn last_leading_applied_commit(cherry_output: &str) -> Option<&str> {
1269    let mut last_applied_commit = None;
1270
1271    for line in cherry_output.lines() {
1272        let trimmed_line = line.trim();
1273        if trimmed_line.is_empty() {
1274            continue;
1275        }
1276
1277        let mut parts = trimmed_line.split_whitespace();
1278        let marker = parts.next()?;
1279        let commit_hash = parts.next()?;
1280
1281        if marker == "-" {
1282            last_applied_commit = Some(commit_hash);
1283
1284            continue;
1285        }
1286
1287        if marker == "+" {
1288            break;
1289        }
1290
1291        break;
1292    }
1293
1294    last_applied_commit
1295}
1296
1297/// Stages all changes and commits or amends with retry behavior for hook
1298/// rewrites.
1299///
1300/// If an amend would make `HEAD` empty, the staged tree has reverted the
1301/// session commit back to its parent. In that case the helper drops the now
1302/// empty session commit and reports the standard no-changes sentinel so the
1303/// app can skip model-assisted commit recovery.
1304async fn commit_all_with_retry(
1305    repo_path: PathBuf,
1306    commit_message: String,
1307    message_strategy: SingleCommitMessageStrategy,
1308    no_verify: bool,
1309    amend_existing_commit: bool,
1310) -> Result<(), GitError> {
1311    spawn_blocking(move || {
1312        stage_all_sync(&repo_path)?;
1313
1314        for _ in 0..COMMIT_ALL_HOOK_RETRY_ATTEMPTS {
1315            let output = run_commit_command(
1316                &repo_path,
1317                &commit_message,
1318                message_strategy,
1319                no_verify,
1320                amend_existing_commit,
1321            )?;
1322
1323            if output.status.success() {
1324                return Ok(());
1325            }
1326
1327            let stderr = String::from_utf8_lossy(&output.stderr);
1328            let stdout = String::from_utf8_lossy(&output.stdout);
1329            if is_nothing_to_commit_output(&stdout, &stderr) {
1330                return Err(nothing_to_commit_error());
1331            }
1332
1333            if amend_existing_commit && is_empty_amend_output(&stdout, &stderr) {
1334                reset_empty_amend_sync(&repo_path)?;
1335
1336                return Err(nothing_to_commit_error());
1337            }
1338
1339            if is_hook_modified_error(&stdout, &stderr) {
1340                stage_all_sync(&repo_path)?;
1341
1342                continue;
1343            }
1344
1345            let detail = command_output_detail(&output.stdout, &output.stderr);
1346
1347            return Err(GitError::CommandFailed {
1348                command: "git commit".to_string(),
1349                stderr: detail,
1350            });
1351        }
1352
1353        Err(GitError::CommandFailed {
1354            command: "git commit".to_string(),
1355            stderr: format!(
1356                "Failed to commit: commit hooks kept modifying files after \
1357                 {COMMIT_ALL_HOOK_RETRY_ATTEMPTS} attempts"
1358            ),
1359        })
1360    })
1361    .await?
1362}
1363
1364/// Ensures repositories declaring pre-commit validation have an executable
1365/// hook.
1366fn ensure_pre_commit_hook_ready(repo_path: &Path) -> Result<(), GitError> {
1367    let Some(config_file) = PRE_COMMIT_CONFIG_FILES
1368        .iter()
1369        .find(|config_file| repo_path.join(config_file).is_file())
1370    else {
1371        return Ok(());
1372    };
1373    let hook_path = resolve_pre_commit_hook_path(repo_path)?;
1374
1375    if is_executable_hook(&hook_path) {
1376        return Ok(());
1377    }
1378
1379    Err(GitError::PreCommitHookMissing {
1380        config_file: (*config_file).to_string(),
1381    })
1382}
1383
1384/// Resolves the pre-commit hook using `core.hooksPath` or Git's default path.
1385fn resolve_pre_commit_hook_path(repo_path: &Path) -> Result<PathBuf, GitError> {
1386    let hooks_path_output =
1387        run_git_command_output_sync(repo_path, &["config", "--path", "--get", "core.hooksPath"])?;
1388    let hooks_path = if hooks_path_output.status.success() {
1389        PathBuf::from(String::from_utf8_lossy(&hooks_path_output.stdout).trim())
1390    } else if hooks_path_output.status.code() == Some(1) {
1391        let default_hook_path = run_git_command_sync(
1392            repo_path,
1393            &["rev-parse", "--git-path", "hooks/pre-commit"],
1394            "Failed to resolve Git pre-commit hook path",
1395        )?;
1396
1397        return Ok(resolve_repo_path(
1398            repo_path,
1399            PathBuf::from(default_hook_path.trim()),
1400        ));
1401    } else {
1402        return Err(GitError::CommandFailed {
1403            command: "git config --path --get core.hooksPath".to_string(),
1404            stderr: command_output_detail(&hooks_path_output.stdout, &hooks_path_output.stderr),
1405        });
1406    };
1407
1408    Ok(resolve_repo_path(repo_path, hooks_path).join("pre-commit"))
1409}
1410
1411fn resolve_repo_path(repo_path: &Path, path: PathBuf) -> PathBuf {
1412    if path.is_absolute() {
1413        return path;
1414    }
1415
1416    repo_path.join(path)
1417}
1418
1419#[cfg(unix)]
1420fn is_executable_hook(hook_path: &Path) -> bool {
1421    hook_path.is_file() && rustix_fs::access(hook_path, Access::EXEC_OK).is_ok()
1422}
1423
1424#[cfg(not(unix))]
1425fn is_executable_hook(hook_path: &Path) -> bool {
1426    hook_path.is_file()
1427}
1428
1429/// Returns the canonical git no-changes error used by app auto-commit flows.
1430fn nothing_to_commit_error() -> GitError {
1431    GitError::CommandFailed {
1432        command: "git commit".to_string(),
1433        stderr: "Nothing to commit: no changes detected".to_string(),
1434    }
1435}
1436
1437/// Returns whether commit output reports that there was no staged work to
1438/// commit.
1439fn is_nothing_to_commit_output(stdout: &str, stderr: &str) -> bool {
1440    let combined = format!("{stdout}\n{stderr}").to_ascii_lowercase();
1441
1442    combined.contains("nothing to commit")
1443}
1444
1445/// Returns whether commit output reports that amending `HEAD` would remove the
1446/// session commit entirely.
1447fn is_empty_amend_output(stdout: &str, stderr: &str) -> bool {
1448    let combined = format!("{stdout}\n{stderr}").to_ascii_lowercase();
1449    let normalized = combined.split_whitespace().collect::<Vec<_>>().join(" ");
1450
1451    normalized.contains("would make it empty") && normalized.contains("allow-empty")
1452}
1453
1454/// Drops an amended session commit whose resulting tree would match its
1455/// parent, leaving the worktree at the reverted state.
1456fn reset_empty_amend_sync(repo_path: &Path) -> Result<(), GitError> {
1457    run_git_command_sync(
1458        repo_path,
1459        &["reset", "HEAD^"],
1460        "Git reset after empty amend failed",
1461    )?;
1462
1463    Ok(())
1464}
1465
1466/// Stages all changed files in the repository.
1467///
1468/// Uses shared git retry behavior for transient `index.lock` contention.
1469fn stage_all_sync(repo_path: &Path) -> Result<(), GitError> {
1470    let output = run_git_command_with_index_lock_retry(repo_path, &["add", "-A"], &[])?;
1471
1472    if !output.status.success() {
1473        let detail = command_output_detail(&output.stdout, &output.stderr);
1474
1475        return Err(GitError::CommandFailed {
1476            command: "git add -A".to_string(),
1477            stderr: format!("Failed to stage changes: {detail}"),
1478        });
1479    }
1480
1481    Ok(())
1482}
1483
1484/// Returns the full `HEAD` commit message, or `None` when no commits exist.
1485fn head_commit_message_sync(repo_path: &Path) -> Result<Option<String>, GitError> {
1486    if !has_head_commit_sync(repo_path)? {
1487        return Ok(None);
1488    }
1489
1490    let output = run_git_command_sync(
1491        repo_path,
1492        &["log", "-1", "--pretty=%B"],
1493        "Failed to read HEAD commit message",
1494    )?;
1495
1496    Ok(Some(output.trim().to_string()))
1497}
1498
1499/// Returns whether `HEAD` resolves to an existing commit.
1500fn has_head_commit_sync(repo_path: &Path) -> Result<bool, GitError> {
1501    let output = run_git_command_output_sync(repo_path, &["rev-parse", "--verify", "HEAD"])?;
1502
1503    if output.status.success() {
1504        return Ok(true);
1505    }
1506
1507    let detail = command_output_detail(&output.stdout, &output.stderr);
1508    let normalized_detail = detail.to_ascii_lowercase();
1509    if normalized_detail.contains("needed a single revision")
1510        || normalized_detail.contains("unknown revision")
1511        || normalized_detail.contains("does not have any commits yet")
1512    {
1513        return Ok(false);
1514    }
1515
1516    Err(GitError::CommandFailed {
1517        command: "git rev-parse --verify HEAD".to_string(),
1518        stderr: detail,
1519    })
1520}
1521
1522/// Runs `git commit` with optional amend and hook settings.
1523///
1524/// Uses shared git retry behavior for transient `index.lock` contention.
1525fn run_commit_command(
1526    repo_path: &Path,
1527    commit_message: &str,
1528    message_strategy: SingleCommitMessageStrategy,
1529    no_verify: bool,
1530    amend_existing_commit: bool,
1531) -> Result<Output, GitError> {
1532    let mut args = vec!["commit"];
1533    if amend_existing_commit {
1534        args.push("--amend");
1535        match message_strategy {
1536            SingleCommitMessageStrategy::Replace => {
1537                args.push("-m");
1538                args.push(commit_message);
1539            }
1540            SingleCommitMessageStrategy::Reuse => {
1541                args.push("--no-edit");
1542            }
1543        }
1544    } else {
1545        args.push("-m");
1546        args.push(commit_message);
1547    }
1548
1549    if no_verify {
1550        args.push("--no-verify");
1551    }
1552
1553    run_git_command_with_index_lock_retry(repo_path, &args, &[])
1554}
1555
1556/// Returns whether commit output indicates hooks rewrote files.
1557fn is_hook_modified_error(stdout: &str, stderr: &str) -> bool {
1558    let combined = format!(
1559        "{stdout}
1560{stderr}"
1561    )
1562    .to_ascii_lowercase();
1563
1564    combined.contains("files were modified by this hook")
1565}
1566
1567/// Returns whether git push output indicates a missing upstream branch.
1568pub(super) fn is_no_upstream_error(detail: &str) -> bool {
1569    let normalized_detail = detail.to_ascii_lowercase();
1570
1571    normalized_detail.contains("has no upstream branch")
1572        || normalized_detail.contains("no upstream branch")
1573        || normalized_detail.contains("set-upstream")
1574}
1575
1576#[cfg(test)]
1577mod tests {
1578    use std::fs;
1579    #[cfg(unix)]
1580    use std::os::unix::fs::PermissionsExt;
1581    use std::path::Path;
1582    use std::process::{Command, Output};
1583
1584    use mockall::Sequence;
1585    use mockall::predicate::function;
1586    use tempfile::tempdir;
1587
1588    use super::*;
1589    use crate::repo::MockAsyncGitCommandRunner;
1590
1591    /// Builds captured asynchronous git output for command-runner tests.
1592    fn async_git_output(
1593        exit_code: i32,
1594        stdout: impl Into<Vec<u8>>,
1595        stderr: impl Into<Vec<u8>>,
1596    ) -> AsyncGitCommandOutput {
1597        AsyncGitCommandOutput {
1598            exit_code: Some(exit_code),
1599            stderr: stderr.into(),
1600            stdout: stdout.into(),
1601        }
1602    }
1603
1604    /// Runs `git` in `repo_path` and asserts the command succeeds.
1605    fn run_git_command(repo_path: &Path, args: &[&str]) {
1606        let output = git_command_output(repo_path, args);
1607
1608        assert!(
1609            output.status.success(),
1610            "git command {:?} failed: {}",
1611            args,
1612            String::from_utf8_lossy(&output.stderr)
1613        );
1614    }
1615
1616    /// Runs `git` in `repo_path` and returns the captured command output.
1617    fn git_command_output(repo_path: &Path, args: &[&str]) -> Output {
1618        Command::new("git")
1619            .args(args)
1620            .current_dir(repo_path)
1621            .output()
1622            .expect("failed to run git command")
1623    }
1624
1625    /// Runs `git` in `repo_path`, asserts success, and returns trimmed stdout.
1626    fn git_command_stdout(repo_path: &Path, args: &[&str]) -> String {
1627        let output = git_command_output(repo_path, args);
1628
1629        assert!(
1630            output.status.success(),
1631            "git command {:?} failed: {}",
1632            args,
1633            String::from_utf8_lossy(&output.stderr)
1634        );
1635
1636        String::from_utf8(output.stdout)
1637            .expect("git stdout should be valid utf-8")
1638            .trim()
1639            .to_string()
1640    }
1641
1642    /// Creates a committed repository rooted at `repo_path`.
1643    fn setup_test_git_repo(repo_path: &Path) {
1644        run_git_command(repo_path, &["init", "-b", "main"]);
1645        run_git_command(repo_path, &["config", "user.name", "Test User"]);
1646        run_git_command(repo_path, &["config", "user.email", "test@example.com"]);
1647        fs::write(repo_path.join("README.md"), "base\n").expect("failed to write base file");
1648        run_git_command(repo_path, &["add", "README.md"]);
1649        run_git_command(repo_path, &["commit", "-m", "Initial commit"]);
1650    }
1651
1652    #[tokio::test]
1653    async fn delete_branch_removes_branch_from_isolated_repository() {
1654        // Arrange
1655        let temp_dir = tempdir().expect("failed to create temp dir");
1656        setup_test_git_repo(temp_dir.path());
1657        run_git_command(temp_dir.path(), &["branch", "review/topic"]);
1658
1659        // Act
1660        delete_branch(temp_dir.path().to_path_buf(), "review/topic".to_string())
1661            .await
1662            .expect("branch deletion should succeed");
1663
1664        // Assert
1665        let branch_lookup = git_command_output(
1666            temp_dir.path(),
1667            &["show-ref", "--verify", "--quiet", "refs/heads/review/topic"],
1668        );
1669        assert!(!branch_lookup.status.success());
1670    }
1671
1672    #[tokio::test]
1673    async fn diff_preserves_staged_changes_and_includes_untracked_files() {
1674        // Arrange
1675        let temp_dir = tempdir().expect("failed to create temp dir");
1676        setup_test_git_repo(temp_dir.path());
1677        fs::write(temp_dir.path().join("README.md"), "staged change\n")
1678            .expect("failed to write staged change");
1679        run_git_command(temp_dir.path(), &["add", "README.md"]);
1680        fs::write(
1681            temp_dir.path().join("README.md"),
1682            "staged change\nunstaged change\n",
1683        )
1684        .expect("failed to write unstaged change");
1685        fs::write(temp_dir.path().join("new.txt"), "untracked change\n")
1686            .expect("failed to write untracked file");
1687        let cached_diff_before = git_command_output(temp_dir.path(), &["diff", "--cached"]).stdout;
1688        let status_before = git_command_output(
1689            temp_dir.path(),
1690            &["status", "--porcelain=v1", "--untracked-files=all"],
1691        )
1692        .stdout;
1693
1694        // Act
1695        let result = diff(temp_dir.path().to_path_buf(), "main".to_string()).await;
1696        let changed_files =
1697            diff_changed_files(temp_dir.path().to_path_buf(), "main".to_string()).await;
1698
1699        // Assert
1700        let diff_output = result.expect("diff should succeed");
1701        let cached_diff_after = git_command_output(temp_dir.path(), &["diff", "--cached"]).stdout;
1702        let status_after = git_command_output(
1703            temp_dir.path(),
1704            &["status", "--porcelain=v1", "--untracked-files=all"],
1705        )
1706        .stdout;
1707        assert!(diff_output.contains("staged change"));
1708        assert!(diff_output.contains("unstaged change"));
1709        assert!(diff_output.contains("untracked change"));
1710        assert_eq!(
1711            changed_files.expect("changed files should load"),
1712            vec!["README.md".to_string(), "new.txt".to_string()]
1713        );
1714        assert_eq!(cached_diff_after, cached_diff_before);
1715        assert_eq!(status_after, status_before);
1716    }
1717
1718    #[tokio::test]
1719    async fn read_worktree_file_returns_text_for_safe_nested_path() {
1720        // Arrange
1721        let temp_dir = tempdir().expect("failed to create temp dir");
1722        let docs_dir = temp_dir.path().join("docs");
1723        fs::create_dir(&docs_dir).expect("failed to create docs directory");
1724        fs::write(docs_dir.join("README.md"), "# Preview\n")
1725            .expect("failed to write markdown file");
1726
1727        // Act
1728        let result =
1729            read_worktree_file(temp_dir.path().to_path_buf(), "docs/README.md".to_string()).await;
1730
1731        // Assert
1732        assert_eq!(
1733            result.expect("worktree read should succeed"),
1734            WorktreeFileContent::Text("# Preview\n".to_string())
1735        );
1736    }
1737
1738    #[tokio::test]
1739    async fn read_worktree_file_classifies_missing_binary_and_oversize_files() {
1740        // Arrange
1741        let temp_dir = tempdir().expect("failed to create temp dir");
1742        fs::write(temp_dir.path().join("binary.md"), [0xff, 0xfe])
1743            .expect("failed to write binary file");
1744        fs::write(
1745            temp_dir.path().join("large.md"),
1746            vec![b'a'; MAX_WORKTREE_FILE_BYTE_COUNT + 1],
1747        )
1748        .expect("failed to write oversize file");
1749
1750        // Act
1751        let missing =
1752            read_worktree_file(temp_dir.path().to_path_buf(), "missing.md".to_string()).await;
1753        let binary =
1754            read_worktree_file(temp_dir.path().to_path_buf(), "binary.md".to_string()).await;
1755        let too_large =
1756            read_worktree_file(temp_dir.path().to_path_buf(), "large.md".to_string()).await;
1757
1758        // Assert
1759        assert_eq!(
1760            missing.expect("missing read should succeed"),
1761            WorktreeFileContent::Missing
1762        );
1763        assert_eq!(
1764            binary.expect("binary read should succeed"),
1765            WorktreeFileContent::Binary
1766        );
1767        assert_eq!(
1768            too_large.expect("oversize read should succeed"),
1769            WorktreeFileContent::TooLarge
1770        );
1771    }
1772
1773    #[tokio::test]
1774    async fn read_worktree_file_rejects_unsafe_relative_paths() {
1775        // Arrange
1776        let temp_dir = tempdir().expect("failed to create temp dir");
1777        let absolute_path = temp_dir.path().join("README.md");
1778
1779        // Act
1780        let empty = read_worktree_file(temp_dir.path().to_path_buf(), String::new()).await;
1781        let parent =
1782            read_worktree_file(temp_dir.path().to_path_buf(), "../README.md".to_string()).await;
1783        let absolute = read_worktree_file(
1784            temp_dir.path().to_path_buf(),
1785            absolute_path.to_string_lossy().into_owned(),
1786        )
1787        .await;
1788
1789        // Assert
1790        for result in [empty, parent, absolute] {
1791            assert!(
1792                matches!(result, Err(GitError::OutputParse(message)) if message.contains("Unsafe worktree file path"))
1793            );
1794        }
1795    }
1796
1797    #[cfg(unix)]
1798    #[tokio::test]
1799    async fn read_worktree_file_rejects_symlinks_outside_repository() {
1800        // Arrange
1801        let temp_dir = tempdir().expect("failed to create temp dir");
1802        let outside_dir = tempdir().expect("failed to create outside temp dir");
1803        let outside_file = outside_dir.path().join("outside.md");
1804        fs::write(&outside_file, "outside").expect("failed to write outside file");
1805        std::os::unix::fs::symlink(&outside_file, temp_dir.path().join("link.md"))
1806            .expect("failed to create outside symlink");
1807
1808        // Act
1809        let result = read_worktree_file(temp_dir.path().to_path_buf(), "link.md".to_string()).await;
1810
1811        // Assert
1812        assert!(
1813            matches!(result, Err(GitError::OutputParse(message)) if message.contains("resolves outside repository"))
1814        );
1815    }
1816
1817    #[cfg(unix)]
1818    #[tokio::test]
1819    async fn read_worktree_file_maps_non_missing_path_resolution_errors() {
1820        // Arrange
1821        let temp_dir = tempdir().expect("failed to create temp dir");
1822        std::os::unix::fs::symlink("loop.md", temp_dir.path().join("loop.md"))
1823            .expect("failed to create symlink loop");
1824
1825        // Act
1826        let result = read_worktree_file(temp_dir.path().to_path_buf(), "loop.md".to_string()).await;
1827
1828        // Assert
1829        assert!(matches!(result, Err(GitError::Io(_))));
1830    }
1831
1832    #[test]
1833    fn copy_git_index_to_temp_maps_path_create_and_copy_failures() {
1834        // Arrange
1835        let temp_dir = tempdir().expect("failed to create temp dir");
1836        let path_without_parent = Path::new("/");
1837        let missing_parent_index = temp_dir.path().join("missing-parent").join("index");
1838        let missing_index = temp_dir.path().join("missing-index");
1839
1840        // Act
1841        let parent_error = copy_git_index_to_temp(path_without_parent);
1842        let create_error = copy_git_index_to_temp(&missing_parent_index);
1843        let copy_error = copy_git_index_to_temp(&missing_index);
1844
1845        // Assert
1846        assert!(matches!(parent_error, Err(GitError::OutputParse(_))));
1847        assert!(matches!(
1848            create_error,
1849            Err(GitError::CommandFailed { ref command, .. })
1850                if command == "create temporary git index"
1851        ));
1852        assert!(matches!(
1853            copy_error,
1854            Err(GitError::CommandFailed { ref command, .. }) if command == "copy git index"
1855        ));
1856    }
1857
1858    #[test]
1859    fn run_git_command_with_index_sync_maps_process_and_command_failures() {
1860        // Arrange
1861        let temp_dir = tempdir().expect("failed to create temp dir");
1862        let index_path = temp_dir.path().join("index");
1863        let missing_repo_path = temp_dir.path().join("missing-repository");
1864        fs::write(&index_path, []).expect("failed to create temporary index");
1865
1866        // Act
1867        let process_error = run_git_command_with_index_sync(
1868            &missing_repo_path,
1869            &["status"],
1870            &index_path,
1871            "Expected process failure",
1872        );
1873        let command_error = run_git_command_with_index_sync(
1874            temp_dir.path(),
1875            &["definitely-not-a-git-command"],
1876            &index_path,
1877            "Expected git failure",
1878        );
1879
1880        // Assert
1881        assert!(matches!(
1882            process_error,
1883            Err(GitError::CommandFailed { ref command, .. }) if command == "git status"
1884        ));
1885        assert!(matches!(
1886            command_error,
1887            Err(GitError::CommandFailed {
1888                ref command,
1889                ref stderr,
1890            }) if command == "git definitely-not-a-git-command"
1891                && stderr.starts_with("Expected git failure:")
1892        ));
1893    }
1894
1895    #[cfg(unix)]
1896    fn write_executable_pre_commit_hook(hook_path: &Path) {
1897        fs::create_dir_all(
1898            hook_path
1899                .parent()
1900                .expect("pre-commit hook should have a parent directory"),
1901        )
1902        .expect("failed to create hooks directory");
1903        fs::write(hook_path, "#!/bin/sh\nexit 0\n").expect("failed to write pre-commit hook");
1904        let mut permissions = fs::metadata(hook_path)
1905            .expect("failed to read pre-commit hook metadata")
1906            .permissions();
1907        permissions.set_mode(0o755);
1908        fs::set_permissions(hook_path, permissions)
1909            .expect("failed to make pre-commit hook executable");
1910    }
1911
1912    #[test]
1913    fn ensure_pre_commit_hook_ready_allows_repositories_without_configuration() {
1914        // Arrange
1915        let temp_dir = tempdir().expect("failed to create temp dir");
1916        setup_test_git_repo(temp_dir.path());
1917
1918        // Act
1919        let result = ensure_pre_commit_hook_ready(temp_dir.path());
1920
1921        // Assert
1922        assert!(result.is_ok());
1923    }
1924
1925    #[test]
1926    fn ensure_pre_commit_hook_ready_rejects_missing_hook() {
1927        // Arrange
1928        let temp_dir = tempdir().expect("failed to create temp dir");
1929        setup_test_git_repo(temp_dir.path());
1930        fs::write(
1931            temp_dir.path().join(".pre-commit-config.yaml"),
1932            "repos: []\n",
1933        )
1934        .expect("failed to write pre-commit configuration");
1935
1936        // Act
1937        let result = ensure_pre_commit_hook_ready(temp_dir.path());
1938
1939        // Assert
1940        assert!(matches!(
1941            result,
1942            Err(GitError::PreCommitHookMissing { ref config_file })
1943                if config_file == ".pre-commit-config.yaml"
1944        ));
1945    }
1946
1947    #[cfg(unix)]
1948    #[test]
1949    fn ensure_pre_commit_hook_ready_accepts_default_executable_hook() {
1950        // Arrange
1951        let temp_dir = tempdir().expect("failed to create temp dir");
1952        setup_test_git_repo(temp_dir.path());
1953        fs::write(
1954            temp_dir.path().join(".pre-commit-config.yaml"),
1955            "repos: []\n",
1956        )
1957        .expect("failed to write pre-commit configuration");
1958        let hook_path = temp_dir.path().join(git_command_stdout(
1959            temp_dir.path(),
1960            &["rev-parse", "--git-path", "hooks/pre-commit"],
1961        ));
1962        write_executable_pre_commit_hook(&hook_path);
1963
1964        // Act
1965        let result = ensure_pre_commit_hook_ready(temp_dir.path());
1966
1967        // Assert
1968        assert!(result.is_ok());
1969    }
1970
1971    #[cfg(unix)]
1972    #[test]
1973    fn ensure_pre_commit_hook_ready_accepts_custom_executable_hook() {
1974        // Arrange
1975        let temp_dir = tempdir().expect("failed to create temp dir");
1976        setup_test_git_repo(temp_dir.path());
1977        fs::write(
1978            temp_dir.path().join(".pre-commit-config.yaml"),
1979            "repos: []\n",
1980        )
1981        .expect("failed to write pre-commit configuration");
1982        run_git_command(
1983            temp_dir.path(),
1984            &["config", "core.hooksPath", ".custom-hooks"],
1985        );
1986        write_executable_pre_commit_hook(&temp_dir.path().join(".custom-hooks").join("pre-commit"));
1987
1988        // Act
1989        let result = ensure_pre_commit_hook_ready(temp_dir.path());
1990
1991        // Assert
1992        assert!(result.is_ok());
1993    }
1994
1995    #[cfg(unix)]
1996    #[test]
1997    fn ensure_pre_commit_hook_ready_rejects_hook_inaccessible_to_owner() {
1998        // Arrange
1999        let temp_dir = tempdir().expect("failed to create temp dir");
2000        setup_test_git_repo(temp_dir.path());
2001        fs::write(
2002            temp_dir.path().join(".pre-commit-config.yaml"),
2003            "repos: []\n",
2004        )
2005        .expect("failed to write pre-commit configuration");
2006        let hook_path = temp_dir.path().join(git_command_stdout(
2007            temp_dir.path(),
2008            &["rev-parse", "--git-path", "hooks/pre-commit"],
2009        ));
2010        fs::write(&hook_path, "#!/bin/sh\nexit 0\n").expect("failed to write pre-commit hook");
2011        fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o011))
2012            .expect("failed to set mismatched execute permissions");
2013
2014        // Act
2015        let result = ensure_pre_commit_hook_ready(temp_dir.path());
2016
2017        // Assert
2018        assert!(matches!(result, Err(GitError::PreCommitHookMissing { .. })));
2019    }
2020
2021    #[tokio::test]
2022    async fn commit_all_allows_configured_validation_without_hook() {
2023        // Arrange
2024        let temp_dir = tempdir().expect("failed to create temp dir");
2025        setup_test_git_repo(temp_dir.path());
2026        fs::write(
2027            temp_dir.path().join(".pre-commit-config.yaml"),
2028            "repos: []\n",
2029        )
2030        .expect("failed to write pre-commit configuration");
2031        fs::write(temp_dir.path().join("README.md"), "changed\n")
2032            .expect("failed to write worktree change");
2033
2034        // Act
2035        let result = commit_all(
2036            temp_dir.path().to_path_buf(),
2037            "Change README".to_string(),
2038            false,
2039        )
2040        .await;
2041
2042        // Assert
2043        assert!(result.is_ok());
2044        assert_eq!(
2045            git_command_stdout(temp_dir.path(), &["log", "-1", "--pretty=%s"]),
2046            "Change README"
2047        );
2048    }
2049
2050    #[tokio::test]
2051    async fn current_branch_name_returns_error_for_detached_head() {
2052        // Arrange
2053        let temp_dir = tempdir().expect("failed to create temp dir");
2054        setup_test_git_repo(temp_dir.path());
2055        run_git_command(temp_dir.path(), &["checkout", "--detach"]);
2056        let command_runner = ProcessAsyncGitCommandRunner;
2057
2058        // Act
2059        let result = current_branch_name(temp_dir.path(), &command_runner).await;
2060
2061        // Assert
2062        let error = result.expect_err("detached HEAD should fail");
2063        assert!(error.to_string().contains("detached HEAD"));
2064    }
2065
2066    #[tokio::test]
2067    async fn current_branch_remote_name_returns_none_when_remote_is_not_configured() {
2068        // Arrange
2069        let temp_dir = tempdir().expect("failed to create temp dir");
2070        setup_test_git_repo(temp_dir.path());
2071        let command_runner = ProcessAsyncGitCommandRunner;
2072
2073        // Act
2074        let remote_name = current_branch_remote_name(temp_dir.path(), &command_runner)
2075            .await
2076            .expect("missing branch remote should not be a command failure");
2077
2078        // Assert
2079        assert_eq!(remote_name, None);
2080    }
2081
2082    #[tokio::test]
2083    async fn current_branch_remote_name_returns_configured_non_origin_remote() {
2084        // Arrange
2085        let temp_dir = tempdir().expect("failed to create temp dir");
2086        setup_test_git_repo(temp_dir.path());
2087        run_git_command(
2088            temp_dir.path(),
2089            &["config", "branch.main.remote", "review-remote"],
2090        );
2091        let command_runner = ProcessAsyncGitCommandRunner;
2092
2093        // Act
2094        let remote_name = current_branch_remote_name(temp_dir.path(), &command_runner)
2095            .await
2096            .expect("configured branch remote should resolve");
2097
2098        // Assert
2099        assert_eq!(remote_name, Some("review-remote".to_string()));
2100    }
2101
2102    #[test]
2103    fn parse_current_branch_remote_output_preserves_fatal_config_error() {
2104        // Arrange
2105        let output = AsyncGitCommandOutput {
2106            exit_code: Some(128),
2107            stderr: b"fatal: bad config line".to_vec(),
2108            stdout: Vec::new(),
2109        };
2110
2111        // Act
2112        let error = parse_current_branch_remote_output(&output, "branch.main.remote")
2113            .expect_err("malformed config should remain an error");
2114
2115        // Assert
2116        assert!(matches!(
2117            error,
2118            GitError::CommandFailed { command, stderr }
2119                if command == "git config --get branch.main.remote"
2120                    && stderr.contains("Failed to resolve current branch remote")
2121        ));
2122    }
2123
2124    #[tokio::test]
2125    async fn primary_upstream_reference_uses_first_non_empty_line() {
2126        // Arrange
2127        let temp_dir = tempdir().expect("failed to create temp dir");
2128        let remote_dir = tempdir().expect("failed to create remote temp dir");
2129        setup_test_git_repo(temp_dir.path());
2130        run_git_command(remote_dir.path(), &["init", "--bare"]);
2131        let remote_path = remote_dir.path().to_string_lossy().to_string();
2132        run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
2133        run_git_command(temp_dir.path(), &["push", "-u", "origin", "main"]);
2134        run_git_command(
2135            temp_dir.path(),
2136            &[
2137                "config",
2138                "--replace-all",
2139                "branch.main.merge",
2140                "refs/heads/main",
2141            ],
2142        );
2143        run_git_command(
2144            temp_dir.path(),
2145            &["config", "--add", "branch.main.merge", "refs/heads/feature"],
2146        );
2147        let command_runner = ProcessAsyncGitCommandRunner;
2148
2149        // Act
2150        let upstream_reference = primary_upstream_reference(temp_dir.path(), &command_runner)
2151            .await
2152            .expect("failed to resolve upstream");
2153
2154        // Assert
2155        assert_eq!(upstream_reference, "origin/main");
2156    }
2157
2158    #[tokio::test]
2159    async fn pull_rebase_retries_index_lock_through_async_runner() {
2160        // Arrange
2161        let repo_path = PathBuf::from("test-repo");
2162        let mut command_runner = MockAsyncGitCommandRunner::new();
2163        let mut sequence = Sequence::new();
2164        command_runner
2165            .expect_run()
2166            .with(function(|command: &AsyncGitCommand| {
2167                command.arguments == ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]
2168            }))
2169            .times(1)
2170            .in_sequence(&mut sequence)
2171            .return_once(|_| {
2172                Box::pin(async { Ok(async_git_output(0, "origin/main\n", Vec::new())) })
2173            });
2174        for output in [
2175            async_git_output(
2176                128,
2177                Vec::new(),
2178                "fatal: Unable to create '.git/index.lock': File exists.",
2179            ),
2180            async_git_output(0, Vec::new(), Vec::new()),
2181        ] {
2182            command_runner
2183                .expect_run()
2184                .with(function(|command: &AsyncGitCommand| {
2185                    command.arguments == ["pull", "--rebase", "origin", "main"]
2186                        && command.environment
2187                            == [
2188                                ("GIT_EDITOR".to_string(), ":".to_string()),
2189                                ("GIT_SEQUENCE_EDITOR".to_string(), ":".to_string()),
2190                            ]
2191                }))
2192                .times(1)
2193                .in_sequence(&mut sequence)
2194                .return_once(move |_| Box::pin(async move { Ok(output) }));
2195        }
2196
2197        // Act
2198        let result = pull_rebase_with_runner(repo_path, &command_runner, Duration::ZERO).await;
2199
2200        // Assert
2201        assert!(matches!(result, Ok(PullRebaseResult::Completed)));
2202    }
2203
2204    #[tokio::test]
2205    async fn pull_rebase_preserves_non_conflict_command_failure() {
2206        // Arrange
2207        let repo_path = PathBuf::from("test-repo");
2208        let mut command_runner = MockAsyncGitCommandRunner::new();
2209        let mut sequence = Sequence::new();
2210        command_runner
2211            .expect_run()
2212            .with(function(|command: &AsyncGitCommand| {
2213                command.arguments == ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]
2214            }))
2215            .times(1)
2216            .in_sequence(&mut sequence)
2217            .return_once(|_| {
2218                Box::pin(async { Ok(async_git_output(0, "origin/main\n", Vec::new())) })
2219            });
2220        command_runner
2221            .expect_run()
2222            .with(function(|command: &AsyncGitCommand| {
2223                command.arguments == ["pull", "--rebase", "origin", "main"]
2224            }))
2225            .times(1)
2226            .in_sequence(&mut sequence)
2227            .return_once(|_| {
2228                Box::pin(async { Ok(async_git_output(128, Vec::new(), "fatal: transport failed")) })
2229            });
2230
2231        // Act
2232        let error = pull_rebase_with_runner(repo_path, &command_runner, Duration::ZERO)
2233            .await
2234            .expect_err("non-conflict pull failure should remain an error");
2235
2236        // Assert
2237        assert!(matches!(
2238            error,
2239            GitError::CommandFailed { command, stderr }
2240                if command == "git pull --rebase" && stderr == "fatal: transport failed"
2241        ));
2242    }
2243
2244    #[tokio::test]
2245    async fn pull_rebase_rejects_local_upstream_without_configured_remote() {
2246        // Arrange
2247        let repo_path = PathBuf::from("test-repo");
2248        let mut command_runner = MockAsyncGitCommandRunner::new();
2249        let mut sequence = Sequence::new();
2250        let expectations = [
2251            (
2252                vec!["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
2253                async_git_output(0, "main\n", Vec::new()),
2254            ),
2255            (
2256                vec!["rev-parse", "--abbrev-ref", "HEAD"],
2257                async_git_output(0, "main\n", Vec::new()),
2258            ),
2259            (
2260                vec!["config", "--get", "branch.main.remote"],
2261                async_git_output(1, Vec::new(), Vec::new()),
2262            ),
2263        ];
2264        for (arguments, output) in expectations {
2265            let arguments = arguments
2266                .into_iter()
2267                .map(str::to_string)
2268                .collect::<Vec<_>>();
2269            command_runner
2270                .expect_run()
2271                .with(function(move |command: &AsyncGitCommand| {
2272                    command.arguments == arguments
2273                }))
2274                .times(1)
2275                .in_sequence(&mut sequence)
2276                .return_once(move |_| Box::pin(async move { Ok(output) }));
2277        }
2278
2279        // Act
2280        let error = pull_rebase_with_runner(repo_path, &command_runner, Duration::ZERO)
2281            .await
2282            .expect_err("local upstream without a remote should fail");
2283
2284        // Assert
2285        assert!(matches!(
2286            error,
2287            GitError::OutputParse(message)
2288                if message == "Failed to resolve current branch remote: not configured"
2289        ));
2290    }
2291
2292    #[tokio::test]
2293    async fn pull_rebase_returns_last_index_lock_failure_after_retry_exhaustion() {
2294        // Arrange
2295        let repo_path = PathBuf::from("test-repo");
2296        let mut command_runner = MockAsyncGitCommandRunner::new();
2297        let mut sequence = Sequence::new();
2298        command_runner
2299            .expect_run()
2300            .with(function(|command: &AsyncGitCommand| {
2301                command.arguments == ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]
2302            }))
2303            .times(1)
2304            .in_sequence(&mut sequence)
2305            .return_once(|_| {
2306                Box::pin(async { Ok(async_git_output(0, "origin/main\n", Vec::new())) })
2307            });
2308        command_runner
2309            .expect_run()
2310            .with(function(|command: &AsyncGitCommand| {
2311                command.arguments == ["pull", "--rebase", "origin", "main"]
2312            }))
2313            .times(GIT_INDEX_LOCK_RETRY_ATTEMPTS)
2314            .in_sequence(&mut sequence)
2315            .returning(|_| {
2316                Box::pin(async {
2317                    Ok(async_git_output(
2318                        128,
2319                        Vec::new(),
2320                        "fatal: Unable to create '.git/index.lock': File exists.",
2321                    ))
2322                })
2323            });
2324
2325        // Act
2326        let error = pull_rebase_with_runner(repo_path, &command_runner, Duration::ZERO)
2327            .await
2328            .expect_err("exhausted index-lock retries should return the last failure");
2329
2330        // Assert
2331        assert!(matches!(
2332            error,
2333            GitError::CommandFailed { command, stderr }
2334                if command == "git pull --rebase" && stderr.contains("index.lock")
2335        ));
2336    }
2337
2338    #[tokio::test]
2339    async fn remote_branch_lookup_uses_origin_fallback_through_async_runner() {
2340        // Arrange
2341        let repo_path = PathBuf::from("test-repo");
2342        let mut command_runner = MockAsyncGitCommandRunner::new();
2343        let mut sequence = Sequence::new();
2344        let expectations = [
2345            (
2346                vec!["rev-parse", "--abbrev-ref", "HEAD"],
2347                async_git_output(0, "main\n", Vec::new()),
2348            ),
2349            (
2350                vec!["config", "--get", "branch.main.remote"],
2351                async_git_output(1, Vec::new(), Vec::new()),
2352            ),
2353            (
2354                vec!["ls-remote", "--heads", "origin", "review/topic"],
2355                async_git_output(0, "abc123\trefs/heads/review/topic\n", Vec::new()),
2356            ),
2357        ];
2358        for (arguments, output) in expectations {
2359            let arguments = arguments
2360                .into_iter()
2361                .map(str::to_string)
2362                .collect::<Vec<_>>();
2363            command_runner
2364                .expect_run()
2365                .with(function(move |command: &AsyncGitCommand| {
2366                    command.arguments == arguments
2367                }))
2368                .times(1)
2369                .in_sequence(&mut sequence)
2370                .return_once(move |_| Box::pin(async move { Ok(output) }));
2371        }
2372
2373        // Act
2374        let exists = remote_branch_exists_with_runner(
2375            repo_path,
2376            "review/topic".to_string(),
2377            &command_runner,
2378        )
2379        .await
2380        .expect("remote branch lookup should succeed");
2381
2382        // Assert
2383        assert!(exists);
2384    }
2385
2386    #[tokio::test]
2387    async fn remote_branch_lookup_checks_isolated_local_remote() {
2388        // Arrange
2389        let temp_dir = tempdir().expect("failed to create temp dir");
2390        let remote_dir = tempdir().expect("failed to create remote temp dir");
2391        setup_test_git_repo(temp_dir.path());
2392        run_git_command(remote_dir.path(), &["init", "--bare"]);
2393        let remote_path = remote_dir.path().to_string_lossy().to_string();
2394        run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
2395        run_git_command(temp_dir.path(), &["push", "-u", "origin", "main"]);
2396
2397        // Act
2398        let exists = remote_branch_exists(temp_dir.path().to_path_buf(), "main".to_string())
2399            .await
2400            .expect("local remote branch lookup should succeed");
2401
2402        // Assert
2403        assert!(exists);
2404    }
2405
2406    #[tokio::test]
2407    async fn remote_branch_lookup_preserves_remote_config_failure() {
2408        // Arrange
2409        let repo_path = PathBuf::from("test-repo");
2410        let mut command_runner = MockAsyncGitCommandRunner::new();
2411        let mut sequence = Sequence::new();
2412        command_runner
2413            .expect_run()
2414            .with(function(|command: &AsyncGitCommand| {
2415                command.arguments == ["rev-parse", "--abbrev-ref", "HEAD"]
2416            }))
2417            .times(1)
2418            .in_sequence(&mut sequence)
2419            .return_once(|_| Box::pin(async { Ok(async_git_output(0, "main\n", Vec::new())) }));
2420        command_runner
2421            .expect_run()
2422            .with(function(|command: &AsyncGitCommand| {
2423                command.arguments == ["config", "--get", "branch.main.remote"]
2424            }))
2425            .times(1)
2426            .in_sequence(&mut sequence)
2427            .return_once(|_| {
2428                Box::pin(async {
2429                    Ok(async_git_output(
2430                        128,
2431                        Vec::new(),
2432                        "fatal: malformed branch config",
2433                    ))
2434                })
2435            });
2436
2437        // Act
2438        let error = remote_branch_exists_with_runner(
2439            repo_path,
2440            "review/topic".to_string(),
2441            &command_runner,
2442        )
2443        .await
2444        .expect_err("remote config failure should not fall back to origin");
2445
2446        // Assert
2447        assert!(matches!(
2448            error,
2449            GitError::CommandFailed { command, stderr }
2450                if command == "git config --get branch.main.remote"
2451                    && stderr.contains("malformed branch config")
2452        ));
2453    }
2454
2455    #[tokio::test]
2456    async fn push_without_upstream_reuses_configured_remote() {
2457        // Arrange
2458        let repo_path = PathBuf::from("test-repo");
2459        let mut command_runner = MockAsyncGitCommandRunner::new();
2460        let mut sequence = Sequence::new();
2461        let expectations = [
2462            (
2463                vec!["push", "--force-with-lease"],
2464                async_git_output(128, Vec::new(), "fatal: no upstream branch"),
2465            ),
2466            (
2467                vec!["rev-parse", "--abbrev-ref", "HEAD"],
2468                async_git_output(0, "main\n", Vec::new()),
2469            ),
2470            (
2471                vec!["config", "--get", "branch.main.remote"],
2472                async_git_output(0, "review-remote\n", Vec::new()),
2473            ),
2474            (
2475                vec![
2476                    "push",
2477                    "--force-with-lease",
2478                    "--set-upstream",
2479                    "review-remote",
2480                    "HEAD",
2481                ],
2482                async_git_output(0, Vec::new(), Vec::new()),
2483            ),
2484            (
2485                vec!["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
2486                async_git_output(0, "review-remote/main\n", Vec::new()),
2487            ),
2488        ];
2489        for (arguments, output) in expectations {
2490            let arguments = arguments
2491                .into_iter()
2492                .map(str::to_string)
2493                .collect::<Vec<_>>();
2494            command_runner
2495                .expect_run()
2496                .with(function(move |command: &AsyncGitCommand| {
2497                    command.arguments == arguments
2498                }))
2499                .times(1)
2500                .in_sequence(&mut sequence)
2501                .return_once(move |_| Box::pin(async move { Ok(output) }));
2502        }
2503
2504        // Act
2505        let upstream_reference = push_current_branch_with_runner(repo_path, &command_runner)
2506            .await
2507            .expect("configured remote push should succeed");
2508
2509        // Assert
2510        assert_eq!(upstream_reference, "review-remote/main");
2511    }
2512
2513    #[test]
2514    fn parse_branch_tracking_statuses_reads_repo_wide_branch_snapshot() {
2515        // Arrange
2516        let output = "\
2517main\torigin/main\tbehind 2\nwt/1234abcd\torigin/wt/1234abcd\tahead 3, behind \
2518                      1\nfeature/local\t\t\nfeature/gone\torigin/feature/gone\tgone\n";
2519
2520        // Act
2521        let branch_tracking_statuses = parse_branch_tracking_statuses(output);
2522
2523        // Assert
2524        assert_eq!(branch_tracking_statuses.get("main"), Some(&Some((0, 2))));
2525        assert_eq!(
2526            branch_tracking_statuses.get("wt/1234abcd"),
2527            Some(&Some((3, 1)))
2528        );
2529        assert_eq!(branch_tracking_statuses.get("feature/local"), Some(&None));
2530        assert_eq!(branch_tracking_statuses.get("feature/gone"), Some(&None));
2531    }
2532
2533    #[tokio::test]
2534    async fn pull_rebase_returns_conflict_detail_for_conflicting_remote_change() {
2535        // Arrange
2536        let temp_dir = tempdir().expect("failed to create temp dir");
2537        let remote_dir = tempdir().expect("failed to create remote temp dir");
2538        let contributor_dir = tempdir().expect("failed to create contributor temp dir");
2539        let contributor_clone_path = contributor_dir.path().join("clone");
2540        setup_test_git_repo(temp_dir.path());
2541        run_git_command(remote_dir.path(), &["init", "--bare"]);
2542        let remote_path = remote_dir.path().to_string_lossy().to_string();
2543        let contributor_clone_path_text = contributor_clone_path.to_string_lossy().to_string();
2544        run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
2545        run_git_command(temp_dir.path(), &["push", "-u", "origin", "main"]);
2546        fs::write(temp_dir.path().join("README.md"), "local change\n")
2547            .expect("failed to write local change");
2548        run_git_command(temp_dir.path(), &["add", "README.md"]);
2549        run_git_command(temp_dir.path(), &["commit", "-m", "Local change"]);
2550        run_git_command(
2551            contributor_dir.path(),
2552            &["clone", &remote_path, &contributor_clone_path_text],
2553        );
2554        run_git_command(
2555            &contributor_clone_path,
2556            &["config", "user.name", "Contributor User"],
2557        );
2558        run_git_command(
2559            &contributor_clone_path,
2560            &["config", "user.email", "contributor@example.com"],
2561        );
2562        run_git_command(
2563            &contributor_clone_path,
2564            &["checkout", "-B", "main", "origin/main"],
2565        );
2566        fs::write(contributor_clone_path.join("README.md"), "remote change\n")
2567            .expect("failed to write remote change");
2568        run_git_command(&contributor_clone_path, &["add", "README.md"]);
2569        run_git_command(&contributor_clone_path, &["commit", "-m", "Remote change"]);
2570        run_git_command(&contributor_clone_path, &["push", "origin", "main"]);
2571
2572        // Act
2573        let result = pull_rebase(temp_dir.path().to_path_buf()).await;
2574
2575        // Assert
2576        assert!(matches!(
2577            result,
2578            Ok(PullRebaseResult::Conflict { ref detail })
2579                if {
2580                    let normalized_detail = detail.to_ascii_lowercase();
2581
2582                    (normalized_detail.contains("conflict")
2583                        || normalized_detail.contains("could not apply"))
2584                        && !detail.is_empty()
2585                }
2586        ));
2587    }
2588
2589    #[tokio::test]
2590    async fn push_current_branch_returns_rejected_error_for_non_fast_forward_push() {
2591        // Arrange
2592        let temp_dir = tempdir().expect("failed to create temp dir");
2593        let remote_dir = tempdir().expect("failed to create remote temp dir");
2594        let contributor_dir = tempdir().expect("failed to create contributor temp dir");
2595        let contributor_clone_path = contributor_dir.path().join("clone");
2596        setup_test_git_repo(temp_dir.path());
2597        run_git_command(remote_dir.path(), &["init", "--bare"]);
2598        let remote_path = remote_dir.path().to_string_lossy().to_string();
2599        let contributor_clone_path_text = contributor_clone_path.to_string_lossy().to_string();
2600        run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
2601        run_git_command(temp_dir.path(), &["push", "-u", "origin", "main"]);
2602        run_git_command(
2603            contributor_dir.path(),
2604            &["clone", &remote_path, &contributor_clone_path_text],
2605        );
2606        run_git_command(
2607            &contributor_clone_path,
2608            &["config", "user.name", "Contributor User"],
2609        );
2610        run_git_command(
2611            &contributor_clone_path,
2612            &["config", "user.email", "contributor@example.com"],
2613        );
2614        run_git_command(
2615            &contributor_clone_path,
2616            &["checkout", "-B", "main", "origin/main"],
2617        );
2618        fs::write(contributor_clone_path.join("remote.txt"), "remote change")
2619            .expect("failed to write remote file");
2620        run_git_command(&contributor_clone_path, &["add", "remote.txt"]);
2621        run_git_command(&contributor_clone_path, &["commit", "-m", "Remote change"]);
2622        run_git_command(&contributor_clone_path, &["push", "origin", "main"]);
2623        fs::write(temp_dir.path().join("local.txt"), "local change")
2624            .expect("failed to write local file");
2625        run_git_command(temp_dir.path(), &["add", "local.txt"]);
2626        run_git_command(temp_dir.path(), &["commit", "-m", "Local change"]);
2627
2628        // Act
2629        let result = push_current_branch(temp_dir.path().to_path_buf()).await;
2630
2631        // Assert
2632        let error = result
2633            .expect_err("non-fast-forward push should fail")
2634            .to_string();
2635        assert!(error.contains("git push"));
2636        assert!(
2637            error.contains("stale info")
2638                || error.contains("rejected")
2639                || error.contains("fetch first")
2640        );
2641    }
2642
2643    #[tokio::test]
2644    async fn push_current_branch_force_with_lease_updates_rewritten_history() {
2645        // Arrange
2646        let temp_dir = tempdir().expect("failed to create temp dir");
2647        let remote_dir = tempdir().expect("failed to create remote temp dir");
2648        setup_test_git_repo(temp_dir.path());
2649        run_git_command(remote_dir.path(), &["init", "--bare"]);
2650        let remote_path = remote_dir.path().to_string_lossy().to_string();
2651        run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
2652        run_git_command(temp_dir.path(), &["push", "-u", "origin", "main"]);
2653        fs::write(
2654            temp_dir.path().join("README.md"),
2655            "first published version\n",
2656        )
2657        .expect("failed to write first version");
2658        run_git_command(temp_dir.path(), &["add", "README.md"]);
2659        run_git_command(temp_dir.path(), &["commit", "-m", "Publish branch change"]);
2660        push_current_branch(temp_dir.path().to_path_buf())
2661            .await
2662            .expect("initial push should succeed");
2663        fs::write(
2664            temp_dir.path().join("README.md"),
2665            "rewritten published version\n",
2666        )
2667        .expect("failed to rewrite published version");
2668        run_git_command(temp_dir.path(), &["add", "README.md"]);
2669        run_git_command(
2670            temp_dir.path(),
2671            &["commit", "--amend", "-m", "Rewrite published branch change"],
2672        );
2673
2674        // Act
2675        let upstream_reference = push_current_branch(temp_dir.path().to_path_buf())
2676            .await
2677            .expect("force-with-lease push should update rewritten history");
2678        let local_head = git_command_stdout(temp_dir.path(), &["rev-parse", "HEAD"]);
2679        let remote_head = git_command_stdout(remote_dir.path(), &["rev-parse", "refs/heads/main"]);
2680
2681        // Assert
2682        assert_eq!(upstream_reference, "origin/main");
2683        assert_eq!(local_head, remote_head);
2684    }
2685
2686    #[tokio::test]
2687    async fn push_current_branch_to_remote_branch_returns_custom_upstream_reference() {
2688        // Arrange
2689        let temp_dir = tempdir().expect("failed to create temp dir");
2690        let remote_dir = tempdir().expect("failed to create remote temp dir");
2691        setup_test_git_repo(temp_dir.path());
2692        run_git_command(remote_dir.path(), &["init", "--bare"]);
2693        let remote_path = remote_dir.path().to_string_lossy().to_string();
2694        run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
2695
2696        // Act
2697        let upstream_reference = push_current_branch_to_remote_branch(
2698            temp_dir.path().to_path_buf(),
2699            "review/custom-branch".to_string(),
2700        )
2701        .await
2702        .expect("failed to push current branch to custom remote branch");
2703
2704        // Assert
2705        assert_eq!(upstream_reference, "origin/review/custom-branch");
2706    }
2707
2708    #[tokio::test]
2709    async fn current_upstream_reference_returns_origin_main() {
2710        // Arrange
2711        let temp_dir = tempdir().expect("failed to create temp dir");
2712        let remote_dir = tempdir().expect("failed to create remote temp dir");
2713        setup_test_git_repo(temp_dir.path());
2714        run_git_command(remote_dir.path(), &["init", "--bare"]);
2715        let remote_path = remote_dir.path().to_string_lossy().to_string();
2716        run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
2717        run_git_command(temp_dir.path(), &["push", "-u", "origin", "main"]);
2718
2719        // Act
2720        let upstream_reference = current_upstream_reference(temp_dir.path().to_path_buf())
2721            .await
2722            .expect("failed to resolve upstream reference");
2723
2724        // Assert
2725        assert_eq!(upstream_reference, "origin/main");
2726    }
2727
2728    #[tokio::test]
2729    async fn get_ref_ahead_behind_returns_counts_between_two_local_branches() {
2730        // Arrange
2731        let temp_dir = tempdir().expect("failed to create temp dir");
2732        setup_test_git_repo(temp_dir.path());
2733        run_git_command(temp_dir.path(), &["checkout", "-b", "wt/1234abcd"]);
2734        fs::write(temp_dir.path().join("session.txt"), "session change\n")
2735            .expect("failed to write session file");
2736        run_git_command(temp_dir.path(), &["add", "session.txt"]);
2737        run_git_command(temp_dir.path(), &["commit", "-m", "Session change"]);
2738        run_git_command(temp_dir.path(), &["checkout", "main"]);
2739        fs::write(temp_dir.path().join("main.txt"), "main change\n")
2740            .expect("failed to write main file");
2741        run_git_command(temp_dir.path(), &["add", "main.txt"]);
2742        run_git_command(temp_dir.path(), &["commit", "-m", "Main change"]);
2743
2744        // Act
2745        let status = get_ref_ahead_behind(
2746            temp_dir.path().to_path_buf(),
2747            "wt/1234abcd".to_string(),
2748            "main".to_string(),
2749        )
2750        .await
2751        .expect("failed to compare branch refs");
2752
2753        // Assert
2754        assert_eq!(status, (1, 1));
2755    }
2756
2757    #[tokio::test]
2758    async fn branch_tracking_statuses_returns_repo_wide_branch_counts() {
2759        // Arrange
2760        let temp_dir = tempdir().expect("failed to create temp dir");
2761        let remote_dir = tempdir().expect("failed to create remote temp dir");
2762        let contributor_dir = tempdir().expect("failed to create contributor temp dir");
2763        let contributor_clone_path = contributor_dir.path().join("clone");
2764        setup_test_git_repo(temp_dir.path());
2765        run_git_command(remote_dir.path(), &["init", "--bare"]);
2766        let remote_path = remote_dir.path().to_string_lossy().to_string();
2767        let contributor_clone_path_text = contributor_clone_path.to_string_lossy().to_string();
2768        run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
2769        run_git_command(temp_dir.path(), &["push", "-u", "origin", "main"]);
2770        run_git_command(
2771            contributor_dir.path(),
2772            &["clone", &remote_path, &contributor_clone_path_text],
2773        );
2774        run_git_command(
2775            &contributor_clone_path,
2776            &["config", "user.name", "Contributor User"],
2777        );
2778        run_git_command(
2779            &contributor_clone_path,
2780            &["config", "user.email", "contributor@example.com"],
2781        );
2782        run_git_command(
2783            &contributor_clone_path,
2784            &["checkout", "-B", "main", "origin/main"],
2785        );
2786        fs::write(contributor_clone_path.join("remote.txt"), "remote change")
2787            .expect("failed to write remote file");
2788        run_git_command(&contributor_clone_path, &["add", "remote.txt"]);
2789        run_git_command(&contributor_clone_path, &["commit", "-m", "Remote change"]);
2790        run_git_command(&contributor_clone_path, &["push", "origin", "main"]);
2791        run_git_command(temp_dir.path(), &["checkout", "-b", "wt/1234abcd"]);
2792        fs::write(temp_dir.path().join("session.txt"), "session change\n")
2793            .expect("failed to write session file");
2794        run_git_command(temp_dir.path(), &["add", "session.txt"]);
2795        run_git_command(temp_dir.path(), &["commit", "-m", "Session change"]);
2796        run_git_command(temp_dir.path(), &["push", "-u", "origin", "wt/1234abcd"]);
2797        fs::write(
2798            temp_dir.path().join("session.txt"),
2799            "session change\nmore local\n",
2800        )
2801        .expect("failed to extend session file");
2802        run_git_command(temp_dir.path(), &["add", "session.txt"]);
2803        run_git_command(temp_dir.path(), &["commit", "-m", "More session work"]);
2804        run_git_command(temp_dir.path(), &["fetch"]);
2805
2806        // Act
2807        let branch_tracking_statuses = branch_tracking_statuses(temp_dir.path().to_path_buf())
2808            .await
2809            .expect("failed to read branch tracking statuses");
2810
2811        // Assert
2812        assert_eq!(branch_tracking_statuses.get("main"), Some(&Some((0, 1))));
2813        assert_eq!(
2814            branch_tracking_statuses.get("wt/1234abcd"),
2815            Some(&Some((1, 0)))
2816        );
2817    }
2818
2819    #[tokio::test]
2820    /// Verifies that amending a session commit whose staged result is identical
2821    /// to the base branch (i.e., all changes were reverted) surfaces the
2822    /// canonical "Nothing to commit" sentinel rather than triggering the assist
2823    /// retry loop with the raw git "allow-empty" error.
2824    async fn test_empty_amend_resets_session_commit_and_returns_no_changes() {
2825        // Arrange
2826        let temp_dir = tempdir().expect("failed to create temp dir");
2827        setup_test_git_repo(temp_dir.path());
2828        run_git_command(temp_dir.path(), &["checkout", "-b", "session-branch"]);
2829        fs::write(temp_dir.path().join("session.txt"), "session work\n")
2830            .expect("failed to write session file");
2831        run_git_command(temp_dir.path(), &["add", "session.txt"]);
2832        run_git_command(temp_dir.path(), &["commit", "-m", "Session commit"]);
2833        fs::remove_file(temp_dir.path().join("session.txt"))
2834            .expect("failed to remove session file");
2835
2836        // Act - the worktree is dirty (session.txt removed) but amending HEAD
2837        // would produce a tree identical to the base branch, making the amend
2838        // result an empty commit.
2839        let result = commit_all_preserving_single_commit(
2840            temp_dir.path().to_path_buf(),
2841            "main".to_string(),
2842            "Session commit".to_string(),
2843            SingleCommitMessageStrategy::Replace,
2844            true,
2845        )
2846        .await;
2847
2848        // Assert
2849        let error = result.expect_err("amend-would-be-empty should fail");
2850        let commit_count = git_command_stdout(temp_dir.path(), &["rev-list", "--count", "HEAD"]);
2851        let head_message = git_command_stdout(temp_dir.path(), &["log", "-1", "--pretty=%B"]);
2852        let status = git_command_stdout(temp_dir.path(), &["status", "--porcelain"]);
2853
2854        assert!(
2855            error.to_string().contains("Nothing to commit"),
2856            "expected 'Nothing to commit' sentinel but got: {error}"
2857        );
2858        assert_eq!(commit_count, "1");
2859        assert_eq!(head_message, "Initial commit");
2860        assert_eq!(status, "");
2861    }
2862}