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