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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 async fn branch_tracking_statuses(repo_path: PathBuf) -> Result<BranchTrackingMap, GitError> {
765    let git_output = run_git_command(
766        repo_path,
767        vec![
768            "for-each-ref".to_string(),
769            "--format=%(refname:short)\t%(upstream:short)\t%(upstream:track,nobracket)".to_string(),
770            "refs/heads".to_string(),
771        ],
772        "Git for-each-ref failed".to_string(),
773    )
774    .await?;
775
776    Ok(parse_branch_tracking_statuses(&git_output))
777}
778
779/// Returns upstream commit subjects that are not yet in local `HEAD`.
780///
781/// The returned order is oldest to newest to match pull application order.
782///
783/// # Arguments
784/// * `repo_path` - Path to the git repository root
785///
786/// # Errors
787/// Returns a [`GitError`] when `git log` fails or upstream tracking refs are
788/// unavailable.
789pub async fn list_upstream_commit_titles(repo_path: PathBuf) -> Result<Vec<String>, GitError> {
790    let git_output = run_git_command(
791        repo_path,
792        vec![
793            "log".to_string(),
794            "--reverse".to_string(),
795            "--pretty=%s".to_string(),
796            "HEAD..@{u}".to_string(),
797        ],
798        "Git log failed".to_string(),
799    )
800    .await?;
801
802    Ok(parse_commit_titles(&git_output))
803}
804
805/// Returns local commit subjects that are not yet present in upstream.
806///
807/// The returned order is oldest to newest to match push application order.
808///
809/// # Arguments
810/// * `repo_path` - Path to the git repository root
811///
812/// # Errors
813/// Returns a [`GitError`] when `git log` fails or upstream tracking refs are
814/// unavailable.
815pub async fn list_local_commit_titles(repo_path: PathBuf) -> Result<Vec<String>, GitError> {
816    let git_output = run_git_command(
817        repo_path,
818        vec![
819            "log".to_string(),
820            "--reverse".to_string(),
821            "--pretty=%s".to_string(),
822            "@{u}..HEAD".to_string(),
823        ],
824        "Git log failed".to_string(),
825    )
826    .await?;
827
828    Ok(parse_commit_titles(&git_output))
829}
830
831/// Returns whether `HEAD` contains commits that are not reachable from
832/// `base_branch`.
833///
834/// # Errors
835/// Returns a [`GitError`] if commit ancestry cannot be queried.
836pub async fn has_commits_since(repo_path: PathBuf, base_branch: String) -> Result<bool, GitError> {
837    spawn_blocking(move || -> Result<bool, GitError> {
838        let rev_list_output = run_git_command_sync(
839            &repo_path,
840            &["rev-list", "--count", &format!("{base_branch}..HEAD")],
841            "Failed to count commits since base branch",
842        )?;
843        let commit_count = rev_list_output.trim().parse::<u32>().map_err(|error| {
844            GitError::OutputParse(format!(
845                "Failed to parse commit count since base branch `{base_branch}`: {error}"
846            ))
847        })?;
848
849        Ok(commit_count > 0)
850    })
851    .await?
852}
853
854/// Parses newline-delimited commit subjects from `git log` output.
855fn parse_commit_titles(output: &str) -> Vec<String> {
856    output
857        .lines()
858        .map(str::trim)
859        .filter(|title| !title.is_empty())
860        .map(ToString::to_string)
861        .collect()
862}
863
864/// Parses repo-wide branch tracking information from `git for-each-ref`.
865fn parse_branch_tracking_statuses(output: &str) -> BranchTrackingMap {
866    let mut branch_tracking_statuses = HashMap::new();
867
868    for line in output
869        .lines()
870        .map(str::trim)
871        .filter(|line| !line.is_empty())
872    {
873        let mut parts = line.splitn(3, '\t');
874        let Some(branch_name) = parts
875            .next()
876            .map(str::trim)
877            .filter(|value| !value.is_empty())
878        else {
879            continue;
880        };
881        let upstream_ref = parts.next().map(str::trim).unwrap_or_default();
882        let track = parts.next().map(str::trim).unwrap_or_default();
883
884        let status = if upstream_ref.is_empty() {
885            None
886        } else {
887            parse_branch_tracking_counts(track)
888        };
889        branch_tracking_statuses.insert(branch_name.to_string(), status);
890    }
891
892    branch_tracking_statuses
893}
894
895/// Parses one `%(upstream:track,nobracket)` marker into ahead/behind counts.
896fn parse_branch_tracking_counts(track: &str) -> Option<(u32, u32)> {
897    let normalized_track = track.trim();
898    if normalized_track.is_empty() || normalized_track == "gone" {
899        return None;
900    }
901
902    let mut ahead = 0;
903    let mut behind = 0;
904
905    for part in normalized_track.split(',').map(str::trim) {
906        if let Some(count) = part.strip_prefix("ahead ") {
907            ahead = count.parse().ok()?;
908        } else if let Some(count) = part.strip_prefix("behind ") {
909            behind = count.parse().ok()?;
910        }
911    }
912
913    Some((ahead, behind))
914}
915
916/// Resolves the commit/tree to use as the `git diff` "before" side.
917///
918/// Starts from the merge-base fallback and, when `git cherry` reports leading
919/// commits already applied to `base_branch`, advances the baseline to the last
920/// such commit so squash-merged session changes are not shown again.
921fn resolve_diff_target(
922    repo_path: &Path,
923    base_branch: &str,
924    merge_base: &str,
925) -> Result<String, GitError> {
926    let cherry_output = run_git_command_output_sync(repo_path, &["cherry", base_branch, "HEAD"])?;
927    if !cherry_output.status.success() {
928        return Ok(merge_base.to_string());
929    }
930
931    let cherry_stdout = String::from_utf8_lossy(&cherry_output.stdout);
932    let Some(last_leading_applied_commit) = last_leading_applied_commit(&cherry_stdout) else {
933        return Ok(merge_base.to_string());
934    };
935
936    Ok(last_leading_applied_commit.to_string())
937}
938
939/// Returns the last leading commit from `git cherry` marked as already applied.
940///
941/// `git cherry` prefixes commits with `-` when an equivalent patch exists in
942/// the upstream branch and `+` when it does not. This helper only consumes the
943/// initial contiguous `-` block and stops at the first `+` to avoid dropping
944/// non-merged changes.
945fn last_leading_applied_commit(cherry_output: &str) -> Option<&str> {
946    let mut last_applied_commit = None;
947
948    for line in cherry_output.lines() {
949        let trimmed_line = line.trim();
950        if trimmed_line.is_empty() {
951            continue;
952        }
953
954        let mut parts = trimmed_line.split_whitespace();
955        let marker = parts.next()?;
956        let commit_hash = parts.next()?;
957
958        if marker == "-" {
959            last_applied_commit = Some(commit_hash);
960
961            continue;
962        }
963
964        if marker == "+" {
965            break;
966        }
967
968        break;
969    }
970
971    last_applied_commit
972}
973
974/// Stages all changes and commits or amends with retry behavior for hook
975/// rewrites.
976///
977/// If an amend would make `HEAD` empty, the staged tree has reverted the
978/// session commit back to its parent. In that case the helper drops the now
979/// empty session commit and reports the standard no-changes sentinel so the
980/// app can skip model-assisted commit recovery.
981async fn commit_all_with_retry(
982    repo_path: PathBuf,
983    commit_message: String,
984    message_strategy: SingleCommitMessageStrategy,
985    no_verify: bool,
986    amend_existing_commit: bool,
987) -> Result<(), GitError> {
988    spawn_blocking(move || {
989        stage_all_sync(&repo_path)?;
990
991        for _ in 0..COMMIT_ALL_HOOK_RETRY_ATTEMPTS {
992            let output = run_commit_command(
993                &repo_path,
994                &commit_message,
995                message_strategy,
996                no_verify,
997                amend_existing_commit,
998            )?;
999
1000            if output.status.success() {
1001                return Ok(());
1002            }
1003
1004            let stderr = String::from_utf8_lossy(&output.stderr);
1005            let stdout = String::from_utf8_lossy(&output.stdout);
1006            if is_nothing_to_commit_output(&stdout, &stderr) {
1007                return Err(nothing_to_commit_error());
1008            }
1009
1010            if amend_existing_commit && is_empty_amend_output(&stdout, &stderr) {
1011                reset_empty_amend_sync(&repo_path)?;
1012
1013                return Err(nothing_to_commit_error());
1014            }
1015
1016            if is_hook_modified_error(&stdout, &stderr) {
1017                stage_all_sync(&repo_path)?;
1018
1019                continue;
1020            }
1021
1022            let detail = command_output_detail(&output.stdout, &output.stderr);
1023
1024            return Err(GitError::CommandFailed {
1025                command: "git commit".to_string(),
1026                stderr: detail,
1027            });
1028        }
1029
1030        Err(GitError::CommandFailed {
1031            command: "git commit".to_string(),
1032            stderr: format!(
1033                "Failed to commit: commit hooks kept modifying files after \
1034                 {COMMIT_ALL_HOOK_RETRY_ATTEMPTS} attempts"
1035            ),
1036        })
1037    })
1038    .await?
1039}
1040
1041/// Returns the canonical git no-changes error used by app auto-commit flows.
1042fn nothing_to_commit_error() -> GitError {
1043    GitError::CommandFailed {
1044        command: "git commit".to_string(),
1045        stderr: "Nothing to commit: no changes detected".to_string(),
1046    }
1047}
1048
1049/// Returns whether commit output reports that there was no staged work to
1050/// commit.
1051fn is_nothing_to_commit_output(stdout: &str, stderr: &str) -> bool {
1052    let combined = format!("{stdout}\n{stderr}").to_ascii_lowercase();
1053
1054    combined.contains("nothing to commit")
1055}
1056
1057/// Returns whether commit output reports that amending `HEAD` would remove the
1058/// session commit entirely.
1059fn is_empty_amend_output(stdout: &str, stderr: &str) -> bool {
1060    let combined = format!("{stdout}\n{stderr}").to_ascii_lowercase();
1061    let normalized = combined.split_whitespace().collect::<Vec<_>>().join(" ");
1062
1063    normalized.contains("would make it empty") && normalized.contains("allow-empty")
1064}
1065
1066/// Drops an amended session commit whose resulting tree would match its
1067/// parent, leaving the worktree at the reverted state.
1068fn reset_empty_amend_sync(repo_path: &Path) -> Result<(), GitError> {
1069    run_git_command_sync(
1070        repo_path,
1071        &["reset", "HEAD^"],
1072        "Git reset after empty amend failed",
1073    )?;
1074
1075    Ok(())
1076}
1077
1078/// Stages all changed files in the repository.
1079///
1080/// Uses shared git retry behavior for transient `index.lock` contention.
1081fn stage_all_sync(repo_path: &Path) -> Result<(), GitError> {
1082    let output = run_git_command_with_index_lock_retry(repo_path, &["add", "-A"], &[])?;
1083
1084    if !output.status.success() {
1085        let detail = command_output_detail(&output.stdout, &output.stderr);
1086
1087        return Err(GitError::CommandFailed {
1088            command: "git add -A".to_string(),
1089            stderr: format!("Failed to stage changes: {detail}"),
1090        });
1091    }
1092
1093    Ok(())
1094}
1095
1096/// Returns the full `HEAD` commit message, or `None` when no commits exist.
1097fn head_commit_message_sync(repo_path: &Path) -> Result<Option<String>, GitError> {
1098    if !has_head_commit_sync(repo_path)? {
1099        return Ok(None);
1100    }
1101
1102    let output = run_git_command_sync(
1103        repo_path,
1104        &["log", "-1", "--pretty=%B"],
1105        "Failed to read HEAD commit message",
1106    )?;
1107
1108    Ok(Some(output.trim().to_string()))
1109}
1110
1111/// Returns whether `HEAD` resolves to an existing commit.
1112fn has_head_commit_sync(repo_path: &Path) -> Result<bool, GitError> {
1113    let output = run_git_command_output_sync(repo_path, &["rev-parse", "--verify", "HEAD"])?;
1114
1115    if output.status.success() {
1116        return Ok(true);
1117    }
1118
1119    let detail = command_output_detail(&output.stdout, &output.stderr);
1120    let normalized_detail = detail.to_ascii_lowercase();
1121    if normalized_detail.contains("needed a single revision")
1122        || normalized_detail.contains("unknown revision")
1123        || normalized_detail.contains("does not have any commits yet")
1124    {
1125        return Ok(false);
1126    }
1127
1128    Err(GitError::CommandFailed {
1129        command: "git rev-parse --verify HEAD".to_string(),
1130        stderr: detail,
1131    })
1132}
1133
1134/// Runs `git commit` with optional amend and hook settings.
1135///
1136/// Uses shared git retry behavior for transient `index.lock` contention.
1137fn run_commit_command(
1138    repo_path: &Path,
1139    commit_message: &str,
1140    message_strategy: SingleCommitMessageStrategy,
1141    no_verify: bool,
1142    amend_existing_commit: bool,
1143) -> Result<Output, GitError> {
1144    let mut args = vec!["commit"];
1145    if amend_existing_commit {
1146        args.push("--amend");
1147        match message_strategy {
1148            SingleCommitMessageStrategy::Replace => {
1149                args.push("-m");
1150                args.push(commit_message);
1151            }
1152            SingleCommitMessageStrategy::Reuse => {
1153                args.push("--no-edit");
1154            }
1155        }
1156    } else {
1157        args.push("-m");
1158        args.push(commit_message);
1159    }
1160
1161    if no_verify {
1162        args.push("--no-verify");
1163    }
1164
1165    run_git_command_with_index_lock_retry(repo_path, &args, &[])
1166}
1167
1168/// Returns whether commit output indicates hooks rewrote files.
1169fn is_hook_modified_error(stdout: &str, stderr: &str) -> bool {
1170    let combined = format!(
1171        "{stdout}
1172{stderr}"
1173    )
1174    .to_ascii_lowercase();
1175
1176    combined.contains("files were modified by this hook")
1177}
1178
1179/// Returns whether git push output indicates a missing upstream branch.
1180pub(super) fn is_no_upstream_error(detail: &str) -> bool {
1181    let normalized_detail = detail.to_ascii_lowercase();
1182
1183    normalized_detail.contains("has no upstream branch")
1184        || normalized_detail.contains("no upstream branch")
1185        || normalized_detail.contains("set-upstream")
1186}
1187
1188#[cfg(test)]
1189mod tests {
1190    use std::fs;
1191    use std::path::Path;
1192    use std::process::{Command, Output};
1193
1194    use tempfile::tempdir;
1195
1196    use super::*;
1197
1198    /// Runs `git` in `repo_path` and asserts the command succeeds.
1199    fn run_git_command(repo_path: &Path, args: &[&str]) {
1200        let output = git_command_output(repo_path, args);
1201
1202        assert!(
1203            output.status.success(),
1204            "git command {:?} failed: {}",
1205            args,
1206            String::from_utf8_lossy(&output.stderr)
1207        );
1208    }
1209
1210    /// Runs `git` in `repo_path` and returns the captured command output.
1211    fn git_command_output(repo_path: &Path, args: &[&str]) -> Output {
1212        Command::new("git")
1213            .args(args)
1214            .current_dir(repo_path)
1215            .output()
1216            .expect("failed to run git command")
1217    }
1218
1219    /// Runs `git` in `repo_path`, asserts success, and returns trimmed stdout.
1220    fn git_command_stdout(repo_path: &Path, args: &[&str]) -> String {
1221        let output = git_command_output(repo_path, args);
1222
1223        assert!(
1224            output.status.success(),
1225            "git command {:?} failed: {}",
1226            args,
1227            String::from_utf8_lossy(&output.stderr)
1228        );
1229
1230        String::from_utf8(output.stdout)
1231            .expect("git stdout should be valid utf-8")
1232            .trim()
1233            .to_string()
1234    }
1235
1236    /// Creates a committed repository rooted at `repo_path`.
1237    fn setup_test_git_repo(repo_path: &Path) {
1238        run_git_command(repo_path, &["init", "-b", "main"]);
1239        run_git_command(repo_path, &["config", "user.name", "Test User"]);
1240        run_git_command(repo_path, &["config", "user.email", "test@example.com"]);
1241        fs::write(repo_path.join("README.md"), "base\n").expect("failed to write base file");
1242        run_git_command(repo_path, &["add", "README.md"]);
1243        run_git_command(repo_path, &["commit", "-m", "Initial commit"]);
1244    }
1245
1246    #[test]
1247    fn current_branch_name_returns_error_for_detached_head() {
1248        // Arrange
1249        let temp_dir = tempdir().expect("failed to create temp dir");
1250        setup_test_git_repo(temp_dir.path());
1251        run_git_command(temp_dir.path(), &["checkout", "--detach"]);
1252
1253        // Act
1254        let result = current_branch_name(temp_dir.path());
1255
1256        // Assert
1257        let error = result.expect_err("detached HEAD should fail");
1258        assert!(error.to_string().contains("detached HEAD"));
1259    }
1260
1261    #[test]
1262    fn primary_upstream_reference_uses_first_non_empty_line() {
1263        // Arrange
1264        let temp_dir = tempdir().expect("failed to create temp dir");
1265        let remote_dir = tempdir().expect("failed to create remote temp dir");
1266        setup_test_git_repo(temp_dir.path());
1267        run_git_command(remote_dir.path(), &["init", "--bare"]);
1268        let remote_path = remote_dir.path().to_string_lossy().to_string();
1269        run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
1270        run_git_command(temp_dir.path(), &["push", "-u", "origin", "main"]);
1271        run_git_command(
1272            temp_dir.path(),
1273            &[
1274                "config",
1275                "--replace-all",
1276                "branch.main.merge",
1277                "refs/heads/main",
1278            ],
1279        );
1280        run_git_command(
1281            temp_dir.path(),
1282            &["config", "--add", "branch.main.merge", "refs/heads/feature"],
1283        );
1284
1285        // Act
1286        let upstream_reference =
1287            primary_upstream_reference(temp_dir.path()).expect("failed to resolve upstream");
1288
1289        // Assert
1290        assert_eq!(upstream_reference, "origin/main");
1291    }
1292
1293    #[test]
1294    fn parse_branch_tracking_statuses_reads_repo_wide_branch_snapshot() {
1295        // Arrange
1296        let output = "\
1297main\torigin/main\tbehind 2\nwt/1234abcd\torigin/wt/1234abcd\tahead 3, behind \
1298                      1\nfeature/local\t\t\nfeature/gone\torigin/feature/gone\tgone\n";
1299
1300        // Act
1301        let branch_tracking_statuses = parse_branch_tracking_statuses(output);
1302
1303        // Assert
1304        assert_eq!(branch_tracking_statuses.get("main"), Some(&Some((0, 2))));
1305        assert_eq!(
1306            branch_tracking_statuses.get("wt/1234abcd"),
1307            Some(&Some((3, 1)))
1308        );
1309        assert_eq!(branch_tracking_statuses.get("feature/local"), Some(&None));
1310        assert_eq!(branch_tracking_statuses.get("feature/gone"), Some(&None));
1311    }
1312
1313    #[tokio::test]
1314    async fn pull_rebase_returns_conflict_detail_for_conflicting_remote_change() {
1315        // Arrange
1316        let temp_dir = tempdir().expect("failed to create temp dir");
1317        let remote_dir = tempdir().expect("failed to create remote temp dir");
1318        let contributor_dir = tempdir().expect("failed to create contributor temp dir");
1319        let contributor_clone_path = contributor_dir.path().join("clone");
1320        setup_test_git_repo(temp_dir.path());
1321        run_git_command(remote_dir.path(), &["init", "--bare"]);
1322        let remote_path = remote_dir.path().to_string_lossy().to_string();
1323        let contributor_clone_path_text = contributor_clone_path.to_string_lossy().to_string();
1324        run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
1325        run_git_command(temp_dir.path(), &["push", "-u", "origin", "main"]);
1326        fs::write(temp_dir.path().join("README.md"), "local change\n")
1327            .expect("failed to write local change");
1328        run_git_command(temp_dir.path(), &["add", "README.md"]);
1329        run_git_command(temp_dir.path(), &["commit", "-m", "Local change"]);
1330        run_git_command(
1331            contributor_dir.path(),
1332            &["clone", &remote_path, &contributor_clone_path_text],
1333        );
1334        run_git_command(
1335            &contributor_clone_path,
1336            &["config", "user.name", "Contributor User"],
1337        );
1338        run_git_command(
1339            &contributor_clone_path,
1340            &["config", "user.email", "contributor@example.com"],
1341        );
1342        run_git_command(
1343            &contributor_clone_path,
1344            &["checkout", "-B", "main", "origin/main"],
1345        );
1346        fs::write(contributor_clone_path.join("README.md"), "remote change\n")
1347            .expect("failed to write remote change");
1348        run_git_command(&contributor_clone_path, &["add", "README.md"]);
1349        run_git_command(&contributor_clone_path, &["commit", "-m", "Remote change"]);
1350        run_git_command(&contributor_clone_path, &["push", "origin", "main"]);
1351
1352        // Act
1353        let result = pull_rebase(temp_dir.path().to_path_buf()).await;
1354
1355        // Assert
1356        assert!(matches!(
1357            result,
1358            Ok(PullRebaseResult::Conflict { ref detail })
1359                if {
1360                    let normalized_detail = detail.to_ascii_lowercase();
1361
1362                    (normalized_detail.contains("conflict")
1363                        || normalized_detail.contains("could not apply"))
1364                        && !detail.is_empty()
1365                }
1366        ));
1367    }
1368
1369    #[tokio::test]
1370    async fn push_current_branch_returns_rejected_error_for_non_fast_forward_push() {
1371        // Arrange
1372        let temp_dir = tempdir().expect("failed to create temp dir");
1373        let remote_dir = tempdir().expect("failed to create remote temp dir");
1374        let contributor_dir = tempdir().expect("failed to create contributor temp dir");
1375        let contributor_clone_path = contributor_dir.path().join("clone");
1376        setup_test_git_repo(temp_dir.path());
1377        run_git_command(remote_dir.path(), &["init", "--bare"]);
1378        let remote_path = remote_dir.path().to_string_lossy().to_string();
1379        let contributor_clone_path_text = contributor_clone_path.to_string_lossy().to_string();
1380        run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
1381        run_git_command(temp_dir.path(), &["push", "-u", "origin", "main"]);
1382        run_git_command(
1383            contributor_dir.path(),
1384            &["clone", &remote_path, &contributor_clone_path_text],
1385        );
1386        run_git_command(
1387            &contributor_clone_path,
1388            &["config", "user.name", "Contributor User"],
1389        );
1390        run_git_command(
1391            &contributor_clone_path,
1392            &["config", "user.email", "contributor@example.com"],
1393        );
1394        run_git_command(
1395            &contributor_clone_path,
1396            &["checkout", "-B", "main", "origin/main"],
1397        );
1398        fs::write(contributor_clone_path.join("remote.txt"), "remote change")
1399            .expect("failed to write remote file");
1400        run_git_command(&contributor_clone_path, &["add", "remote.txt"]);
1401        run_git_command(&contributor_clone_path, &["commit", "-m", "Remote change"]);
1402        run_git_command(&contributor_clone_path, &["push", "origin", "main"]);
1403        fs::write(temp_dir.path().join("local.txt"), "local change")
1404            .expect("failed to write local file");
1405        run_git_command(temp_dir.path(), &["add", "local.txt"]);
1406        run_git_command(temp_dir.path(), &["commit", "-m", "Local change"]);
1407
1408        // Act
1409        let result = push_current_branch(temp_dir.path().to_path_buf()).await;
1410
1411        // Assert
1412        let error = result
1413            .expect_err("non-fast-forward push should fail")
1414            .to_string();
1415        assert!(error.contains("git push"));
1416        assert!(
1417            error.contains("stale info")
1418                || error.contains("rejected")
1419                || error.contains("fetch first")
1420        );
1421    }
1422
1423    #[tokio::test]
1424    async fn push_current_branch_force_with_lease_updates_rewritten_history() {
1425        // Arrange
1426        let temp_dir = tempdir().expect("failed to create temp dir");
1427        let remote_dir = tempdir().expect("failed to create remote temp dir");
1428        setup_test_git_repo(temp_dir.path());
1429        run_git_command(remote_dir.path(), &["init", "--bare"]);
1430        let remote_path = remote_dir.path().to_string_lossy().to_string();
1431        run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
1432        run_git_command(temp_dir.path(), &["push", "-u", "origin", "main"]);
1433        fs::write(
1434            temp_dir.path().join("README.md"),
1435            "first published version\n",
1436        )
1437        .expect("failed to write first version");
1438        run_git_command(temp_dir.path(), &["add", "README.md"]);
1439        run_git_command(temp_dir.path(), &["commit", "-m", "Publish branch change"]);
1440        push_current_branch(temp_dir.path().to_path_buf())
1441            .await
1442            .expect("initial push should succeed");
1443        fs::write(
1444            temp_dir.path().join("README.md"),
1445            "rewritten published version\n",
1446        )
1447        .expect("failed to rewrite published version");
1448        run_git_command(temp_dir.path(), &["add", "README.md"]);
1449        run_git_command(
1450            temp_dir.path(),
1451            &["commit", "--amend", "-m", "Rewrite published branch change"],
1452        );
1453
1454        // Act
1455        let upstream_reference = push_current_branch(temp_dir.path().to_path_buf())
1456            .await
1457            .expect("force-with-lease push should update rewritten history");
1458        let local_head = git_command_stdout(temp_dir.path(), &["rev-parse", "HEAD"]);
1459        let remote_head = git_command_stdout(remote_dir.path(), &["rev-parse", "refs/heads/main"]);
1460
1461        // Assert
1462        assert_eq!(upstream_reference, "origin/main");
1463        assert_eq!(local_head, remote_head);
1464    }
1465
1466    #[tokio::test]
1467    async fn push_current_branch_to_remote_branch_returns_custom_upstream_reference() {
1468        // Arrange
1469        let temp_dir = tempdir().expect("failed to create temp dir");
1470        let remote_dir = tempdir().expect("failed to create remote temp dir");
1471        setup_test_git_repo(temp_dir.path());
1472        run_git_command(remote_dir.path(), &["init", "--bare"]);
1473        let remote_path = remote_dir.path().to_string_lossy().to_string();
1474        run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
1475
1476        // Act
1477        let upstream_reference = push_current_branch_to_remote_branch(
1478            temp_dir.path().to_path_buf(),
1479            "review/custom-branch".to_string(),
1480        )
1481        .await
1482        .expect("failed to push current branch to custom remote branch");
1483
1484        // Assert
1485        assert_eq!(upstream_reference, "origin/review/custom-branch");
1486    }
1487
1488    #[tokio::test]
1489    async fn current_upstream_reference_returns_origin_main() {
1490        // Arrange
1491        let temp_dir = tempdir().expect("failed to create temp dir");
1492        let remote_dir = tempdir().expect("failed to create remote temp dir");
1493        setup_test_git_repo(temp_dir.path());
1494        run_git_command(remote_dir.path(), &["init", "--bare"]);
1495        let remote_path = remote_dir.path().to_string_lossy().to_string();
1496        run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
1497        run_git_command(temp_dir.path(), &["push", "-u", "origin", "main"]);
1498
1499        // Act
1500        let upstream_reference = current_upstream_reference(temp_dir.path().to_path_buf())
1501            .await
1502            .expect("failed to resolve upstream reference");
1503
1504        // Assert
1505        assert_eq!(upstream_reference, "origin/main");
1506    }
1507
1508    #[tokio::test]
1509    async fn get_ref_ahead_behind_returns_counts_between_two_local_branches() {
1510        // Arrange
1511        let temp_dir = tempdir().expect("failed to create temp dir");
1512        setup_test_git_repo(temp_dir.path());
1513        run_git_command(temp_dir.path(), &["checkout", "-b", "wt/1234abcd"]);
1514        fs::write(temp_dir.path().join("session.txt"), "session change\n")
1515            .expect("failed to write session file");
1516        run_git_command(temp_dir.path(), &["add", "session.txt"]);
1517        run_git_command(temp_dir.path(), &["commit", "-m", "Session change"]);
1518        run_git_command(temp_dir.path(), &["checkout", "main"]);
1519        fs::write(temp_dir.path().join("main.txt"), "main change\n")
1520            .expect("failed to write main file");
1521        run_git_command(temp_dir.path(), &["add", "main.txt"]);
1522        run_git_command(temp_dir.path(), &["commit", "-m", "Main change"]);
1523
1524        // Act
1525        let status = get_ref_ahead_behind(
1526            temp_dir.path().to_path_buf(),
1527            "wt/1234abcd".to_string(),
1528            "main".to_string(),
1529        )
1530        .await
1531        .expect("failed to compare branch refs");
1532
1533        // Assert
1534        assert_eq!(status, (1, 1));
1535    }
1536
1537    #[tokio::test]
1538    async fn branch_tracking_statuses_returns_repo_wide_branch_counts() {
1539        // Arrange
1540        let temp_dir = tempdir().expect("failed to create temp dir");
1541        let remote_dir = tempdir().expect("failed to create remote temp dir");
1542        let contributor_dir = tempdir().expect("failed to create contributor temp dir");
1543        let contributor_clone_path = contributor_dir.path().join("clone");
1544        setup_test_git_repo(temp_dir.path());
1545        run_git_command(remote_dir.path(), &["init", "--bare"]);
1546        let remote_path = remote_dir.path().to_string_lossy().to_string();
1547        let contributor_clone_path_text = contributor_clone_path.to_string_lossy().to_string();
1548        run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
1549        run_git_command(temp_dir.path(), &["push", "-u", "origin", "main"]);
1550        run_git_command(
1551            contributor_dir.path(),
1552            &["clone", &remote_path, &contributor_clone_path_text],
1553        );
1554        run_git_command(
1555            &contributor_clone_path,
1556            &["config", "user.name", "Contributor User"],
1557        );
1558        run_git_command(
1559            &contributor_clone_path,
1560            &["config", "user.email", "contributor@example.com"],
1561        );
1562        run_git_command(
1563            &contributor_clone_path,
1564            &["checkout", "-B", "main", "origin/main"],
1565        );
1566        fs::write(contributor_clone_path.join("remote.txt"), "remote change")
1567            .expect("failed to write remote file");
1568        run_git_command(&contributor_clone_path, &["add", "remote.txt"]);
1569        run_git_command(&contributor_clone_path, &["commit", "-m", "Remote change"]);
1570        run_git_command(&contributor_clone_path, &["push", "origin", "main"]);
1571        run_git_command(temp_dir.path(), &["checkout", "-b", "wt/1234abcd"]);
1572        fs::write(temp_dir.path().join("session.txt"), "session change\n")
1573            .expect("failed to write session file");
1574        run_git_command(temp_dir.path(), &["add", "session.txt"]);
1575        run_git_command(temp_dir.path(), &["commit", "-m", "Session change"]);
1576        run_git_command(temp_dir.path(), &["push", "-u", "origin", "wt/1234abcd"]);
1577        fs::write(
1578            temp_dir.path().join("session.txt"),
1579            "session change\nmore local\n",
1580        )
1581        .expect("failed to extend session file");
1582        run_git_command(temp_dir.path(), &["add", "session.txt"]);
1583        run_git_command(temp_dir.path(), &["commit", "-m", "More session work"]);
1584        run_git_command(temp_dir.path(), &["fetch"]);
1585
1586        // Act
1587        let branch_tracking_statuses = branch_tracking_statuses(temp_dir.path().to_path_buf())
1588            .await
1589            .expect("failed to read branch tracking statuses");
1590
1591        // Assert
1592        assert_eq!(branch_tracking_statuses.get("main"), Some(&Some((0, 1))));
1593        assert_eq!(
1594            branch_tracking_statuses.get("wt/1234abcd"),
1595            Some(&Some((1, 0)))
1596        );
1597    }
1598
1599    #[tokio::test]
1600    /// Verifies that amending a session commit whose staged result is identical
1601    /// to the base branch (i.e., all changes were reverted) surfaces the
1602    /// canonical "Nothing to commit" sentinel rather than triggering the assist
1603    /// retry loop with the raw git "allow-empty" error.
1604    async fn test_empty_amend_resets_session_commit_and_returns_no_changes() {
1605        // Arrange
1606        let temp_dir = tempdir().expect("failed to create temp dir");
1607        setup_test_git_repo(temp_dir.path());
1608        run_git_command(temp_dir.path(), &["checkout", "-b", "session-branch"]);
1609        fs::write(temp_dir.path().join("session.txt"), "session work\n")
1610            .expect("failed to write session file");
1611        run_git_command(temp_dir.path(), &["add", "session.txt"]);
1612        run_git_command(temp_dir.path(), &["commit", "-m", "Session commit"]);
1613        fs::remove_file(temp_dir.path().join("session.txt"))
1614            .expect("failed to remove session file");
1615
1616        // Act - the worktree is dirty (session.txt removed) but amending HEAD
1617        // would produce a tree identical to the base branch, making the amend
1618        // result an empty commit.
1619        let result = commit_all_preserving_single_commit(
1620            temp_dir.path().to_path_buf(),
1621            "main".to_string(),
1622            "Session commit".to_string(),
1623            SingleCommitMessageStrategy::Replace,
1624            true,
1625        )
1626        .await;
1627
1628        // Assert
1629        let error = result.expect_err("amend-would-be-empty should fail");
1630        let commit_count = git_command_stdout(temp_dir.path(), &["rev-list", "--count", "HEAD"]);
1631        let head_message = git_command_stdout(temp_dir.path(), &["log", "-1", "--pretty=%B"]);
1632        let status = git_command_stdout(temp_dir.path(), &["status", "--porcelain"]);
1633
1634        assert!(
1635            error.to_string().contains("Nothing to commit"),
1636            "expected 'Nothing to commit' sentinel but got: {error}"
1637        );
1638        assert_eq!(commit_count, "1");
1639        assert_eq!(head_message, "Initial commit");
1640        assert!(status.is_empty());
1641    }
1642}