Skip to main content

ag_git/
sync.rs

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