Skip to main content

ag_git/
rebase.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3use std::process::Output;
4use std::time::Duration;
5
6use tokio::task::spawn_blocking;
7
8use super::error::GitError;
9use super::repo::{
10    command_output_detail, resolve_git_dir, run_git_command_output_sync,
11    run_git_command_output_with_env_sync, run_git_command_sync,
12};
13use crate::{Sleeper, ThreadSleeper};
14
15const GIT_INDEX_LOCK_RETRY_ATTEMPTS: usize = 5;
16const GIT_INDEX_LOCK_RETRY_DELAY: Duration = Duration::from_millis(100);
17
18/// Executes git commands for rebase operations.
19#[cfg_attr(test, mockall::automock)]
20trait GitCommandRunner: Send + Sync {
21    /// Runs a git command in `repo_path` with environment overrides.
22    fn run_git_command_output_with_env(
23        &self,
24        repo_path: &Path,
25        args: &[String],
26        environment: &[(String, String)],
27    ) -> Result<Output, GitError>;
28}
29
30/// Git command runner backed by process execution.
31struct ProcessGitCommandRunner;
32
33impl GitCommandRunner for ProcessGitCommandRunner {
34    fn run_git_command_output_with_env(
35        &self,
36        repo_path: &Path,
37        args: &[String],
38        environment: &[(String, String)],
39    ) -> Result<Output, GitError> {
40        let args = args.iter().map(String::as_str).collect::<Vec<_>>();
41        let environment = environment
42            .iter()
43            .map(|(key, value)| (key.as_str(), value.as_str()))
44            .collect::<Vec<_>>();
45
46        run_git_command_output_with_env_sync(repo_path, &args, &environment)
47    }
48}
49
50/// Result of attempting a rebase step.
51#[derive(Clone, Debug, Eq, PartialEq)]
52pub enum RebaseStepResult {
53    /// Rebase step completed successfully.
54    Completed,
55    /// Rebase step stopped because of merge conflicts.
56    Conflict { detail: String },
57}
58
59/// Git operation metadata that marks a worktree as unsafe for branch pushes.
60#[derive(Clone, Copy, Debug, Eq, PartialEq)]
61pub enum InProgressGitOperation {
62    /// A cherry-pick is in progress.
63    CherryPick,
64    /// A merge is in progress.
65    Merge,
66    /// A rebase is in progress.
67    Rebase,
68    /// A revert is in progress.
69    Revert,
70}
71
72impl InProgressGitOperation {
73    /// Returns an indefinite article plus the operation name for user-facing
74    /// status text.
75    pub fn article_name(self) -> &'static str {
76        match self {
77            Self::CherryPick => "a cherry-pick",
78            Self::Merge => "a merge",
79            Self::Rebase => "a rebase",
80            Self::Revert => "a revert",
81        }
82    }
83
84    /// Returns the operation name for user-facing status text.
85    pub fn name(self) -> &'static str {
86        match self {
87            Self::CherryPick => "cherry-pick",
88            Self::Merge => "merge",
89            Self::Rebase => "rebase",
90            Self::Revert => "revert",
91        }
92    }
93}
94
95/// Rebases the current branch onto `target_branch`.
96///
97/// If the rebase fails due to conflict, this function aborts it immediately so
98/// the repository does not remain in an in-progress rebase state.
99///
100/// # Arguments
101/// * `repo_path` - Path to the git repository or worktree
102/// * `target_branch` - Branch to rebase onto (e.g., `main`)
103///
104/// # Returns
105/// Ok(()) on success.
106///
107/// # Errors
108/// Returns a [`GitError`] if rebase fails, or aborting a conflicted rebase
109/// also fails.
110pub(crate) async fn rebase(repo_path: PathBuf, target_branch: String) -> Result<(), GitError> {
111    match rebase_start(repo_path.clone(), target_branch.clone()).await? {
112        RebaseStepResult::Completed => Ok(()),
113        RebaseStepResult::Conflict { detail } => {
114            let abort_suffix = match abort_rebase(repo_path).await {
115                Ok(()) => String::new(),
116                Err(error) => format!(" {error}"),
117            };
118
119            Err(GitError::CommandFailed {
120                command: "git rebase".to_string(),
121                stderr: format!("Failed to rebase onto {target_branch}: {detail}.{abort_suffix}"),
122            })
123        }
124    }
125}
126
127/// Rebases the current branch onto `target_branch`.
128///
129/// Returns a conflict outcome when the rebase stops for manual resolution.
130///
131/// # Arguments
132/// * `repo_path` - Path to the git repository or worktree
133/// * `target_branch` - Branch to rebase onto (e.g., `main`)
134///
135/// # Returns
136/// A [`RebaseStepResult`] describing whether the rebase completed or
137/// encountered conflicts.
138///
139/// # Errors
140/// Returns a [`GitError`] for non-conflict git failures.
141pub(crate) async fn rebase_start(
142    repo_path: PathBuf,
143    target_branch: String,
144) -> Result<RebaseStepResult, GitError> {
145    spawn_blocking(move || {
146        let rebase_args = ["rebase", target_branch.as_str()];
147        run_rebase_step(&repo_path, &rebase_args, "git rebase", |detail| {
148            format!("Failed to rebase onto {target_branch}: {detail}.")
149        })
150    })
151    .await?
152}
153
154/// Starts a rebase that moves commits after `old_base` onto `new_base`.
155///
156/// This is used for stacked sessions to drop commits that came from a parent
157/// branch after that parent has moved or squash-merged into its own base.
158///
159/// # Arguments
160/// * `repo_path` - Path to the git repository or worktree.
161/// * `new_base` - Ref that should become the new base of replayed commits.
162/// * `old_base` - Commit/ref whose ancestors should be left behind.
163///
164/// # Returns
165/// A [`RebaseStepResult`] describing whether the rebase completed or
166/// encountered conflicts.
167///
168/// # Errors
169/// Returns a [`GitError`] for non-conflict git failures.
170pub(crate) async fn rebase_onto_start(
171    repo_path: PathBuf,
172    new_base: String,
173    old_base: String,
174) -> Result<RebaseStepResult, GitError> {
175    spawn_blocking(move || {
176        let rebase_args = ["rebase", "--onto", new_base.as_str(), old_base.as_str()];
177        run_rebase_step(&repo_path, &rebase_args, "git rebase --onto", |detail| {
178            format!("Failed to rebase onto {new_base} after {old_base}: {detail}.")
179        })
180    })
181    .await?
182}
183
184/// Continues an in-progress rebase.
185///
186/// # Arguments
187/// * `repo_path` - Path to the git repository or worktree
188///
189/// # Returns
190/// A [`RebaseStepResult`] describing whether the rebase completed or
191/// encountered conflicts.
192///
193/// # Errors
194/// Returns a [`GitError`] for non-conflict git failures.
195pub(crate) async fn rebase_continue(repo_path: PathBuf) -> Result<RebaseStepResult, GitError> {
196    spawn_blocking(move || {
197        let output = run_git_command_with_index_lock_retry(
198            &repo_path,
199            &["rebase", "--continue"],
200            &[("GIT_EDITOR", ":"), ("GIT_SEQUENCE_EDITOR", ":")],
201        )?;
202
203        if output.status.success() {
204            return Ok(RebaseStepResult::Completed);
205        }
206
207        let detail = command_output_detail(&output.stdout, &output.stderr);
208        if is_rebase_conflict(&detail) {
209            return Ok(RebaseStepResult::Conflict { detail });
210        }
211
212        Err(GitError::CommandFailed {
213            command: "git rebase --continue".to_string(),
214            stderr: format!("Failed to continue rebase: {detail}."),
215        })
216    })
217    .await?
218}
219
220/// Aborts an in-progress rebase.
221///
222/// When git reports stale or inconsistent rebase metadata and abort cannot
223/// complete normally, this helper removes stale `rebase-merge`/`rebase-apply`
224/// paths as a recovery fallback.
225///
226/// # Arguments
227/// * `repo_path` - Path to the git repository or worktree
228///
229/// # Returns
230/// Ok(()) on success.
231///
232/// # Errors
233/// Returns a [`GitError`] when `git rebase --abort` cannot be executed.
234pub(crate) async fn abort_rebase(repo_path: PathBuf) -> Result<(), GitError> {
235    spawn_blocking(move || {
236        let output =
237            run_git_command_with_index_lock_retry(&repo_path, &["rebase", "--abort"], &[])?;
238
239        if !output.status.success() {
240            let detail = command_output_detail(&output.stdout, &output.stderr);
241            if !is_stale_or_inactive_rebase_error(&detail) {
242                return Err(GitError::CommandFailed {
243                    command: "git rebase --abort".to_string(),
244                    stderr: format!("Failed to abort rebase: {detail}."),
245                });
246            }
247
248            let cleaned_stale_metadata = clean_stale_rebase_metadata(&repo_path)?;
249            if cleaned_stale_metadata {
250                return Ok(());
251            }
252
253            return Err(GitError::CommandFailed {
254                command: "git rebase --abort".to_string(),
255                stderr: format!("Failed to abort rebase: {detail}."),
256            });
257        }
258
259        Ok(())
260    })
261    .await?
262}
263
264/// Returns whether a rebase is currently in progress in the repository or
265/// worktree.
266///
267/// # Arguments
268/// * `repo_path` - Path to the git repository or worktree
269///
270/// # Returns
271/// `true` when `.git/rebase-merge` or `.git/rebase-apply` exists, `false`
272/// otherwise.
273///
274/// # Errors
275/// Returns a [`GitError`] when the git directory cannot be resolved.
276pub(crate) async fn is_rebase_in_progress(repo_path: PathBuf) -> Result<bool, GitError> {
277    spawn_blocking(move || -> Result<bool, GitError> {
278        let git_dir = resolve_git_dir(&repo_path)
279            .ok_or_else(|| GitError::OutputParse("Failed to resolve git directory".to_string()))?;
280
281        Ok(has_rebase_metadata(&git_dir))
282    })
283    .await?
284}
285
286/// Returns the first detected in-progress git operation in `repo_path`.
287///
288/// # Arguments
289/// * `repo_path` - Path to the git repository or worktree
290///
291/// # Returns
292/// An operation when rebase, merge, cherry-pick, or revert metadata exists.
293///
294/// # Errors
295/// Returns a [`GitError`] when the git directory cannot be resolved.
296pub(crate) async fn in_progress_operation(
297    repo_path: PathBuf,
298) -> Result<Option<InProgressGitOperation>, GitError> {
299    spawn_blocking(move || in_progress_operation_sync(&repo_path)).await?
300}
301
302fn in_progress_operation_sync(
303    repo_path: &Path,
304) -> Result<Option<InProgressGitOperation>, GitError> {
305    let git_dir = resolve_git_dir(repo_path)
306        .ok_or_else(|| GitError::OutputParse("Failed to resolve git directory".to_string()))?;
307    if has_rebase_metadata(&git_dir) {
308        return Ok(Some(InProgressGitOperation::Rebase));
309    }
310    if git_dir.join("MERGE_HEAD").exists() {
311        return Ok(Some(InProgressGitOperation::Merge));
312    }
313    if git_dir.join("CHERRY_PICK_HEAD").exists() {
314        return Ok(Some(InProgressGitOperation::CherryPick));
315    }
316    if git_dir.join("REVERT_HEAD").exists() {
317        return Ok(Some(InProgressGitOperation::Revert));
318    }
319
320    Ok(None)
321}
322
323fn has_rebase_metadata(git_dir: &Path) -> bool {
324    let rebase_merge = git_dir.join("rebase-merge");
325    let rebase_apply = git_dir.join("rebase-apply");
326
327    rebase_merge.exists() || rebase_apply.exists()
328}
329
330/// Returns whether unresolved paths still exist in the index.
331///
332/// # Arguments
333/// * `repo_path` - Path to the git repository or worktree
334///
335/// # Returns
336/// `true` when unresolved paths exist, `false` otherwise.
337///
338/// # Errors
339/// Returns a [`GitError`] when conflicted files cannot be queried.
340pub(crate) async fn has_unmerged_paths(repo_path: PathBuf) -> Result<bool, GitError> {
341    let conflicted_files = list_conflicted_files(repo_path).await?;
342
343    Ok(!conflicted_files.is_empty())
344}
345
346/// Returns which of the given `paths` still contain git conflict markers
347/// (`<<<<<<<`) in their staged content.
348///
349/// Uses `git grep --cached -l` to search indexed content directly, so it
350/// detects files that were staged via `git add` while still containing
351/// unresolved conflict markers. The search is scoped to `paths` to avoid
352/// false positives from files that legitimately contain `<<<<<<<` (e.g.
353/// test fixtures or documentation).
354///
355/// # Arguments
356/// * `repo_path` - Path to the git repository or worktree
357/// * `paths` - Relative file paths to inspect (typically the files that were
358///   involved in the current conflict)
359///
360/// # Returns
361/// The subset of `paths` whose staged content contains lines starting with
362/// `<<<<<<<`. Returns an empty list when no matches are found or when
363/// `paths` is empty.
364///
365/// # Errors
366/// Returns a [`GitError`] if `git grep` cannot be executed or exits with an
367/// unexpected error code. An exit code of `1` (no matches) is treated as
368/// success with an empty result.
369pub(crate) async fn list_staged_conflict_marker_files(
370    repo_path: PathBuf,
371    paths: Vec<String>,
372) -> Result<Vec<String>, GitError> {
373    if paths.is_empty() {
374        return Ok(vec![]);
375    }
376
377    spawn_blocking(move || -> Result<Vec<String>, GitError> {
378        let mut grep_arguments = vec!["grep", "--cached", "-l", "^<<<<<<<", "--"];
379        let path_arguments: Vec<&str> = paths.iter().map(String::as_str).collect();
380        grep_arguments.extend(path_arguments);
381        let output = run_git_command_output_sync(&repo_path, &grep_arguments)?;
382
383        // git grep exits with 1 when no matches are found.
384        let exit_code = output.status.code().unwrap_or(2);
385        if !output.status.success() && exit_code != 1 {
386            let detail = command_output_detail(&output.stdout, &output.stderr);
387
388            return Err(GitError::CommandFailed {
389                command: "git grep".to_string(),
390                stderr: format!("Failed to check for staged conflict markers: {detail}"),
391            });
392        }
393
394        let files = String::from_utf8_lossy(&output.stdout)
395            .lines()
396            .map(str::trim)
397            .filter(|line| !line.is_empty())
398            .map(ToString::to_string)
399            .collect();
400
401        Ok(files)
402    })
403    .await?
404}
405
406/// Returns conflicted file paths for the current index.
407///
408/// # Arguments
409/// * `repo_path` - Path to the git repository or worktree
410///
411/// # Returns
412/// A list of relative file paths with unresolved conflicts.
413///
414/// # Errors
415/// Returns a [`GitError`] if invoking `git diff --name-only --diff-filter=U`
416/// fails.
417pub(crate) async fn list_conflicted_files(repo_path: PathBuf) -> Result<Vec<String>, GitError> {
418    spawn_blocking(move || -> Result<Vec<String>, GitError> {
419        let output = run_git_command_sync(
420            &repo_path,
421            &["diff", "--name-only", "--diff-filter=U"],
422            "Failed to read conflicted files",
423        )?;
424        let files = output
425            .lines()
426            .map(str::trim)
427            .filter(|line| !line.is_empty())
428            .map(ToString::to_string)
429            .collect();
430
431        Ok(files)
432    })
433    .await?
434}
435
436/// Runs one rebase command and maps git output to a step result.
437fn run_rebase_step(
438    repo_path: &Path,
439    args: &[&str],
440    command: &str,
441    failure_message: impl FnOnce(&str) -> String,
442) -> Result<RebaseStepResult, GitError> {
443    let output = run_git_command_with_index_lock_retry(repo_path, args, &[])?;
444
445    if output.status.success() {
446        return Ok(RebaseStepResult::Completed);
447    }
448
449    let detail = command_output_detail(&output.stdout, &output.stderr);
450    if is_rebase_conflict(&detail) {
451        return Ok(RebaseStepResult::Conflict { detail });
452    }
453
454    Err(GitError::CommandFailed {
455        command: command.to_string(),
456        stderr: failure_message(&detail),
457    })
458}
459
460/// Runs a git command and retries when `index.lock` contention occurs.
461pub(super) fn run_git_command_with_index_lock_retry(
462    repo_path: &Path,
463    args: &[&str],
464    environment: &[(&str, &str)],
465) -> Result<Output, GitError> {
466    let command_runner = ProcessGitCommandRunner;
467    let sleeper = ThreadSleeper;
468
469    run_git_command_with_index_lock_retry_with_dependencies(
470        repo_path,
471        args,
472        environment,
473        &command_runner,
474        &sleeper,
475    )
476}
477
478/// Runs a git command with retries using injected command and sleep
479/// dependencies.
480fn run_git_command_with_index_lock_retry_with_dependencies(
481    repo_path: &Path,
482    args: &[&str],
483    environment: &[(&str, &str)],
484    command_runner: &dyn GitCommandRunner,
485    sleeper: &dyn Sleeper,
486) -> Result<Output, GitError> {
487    let args = args
488        .iter()
489        .map(|arg| String::from(*arg))
490        .collect::<Vec<_>>();
491    let environment = environment
492        .iter()
493        .map(|(key, value)| (String::from(*key), String::from(*value)))
494        .collect::<Vec<_>>();
495
496    for attempt in 0..GIT_INDEX_LOCK_RETRY_ATTEMPTS {
497        let output =
498            command_runner.run_git_command_output_with_env(repo_path, &args, &environment)?;
499        if output.status.success() {
500            return Ok(output);
501        }
502
503        let detail = command_output_detail(&output.stdout, &output.stderr);
504        let is_last_attempt = attempt + 1 == GIT_INDEX_LOCK_RETRY_ATTEMPTS;
505        if !is_git_index_lock_error(&detail) || is_last_attempt {
506            return Ok(output);
507        }
508
509        sleeper.sleep(GIT_INDEX_LOCK_RETRY_DELAY);
510    }
511
512    unreachable!("index lock retry loop should always return an output")
513}
514
515/// Returns whether git output detail indicates a rebase conflict state.
516///
517/// Matches all known git messages that signal a conflict requiring manual
518/// resolution, including messages emitted when staging partially-resolved
519/// files and attempting `git rebase --continue` prematurely.
520pub(super) fn is_rebase_conflict(detail: &str) -> bool {
521    detail.contains("CONFLICT")
522        || detail.contains("Resolve all conflicts manually")
523        || detail.contains("could not apply")
524        || detail.contains("mark them as resolved")
525        || detail.contains("unresolved conflict")
526        || detail.contains("Committing is not possible")
527}
528
529/// Returns whether abort output indicates stale or inactive rebase metadata.
530fn is_stale_or_inactive_rebase_error(detail: &str) -> bool {
531    let normalized_detail = detail.to_ascii_lowercase();
532
533    normalized_detail.contains("already a rebase-merge directory")
534        || normalized_detail.contains("already a rebase-apply directory")
535        || normalized_detail.contains("middle of another rebase")
536        || normalized_detail.contains("no rebase in progress")
537        || normalized_detail.contains("rebase-merge")
538        || normalized_detail.contains("rebase-apply")
539}
540
541/// Removes stale rebase metadata directories/files from the git directory.
542///
543/// Returns `true` when at least one stale metadata path was removed.
544///
545/// # Errors
546/// Returns a [`GitError`] when the git directory cannot be resolved or
547/// metadata cleanup fails.
548fn clean_stale_rebase_metadata(repo_path: &Path) -> Result<bool, GitError> {
549    let git_dir = resolve_git_dir(repo_path)
550        .ok_or_else(|| GitError::OutputParse("Failed to resolve git directory".to_string()))?;
551    let rebase_merge = git_dir.join("rebase-merge");
552    let rebase_apply = git_dir.join("rebase-apply");
553    let removed_rebase_merge = remove_stale_rebase_metadata_path(&rebase_merge)?;
554    let removed_rebase_apply = remove_stale_rebase_metadata_path(&rebase_apply)?;
555
556    Ok(removed_rebase_merge || removed_rebase_apply)
557}
558
559/// Removes one stale rebase metadata path and returns whether anything changed.
560///
561/// # Errors
562/// Returns a [`GitError`] when a stale metadata path exists but cannot be
563/// removed.
564fn remove_stale_rebase_metadata_path(path: &Path) -> Result<bool, GitError> {
565    if !path.exists() {
566        return Ok(false);
567    }
568
569    if path.is_dir() {
570        fs::remove_dir_all(path)?;
571
572        return Ok(true);
573    }
574
575    fs::remove_file(path)?;
576
577    Ok(true)
578}
579
580/// Returns whether git output indicates transient index lock contention.
581fn is_git_index_lock_error(detail: &str) -> bool {
582    let normalized_detail = detail.to_ascii_lowercase();
583
584    normalized_detail.contains("index.lock")
585        && (normalized_detail.contains("file exists")
586            || normalized_detail.contains("unable to create")
587            || normalized_detail.contains("another git process"))
588}
589
590#[cfg(test)]
591mod tests {
592    use std::fs;
593    use std::process::{Command, Output};
594
595    use mockall::predicate::eq;
596    use tempfile::tempdir;
597
598    use super::*;
599    use crate::MockSleeper;
600
601    #[test]
602    fn test_run_git_command_with_index_lock_retry_retries_and_sleeps_before_success() {
603        // Arrange
604        let mut command_runner = MockGitCommandRunner::new();
605        let mut sleeper = MockSleeper::new();
606        let repo_path = Path::new(".");
607        let args = ["rebase", "main"];
608        let environment: [(&str, &str); 0] = [];
609
610        command_runner
611            .expect_run_git_command_output_with_env()
612            .times(1)
613            .returning(|_, _, _| Ok(git_index_lock_output()));
614        command_runner
615            .expect_run_git_command_output_with_env()
616            .times(1)
617            .returning(|_, _, _| Ok(success_output()));
618
619        sleeper
620            .expect_sleep()
621            .with(eq(GIT_INDEX_LOCK_RETRY_DELAY))
622            .times(1)
623            .return_once(|_| {});
624
625        // Act
626        let output = run_git_command_with_index_lock_retry_with_dependencies(
627            repo_path,
628            &args,
629            &environment,
630            &command_runner,
631            &sleeper,
632        )
633        .expect("retry helper should return command output");
634
635        // Assert
636        assert!(output.status.success());
637    }
638
639    #[test]
640    fn test_run_git_command_with_index_lock_retry_passes_owned_args_and_environment() {
641        // Arrange
642        let mut command_runner = MockGitCommandRunner::new();
643        let mut sleeper = MockSleeper::new();
644        let repo_path = Path::new(".");
645        let args = ["-c", "core.editor=true", "rebase", "main"];
646        let environment = [("GIT_EDITOR", "true")];
647
648        command_runner
649            .expect_run_git_command_output_with_env()
650            .withf(|repo_path, args, environment| {
651                repo_path == Path::new(".")
652                    && args.iter().map(String::as_str).eq([
653                        "-c",
654                        "core.editor=true",
655                        "rebase",
656                        "main",
657                    ])
658                    && environment
659                        .iter()
660                        .map(|(key, value)| (key.as_str(), value.as_str()))
661                        .eq([("GIT_EDITOR", "true")])
662            })
663            .times(1)
664            .returning(|_, _, _| Ok(success_output()));
665        sleeper.expect_sleep().times(0);
666
667        // Act
668        let output = run_git_command_with_index_lock_retry_with_dependencies(
669            repo_path,
670            &args,
671            &environment,
672            &command_runner,
673            &sleeper,
674        )
675        .expect("retry helper should return command output");
676
677        // Assert
678        assert!(output.status.success());
679    }
680
681    #[test]
682    fn test_run_git_command_with_index_lock_retry_returns_last_lock_failure() {
683        // Arrange
684        let mut command_runner = MockGitCommandRunner::new();
685        let mut sleeper = MockSleeper::new();
686        let repo_path = Path::new(".");
687        let args = ["rebase", "main"];
688        let environment: [(&str, &str); 0] = [];
689
690        command_runner
691            .expect_run_git_command_output_with_env()
692            .times(GIT_INDEX_LOCK_RETRY_ATTEMPTS)
693            .returning(|_, _, _| Ok(git_index_lock_output()));
694        sleeper
695            .expect_sleep()
696            .with(eq(GIT_INDEX_LOCK_RETRY_DELAY))
697            .times(GIT_INDEX_LOCK_RETRY_ATTEMPTS - 1)
698            .returning(|_| {});
699
700        // Act
701        let output = run_git_command_with_index_lock_retry_with_dependencies(
702            repo_path,
703            &args,
704            &environment,
705            &command_runner,
706            &sleeper,
707        )
708        .expect("retry helper should return command output");
709
710        // Assert
711        assert!(!output.status.success());
712        assert!(command_output_detail(&output.stdout, &output.stderr).contains("index.lock"));
713    }
714
715    #[test]
716    fn test_run_git_command_with_index_lock_retry_returns_command_error_without_sleeping() {
717        // Arrange
718        let mut command_runner = MockGitCommandRunner::new();
719        let mut sleeper = MockSleeper::new();
720        let repo_path = Path::new(".");
721        let args = ["rebase", "main"];
722        let environment: [(&str, &str); 0] = [];
723
724        command_runner
725            .expect_run_git_command_output_with_env()
726            .times(1)
727            .return_once(|_, _, _| {
728                Err(GitError::CommandFailed {
729                    command: "git".to_string(),
730                    stderr: "git execution failed".to_string(),
731                })
732            });
733        sleeper.expect_sleep().times(0);
734
735        // Act
736        let error = run_git_command_with_index_lock_retry_with_dependencies(
737            repo_path,
738            &args,
739            &environment,
740            &command_runner,
741            &sleeper,
742        )
743        .expect_err("retry helper should surface command execution errors");
744
745        // Assert
746        assert_eq!(error.to_string(), "git: git execution failed");
747    }
748
749    #[test]
750    fn test_run_git_command_with_index_lock_retry_does_not_sleep_for_non_lock_errors() {
751        // Arrange
752        let mut command_runner = MockGitCommandRunner::new();
753        let mut sleeper = MockSleeper::new();
754        let repo_path = Path::new(".");
755        let args = ["rebase", "main"];
756        let environment: [(&str, &str); 0] = [];
757
758        command_runner
759            .expect_run_git_command_output_with_env()
760            .times(1)
761            .returning(|_, _, _| Ok(non_lock_failure_output()));
762        sleeper.expect_sleep().times(0);
763
764        // Act
765        let output = run_git_command_with_index_lock_retry_with_dependencies(
766            repo_path,
767            &args,
768            &environment,
769            &command_runner,
770            &sleeper,
771        )
772        .expect("retry helper should return command output");
773
774        // Assert
775        assert!(!output.status.success());
776    }
777
778    #[test]
779    fn test_is_rebase_conflict_matches_unmerged_files_message() {
780        // Arrange
781        let detail = "Committing is not possible because you have unmerged files.";
782
783        // Act
784        let is_conflict = is_rebase_conflict(detail);
785
786        // Assert
787        assert!(is_conflict);
788    }
789
790    #[test]
791    fn test_is_stale_or_inactive_rebase_error_matches_no_rebase_message() {
792        // Arrange
793        let detail = "fatal: No rebase in progress?";
794
795        // Act
796        let is_stale_metadata_error = is_stale_or_inactive_rebase_error(detail);
797
798        // Assert
799        assert!(is_stale_metadata_error);
800    }
801
802    #[test]
803    fn test_in_progress_operation_detects_rebase_metadata() {
804        // Arrange
805        let temp_dir = tempdir().expect("tempdir should be created");
806        let git_dir = temp_dir.path().join(".git");
807        fs::create_dir(&git_dir).expect("git dir should be created");
808        fs::create_dir(git_dir.join("rebase-merge")).expect("rebase metadata should be created");
809
810        // Act
811        let operation =
812            in_progress_operation_sync(temp_dir.path()).expect("operation should be detected");
813
814        // Assert
815        assert_eq!(operation, Some(InProgressGitOperation::Rebase));
816    }
817
818    #[test]
819    fn test_in_progress_operation_detects_merge_metadata() {
820        // Arrange
821        let temp_dir = tempdir().expect("tempdir should be created");
822        let git_dir = temp_dir.path().join(".git");
823        fs::create_dir(&git_dir).expect("git dir should be created");
824        fs::write(git_dir.join("MERGE_HEAD"), "merge").expect("merge metadata should be created");
825
826        // Act
827        let operation =
828            in_progress_operation_sync(temp_dir.path()).expect("operation should be detected");
829
830        // Assert
831        assert_eq!(operation, Some(InProgressGitOperation::Merge));
832    }
833
834    #[test]
835    fn test_in_progress_operation_detects_cherry_pick_metadata() {
836        // Arrange
837        let temp_dir = tempdir().expect("tempdir should be created");
838        let git_dir = temp_dir.path().join(".git");
839        fs::create_dir(&git_dir).expect("git dir should be created");
840        fs::write(git_dir.join("CHERRY_PICK_HEAD"), "cherry-pick")
841            .expect("cherry-pick metadata should be created");
842
843        // Act
844        let operation =
845            in_progress_operation_sync(temp_dir.path()).expect("operation should be detected");
846
847        // Assert
848        assert_eq!(operation, Some(InProgressGitOperation::CherryPick));
849    }
850
851    #[test]
852    fn test_in_progress_operation_detects_revert_metadata() {
853        // Arrange
854        let temp_dir = tempdir().expect("tempdir should be created");
855        let git_dir = temp_dir.path().join(".git");
856        fs::create_dir(&git_dir).expect("git dir should be created");
857        fs::write(git_dir.join("REVERT_HEAD"), "revert")
858            .expect("revert metadata should be created");
859
860        // Act
861        let operation =
862            in_progress_operation_sync(temp_dir.path()).expect("operation should be detected");
863
864        // Assert
865        assert_eq!(operation, Some(InProgressGitOperation::Revert));
866    }
867
868    #[test]
869    fn test_in_progress_operation_returns_none_for_clean_git_dir() {
870        // Arrange
871        let temp_dir = tempdir().expect("tempdir should be created");
872        fs::create_dir(temp_dir.path().join(".git")).expect("git dir should be created");
873
874        // Act
875        let operation =
876            in_progress_operation_sync(temp_dir.path()).expect("operation should be detected");
877
878        // Assert
879        assert_eq!(operation, None);
880    }
881
882    #[test]
883    fn test_clean_stale_rebase_metadata_removes_existing_paths() {
884        // Arrange
885        let temp_dir = tempdir().expect("tempdir should be created");
886        let git_dir = temp_dir.path().join(".git");
887        let rebase_merge = git_dir.join("rebase-merge");
888        let rebase_apply = git_dir.join("rebase-apply");
889
890        fs::create_dir(&git_dir).expect("git dir should be created");
891        fs::create_dir(&rebase_merge).expect("rebase-merge dir should be created");
892        fs::write(&rebase_apply, "apply state").expect("rebase-apply file should be created");
893
894        // Act
895        let cleaned = clean_stale_rebase_metadata(temp_dir.path())
896            .expect("stale metadata cleanup should succeed");
897
898        // Assert
899        assert!(cleaned);
900        assert!(!rebase_merge.exists());
901        assert!(!rebase_apply.exists());
902    }
903
904    /// Returns a successful git command output.
905    fn success_output() -> Output {
906        Command::new("git")
907            .arg("--version")
908            .output()
909            .expect("failed to run git --version")
910    }
911
912    /// Returns a failing git command output that matches index lock contention.
913    fn git_index_lock_output() -> Output {
914        let mut output = Command::new("git")
915            .arg("definitely-invalid-subcommand")
916            .output()
917            .expect("failed to run git invalid command");
918        output.stdout = vec![];
919        output.stderr = b"fatal: Unable to create '.git/index.lock': File exists.".to_vec();
920
921        output
922    }
923
924    /// Returns a failing git command output that is unrelated to index locking.
925    fn non_lock_failure_output() -> Output {
926        let mut output = Command::new("git")
927            .arg("definitely-invalid-subcommand")
928            .output()
929            .expect("failed to run git invalid command");
930        output.stdout = vec![];
931        output.stderr = b"fatal: not a git repository".to_vec();
932
933        output
934    }
935}