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
16pub(super) const GIT_INDEX_LOCK_RETRY_ATTEMPTS: usize = 5;
17pub(super) const GIT_INDEX_LOCK_RETRY_DELAY: Duration = Duration::from_millis(100);
18
19/// Executes git commands for rebase operations.
20#[cfg_attr(test, mockall::automock)]
21trait GitCommandRunner: Send + Sync {
22    /// Runs a git command in `repo_path` with environment overrides.
23    fn run_git_command_output_with_env(
24        &self,
25        repo_path: &Path,
26        args: &[String],
27        environment: &[(String, String)],
28    ) -> Result<Output, GitError>;
29}
30
31/// Removes stale rebase metadata through an injectable filesystem boundary.
32#[cfg_attr(test, mockall::automock)]
33trait RebaseMetadataCleaner: Send + Sync {
34    /// Removes exact rebase metadata entries under the resolved git directory.
35    fn clean_stale_metadata(&self, repo_path: &Path) -> Result<bool, GitError>;
36}
37
38/// Rebase metadata cleaner backed by the local filesystem.
39struct FilesystemRebaseMetadataCleaner;
40
41impl RebaseMetadataCleaner for FilesystemRebaseMetadataCleaner {
42    fn clean_stale_metadata(&self, repo_path: &Path) -> Result<bool, GitError> {
43        clean_stale_rebase_metadata(repo_path)
44    }
45}
46
47/// Git command runner backed by process execution.
48struct ProcessGitCommandRunner;
49
50impl GitCommandRunner for ProcessGitCommandRunner {
51    fn run_git_command_output_with_env(
52        &self,
53        repo_path: &Path,
54        args: &[String],
55        environment: &[(String, String)],
56    ) -> Result<Output, GitError> {
57        let args = args.iter().map(String::as_str).collect::<Vec<_>>();
58        let environment = environment
59            .iter()
60            .map(|(key, value)| (key.as_str(), value.as_str()))
61            .collect::<Vec<_>>();
62
63        run_git_command_output_with_env_sync(repo_path, &args, &environment)
64    }
65}
66
67/// Result of attempting a rebase step.
68#[derive(Clone, Debug, Eq, PartialEq)]
69pub enum RebaseStepResult {
70    /// Rebase step completed successfully.
71    Completed,
72    /// Rebase step stopped because of merge conflicts.
73    Conflict {
74        /// Git diagnostic describing the conflict state.
75        detail: String,
76    },
77}
78
79/// Git operation metadata that marks a worktree as unsafe for branch pushes.
80#[derive(Clone, Copy, Debug, Eq, PartialEq)]
81pub enum InProgressGitOperation {
82    /// A cherry-pick is in progress.
83    CherryPick,
84    /// A merge is in progress.
85    Merge,
86    /// A rebase is in progress.
87    Rebase,
88    /// A revert is in progress.
89    Revert,
90}
91
92impl InProgressGitOperation {
93    /// Returns an indefinite article plus the operation name for user-facing
94    /// status text.
95    pub fn article_name(self) -> &'static str {
96        match self {
97            Self::CherryPick => "a cherry-pick",
98            Self::Merge => "a merge",
99            Self::Rebase => "a rebase",
100            Self::Revert => "a revert",
101        }
102    }
103
104    /// Returns the operation name for user-facing status text.
105    pub fn name(self) -> &'static str {
106        match self {
107            Self::CherryPick => "cherry-pick",
108            Self::Merge => "merge",
109            Self::Rebase => "rebase",
110            Self::Revert => "revert",
111        }
112    }
113}
114
115/// Rebases the current branch onto `target_branch`.
116///
117/// If the rebase fails due to conflict, this function aborts it immediately so
118/// the repository does not remain in an in-progress rebase state.
119///
120/// # Arguments
121/// * `repo_path` - Path to the git repository or worktree
122/// * `target_branch` - Branch to rebase onto (e.g., `main`)
123///
124/// # Returns
125/// Ok(()) on success.
126///
127/// # Errors
128/// Returns a [`GitError`] if rebase fails, or aborting a conflicted rebase
129/// also fails.
130pub(crate) async fn rebase(repo_path: PathBuf, target_branch: String) -> Result<(), GitError> {
131    match rebase_start(repo_path.clone(), target_branch.clone()).await? {
132        RebaseStepResult::Completed => Ok(()),
133        RebaseStepResult::Conflict { detail } => {
134            let abort_suffix = match abort_rebase(repo_path).await {
135                Ok(()) => String::new(),
136                Err(error) => format!(" {error}"),
137            };
138
139            Err(GitError::CommandFailed {
140                command: "git rebase".to_string(),
141                stderr: format!("Failed to rebase onto {target_branch}: {detail}.{abort_suffix}"),
142            })
143        }
144    }
145}
146
147/// Rebases the current branch onto `target_branch`.
148///
149/// Returns a conflict outcome when the rebase stops for manual resolution.
150///
151/// # Arguments
152/// * `repo_path` - Path to the git repository or worktree
153/// * `target_branch` - Branch to rebase onto (e.g., `main`)
154///
155/// # Returns
156/// A [`RebaseStepResult`] describing whether the rebase completed or
157/// encountered conflicts.
158///
159/// # Errors
160/// Returns a [`GitError`] for non-conflict git failures.
161pub(crate) async fn rebase_start(
162    repo_path: PathBuf,
163    target_branch: String,
164) -> Result<RebaseStepResult, GitError> {
165    spawn_blocking(move || {
166        let rebase_args = ["rebase", target_branch.as_str()];
167        run_rebase_step(&repo_path, &rebase_args, "git rebase", |detail| {
168            format!("Failed to rebase onto {target_branch}: {detail}.")
169        })
170    })
171    .await?
172}
173
174/// Starts a rebase that moves commits after `old_base` onto `new_base`.
175///
176/// This is used for stacked sessions to drop commits that came from a parent
177/// branch after that parent has moved or squash-merged into its own base.
178///
179/// # Arguments
180/// * `repo_path` - Path to the git repository or worktree.
181/// * `new_base` - Ref that should become the new base of replayed commits.
182/// * `old_base` - Commit/ref whose ancestors should be left behind.
183///
184/// # Returns
185/// A [`RebaseStepResult`] describing whether the rebase completed or
186/// encountered conflicts.
187///
188/// # Errors
189/// Returns a [`GitError`] for non-conflict git failures.
190pub(crate) async fn rebase_onto_start(
191    repo_path: PathBuf,
192    new_base: String,
193    old_base: String,
194) -> Result<RebaseStepResult, GitError> {
195    spawn_blocking(move || {
196        let rebase_args = ["rebase", "--onto", new_base.as_str(), old_base.as_str()];
197        run_rebase_step(&repo_path, &rebase_args, "git rebase --onto", |detail| {
198            format!("Failed to rebase onto {new_base} after {old_base}: {detail}.")
199        })
200    })
201    .await?
202}
203
204/// Continues an in-progress rebase.
205///
206/// # Arguments
207/// * `repo_path` - Path to the git repository or worktree
208///
209/// # Returns
210/// A [`RebaseStepResult`] describing whether the rebase completed or
211/// encountered conflicts.
212///
213/// # Errors
214/// Returns a [`GitError`] for non-conflict git failures.
215pub(crate) async fn rebase_continue(repo_path: PathBuf) -> Result<RebaseStepResult, GitError> {
216    spawn_blocking(move || {
217        let output = run_git_command_with_index_lock_retry(
218            &repo_path,
219            &["rebase", "--continue"],
220            &[("GIT_EDITOR", ":"), ("GIT_SEQUENCE_EDITOR", ":")],
221        )?;
222
223        if output.status.success() {
224            return Ok(RebaseStepResult::Completed);
225        }
226
227        let detail = command_output_detail(&output.stdout, &output.stderr);
228        if is_rebase_conflict(&detail) {
229            return Ok(RebaseStepResult::Conflict { detail });
230        }
231
232        Err(GitError::CommandFailed {
233            command: "git rebase --continue".to_string(),
234            stderr: format!("Failed to continue rebase: {detail}."),
235        })
236    })
237    .await?
238}
239
240/// Aborts an in-progress rebase.
241///
242/// When Git reports a known stale or inactive rebase state, this removes only
243/// `rebase-merge` and `rebase-apply` under the resolved git directory. Other
244/// failures are returned unchanged with their command output.
245///
246/// # Arguments
247/// * `repo_path` - Path to the git repository or worktree
248///
249/// # Returns
250/// Ok(()) on success.
251///
252/// # Errors
253/// Returns a [`GitError`] when `git rebase --abort` cannot be executed.
254pub(crate) async fn abort_rebase(repo_path: PathBuf) -> Result<(), GitError> {
255    spawn_blocking(move || {
256        let command_runner = ProcessGitCommandRunner;
257        let metadata_cleaner = FilesystemRebaseMetadataCleaner;
258        let sleeper = ThreadSleeper;
259
260        abort_rebase_with_dependencies(&repo_path, &command_runner, &sleeper, &metadata_cleaner)
261    })
262    .await?
263}
264
265/// Returns whether a rebase is currently in progress in the repository or
266/// worktree.
267///
268/// # Arguments
269/// * `repo_path` - Path to the git repository or worktree
270///
271/// # Returns
272/// `true` when `.git/rebase-merge` or `.git/rebase-apply` exists, `false`
273/// otherwise.
274///
275/// # Errors
276/// Returns a [`GitError`] when the git directory cannot be resolved.
277pub(crate) async fn is_rebase_in_progress(repo_path: PathBuf) -> Result<bool, GitError> {
278    spawn_blocking(move || -> Result<bool, GitError> {
279        let git_dir = resolve_git_dir(&repo_path)
280            .ok_or_else(|| GitError::OutputParse("Failed to resolve git directory".to_string()))?;
281
282        Ok(has_rebase_metadata(&git_dir))
283    })
284    .await?
285}
286
287/// Returns the first detected in-progress git operation in `repo_path`.
288///
289/// # Arguments
290/// * `repo_path` - Path to the git repository or worktree
291///
292/// # Returns
293/// An operation when rebase, merge, cherry-pick, or revert metadata exists.
294///
295/// # Errors
296/// Returns a [`GitError`] when the git directory cannot be resolved.
297pub(crate) async fn in_progress_operation(
298    repo_path: PathBuf,
299) -> Result<Option<InProgressGitOperation>, GitError> {
300    spawn_blocking(move || in_progress_operation_sync(&repo_path)).await?
301}
302
303fn in_progress_operation_sync(
304    repo_path: &Path,
305) -> Result<Option<InProgressGitOperation>, GitError> {
306    let git_dir = resolve_git_dir(repo_path)
307        .ok_or_else(|| GitError::OutputParse("Failed to resolve git directory".to_string()))?;
308    if has_rebase_metadata(&git_dir) {
309        return Ok(Some(InProgressGitOperation::Rebase));
310    }
311    if git_dir.join("MERGE_HEAD").exists() {
312        return Ok(Some(InProgressGitOperation::Merge));
313    }
314    if git_dir.join("CHERRY_PICK_HEAD").exists() {
315        return Ok(Some(InProgressGitOperation::CherryPick));
316    }
317    if git_dir.join("REVERT_HEAD").exists() {
318        return Ok(Some(InProgressGitOperation::Revert));
319    }
320
321    Ok(None)
322}
323
324fn has_rebase_metadata(git_dir: &Path) -> bool {
325    let rebase_merge = git_dir.join("rebase-merge");
326    let rebase_apply = git_dir.join("rebase-apply");
327
328    rebase_merge.exists() || rebase_apply.exists()
329}
330
331/// Returns whether unresolved paths still exist in the index.
332///
333/// # Arguments
334/// * `repo_path` - Path to the git repository or worktree
335///
336/// # Returns
337/// `true` when unresolved paths exist, `false` otherwise.
338///
339/// # Errors
340/// Returns a [`GitError`] when conflicted files cannot be queried.
341pub(crate) async fn has_unmerged_paths(repo_path: PathBuf) -> Result<bool, GitError> {
342    let conflicted_files = list_conflicted_files(repo_path).await?;
343
344    Ok(!conflicted_files.is_empty())
345}
346
347/// Returns which of the given `paths` still contain git conflict markers
348/// (`<<<<<<<`) in their staged content.
349///
350/// Uses `git grep --cached -l` to search indexed content directly, so it
351/// detects files that were staged via `git add` while still containing
352/// unresolved conflict markers. The search is scoped to `paths` to avoid
353/// false positives from files that legitimately contain `<<<<<<<` (e.g.
354/// test fixtures or documentation).
355///
356/// # Arguments
357/// * `repo_path` - Path to the git repository or worktree
358/// * `paths` - Relative file paths to inspect (typically the files that were
359///   involved in the current conflict)
360///
361/// # Returns
362/// The subset of `paths` whose staged content contains lines starting with
363/// `<<<<<<<`. Returns an empty list when no matches are found or when
364/// `paths` is empty.
365///
366/// # Errors
367/// Returns a [`GitError`] if `git grep` cannot be executed or exits with an
368/// unexpected error code. An exit code of `1` (no matches) is treated as
369/// success with an empty result.
370pub(crate) async fn list_staged_conflict_marker_files(
371    repo_path: PathBuf,
372    paths: Vec<String>,
373) -> Result<Vec<String>, GitError> {
374    if paths.is_empty() {
375        return Ok(vec![]);
376    }
377
378    spawn_blocking(move || -> Result<Vec<String>, GitError> {
379        let mut grep_arguments = vec!["grep", "--cached", "-l", "^<<<<<<<", "--"];
380        let path_arguments: Vec<&str> = paths.iter().map(String::as_str).collect();
381        grep_arguments.extend(path_arguments);
382        let output = run_git_command_output_sync(&repo_path, &grep_arguments)?;
383
384        // git grep exits with 1 when no matches are found.
385        let exit_code = output.status.code().unwrap_or(2);
386        if !output.status.success() && exit_code != 1 {
387            let detail = command_output_detail(&output.stdout, &output.stderr);
388
389            return Err(GitError::CommandFailed {
390                command: "git grep".to_string(),
391                stderr: format!("Failed to check for staged conflict markers: {detail}"),
392            });
393        }
394
395        let files = String::from_utf8_lossy(&output.stdout)
396            .lines()
397            .map(str::trim)
398            .filter(|line| !line.is_empty())
399            .map(ToString::to_string)
400            .collect();
401
402        Ok(files)
403    })
404    .await?
405}
406
407/// Returns conflicted file paths for the current index.
408///
409/// # Arguments
410/// * `repo_path` - Path to the git repository or worktree
411///
412/// # Returns
413/// A list of relative file paths with unresolved conflicts.
414///
415/// # Errors
416/// Returns a [`GitError`] if invoking `git diff --name-only --diff-filter=U`
417/// fails.
418pub(crate) async fn list_conflicted_files(repo_path: PathBuf) -> Result<Vec<String>, GitError> {
419    spawn_blocking(move || -> Result<Vec<String>, GitError> {
420        let output = run_git_command_sync(
421            &repo_path,
422            &["diff", "--name-only", "--diff-filter=U"],
423            "Failed to read conflicted files",
424        )?;
425        let files = output
426            .lines()
427            .map(str::trim)
428            .filter(|line| !line.is_empty())
429            .map(ToString::to_string)
430            .collect();
431
432        Ok(files)
433    })
434    .await?
435}
436
437/// Runs one rebase command and maps git output to a step result.
438fn run_rebase_step(
439    repo_path: &Path,
440    args: &[&str],
441    command: &str,
442    failure_message: impl FnOnce(&str) -> String,
443) -> Result<RebaseStepResult, GitError> {
444    let output = run_git_command_with_index_lock_retry(repo_path, args, &[])?;
445
446    if output.status.success() {
447        return Ok(RebaseStepResult::Completed);
448    }
449
450    let detail = command_output_detail(&output.stdout, &output.stderr);
451    if is_rebase_conflict(&detail) {
452        return Ok(RebaseStepResult::Conflict { detail });
453    }
454
455    Err(GitError::CommandFailed {
456        command: command.to_string(),
457        stderr: failure_message(&detail),
458    })
459}
460
461/// Aborts one rebase through injected process and retry boundaries.
462fn abort_rebase_with_dependencies(
463    repo_path: &Path,
464    command_runner: &dyn GitCommandRunner,
465    sleeper: &dyn Sleeper,
466    metadata_cleaner: &dyn RebaseMetadataCleaner,
467) -> Result<(), GitError> {
468    let output = run_git_command_with_index_lock_retry_with_dependencies(
469        repo_path,
470        &["rebase", "--abort"],
471        &[],
472        command_runner,
473        sleeper,
474    )?;
475    if !output.status.success() {
476        let detail = command_output_detail(&output.stdout, &output.stderr);
477        if is_stale_or_inactive_rebase_error(&detail) {
478            match metadata_cleaner.clean_stale_metadata(repo_path) {
479                Ok(true) => return Ok(()),
480                Ok(false) => {}
481                Err(cleanup_error) => {
482                    return Err(GitError::CommandFailed {
483                        command: "git rebase --abort".to_string(),
484                        stderr: format!(
485                            "Failed to abort rebase: {detail}. Stale rebase metadata cleanup \
486                             failed: {cleanup_error}."
487                        ),
488                    });
489                }
490            }
491        }
492
493        return Err(GitError::CommandFailed {
494            command: "git rebase --abort".to_string(),
495            stderr: format!("Failed to abort rebase: {detail}."),
496        });
497    }
498
499    Ok(())
500}
501
502/// Returns whether abort output identifies a known stale or inactive rebase.
503fn is_stale_or_inactive_rebase_error(detail: &str) -> bool {
504    let normalized_detail = detail.to_ascii_lowercase();
505
506    normalized_detail.contains("no rebase in progress")
507        || normalized_detail.contains("already a rebase-merge directory")
508        || normalized_detail.contains("already a rebase-apply directory")
509        || normalized_detail.contains("middle of another rebase")
510}
511
512/// Removes exact stale rebase metadata entries from the resolved git directory.
513fn clean_stale_rebase_metadata(repo_path: &Path) -> Result<bool, GitError> {
514    let git_dir = resolve_git_dir(repo_path)
515        .ok_or_else(|| GitError::OutputParse("Failed to resolve git directory".to_string()))?;
516    let removed_rebase_merge = remove_stale_rebase_metadata_path(&git_dir.join("rebase-merge"))?;
517    let removed_rebase_apply = remove_stale_rebase_metadata_path(&git_dir.join("rebase-apply"))?;
518
519    Ok(removed_rebase_merge || removed_rebase_apply)
520}
521
522/// Removes one exact metadata path without following directory symlinks.
523fn remove_stale_rebase_metadata_path(path: &Path) -> Result<bool, GitError> {
524    let metadata = match fs::symlink_metadata(path) {
525        Ok(metadata) => metadata,
526        Err(error) if error.kind() == ErrorKind::NotFound => return Ok(false),
527        Err(error) => return Err(error.into()),
528    };
529
530    if metadata.file_type().is_dir() {
531        fs::remove_dir_all(path)?;
532    } else {
533        fs::remove_file(path)?;
534    }
535
536    Ok(true)
537}
538
539/// Runs a git command and retries when `index.lock` contention occurs.
540pub(super) fn run_git_command_with_index_lock_retry(
541    repo_path: &Path,
542    args: &[&str],
543    environment: &[(&str, &str)],
544) -> Result<Output, GitError> {
545    let command_runner = ProcessGitCommandRunner;
546    let sleeper = ThreadSleeper;
547
548    run_git_command_with_index_lock_retry_with_dependencies(
549        repo_path,
550        args,
551        environment,
552        &command_runner,
553        &sleeper,
554    )
555}
556
557/// Runs a git command with retries using injected command and sleep
558/// dependencies.
559fn run_git_command_with_index_lock_retry_with_dependencies(
560    repo_path: &Path,
561    args: &[&str],
562    environment: &[(&str, &str)],
563    command_runner: &dyn GitCommandRunner,
564    sleeper: &dyn Sleeper,
565) -> Result<Output, GitError> {
566    let args = args
567        .iter()
568        .map(|arg| String::from(*arg))
569        .collect::<Vec<_>>();
570    let environment = environment
571        .iter()
572        .map(|(key, value)| (String::from(*key), String::from(*value)))
573        .collect::<Vec<_>>();
574
575    for attempt in 0..GIT_INDEX_LOCK_RETRY_ATTEMPTS {
576        let output =
577            command_runner.run_git_command_output_with_env(repo_path, &args, &environment)?;
578        if output.status.success() {
579            return Ok(output);
580        }
581
582        let detail = command_output_detail(&output.stdout, &output.stderr);
583        let is_last_attempt = attempt + 1 == GIT_INDEX_LOCK_RETRY_ATTEMPTS;
584        if !is_git_index_lock_error(&detail) || is_last_attempt {
585            return Ok(output);
586        }
587
588        sleeper.sleep(GIT_INDEX_LOCK_RETRY_DELAY);
589    }
590
591    unreachable!("index lock retry loop should always return an output")
592}
593
594/// Returns whether git output detail indicates a rebase conflict state.
595///
596/// Matches all known git messages that signal a conflict requiring manual
597/// resolution, including messages emitted when staging partially-resolved
598/// files and attempting `git rebase --continue` prematurely.
599pub(super) fn is_rebase_conflict(detail: &str) -> bool {
600    detail.contains("CONFLICT")
601        || detail.contains("Resolve all conflicts manually")
602        || detail.contains("could not apply")
603        || detail.contains("mark them as resolved")
604        || detail.contains("unresolved conflict")
605        || detail.contains("Committing is not possible")
606}
607
608/// Returns whether git output indicates transient index lock contention.
609pub(super) fn is_git_index_lock_error(detail: &str) -> bool {
610    let normalized_detail = detail.to_ascii_lowercase();
611
612    normalized_detail.contains("index.lock")
613        && (normalized_detail.contains("file exists")
614            || normalized_detail.contains("unable to create")
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}