Skip to main content

ag_git/
sync.rs

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