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