Skip to main content

ag_git/
rebase.rs

1use std::fs;
2use std::io::ErrorKind;
3use std::path::{Path, PathBuf};
4use std::process::Output;
5use std::time::Duration;
6
7use tokio::task::spawn_blocking;
8
9use super::error::GitError;
10use super::repo::{
11    command_output_detail, resolve_git_dir, run_git_command_output_sync,
12    run_git_command_output_with_env_sync, run_git_command_sync,
13};
14use crate::{Sleeper, ThreadSleeper};
15
16/// Allow five seconds of waiting for an in-flight index writer to finish.
17pub(super) const GIT_INDEX_LOCK_RETRY_ATTEMPTS: usize = 21;
18pub(super) const GIT_INDEX_LOCK_RETRY_DELAY: Duration = Duration::from_millis(250);
19
20/// Executes git commands for rebase operations.
21#[cfg_attr(test, mockall::automock)]
22trait GitCommandRunner: Send + Sync {
23    /// Runs a git command in `repo_path` with environment overrides.
24    fn run_git_command_output_with_env(
25        &self,
26        repo_path: &Path,
27        args: &[String],
28        environment: &[(String, String)],
29    ) -> Result<Output, GitError>;
30}
31
32/// Removes stale rebase metadata through an injectable filesystem boundary.
33#[cfg_attr(test, mockall::automock)]
34trait RebaseMetadataCleaner: Send + Sync {
35    /// Removes exact rebase metadata entries under the resolved git directory.
36    fn clean_stale_metadata(&self, repo_path: &Path) -> Result<bool, GitError>;
37}
38
39/// Rebase metadata cleaner backed by the local filesystem.
40struct FilesystemRebaseMetadataCleaner;
41
42impl RebaseMetadataCleaner for FilesystemRebaseMetadataCleaner {
43    fn clean_stale_metadata(&self, repo_path: &Path) -> Result<bool, GitError> {
44        clean_stale_rebase_metadata(repo_path)
45    }
46}
47
48/// Git command runner backed by process execution.
49struct ProcessGitCommandRunner;
50
51impl GitCommandRunner for ProcessGitCommandRunner {
52    fn run_git_command_output_with_env(
53        &self,
54        repo_path: &Path,
55        args: &[String],
56        environment: &[(String, String)],
57    ) -> Result<Output, GitError> {
58        let args = args.iter().map(String::as_str).collect::<Vec<_>>();
59        let environment = environment
60            .iter()
61            .map(|(key, value)| (key.as_str(), value.as_str()))
62            .collect::<Vec<_>>();
63
64        run_git_command_output_with_env_sync(repo_path, &args, &environment)
65    }
66}
67
68/// Result of attempting a rebase step.
69#[derive(Clone, Debug, Eq, PartialEq)]
70pub enum RebaseStepResult {
71    /// Rebase step completed successfully.
72    Completed,
73    /// Rebase step stopped because of merge conflicts.
74    Conflict {
75        /// Git diagnostic describing the conflict state.
76        detail: String,
77    },
78}
79
80/// Git operation metadata that marks a worktree as unsafe for branch pushes.
81#[derive(Clone, Copy, Debug, Eq, PartialEq)]
82pub enum InProgressGitOperation {
83    /// A cherry-pick is in progress.
84    CherryPick,
85    /// A merge is in progress.
86    Merge,
87    /// A rebase is in progress.
88    Rebase,
89    /// A revert is in progress.
90    Revert,
91}
92
93impl InProgressGitOperation {
94    /// Returns an indefinite article plus the operation name for user-facing
95    /// status text.
96    pub fn article_name(self) -> &'static str {
97        match self {
98            Self::CherryPick => "a cherry-pick",
99            Self::Merge => "a merge",
100            Self::Rebase => "a rebase",
101            Self::Revert => "a revert",
102        }
103    }
104
105    /// Returns the operation name for user-facing status text.
106    pub fn name(self) -> &'static str {
107        match self {
108            Self::CherryPick => "cherry-pick",
109            Self::Merge => "merge",
110            Self::Rebase => "rebase",
111            Self::Revert => "revert",
112        }
113    }
114}
115
116/// Rebases the current branch onto `target_branch`.
117///
118/// If the rebase fails due to conflict, this function aborts it immediately so
119/// the repository does not remain in an in-progress rebase state.
120///
121/// # Arguments
122/// * `repo_path` - Path to the git repository or worktree
123/// * `target_branch` - Branch to rebase onto (e.g., `main`)
124///
125/// # Returns
126/// Ok(()) on success.
127///
128/// # Errors
129/// Returns a [`GitError`] if rebase fails, or aborting a conflicted rebase
130/// also fails.
131pub(crate) async fn rebase(repo_path: PathBuf, target_branch: String) -> Result<(), GitError> {
132    match rebase_start(repo_path.clone(), target_branch.clone()).await? {
133        RebaseStepResult::Completed => Ok(()),
134        RebaseStepResult::Conflict { detail } => {
135            let abort_suffix = match abort_rebase(repo_path).await {
136                Ok(()) => String::new(),
137                Err(error) => format!(" {error}"),
138            };
139
140            Err(GitError::CommandFailed {
141                command: "git rebase".to_string(),
142                stderr: format!("Failed to rebase onto {target_branch}: {detail}.{abort_suffix}"),
143            })
144        }
145    }
146}
147
148/// Rebases the current branch onto `target_branch`.
149///
150/// Returns a conflict outcome when the rebase stops for manual resolution.
151///
152/// # Arguments
153/// * `repo_path` - Path to the git repository or worktree
154/// * `target_branch` - Branch to rebase onto (e.g., `main`)
155///
156/// # Returns
157/// A [`RebaseStepResult`] describing whether the rebase completed or
158/// encountered conflicts.
159///
160/// # Errors
161/// Returns a [`GitError`] for non-conflict git failures.
162pub(crate) async fn rebase_start(
163    repo_path: PathBuf,
164    target_branch: String,
165) -> Result<RebaseStepResult, GitError> {
166    spawn_blocking(move || {
167        let rebase_args = ["rebase", target_branch.as_str()];
168        run_rebase_step(&repo_path, &rebase_args, "git rebase", |detail| {
169            format!("Failed to rebase onto {target_branch}: {detail}.")
170        })
171    })
172    .await?
173}
174
175/// Starts a rebase that moves commits after `old_base` onto `new_base`.
176///
177/// This is used for stacked sessions to drop commits that came from a parent
178/// branch after that parent has moved or squash-merged into its own base.
179///
180/// # Arguments
181/// * `repo_path` - Path to the git repository or worktree.
182/// * `new_base` - Ref that should become the new base of replayed commits.
183/// * `old_base` - Commit/ref whose ancestors should be left behind.
184///
185/// # Returns
186/// A [`RebaseStepResult`] describing whether the rebase completed or
187/// encountered conflicts.
188///
189/// # Errors
190/// Returns a [`GitError`] for non-conflict git failures.
191pub(crate) async fn rebase_onto_start(
192    repo_path: PathBuf,
193    new_base: String,
194    old_base: String,
195) -> Result<RebaseStepResult, GitError> {
196    spawn_blocking(move || {
197        let rebase_args = ["rebase", "--onto", new_base.as_str(), old_base.as_str()];
198        run_rebase_step(&repo_path, &rebase_args, "git rebase --onto", |detail| {
199            format!("Failed to rebase onto {new_base} after {old_base}: {detail}.")
200        })
201    })
202    .await?
203}
204
205/// Continues an in-progress rebase.
206///
207/// # Arguments
208/// * `repo_path` - Path to the git repository or worktree
209///
210/// # Returns
211/// A [`RebaseStepResult`] describing whether the rebase completed or
212/// encountered conflicts.
213///
214/// # Errors
215/// Returns a [`GitError`] for non-conflict git failures.
216pub(crate) async fn rebase_continue(repo_path: PathBuf) -> Result<RebaseStepResult, GitError> {
217    spawn_blocking(move || {
218        let output = run_git_command_with_index_lock_retry(
219            &repo_path,
220            &["rebase", "--continue"],
221            &[("GIT_EDITOR", ":"), ("GIT_SEQUENCE_EDITOR", ":")],
222        )?;
223
224        if output.status.success() {
225            return Ok(RebaseStepResult::Completed);
226        }
227
228        let detail = command_output_detail(&output.stdout, &output.stderr);
229        if is_rebase_conflict(&detail) {
230            return Ok(RebaseStepResult::Conflict { detail });
231        }
232
233        Err(GitError::CommandFailed {
234            command: "git rebase --continue".to_string(),
235            stderr: format!("Failed to continue rebase: {detail}."),
236        })
237    })
238    .await?
239}
240
241/// Aborts an in-progress rebase.
242///
243/// When Git reports a known stale or inactive rebase state, this removes only
244/// `rebase-merge` and `rebase-apply` under the resolved git directory. Other
245/// failures are returned unchanged with their command output.
246///
247/// # Arguments
248/// * `repo_path` - Path to the git repository or worktree
249///
250/// # Returns
251/// Ok(()) on success.
252///
253/// # Errors
254/// Returns a [`GitError`] when `git rebase --abort` cannot be executed.
255pub(crate) async fn abort_rebase(repo_path: PathBuf) -> Result<(), GitError> {
256    spawn_blocking(move || {
257        let command_runner = ProcessGitCommandRunner;
258        let metadata_cleaner = FilesystemRebaseMetadataCleaner;
259        let sleeper = ThreadSleeper;
260
261        abort_rebase_with_dependencies(&repo_path, &command_runner, &sleeper, &metadata_cleaner)
262    })
263    .await?
264}
265
266/// Returns whether a rebase is currently in progress in the repository or
267/// worktree.
268///
269/// # Arguments
270/// * `repo_path` - Path to the git repository or worktree
271///
272/// # Returns
273/// `true` when `.git/rebase-merge` or `.git/rebase-apply` exists, `false`
274/// otherwise.
275///
276/// # Errors
277/// Returns a [`GitError`] when the git directory cannot be resolved.
278pub(crate) async fn is_rebase_in_progress(repo_path: PathBuf) -> Result<bool, GitError> {
279    spawn_blocking(move || -> Result<bool, GitError> {
280        let git_dir = resolve_git_dir(&repo_path)
281            .ok_or_else(|| GitError::OutputParse("Failed to resolve git directory".to_string()))?;
282
283        Ok(has_rebase_metadata(&git_dir))
284    })
285    .await?
286}
287
288/// Returns the first detected in-progress git operation in `repo_path`.
289///
290/// # Arguments
291/// * `repo_path` - Path to the git repository or worktree
292///
293/// # Returns
294/// An operation when rebase, merge, cherry-pick, or revert metadata exists.
295///
296/// # Errors
297/// Returns a [`GitError`] when the git directory cannot be resolved.
298pub(crate) async fn in_progress_operation(
299    repo_path: PathBuf,
300) -> Result<Option<InProgressGitOperation>, GitError> {
301    spawn_blocking(move || in_progress_operation_sync(&repo_path)).await?
302}
303
304fn in_progress_operation_sync(
305    repo_path: &Path,
306) -> Result<Option<InProgressGitOperation>, GitError> {
307    let git_dir = resolve_git_dir(repo_path)
308        .ok_or_else(|| GitError::OutputParse("Failed to resolve git directory".to_string()))?;
309    if has_rebase_metadata(&git_dir) {
310        return Ok(Some(InProgressGitOperation::Rebase));
311    }
312    if git_dir.join("MERGE_HEAD").exists() {
313        return Ok(Some(InProgressGitOperation::Merge));
314    }
315    if git_dir.join("CHERRY_PICK_HEAD").exists() {
316        return Ok(Some(InProgressGitOperation::CherryPick));
317    }
318    if git_dir.join("REVERT_HEAD").exists() {
319        return Ok(Some(InProgressGitOperation::Revert));
320    }
321
322    Ok(None)
323}
324
325fn has_rebase_metadata(git_dir: &Path) -> bool {
326    let rebase_merge = git_dir.join("rebase-merge");
327    let rebase_apply = git_dir.join("rebase-apply");
328
329    rebase_merge.exists() || rebase_apply.exists()
330}
331
332/// Returns whether unresolved paths still exist in the index.
333///
334/// # Arguments
335/// * `repo_path` - Path to the git repository or worktree
336///
337/// # Returns
338/// `true` when unresolved paths exist, `false` otherwise.
339///
340/// # Errors
341/// Returns a [`GitError`] when conflicted files cannot be queried.
342pub(crate) async fn has_unmerged_paths(repo_path: PathBuf) -> Result<bool, GitError> {
343    let conflicted_files = list_conflicted_files(repo_path).await?;
344
345    Ok(!conflicted_files.is_empty())
346}
347
348/// Returns which of the given `paths` still contain git conflict markers
349/// (`<<<<<<<`) in their staged content.
350///
351/// Uses `git grep --cached -l` to search indexed content directly, so it
352/// detects files that were staged via `git add` while still containing
353/// unresolved conflict markers. The search is scoped to `paths` to avoid
354/// false positives from files that legitimately contain `<<<<<<<` (e.g.
355/// test fixtures or documentation).
356///
357/// # Arguments
358/// * `repo_path` - Path to the git repository or worktree
359/// * `paths` - Relative file paths to inspect (typically the files that were
360///   involved in the current conflict)
361///
362/// # Returns
363/// The subset of `paths` whose staged content contains lines starting with
364/// `<<<<<<<`. Returns an empty list when no matches are found or when
365/// `paths` is empty.
366///
367/// # Errors
368/// Returns a [`GitError`] if `git grep` cannot be executed or exits with an
369/// unexpected error code. An exit code of `1` (no matches) is treated as
370/// success with an empty result.
371pub(crate) async fn list_staged_conflict_marker_files(
372    repo_path: PathBuf,
373    paths: Vec<String>,
374) -> Result<Vec<String>, GitError> {
375    if paths.is_empty() {
376        return Ok(vec![]);
377    }
378
379    spawn_blocking(move || -> Result<Vec<String>, GitError> {
380        let mut grep_arguments = vec!["grep", "--cached", "-l", "^<<<<<<<", "--"];
381        let path_arguments: Vec<&str> = paths.iter().map(String::as_str).collect();
382        grep_arguments.extend(path_arguments);
383        let output = run_git_command_output_sync(&repo_path, &grep_arguments)?;
384
385        // git grep exits with 1 when no matches are found.
386        let exit_code = output.status.code().unwrap_or(2);
387        if !output.status.success() && exit_code != 1 {
388            let detail = command_output_detail(&output.stdout, &output.stderr);
389
390            return Err(GitError::CommandFailed {
391                command: "git grep".to_string(),
392                stderr: format!("Failed to check for staged conflict markers: {detail}"),
393            });
394        }
395
396        let files = String::from_utf8_lossy(&output.stdout)
397            .lines()
398            .map(str::trim)
399            .filter(|line| !line.is_empty())
400            .map(ToString::to_string)
401            .collect();
402
403        Ok(files)
404    })
405    .await?
406}
407
408/// Returns conflicted file paths for the current index.
409///
410/// # Arguments
411/// * `repo_path` - Path to the git repository or worktree
412///
413/// # Returns
414/// A list of relative file paths with unresolved conflicts.
415///
416/// # Errors
417/// Returns a [`GitError`] if invoking `git diff --name-only --diff-filter=U`
418/// fails.
419pub(crate) async fn list_conflicted_files(repo_path: PathBuf) -> Result<Vec<String>, GitError> {
420    spawn_blocking(move || -> Result<Vec<String>, GitError> {
421        let output = run_git_command_sync(
422            &repo_path,
423            &["diff", "--name-only", "--diff-filter=U"],
424            "Failed to read conflicted files",
425        )?;
426        let files = output
427            .lines()
428            .map(str::trim)
429            .filter(|line| !line.is_empty())
430            .map(ToString::to_string)
431            .collect();
432
433        Ok(files)
434    })
435    .await?
436}
437
438/// Runs one rebase command and maps git output to a step result.
439fn run_rebase_step(
440    repo_path: &Path,
441    args: &[&str],
442    command: &str,
443    failure_message: impl FnOnce(&str) -> String,
444) -> Result<RebaseStepResult, GitError> {
445    let output = run_git_command_with_index_lock_retry(repo_path, args, &[])?;
446
447    if output.status.success() {
448        return Ok(RebaseStepResult::Completed);
449    }
450
451    let detail = command_output_detail(&output.stdout, &output.stderr);
452    if is_rebase_conflict(&detail) {
453        return Ok(RebaseStepResult::Conflict { detail });
454    }
455
456    Err(GitError::CommandFailed {
457        command: command.to_string(),
458        stderr: failure_message(&detail),
459    })
460}
461
462/// Aborts one rebase through injected process and retry boundaries.
463fn abort_rebase_with_dependencies(
464    repo_path: &Path,
465    command_runner: &dyn GitCommandRunner,
466    sleeper: &dyn Sleeper,
467    metadata_cleaner: &dyn RebaseMetadataCleaner,
468) -> Result<(), GitError> {
469    let output = run_git_command_with_index_lock_retry_with_dependencies(
470        repo_path,
471        &["rebase", "--abort"],
472        &[],
473        command_runner,
474        sleeper,
475    )?;
476    if !output.status.success() {
477        let detail = command_output_detail(&output.stdout, &output.stderr);
478        if is_stale_or_inactive_rebase_error(&detail) {
479            match metadata_cleaner.clean_stale_metadata(repo_path) {
480                Ok(true) => return Ok(()),
481                Ok(false) => {}
482                Err(cleanup_error) => {
483                    return Err(GitError::CommandFailed {
484                        command: "git rebase --abort".to_string(),
485                        stderr: format!(
486                            "Failed to abort rebase: {detail}. Stale rebase metadata cleanup \
487                             failed: {cleanup_error}."
488                        ),
489                    });
490                }
491            }
492        }
493
494        return Err(GitError::CommandFailed {
495            command: "git rebase --abort".to_string(),
496            stderr: format!("Failed to abort rebase: {detail}."),
497        });
498    }
499
500    Ok(())
501}
502
503/// Returns whether abort output identifies a known stale or inactive rebase.
504fn is_stale_or_inactive_rebase_error(detail: &str) -> bool {
505    let normalized_detail = detail.to_ascii_lowercase();
506
507    normalized_detail.contains("no rebase in progress")
508        || normalized_detail.contains("already a rebase-merge directory")
509        || normalized_detail.contains("already a rebase-apply directory")
510        || normalized_detail.contains("middle of another rebase")
511}
512
513/// Removes exact stale rebase metadata entries from the resolved git directory.
514fn clean_stale_rebase_metadata(repo_path: &Path) -> Result<bool, GitError> {
515    let git_dir = resolve_git_dir(repo_path)
516        .ok_or_else(|| GitError::OutputParse("Failed to resolve git directory".to_string()))?;
517    let removed_rebase_merge = remove_stale_rebase_metadata_path(&git_dir.join("rebase-merge"))?;
518    let removed_rebase_apply = remove_stale_rebase_metadata_path(&git_dir.join("rebase-apply"))?;
519
520    Ok(removed_rebase_merge || removed_rebase_apply)
521}
522
523/// Removes one exact metadata path without following directory symlinks.
524fn remove_stale_rebase_metadata_path(path: &Path) -> Result<bool, GitError> {
525    let metadata = match fs::symlink_metadata(path) {
526        Ok(metadata) => metadata,
527        Err(error) if error.kind() == ErrorKind::NotFound => return Ok(false),
528        Err(error) => return Err(error.into()),
529    };
530
531    if metadata.file_type().is_dir() {
532        fs::remove_dir_all(path)?;
533    } else {
534        fs::remove_file(path)?;
535    }
536
537    Ok(true)
538}
539
540/// Runs a git command and retries when `index.lock` contention occurs.
541pub(super) fn run_git_command_with_index_lock_retry(
542    repo_path: &Path,
543    args: &[&str],
544    environment: &[(&str, &str)],
545) -> Result<Output, GitError> {
546    let command_runner = ProcessGitCommandRunner;
547    let sleeper = ThreadSleeper;
548
549    run_git_command_with_index_lock_retry_with_dependencies(
550        repo_path,
551        args,
552        environment,
553        &command_runner,
554        &sleeper,
555    )
556}
557
558/// Runs a git command with retries using injected command and sleep
559/// dependencies.
560fn run_git_command_with_index_lock_retry_with_dependencies(
561    repo_path: &Path,
562    args: &[&str],
563    environment: &[(&str, &str)],
564    command_runner: &dyn GitCommandRunner,
565    sleeper: &dyn Sleeper,
566) -> Result<Output, GitError> {
567    let args = args
568        .iter()
569        .map(|arg| String::from(*arg))
570        .collect::<Vec<_>>();
571    let environment = environment
572        .iter()
573        .map(|(key, value)| (String::from(*key), String::from(*value)))
574        .collect::<Vec<_>>();
575
576    for attempt in 0..GIT_INDEX_LOCK_RETRY_ATTEMPTS {
577        let output =
578            command_runner.run_git_command_output_with_env(repo_path, &args, &environment)?;
579        if output.status.success() {
580            return Ok(output);
581        }
582
583        let detail = command_output_detail(&output.stdout, &output.stderr);
584        let is_last_attempt = attempt + 1 == GIT_INDEX_LOCK_RETRY_ATTEMPTS;
585        if !is_git_index_lock_error(&detail) || is_last_attempt {
586            return Ok(output);
587        }
588
589        sleeper.sleep(GIT_INDEX_LOCK_RETRY_DELAY);
590    }
591
592    unreachable!("index lock retry loop should always return an output")
593}
594
595/// Returns whether git output detail indicates a rebase conflict state.
596///
597/// Matches all known git messages that signal a conflict requiring manual
598/// resolution, including messages emitted when staging partially-resolved
599/// files and attempting `git rebase --continue` prematurely.
600pub(super) fn is_rebase_conflict(detail: &str) -> bool {
601    detail.contains("CONFLICT")
602        || detail.contains("Resolve all conflicts manually")
603        || detail.contains("could not apply")
604        || detail.contains("mark them as resolved")
605        || detail.contains("unresolved conflict")
606        || detail.contains("Committing is not possible")
607}
608
609/// Returns whether git output indicates transient index lock contention.
610pub(super) fn is_git_index_lock_error(detail: &str) -> bool {
611    let normalized_detail = detail.to_ascii_lowercase();
612
613    normalized_detail.contains("index.lock")
614        && (normalized_detail.contains("file exists")
615            || normalized_detail.contains("another git process"))
616}
617
618#[cfg(test)]
619mod tests {
620    use std::fs;
621    #[cfg(unix)]
622    use std::os::unix::fs as unix_fs;
623    use std::process::{Command, Output};
624
625    use mockall::predicate::eq;
626    use tempfile::tempdir;
627
628    use super::*;
629    use crate::MockSleeper;
630
631    #[test]
632    fn test_run_git_command_with_index_lock_retry_retries_and_sleeps_before_success() {
633        // Arrange
634        let mut command_runner = MockGitCommandRunner::new();
635        let mut sleeper = MockSleeper::new();
636        let repo_path = Path::new(".");
637        let args = ["rebase", "main"];
638        let environment: [(&str, &str); 0] = [];
639
640        command_runner
641            .expect_run_git_command_output_with_env()
642            .times(1)
643            .returning(|_, _, _| Ok(git_index_lock_output()));
644        command_runner
645            .expect_run_git_command_output_with_env()
646            .times(1)
647            .returning(|_, _, _| Ok(success_output()));
648
649        sleeper
650            .expect_sleep()
651            .with(eq(GIT_INDEX_LOCK_RETRY_DELAY))
652            .times(1)
653            .return_once(|_| {});
654
655        // Act
656        let output = run_git_command_with_index_lock_retry_with_dependencies(
657            repo_path,
658            &args,
659            &environment,
660            &command_runner,
661            &sleeper,
662        )
663        .expect("retry helper should return command output");
664
665        // Assert
666        assert!(output.status.success());
667    }
668
669    #[test]
670    fn test_run_git_command_with_index_lock_retry_passes_owned_args_and_environment() {
671        // Arrange
672        let mut command_runner = MockGitCommandRunner::new();
673        let mut sleeper = MockSleeper::new();
674        let repo_path = Path::new(".");
675        let args = ["-c", "core.editor=true", "rebase", "main"];
676        let environment = [("GIT_EDITOR", "true")];
677
678        command_runner
679            .expect_run_git_command_output_with_env()
680            .withf(|repo_path, args, environment| {
681                repo_path == Path::new(".")
682                    && args.iter().map(String::as_str).eq([
683                        "-c",
684                        "core.editor=true",
685                        "rebase",
686                        "main",
687                    ])
688                    && environment
689                        .iter()
690                        .map(|(key, value)| (key.as_str(), value.as_str()))
691                        .eq([("GIT_EDITOR", "true")])
692            })
693            .times(1)
694            .returning(|_, _, _| Ok(success_output()));
695        sleeper.expect_sleep().times(0);
696
697        // Act
698        let output = run_git_command_with_index_lock_retry_with_dependencies(
699            repo_path,
700            &args,
701            &environment,
702            &command_runner,
703            &sleeper,
704        )
705        .expect("retry helper should return command output");
706
707        // Assert
708        assert!(output.status.success());
709    }
710
711    #[test]
712    fn test_run_git_command_with_index_lock_retry_returns_last_lock_failure() {
713        // Arrange
714        let mut command_runner = MockGitCommandRunner::new();
715        let mut sleeper = MockSleeper::new();
716        let repo_path = Path::new(".");
717        let args = ["rebase", "main"];
718        let environment: [(&str, &str); 0] = [];
719
720        command_runner
721            .expect_run_git_command_output_with_env()
722            .times(GIT_INDEX_LOCK_RETRY_ATTEMPTS)
723            .returning(|_, _, _| Ok(git_index_lock_output()));
724        sleeper
725            .expect_sleep()
726            .with(eq(GIT_INDEX_LOCK_RETRY_DELAY))
727            .times(GIT_INDEX_LOCK_RETRY_ATTEMPTS - 1)
728            .returning(|_| {});
729
730        // Act
731        let output = run_git_command_with_index_lock_retry_with_dependencies(
732            repo_path,
733            &args,
734            &environment,
735            &command_runner,
736            &sleeper,
737        )
738        .expect("retry helper should return command output");
739
740        // Assert
741        assert!(!output.status.success());
742        assert!(command_output_detail(&output.stdout, &output.stderr).contains("index.lock"));
743    }
744
745    #[test]
746    fn test_run_git_command_with_index_lock_retry_returns_command_error_without_sleeping() {
747        // Arrange
748        let mut command_runner = MockGitCommandRunner::new();
749        let mut sleeper = MockSleeper::new();
750        let repo_path = Path::new(".");
751        let args = ["rebase", "main"];
752        let environment: [(&str, &str); 0] = [];
753
754        command_runner
755            .expect_run_git_command_output_with_env()
756            .times(1)
757            .return_once(|_, _, _| {
758                Err(GitError::CommandFailed {
759                    command: "git".to_string(),
760                    stderr: "git execution failed".to_string(),
761                })
762            });
763        sleeper.expect_sleep().times(0);
764
765        // Act
766        let error = run_git_command_with_index_lock_retry_with_dependencies(
767            repo_path,
768            &args,
769            &environment,
770            &command_runner,
771            &sleeper,
772        )
773        .expect_err("retry helper should surface command execution errors");
774
775        // Assert
776        assert_eq!(error.to_string(), "git: git execution failed");
777    }
778
779    #[test]
780    fn test_run_git_command_with_index_lock_retry_does_not_sleep_for_non_lock_errors() {
781        // Arrange
782        let mut command_runner = MockGitCommandRunner::new();
783        let mut sleeper = MockSleeper::new();
784        let repo_path = Path::new(".");
785        let args = ["rebase", "main"];
786        let environment: [(&str, &str); 0] = [];
787
788        command_runner
789            .expect_run_git_command_output_with_env()
790            .times(1)
791            .returning(|_, _, _| Ok(non_lock_failure_output()));
792        sleeper.expect_sleep().times(0);
793
794        // Act
795        let output = run_git_command_with_index_lock_retry_with_dependencies(
796            repo_path,
797            &args,
798            &environment,
799            &command_runner,
800            &sleeper,
801        )
802        .expect("retry helper should return command output");
803
804        // Assert
805        assert!(!output.status.success());
806    }
807
808    #[test]
809    fn test_is_rebase_conflict_matches_unmerged_files_message() {
810        // Arrange
811        let detail = "Committing is not possible because you have unmerged files.";
812
813        // Act
814        let is_conflict = is_rebase_conflict(detail);
815
816        // Assert
817        assert!(is_conflict);
818    }
819
820    #[test]
821    fn abort_rebase_succeeds_through_injected_boundaries() {
822        // Arrange
823        let mut command_runner = MockGitCommandRunner::new();
824        let metadata_cleaner = MockRebaseMetadataCleaner::new();
825        let mut sleeper = MockSleeper::new();
826        command_runner
827            .expect_run_git_command_output_with_env()
828            .withf(|repo_path, args, environment| {
829                repo_path == Path::new("session-worktree")
830                    && args == ["rebase", "--abort"]
831                    && environment.is_empty()
832            })
833            .once()
834            .returning(|_, _, _| Ok(success_output()));
835        sleeper.expect_sleep().times(0);
836
837        // Act
838        let result = abort_rebase_with_dependencies(
839            Path::new("session-worktree"),
840            &command_runner,
841            &sleeper,
842            &metadata_cleaner,
843        );
844
845        // Assert
846        assert!(result.is_ok());
847    }
848
849    #[test]
850    fn abort_rebase_preserves_command_runner_error() {
851        // Arrange
852        let mut command_runner = MockGitCommandRunner::new();
853        let metadata_cleaner = MockRebaseMetadataCleaner::new();
854        let mut sleeper = MockSleeper::new();
855        command_runner
856            .expect_run_git_command_output_with_env()
857            .once()
858            .return_once(|_, _, _| {
859                Err(GitError::CommandFailed {
860                    command: "git rebase --abort".to_string(),
861                    stderr: "failed to spawn git".to_string(),
862                })
863            });
864        sleeper.expect_sleep().times(0);
865
866        // Act
867        let error = abort_rebase_with_dependencies(
868            Path::new("session-worktree"),
869            &command_runner,
870            &sleeper,
871            &metadata_cleaner,
872        )
873        .expect_err("command runner failure should be preserved");
874
875        // Assert
876        assert!(matches!(
877            error,
878            GitError::CommandFailed { command, stderr }
879                if command == "git rebase --abort" && stderr == "failed to spawn git"
880        ));
881    }
882
883    #[test]
884    fn abort_rebase_preserves_actionable_command_failure() {
885        // Arrange
886        let mut command_runner = MockGitCommandRunner::new();
887        let metadata_cleaner = MockRebaseMetadataCleaner::new();
888        let mut sleeper = MockSleeper::new();
889        command_runner
890            .expect_run_git_command_output_with_env()
891            .once()
892            .returning(|_, _, _| {
893                let mut output = non_lock_failure_output();
894                output.stderr = b"fatal: cannot open .git/rebase-merge/head-name".to_vec();
895
896                Ok(output)
897            });
898        sleeper.expect_sleep().times(0);
899
900        // Act
901        let error = abort_rebase_with_dependencies(
902            Path::new("session-worktree"),
903            &command_runner,
904            &sleeper,
905            &metadata_cleaner,
906        )
907        .expect_err("failed abort should preserve the git error");
908
909        // Assert
910        assert!(matches!(
911            error,
912            GitError::CommandFailed { command, stderr }
913                if command == "git rebase --abort"
914                    && stderr.contains(".git/rebase-merge/head-name")
915        ));
916    }
917
918    #[test]
919    fn abort_rebase_recovers_when_stale_metadata_is_removed() {
920        // Arrange
921        let temp_dir = tempdir().expect("tempdir should be created");
922        let git_dir = temp_dir.path().join(".git");
923        let stale_metadata = git_dir.join("rebase-merge");
924        fs::create_dir(&git_dir).expect("git dir should be created");
925        fs::create_dir(&stale_metadata).expect("stale metadata should be created");
926        let mut command_runner = MockGitCommandRunner::new();
927        let metadata_cleaner = FilesystemRebaseMetadataCleaner;
928        let mut sleeper = MockSleeper::new();
929        command_runner
930            .expect_run_git_command_output_with_env()
931            .once()
932            .returning(|_, _, _| Ok(stale_rebase_failure_output()));
933        sleeper.expect_sleep().times(0);
934
935        // Act
936        let result = abort_rebase_with_dependencies(
937            temp_dir.path(),
938            &command_runner,
939            &sleeper,
940            &metadata_cleaner,
941        );
942
943        // Assert
944        assert!(result.is_ok());
945        assert!(!stale_metadata.exists());
946    }
947
948    #[test]
949    fn abort_rebase_preserves_stale_error_when_no_metadata_is_removed() {
950        // Arrange
951        let mut command_runner = MockGitCommandRunner::new();
952        let mut metadata_cleaner = MockRebaseMetadataCleaner::new();
953        let mut sleeper = MockSleeper::new();
954        command_runner
955            .expect_run_git_command_output_with_env()
956            .once()
957            .returning(|_, _, _| Ok(stale_rebase_failure_output()));
958        metadata_cleaner
959            .expect_clean_stale_metadata()
960            .once()
961            .returning(|_| Ok(false));
962        sleeper.expect_sleep().times(0);
963
964        // Act
965        let error = abort_rebase_with_dependencies(
966            Path::new("session-worktree"),
967            &command_runner,
968            &sleeper,
969            &metadata_cleaner,
970        )
971        .expect_err("missing stale metadata should preserve the abort failure");
972
973        // Assert
974        assert!(matches!(
975            error,
976            GitError::CommandFailed { command, stderr }
977                if command == "git rebase --abort" && stderr.contains("No rebase in progress")
978        ));
979    }
980
981    #[test]
982    fn abort_rebase_appends_stale_metadata_cleanup_failure() {
983        // Arrange
984        let mut command_runner = MockGitCommandRunner::new();
985        let mut metadata_cleaner = MockRebaseMetadataCleaner::new();
986        let mut sleeper = MockSleeper::new();
987        command_runner
988            .expect_run_git_command_output_with_env()
989            .once()
990            .returning(|_, _, _| Ok(stale_rebase_failure_output()));
991        metadata_cleaner
992            .expect_clean_stale_metadata()
993            .once()
994            .returning(|_| {
995                Err(GitError::Io(std::io::Error::new(
996                    ErrorKind::PermissionDenied,
997                    "metadata is read-only",
998                )))
999            });
1000        sleeper.expect_sleep().times(0);
1001
1002        // Act
1003        let error = abort_rebase_with_dependencies(
1004            Path::new("session-worktree"),
1005            &command_runner,
1006            &sleeper,
1007            &metadata_cleaner,
1008        )
1009        .expect_err("cleanup failure should preserve both error contexts");
1010
1011        // Assert
1012        assert!(matches!(
1013            error,
1014            GitError::CommandFailed { command, stderr }
1015                if command == "git rebase --abort"
1016                    && stderr.contains("No rebase in progress")
1017                    && stderr.contains("metadata is read-only")
1018        ));
1019    }
1020
1021    #[test]
1022    fn stale_rebase_error_detection_matches_only_known_diagnostics() {
1023        // Arrange
1024        let stale_diagnostics = [
1025            "fatal: No rebase in progress?",
1026            "fatal: It seems that there is already a rebase-merge directory",
1027            "fatal: It seems that there is already a rebase-apply directory",
1028            "fatal: It seems that I cannot tell whether you are in the middle of another rebase",
1029        ];
1030
1031        // Act
1032        let stale_results = stale_diagnostics.map(is_stale_or_inactive_rebase_error);
1033        let unrelated_result =
1034            is_stale_or_inactive_rebase_error("fatal: cannot read rebase-merge/head-name");
1035
1036        // Assert
1037        assert!(stale_results.into_iter().all(|is_stale| is_stale));
1038        assert!(!unrelated_result);
1039    }
1040
1041    #[test]
1042    fn filesystem_metadata_cleaner_removes_exact_rebase_entries() {
1043        // Arrange
1044        let temp_dir = tempdir().expect("tempdir should be created");
1045        let git_dir = temp_dir.path().join(".git");
1046        let rebase_merge = git_dir.join("rebase-merge");
1047        let rebase_apply = git_dir.join("rebase-apply");
1048        let unrelated_metadata = git_dir.join("MERGE_HEAD");
1049        fs::create_dir(&git_dir).expect("git dir should be created");
1050        fs::create_dir(&rebase_merge).expect("rebase-merge should be created");
1051        fs::write(rebase_merge.join("head-name"), "refs/heads/main")
1052            .expect("rebase-merge metadata should be written");
1053        fs::write(&rebase_apply, "apply state").expect("rebase-apply should be written");
1054        fs::write(&unrelated_metadata, "merge state").expect("merge metadata should be written");
1055        let metadata_cleaner = FilesystemRebaseMetadataCleaner;
1056
1057        // Act
1058        let removed = metadata_cleaner
1059            .clean_stale_metadata(temp_dir.path())
1060            .expect("stale metadata cleanup should succeed");
1061
1062        // Assert
1063        assert!(removed);
1064        assert!(!rebase_merge.exists());
1065        assert!(!rebase_apply.exists());
1066        assert!(unrelated_metadata.exists());
1067    }
1068
1069    #[test]
1070    fn filesystem_metadata_cleaner_reports_no_change_without_rebase_entries() {
1071        // Arrange
1072        let temp_dir = tempdir().expect("tempdir should be created");
1073        fs::create_dir(temp_dir.path().join(".git")).expect("git dir should be created");
1074        let metadata_cleaner = FilesystemRebaseMetadataCleaner;
1075
1076        // Act
1077        let removed = metadata_cleaner
1078            .clean_stale_metadata(temp_dir.path())
1079            .expect("empty metadata cleanup should succeed");
1080
1081        // Assert
1082        assert!(!removed);
1083    }
1084
1085    #[test]
1086    fn filesystem_metadata_cleaner_rejects_missing_git_directory() {
1087        // Arrange
1088        let temp_dir = tempdir().expect("tempdir should be created");
1089        let metadata_cleaner = FilesystemRebaseMetadataCleaner;
1090
1091        // Act
1092        let error = metadata_cleaner
1093            .clean_stale_metadata(temp_dir.path())
1094            .expect_err("repository without git metadata should fail");
1095
1096        // Assert
1097        assert!(matches!(
1098            error,
1099            GitError::OutputParse(message) if message == "Failed to resolve git directory"
1100        ));
1101    }
1102
1103    #[test]
1104    fn remove_stale_metadata_preserves_non_not_found_io_error() {
1105        // Arrange
1106        let temp_dir = tempdir().expect("tempdir should be created");
1107        let parent_file = temp_dir.path().join("parent-file");
1108        fs::write(&parent_file, "not a directory").expect("parent file should be written");
1109
1110        // Act
1111        let error = remove_stale_rebase_metadata_path(&parent_file.join("rebase-merge"))
1112            .expect_err("non-directory parent should remain an I/O error");
1113
1114        // Assert
1115        assert!(matches!(error, GitError::Io(_)));
1116    }
1117
1118    #[cfg(unix)]
1119    #[test]
1120    fn filesystem_metadata_cleaner_does_not_follow_directory_symlink() {
1121        // Arrange
1122        let temp_dir = tempdir().expect("tempdir should be created");
1123        let git_dir = temp_dir.path().join(".git");
1124        let external_dir = temp_dir.path().join("external-rebase-data");
1125        let external_marker = external_dir.join("marker");
1126        fs::create_dir(&git_dir).expect("git dir should be created");
1127        fs::create_dir(&external_dir).expect("external directory should be created");
1128        fs::write(&external_marker, "preserve").expect("external marker should be written");
1129        unix_fs::symlink(&external_dir, git_dir.join("rebase-merge"))
1130            .expect("metadata symlink should be created");
1131        let metadata_cleaner = FilesystemRebaseMetadataCleaner;
1132
1133        // Act
1134        let removed = metadata_cleaner
1135            .clean_stale_metadata(temp_dir.path())
1136            .expect("symlink cleanup should succeed");
1137
1138        // Assert
1139        assert!(removed);
1140        assert!(!git_dir.join("rebase-merge").exists());
1141        assert!(external_marker.exists());
1142    }
1143
1144    #[test]
1145    fn test_in_progress_operation_detects_rebase_metadata() {
1146        // Arrange
1147        let temp_dir = tempdir().expect("tempdir should be created");
1148        let git_dir = temp_dir.path().join(".git");
1149        fs::create_dir(&git_dir).expect("git dir should be created");
1150        fs::create_dir(git_dir.join("rebase-merge")).expect("rebase metadata should be created");
1151
1152        // Act
1153        let operation =
1154            in_progress_operation_sync(temp_dir.path()).expect("operation should be detected");
1155
1156        // Assert
1157        assert_eq!(operation, Some(InProgressGitOperation::Rebase));
1158    }
1159
1160    #[test]
1161    fn test_in_progress_operation_detects_merge_metadata() {
1162        // Arrange
1163        let temp_dir = tempdir().expect("tempdir should be created");
1164        let git_dir = temp_dir.path().join(".git");
1165        fs::create_dir(&git_dir).expect("git dir should be created");
1166        fs::write(git_dir.join("MERGE_HEAD"), "merge").expect("merge metadata should be created");
1167
1168        // Act
1169        let operation =
1170            in_progress_operation_sync(temp_dir.path()).expect("operation should be detected");
1171
1172        // Assert
1173        assert_eq!(operation, Some(InProgressGitOperation::Merge));
1174    }
1175
1176    #[test]
1177    fn test_in_progress_operation_detects_cherry_pick_metadata() {
1178        // Arrange
1179        let temp_dir = tempdir().expect("tempdir should be created");
1180        let git_dir = temp_dir.path().join(".git");
1181        fs::create_dir(&git_dir).expect("git dir should be created");
1182        fs::write(git_dir.join("CHERRY_PICK_HEAD"), "cherry-pick")
1183            .expect("cherry-pick metadata should be created");
1184
1185        // Act
1186        let operation =
1187            in_progress_operation_sync(temp_dir.path()).expect("operation should be detected");
1188
1189        // Assert
1190        assert_eq!(operation, Some(InProgressGitOperation::CherryPick));
1191    }
1192
1193    #[test]
1194    fn test_in_progress_operation_detects_revert_metadata() {
1195        // Arrange
1196        let temp_dir = tempdir().expect("tempdir should be created");
1197        let git_dir = temp_dir.path().join(".git");
1198        fs::create_dir(&git_dir).expect("git dir should be created");
1199        fs::write(git_dir.join("REVERT_HEAD"), "revert")
1200            .expect("revert metadata should be created");
1201
1202        // Act
1203        let operation =
1204            in_progress_operation_sync(temp_dir.path()).expect("operation should be detected");
1205
1206        // Assert
1207        assert_eq!(operation, Some(InProgressGitOperation::Revert));
1208    }
1209
1210    #[test]
1211    fn test_in_progress_operation_returns_none_for_clean_git_dir() {
1212        // Arrange
1213        let temp_dir = tempdir().expect("tempdir should be created");
1214        fs::create_dir(temp_dir.path().join(".git")).expect("git dir should be created");
1215
1216        // Act
1217        let operation =
1218            in_progress_operation_sync(temp_dir.path()).expect("operation should be detected");
1219
1220        // Assert
1221        assert_eq!(operation, None);
1222    }
1223
1224    /// Returns a successful git command output.
1225    fn success_output() -> Output {
1226        Command::new("git")
1227            .arg("--version")
1228            .output()
1229            .expect("failed to run git --version")
1230    }
1231
1232    /// Returns a failing git command output that matches index lock contention.
1233    fn git_index_lock_output() -> Output {
1234        let mut output = Command::new("git")
1235            .arg("definitely-invalid-subcommand")
1236            .output()
1237            .expect("failed to run git invalid command");
1238        output.stdout = vec![];
1239        output.stderr = b"fatal: Unable to create '.git/index.lock': File exists.".to_vec();
1240
1241        output
1242    }
1243
1244    /// Returns a failing git command output that is unrelated to index locking.
1245    fn non_lock_failure_output() -> Output {
1246        let mut output = Command::new("git")
1247            .arg("definitely-invalid-subcommand")
1248            .output()
1249            .expect("failed to run git invalid command");
1250        output.stdout = vec![];
1251        output.stderr = b"fatal: not a git repository".to_vec();
1252
1253        output
1254    }
1255
1256    /// Returns a failing git command output for known stale rebase metadata.
1257    fn stale_rebase_failure_output() -> Output {
1258        let mut output = non_lock_failure_output();
1259        output.stderr = b"fatal: No rebase in progress?".to_vec();
1260
1261        output
1262    }
1263}