Skip to main content

ag_git/
sync.rs

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