Skip to main content

ag_git/
sync.rs

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