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