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 [`GitError::RepositoryUnavailable`] when the repository folder is
278/// missing, or another [`GitError`] when its git directory cannot be resolved.
279pub(crate) async fn is_rebase_in_progress(repo_path: PathBuf) -> Result<bool, GitError> {
280    spawn_blocking(move || -> Result<bool, GitError> {
281        match fs::metadata(&repo_path) {
282            Ok(_) => {}
283            Err(error) if error.kind() == ErrorKind::NotFound => {
284                return Err(GitError::RepositoryUnavailable {
285                    detail: format!("Repository folder is missing: {}", repo_path.display()),
286                });
287            }
288            Err(error) => return Err(error.into()),
289        }
290        let git_dir = resolve_git_dir(&repo_path).ok_or_else(|| {
291            GitError::OutputParse(format!(
292                "Failed to resolve git directory for `{}`",
293                repo_path.display()
294            ))
295        })?;
296
297        Ok(has_rebase_metadata(&git_dir))
298    })
299    .await?
300}
301
302/// Returns the first detected in-progress git operation in `repo_path`.
303///
304/// # Arguments
305/// * `repo_path` - Path to the git repository or worktree
306///
307/// # Returns
308/// An operation when rebase, merge, cherry-pick, or revert metadata exists.
309///
310/// # Errors
311/// Returns a [`GitError`] when the git directory cannot be resolved.
312pub(crate) async fn in_progress_operation(
313    repo_path: PathBuf,
314) -> Result<Option<InProgressGitOperation>, GitError> {
315    spawn_blocking(move || in_progress_operation_sync(&repo_path)).await?
316}
317
318fn in_progress_operation_sync(
319    repo_path: &Path,
320) -> Result<Option<InProgressGitOperation>, GitError> {
321    let git_dir = resolve_git_dir(repo_path)
322        .ok_or_else(|| GitError::OutputParse("Failed to resolve git directory".to_string()))?;
323    if has_rebase_metadata(&git_dir) {
324        return Ok(Some(InProgressGitOperation::Rebase));
325    }
326    if git_dir.join("MERGE_HEAD").exists() {
327        return Ok(Some(InProgressGitOperation::Merge));
328    }
329    if git_dir.join("CHERRY_PICK_HEAD").exists() {
330        return Ok(Some(InProgressGitOperation::CherryPick));
331    }
332    if git_dir.join("REVERT_HEAD").exists() {
333        return Ok(Some(InProgressGitOperation::Revert));
334    }
335
336    Ok(None)
337}
338
339fn has_rebase_metadata(git_dir: &Path) -> bool {
340    let rebase_merge = git_dir.join("rebase-merge");
341    let rebase_apply = git_dir.join("rebase-apply");
342
343    rebase_merge.exists() || rebase_apply.exists()
344}
345
346/// Returns whether unresolved paths still exist in the index.
347///
348/// # Arguments
349/// * `repo_path` - Path to the git repository or worktree
350///
351/// # Returns
352/// `true` when unresolved paths exist, `false` otherwise.
353///
354/// # Errors
355/// Returns a [`GitError`] when conflicted files cannot be queried.
356pub(crate) async fn has_unmerged_paths(repo_path: PathBuf) -> Result<bool, GitError> {
357    let conflicted_files = list_conflicted_files(repo_path).await?;
358
359    Ok(!conflicted_files.is_empty())
360}
361
362/// Returns which of the given `paths` still contain git conflict markers
363/// (`<<<<<<<`) in their staged content.
364///
365/// Uses `git grep --cached -l` to search indexed content directly, so it
366/// detects files that were staged via `git add` while still containing
367/// unresolved conflict markers. The search is scoped to `paths` to avoid
368/// false positives from files that legitimately contain `<<<<<<<` (e.g.
369/// test fixtures or documentation).
370///
371/// # Arguments
372/// * `repo_path` - Path to the git repository or worktree
373/// * `paths` - Relative file paths to inspect (typically the files that were
374///   involved in the current conflict)
375///
376/// # Returns
377/// The subset of `paths` whose staged content contains lines starting with
378/// `<<<<<<<`. Returns an empty list when no matches are found or when
379/// `paths` is empty.
380///
381/// # Errors
382/// Returns a [`GitError`] if `git grep` cannot be executed or exits with an
383/// unexpected error code. An exit code of `1` (no matches) is treated as
384/// success with an empty result.
385pub(crate) async fn list_staged_conflict_marker_files(
386    repo_path: PathBuf,
387    paths: Vec<String>,
388) -> Result<Vec<String>, GitError> {
389    if paths.is_empty() {
390        return Ok(vec![]);
391    }
392
393    spawn_blocking(move || -> Result<Vec<String>, GitError> {
394        let mut grep_arguments = vec!["grep", "--cached", "-l", "^<<<<<<<", "--"];
395        let path_arguments: Vec<&str> = paths.iter().map(String::as_str).collect();
396        grep_arguments.extend(path_arguments);
397        let output = run_git_command_output_sync(&repo_path, &grep_arguments)?;
398
399        // git grep exits with 1 when no matches are found.
400        let exit_code = output.status.code().unwrap_or(2);
401        if !output.status.success() && exit_code != 1 {
402            let detail = command_output_detail(&output.stdout, &output.stderr);
403
404            return Err(GitError::CommandFailed {
405                command: "git grep".to_string(),
406                stderr: format!("Failed to check for staged conflict markers: {detail}"),
407            });
408        }
409
410        let files = String::from_utf8_lossy(&output.stdout)
411            .lines()
412            .map(str::trim)
413            .filter(|line| !line.is_empty())
414            .map(ToString::to_string)
415            .collect();
416
417        Ok(files)
418    })
419    .await?
420}
421
422/// Returns conflicted file paths for the current index.
423///
424/// # Arguments
425/// * `repo_path` - Path to the git repository or worktree
426///
427/// # Returns
428/// A list of relative file paths with unresolved conflicts.
429///
430/// # Errors
431/// Returns a [`GitError`] if invoking `git diff --name-only --diff-filter=U`
432/// fails.
433pub(crate) async fn list_conflicted_files(repo_path: PathBuf) -> Result<Vec<String>, GitError> {
434    spawn_blocking(move || -> Result<Vec<String>, GitError> {
435        let output = run_git_command_sync(
436            &repo_path,
437            &["diff", "--name-only", "--diff-filter=U"],
438            "Failed to read conflicted files",
439        )?;
440        let files = output
441            .lines()
442            .map(str::trim)
443            .filter(|line| !line.is_empty())
444            .map(ToString::to_string)
445            .collect();
446
447        Ok(files)
448    })
449    .await?
450}
451
452/// Runs one rebase command and maps git output to a step result.
453fn run_rebase_step(
454    repo_path: &Path,
455    args: &[&str],
456    command: &str,
457    failure_message: impl FnOnce(&str) -> String,
458) -> Result<RebaseStepResult, GitError> {
459    let output = run_git_command_with_index_lock_retry(repo_path, args, &[])?;
460
461    if output.status.success() {
462        return Ok(RebaseStepResult::Completed);
463    }
464
465    let detail = command_output_detail(&output.stdout, &output.stderr);
466    if is_rebase_conflict(&detail) {
467        return Ok(RebaseStepResult::Conflict { detail });
468    }
469
470    Err(GitError::CommandFailed {
471        command: command.to_string(),
472        stderr: failure_message(&detail),
473    })
474}
475
476/// Aborts one rebase through injected process and retry boundaries.
477fn abort_rebase_with_dependencies(
478    repo_path: &Path,
479    command_runner: &dyn GitCommandRunner,
480    sleeper: &dyn Sleeper,
481    metadata_cleaner: &dyn RebaseMetadataCleaner,
482) -> Result<(), GitError> {
483    let output = run_git_command_with_index_lock_retry_with_dependencies(
484        repo_path,
485        &["rebase", "--abort"],
486        &[],
487        command_runner,
488        sleeper,
489    )?;
490    if !output.status.success() {
491        let detail = command_output_detail(&output.stdout, &output.stderr);
492        if is_stale_or_inactive_rebase_error(&detail) {
493            match metadata_cleaner.clean_stale_metadata(repo_path) {
494                Ok(true) => return Ok(()),
495                Ok(false) => {}
496                Err(cleanup_error) => {
497                    return Err(GitError::CommandFailed {
498                        command: "git rebase --abort".to_string(),
499                        stderr: format!(
500                            "Failed to abort rebase: {detail}. Stale rebase metadata cleanup \
501                             failed: {cleanup_error}."
502                        ),
503                    });
504                }
505            }
506        }
507
508        return Err(GitError::CommandFailed {
509            command: "git rebase --abort".to_string(),
510            stderr: format!("Failed to abort rebase: {detail}."),
511        });
512    }
513
514    Ok(())
515}
516
517/// Returns whether abort output identifies a known stale or inactive rebase.
518fn is_stale_or_inactive_rebase_error(detail: &str) -> bool {
519    let normalized_detail = detail.to_ascii_lowercase();
520
521    normalized_detail.contains("no rebase in progress")
522        || normalized_detail.contains("already a rebase-merge directory")
523        || normalized_detail.contains("already a rebase-apply directory")
524        || normalized_detail.contains("middle of another rebase")
525}
526
527/// Removes exact stale rebase metadata entries from the resolved git directory.
528fn clean_stale_rebase_metadata(repo_path: &Path) -> Result<bool, GitError> {
529    let git_dir = resolve_git_dir(repo_path)
530        .ok_or_else(|| GitError::OutputParse("Failed to resolve git directory".to_string()))?;
531    let removed_rebase_merge = remove_stale_rebase_metadata_path(&git_dir.join("rebase-merge"))?;
532    let removed_rebase_apply = remove_stale_rebase_metadata_path(&git_dir.join("rebase-apply"))?;
533
534    Ok(removed_rebase_merge || removed_rebase_apply)
535}
536
537/// Removes one exact metadata path without following directory symlinks.
538fn remove_stale_rebase_metadata_path(path: &Path) -> Result<bool, GitError> {
539    let metadata = match fs::symlink_metadata(path) {
540        Ok(metadata) => metadata,
541        Err(error) if error.kind() == ErrorKind::NotFound => return Ok(false),
542        Err(error) => return Err(error.into()),
543    };
544
545    if metadata.file_type().is_dir() {
546        fs::remove_dir_all(path)?;
547    } else {
548        fs::remove_file(path)?;
549    }
550
551    Ok(true)
552}
553
554/// Runs a git command and retries when `index.lock` contention occurs.
555pub(super) fn run_git_command_with_index_lock_retry(
556    repo_path: &Path,
557    args: &[&str],
558    environment: &[(&str, &str)],
559) -> Result<Output, GitError> {
560    let command_runner = ProcessGitCommandRunner;
561    let sleeper = ThreadSleeper;
562
563    run_git_command_with_index_lock_retry_with_dependencies(
564        repo_path,
565        args,
566        environment,
567        &command_runner,
568        &sleeper,
569    )
570}
571
572/// Runs a git command with retries using injected command and sleep
573/// dependencies.
574fn run_git_command_with_index_lock_retry_with_dependencies(
575    repo_path: &Path,
576    args: &[&str],
577    environment: &[(&str, &str)],
578    command_runner: &dyn GitCommandRunner,
579    sleeper: &dyn Sleeper,
580) -> Result<Output, GitError> {
581    let args = args
582        .iter()
583        .map(|arg| String::from(*arg))
584        .collect::<Vec<_>>();
585    let environment = environment
586        .iter()
587        .map(|(key, value)| (String::from(*key), String::from(*value)))
588        .collect::<Vec<_>>();
589
590    for attempt in 0..GIT_INDEX_LOCK_RETRY_ATTEMPTS {
591        let output =
592            command_runner.run_git_command_output_with_env(repo_path, &args, &environment)?;
593        if output.status.success() {
594            return Ok(output);
595        }
596
597        let detail = command_output_detail(&output.stdout, &output.stderr);
598        let is_last_attempt = attempt + 1 == GIT_INDEX_LOCK_RETRY_ATTEMPTS;
599        if !is_git_index_lock_error(&detail) || is_last_attempt {
600            return Ok(output);
601        }
602
603        sleeper.sleep(GIT_INDEX_LOCK_RETRY_DELAY);
604    }
605
606    unreachable!("index lock retry loop should always return an output")
607}
608
609/// Returns whether git output detail indicates a rebase conflict state.
610///
611/// Matches all known git messages that signal a conflict requiring manual
612/// resolution, including messages emitted when staging partially-resolved
613/// files and attempting `git rebase --continue` prematurely.
614pub(super) fn is_rebase_conflict(detail: &str) -> bool {
615    detail.contains("CONFLICT")
616        || detail.contains("Resolve all conflicts manually")
617        || detail.contains("could not apply")
618        || detail.contains("mark them as resolved")
619        || detail.contains("unresolved conflict")
620        || detail.contains("Committing is not possible")
621}
622
623/// Returns whether git output indicates transient index lock contention.
624pub(super) fn is_git_index_lock_error(detail: &str) -> bool {
625    let normalized_detail = detail.to_ascii_lowercase();
626
627    normalized_detail.contains("index.lock")
628        && (normalized_detail.contains("file exists")
629            || normalized_detail.contains("another git process"))
630}
631
632#[cfg(test)]
633mod tests {
634    use std::fs;
635    #[cfg(unix)]
636    use std::os::unix::fs as unix_fs;
637    use std::process::{Command, Output};
638
639    use mockall::predicate::eq;
640    use tempfile::tempdir;
641
642    use super::*;
643    use crate::MockSleeper;
644
645    #[tokio::test]
646    async fn rebase_probe_reports_missing_repository() {
647        // Arrange
648        let directory = tempdir().expect("temporary directory should exist");
649        let missing = directory.path().join("removed-worktree");
650
651        // Act
652        let error = is_rebase_in_progress(missing.clone())
653            .await
654            .expect_err("missing worktree should be classified");
655
656        // Assert
657        assert!(
658            matches!(error, GitError::RepositoryUnavailable { ref detail }
659            if detail.contains(&missing.display().to_string()))
660        );
661    }
662
663    #[tokio::test]
664    async fn rebase_probe_preserves_invalid_repository_and_io_errors() {
665        // Arrange
666        let directory = tempdir().expect("temporary directory should exist");
667        let parent_file = directory.path().join("file");
668        fs::write(&parent_file, "not a directory").expect("file should be written");
669
670        // Act
671        let invalid_repository = is_rebase_in_progress(directory.path().to_path_buf()).await;
672        let io_error = is_rebase_in_progress(parent_file.join("worktree")).await;
673
674        // Assert
675        assert!(
676            matches!(invalid_repository, Err(GitError::OutputParse(message))
677            if message.contains(&directory.path().display().to_string()))
678        );
679        assert!(matches!(io_error, Err(GitError::Io(_))));
680    }
681
682    #[tokio::test]
683    async fn rebase_probe_detects_metadata_in_checkout_and_linked_worktree() {
684        // Arrange
685        let directory = tempdir().expect("temporary directory should exist");
686        let checkout = directory.path().join("checkout");
687        let linked = directory.path().join("linked");
688        let git_dir = checkout.join(".git");
689        fs::create_dir_all(&git_dir).expect("git directory should exist");
690        fs::create_dir(&linked).expect("worktree directory should exist");
691        fs::write(linked.join(".git"), "gitdir: ../checkout/.git\n")
692            .expect("gitdir file should be written");
693
694        // Act / Assert
695        assert!(
696            !is_rebase_in_progress(checkout.clone())
697                .await
698                .expect("probe should succeed")
699        );
700        for metadata in ["rebase-merge", "rebase-apply"] {
701            fs::create_dir(git_dir.join(metadata)).expect("rebase metadata should exist");
702            assert!(
703                is_rebase_in_progress(checkout.clone())
704                    .await
705                    .expect("probe should succeed")
706            );
707            assert!(
708                is_rebase_in_progress(linked.clone())
709                    .await
710                    .expect("probe should succeed")
711            );
712            fs::remove_dir(git_dir.join(metadata)).expect("rebase metadata should be removed");
713        }
714    }
715
716    #[test]
717    fn test_run_git_command_with_index_lock_retry_retries_and_sleeps_before_success() {
718        // Arrange
719        let mut command_runner = MockGitCommandRunner::new();
720        let mut sleeper = MockSleeper::new();
721        let repo_path = Path::new(".");
722        let args = ["rebase", "main"];
723        let environment: [(&str, &str); 0] = [];
724
725        command_runner
726            .expect_run_git_command_output_with_env()
727            .times(1)
728            .returning(|_, _, _| Ok(git_index_lock_output()));
729        command_runner
730            .expect_run_git_command_output_with_env()
731            .times(1)
732            .returning(|_, _, _| Ok(success_output()));
733
734        sleeper
735            .expect_sleep()
736            .with(eq(GIT_INDEX_LOCK_RETRY_DELAY))
737            .times(1)
738            .return_once(|_| {});
739
740        // Act
741        let output = run_git_command_with_index_lock_retry_with_dependencies(
742            repo_path,
743            &args,
744            &environment,
745            &command_runner,
746            &sleeper,
747        )
748        .expect("retry helper should return command output");
749
750        // Assert
751        assert!(output.status.success());
752    }
753
754    #[test]
755    fn test_run_git_command_with_index_lock_retry_passes_owned_args_and_environment() {
756        // Arrange
757        let mut command_runner = MockGitCommandRunner::new();
758        let mut sleeper = MockSleeper::new();
759        let repo_path = Path::new(".");
760        let args = ["-c", "core.editor=true", "rebase", "main"];
761        let environment = [("GIT_EDITOR", "true")];
762
763        command_runner
764            .expect_run_git_command_output_with_env()
765            .withf(|repo_path, args, environment| {
766                repo_path == Path::new(".")
767                    && args.iter().map(String::as_str).eq([
768                        "-c",
769                        "core.editor=true",
770                        "rebase",
771                        "main",
772                    ])
773                    && environment
774                        .iter()
775                        .map(|(key, value)| (key.as_str(), value.as_str()))
776                        .eq([("GIT_EDITOR", "true")])
777            })
778            .times(1)
779            .returning(|_, _, _| Ok(success_output()));
780        sleeper.expect_sleep().times(0);
781
782        // Act
783        let output = run_git_command_with_index_lock_retry_with_dependencies(
784            repo_path,
785            &args,
786            &environment,
787            &command_runner,
788            &sleeper,
789        )
790        .expect("retry helper should return command output");
791
792        // Assert
793        assert!(output.status.success());
794    }
795
796    #[test]
797    fn test_run_git_command_with_index_lock_retry_returns_last_lock_failure() {
798        // Arrange
799        let mut command_runner = MockGitCommandRunner::new();
800        let mut sleeper = MockSleeper::new();
801        let repo_path = Path::new(".");
802        let args = ["rebase", "main"];
803        let environment: [(&str, &str); 0] = [];
804
805        command_runner
806            .expect_run_git_command_output_with_env()
807            .times(GIT_INDEX_LOCK_RETRY_ATTEMPTS)
808            .returning(|_, _, _| Ok(git_index_lock_output()));
809        sleeper
810            .expect_sleep()
811            .with(eq(GIT_INDEX_LOCK_RETRY_DELAY))
812            .times(GIT_INDEX_LOCK_RETRY_ATTEMPTS - 1)
813            .returning(|_| {});
814
815        // Act
816        let output = run_git_command_with_index_lock_retry_with_dependencies(
817            repo_path,
818            &args,
819            &environment,
820            &command_runner,
821            &sleeper,
822        )
823        .expect("retry helper should return command output");
824
825        // Assert
826        assert!(!output.status.success());
827        assert!(command_output_detail(&output.stdout, &output.stderr).contains("index.lock"));
828    }
829
830    #[test]
831    fn test_run_git_command_with_index_lock_retry_returns_command_error_without_sleeping() {
832        // Arrange
833        let mut command_runner = MockGitCommandRunner::new();
834        let mut sleeper = MockSleeper::new();
835        let repo_path = Path::new(".");
836        let args = ["rebase", "main"];
837        let environment: [(&str, &str); 0] = [];
838
839        command_runner
840            .expect_run_git_command_output_with_env()
841            .times(1)
842            .return_once(|_, _, _| {
843                Err(GitError::CommandFailed {
844                    command: "git".to_string(),
845                    stderr: "git execution failed".to_string(),
846                })
847            });
848        sleeper.expect_sleep().times(0);
849
850        // Act
851        let error = run_git_command_with_index_lock_retry_with_dependencies(
852            repo_path,
853            &args,
854            &environment,
855            &command_runner,
856            &sleeper,
857        )
858        .expect_err("retry helper should surface command execution errors");
859
860        // Assert
861        assert_eq!(error.to_string(), "git: git execution failed");
862    }
863
864    #[test]
865    fn test_run_git_command_with_index_lock_retry_does_not_sleep_for_non_lock_errors() {
866        // Arrange
867        let mut command_runner = MockGitCommandRunner::new();
868        let mut sleeper = MockSleeper::new();
869        let repo_path = Path::new(".");
870        let args = ["rebase", "main"];
871        let environment: [(&str, &str); 0] = [];
872
873        command_runner
874            .expect_run_git_command_output_with_env()
875            .times(1)
876            .returning(|_, _, _| Ok(non_lock_failure_output()));
877        sleeper.expect_sleep().times(0);
878
879        // Act
880        let output = run_git_command_with_index_lock_retry_with_dependencies(
881            repo_path,
882            &args,
883            &environment,
884            &command_runner,
885            &sleeper,
886        )
887        .expect("retry helper should return command output");
888
889        // Assert
890        assert!(!output.status.success());
891    }
892
893    #[test]
894    fn test_is_rebase_conflict_matches_unmerged_files_message() {
895        // Arrange
896        let detail = "Committing is not possible because you have unmerged files.";
897
898        // Act
899        let is_conflict = is_rebase_conflict(detail);
900
901        // Assert
902        assert!(is_conflict);
903    }
904
905    #[test]
906    fn abort_rebase_succeeds_through_injected_boundaries() {
907        // Arrange
908        let mut command_runner = MockGitCommandRunner::new();
909        let metadata_cleaner = MockRebaseMetadataCleaner::new();
910        let mut sleeper = MockSleeper::new();
911        command_runner
912            .expect_run_git_command_output_with_env()
913            .withf(|repo_path, args, environment| {
914                repo_path == Path::new("session-worktree")
915                    && args == ["rebase", "--abort"]
916                    && environment.is_empty()
917            })
918            .once()
919            .returning(|_, _, _| Ok(success_output()));
920        sleeper.expect_sleep().times(0);
921
922        // Act
923        let result = abort_rebase_with_dependencies(
924            Path::new("session-worktree"),
925            &command_runner,
926            &sleeper,
927            &metadata_cleaner,
928        );
929
930        // Assert
931        assert!(result.is_ok());
932    }
933
934    #[test]
935    fn abort_rebase_preserves_command_runner_error() {
936        // Arrange
937        let mut command_runner = MockGitCommandRunner::new();
938        let metadata_cleaner = MockRebaseMetadataCleaner::new();
939        let mut sleeper = MockSleeper::new();
940        command_runner
941            .expect_run_git_command_output_with_env()
942            .once()
943            .return_once(|_, _, _| {
944                Err(GitError::CommandFailed {
945                    command: "git rebase --abort".to_string(),
946                    stderr: "failed to spawn git".to_string(),
947                })
948            });
949        sleeper.expect_sleep().times(0);
950
951        // Act
952        let error = abort_rebase_with_dependencies(
953            Path::new("session-worktree"),
954            &command_runner,
955            &sleeper,
956            &metadata_cleaner,
957        )
958        .expect_err("command runner failure should be preserved");
959
960        // Assert
961        assert!(matches!(
962            error,
963            GitError::CommandFailed { command, stderr }
964                if command == "git rebase --abort" && stderr == "failed to spawn git"
965        ));
966    }
967
968    #[test]
969    fn abort_rebase_preserves_actionable_command_failure() {
970        // Arrange
971        let mut command_runner = MockGitCommandRunner::new();
972        let metadata_cleaner = MockRebaseMetadataCleaner::new();
973        let mut sleeper = MockSleeper::new();
974        command_runner
975            .expect_run_git_command_output_with_env()
976            .once()
977            .returning(|_, _, _| {
978                let mut output = non_lock_failure_output();
979                output.stderr = b"fatal: cannot open .git/rebase-merge/head-name".to_vec();
980
981                Ok(output)
982            });
983        sleeper.expect_sleep().times(0);
984
985        // Act
986        let error = abort_rebase_with_dependencies(
987            Path::new("session-worktree"),
988            &command_runner,
989            &sleeper,
990            &metadata_cleaner,
991        )
992        .expect_err("failed abort should preserve the git error");
993
994        // Assert
995        assert!(matches!(
996            error,
997            GitError::CommandFailed { command, stderr }
998                if command == "git rebase --abort"
999                    && stderr.contains(".git/rebase-merge/head-name")
1000        ));
1001    }
1002
1003    #[test]
1004    fn abort_rebase_recovers_when_stale_metadata_is_removed() {
1005        // Arrange
1006        let temp_dir = tempdir().expect("tempdir should be created");
1007        let git_dir = temp_dir.path().join(".git");
1008        let stale_metadata = git_dir.join("rebase-merge");
1009        fs::create_dir(&git_dir).expect("git dir should be created");
1010        fs::create_dir(&stale_metadata).expect("stale metadata should be created");
1011        let mut command_runner = MockGitCommandRunner::new();
1012        let metadata_cleaner = FilesystemRebaseMetadataCleaner;
1013        let mut sleeper = MockSleeper::new();
1014        command_runner
1015            .expect_run_git_command_output_with_env()
1016            .once()
1017            .returning(|_, _, _| Ok(stale_rebase_failure_output()));
1018        sleeper.expect_sleep().times(0);
1019
1020        // Act
1021        let result = abort_rebase_with_dependencies(
1022            temp_dir.path(),
1023            &command_runner,
1024            &sleeper,
1025            &metadata_cleaner,
1026        );
1027
1028        // Assert
1029        assert!(result.is_ok());
1030        assert!(!stale_metadata.exists());
1031    }
1032
1033    #[test]
1034    fn abort_rebase_preserves_stale_error_when_no_metadata_is_removed() {
1035        // Arrange
1036        let mut command_runner = MockGitCommandRunner::new();
1037        let mut metadata_cleaner = MockRebaseMetadataCleaner::new();
1038        let mut sleeper = MockSleeper::new();
1039        command_runner
1040            .expect_run_git_command_output_with_env()
1041            .once()
1042            .returning(|_, _, _| Ok(stale_rebase_failure_output()));
1043        metadata_cleaner
1044            .expect_clean_stale_metadata()
1045            .once()
1046            .returning(|_| Ok(false));
1047        sleeper.expect_sleep().times(0);
1048
1049        // Act
1050        let error = abort_rebase_with_dependencies(
1051            Path::new("session-worktree"),
1052            &command_runner,
1053            &sleeper,
1054            &metadata_cleaner,
1055        )
1056        .expect_err("missing stale metadata should preserve the abort failure");
1057
1058        // Assert
1059        assert!(matches!(
1060            error,
1061            GitError::CommandFailed { command, stderr }
1062                if command == "git rebase --abort" && stderr.contains("No rebase in progress")
1063        ));
1064    }
1065
1066    #[test]
1067    fn abort_rebase_appends_stale_metadata_cleanup_failure() {
1068        // Arrange
1069        let mut command_runner = MockGitCommandRunner::new();
1070        let mut metadata_cleaner = MockRebaseMetadataCleaner::new();
1071        let mut sleeper = MockSleeper::new();
1072        command_runner
1073            .expect_run_git_command_output_with_env()
1074            .once()
1075            .returning(|_, _, _| Ok(stale_rebase_failure_output()));
1076        metadata_cleaner
1077            .expect_clean_stale_metadata()
1078            .once()
1079            .returning(|_| {
1080                Err(GitError::Io(std::io::Error::new(
1081                    ErrorKind::PermissionDenied,
1082                    "metadata is read-only",
1083                )))
1084            });
1085        sleeper.expect_sleep().times(0);
1086
1087        // Act
1088        let error = abort_rebase_with_dependencies(
1089            Path::new("session-worktree"),
1090            &command_runner,
1091            &sleeper,
1092            &metadata_cleaner,
1093        )
1094        .expect_err("cleanup failure should preserve both error contexts");
1095
1096        // Assert
1097        assert!(matches!(
1098            error,
1099            GitError::CommandFailed { command, stderr }
1100                if command == "git rebase --abort"
1101                    && stderr.contains("No rebase in progress")
1102                    && stderr.contains("metadata is read-only")
1103        ));
1104    }
1105
1106    #[test]
1107    fn stale_rebase_error_detection_matches_only_known_diagnostics() {
1108        // Arrange
1109        let stale_diagnostics = [
1110            "fatal: No rebase in progress?",
1111            "fatal: It seems that there is already a rebase-merge directory",
1112            "fatal: It seems that there is already a rebase-apply directory",
1113            "fatal: It seems that I cannot tell whether you are in the middle of another rebase",
1114        ];
1115
1116        // Act
1117        let stale_results = stale_diagnostics.map(is_stale_or_inactive_rebase_error);
1118        let unrelated_result =
1119            is_stale_or_inactive_rebase_error("fatal: cannot read rebase-merge/head-name");
1120
1121        // Assert
1122        assert!(stale_results.into_iter().all(|is_stale| is_stale));
1123        assert!(!unrelated_result);
1124    }
1125
1126    #[test]
1127    fn filesystem_metadata_cleaner_removes_exact_rebase_entries() {
1128        // Arrange
1129        let temp_dir = tempdir().expect("tempdir should be created");
1130        let git_dir = temp_dir.path().join(".git");
1131        let rebase_merge = git_dir.join("rebase-merge");
1132        let rebase_apply = git_dir.join("rebase-apply");
1133        let unrelated_metadata = git_dir.join("MERGE_HEAD");
1134        fs::create_dir(&git_dir).expect("git dir should be created");
1135        fs::create_dir(&rebase_merge).expect("rebase-merge should be created");
1136        fs::write(rebase_merge.join("head-name"), "refs/heads/main")
1137            .expect("rebase-merge metadata should be written");
1138        fs::write(&rebase_apply, "apply state").expect("rebase-apply should be written");
1139        fs::write(&unrelated_metadata, "merge state").expect("merge metadata should be written");
1140        let metadata_cleaner = FilesystemRebaseMetadataCleaner;
1141
1142        // Act
1143        let removed = metadata_cleaner
1144            .clean_stale_metadata(temp_dir.path())
1145            .expect("stale metadata cleanup should succeed");
1146
1147        // Assert
1148        assert!(removed);
1149        assert!(!rebase_merge.exists());
1150        assert!(!rebase_apply.exists());
1151        assert!(unrelated_metadata.exists());
1152    }
1153
1154    #[test]
1155    fn filesystem_metadata_cleaner_reports_no_change_without_rebase_entries() {
1156        // Arrange
1157        let temp_dir = tempdir().expect("tempdir should be created");
1158        fs::create_dir(temp_dir.path().join(".git")).expect("git dir should be created");
1159        let metadata_cleaner = FilesystemRebaseMetadataCleaner;
1160
1161        // Act
1162        let removed = metadata_cleaner
1163            .clean_stale_metadata(temp_dir.path())
1164            .expect("empty metadata cleanup should succeed");
1165
1166        // Assert
1167        assert!(!removed);
1168    }
1169
1170    #[test]
1171    fn filesystem_metadata_cleaner_rejects_missing_git_directory() {
1172        // Arrange
1173        let temp_dir = tempdir().expect("tempdir should be created");
1174        let metadata_cleaner = FilesystemRebaseMetadataCleaner;
1175
1176        // Act
1177        let error = metadata_cleaner
1178            .clean_stale_metadata(temp_dir.path())
1179            .expect_err("repository without git metadata should fail");
1180
1181        // Assert
1182        assert!(matches!(
1183            error,
1184            GitError::OutputParse(message) if message == "Failed to resolve git directory"
1185        ));
1186    }
1187
1188    #[test]
1189    fn remove_stale_metadata_preserves_non_not_found_io_error() {
1190        // Arrange
1191        let temp_dir = tempdir().expect("tempdir should be created");
1192        let parent_file = temp_dir.path().join("parent-file");
1193        fs::write(&parent_file, "not a directory").expect("parent file should be written");
1194
1195        // Act
1196        let error = remove_stale_rebase_metadata_path(&parent_file.join("rebase-merge"))
1197            .expect_err("non-directory parent should remain an I/O error");
1198
1199        // Assert
1200        assert!(matches!(error, GitError::Io(_)));
1201    }
1202
1203    #[cfg(unix)]
1204    #[test]
1205    fn filesystem_metadata_cleaner_does_not_follow_directory_symlink() {
1206        // Arrange
1207        let temp_dir = tempdir().expect("tempdir should be created");
1208        let git_dir = temp_dir.path().join(".git");
1209        let external_dir = temp_dir.path().join("external-rebase-data");
1210        let external_marker = external_dir.join("marker");
1211        fs::create_dir(&git_dir).expect("git dir should be created");
1212        fs::create_dir(&external_dir).expect("external directory should be created");
1213        fs::write(&external_marker, "preserve").expect("external marker should be written");
1214        unix_fs::symlink(&external_dir, git_dir.join("rebase-merge"))
1215            .expect("metadata symlink should be created");
1216        let metadata_cleaner = FilesystemRebaseMetadataCleaner;
1217
1218        // Act
1219        let removed = metadata_cleaner
1220            .clean_stale_metadata(temp_dir.path())
1221            .expect("symlink cleanup should succeed");
1222
1223        // Assert
1224        assert!(removed);
1225        assert!(!git_dir.join("rebase-merge").exists());
1226        assert!(external_marker.exists());
1227    }
1228
1229    #[test]
1230    fn test_in_progress_operation_detects_rebase_metadata() {
1231        // Arrange
1232        let temp_dir = tempdir().expect("tempdir should be created");
1233        let git_dir = temp_dir.path().join(".git");
1234        fs::create_dir(&git_dir).expect("git dir should be created");
1235        fs::create_dir(git_dir.join("rebase-merge")).expect("rebase metadata should be created");
1236
1237        // Act
1238        let operation =
1239            in_progress_operation_sync(temp_dir.path()).expect("operation should be detected");
1240
1241        // Assert
1242        assert_eq!(operation, Some(InProgressGitOperation::Rebase));
1243    }
1244
1245    #[test]
1246    fn test_in_progress_operation_detects_merge_metadata() {
1247        // Arrange
1248        let temp_dir = tempdir().expect("tempdir should be created");
1249        let git_dir = temp_dir.path().join(".git");
1250        fs::create_dir(&git_dir).expect("git dir should be created");
1251        fs::write(git_dir.join("MERGE_HEAD"), "merge").expect("merge metadata should be created");
1252
1253        // Act
1254        let operation =
1255            in_progress_operation_sync(temp_dir.path()).expect("operation should be detected");
1256
1257        // Assert
1258        assert_eq!(operation, Some(InProgressGitOperation::Merge));
1259    }
1260
1261    #[test]
1262    fn test_in_progress_operation_detects_cherry_pick_metadata() {
1263        // Arrange
1264        let temp_dir = tempdir().expect("tempdir should be created");
1265        let git_dir = temp_dir.path().join(".git");
1266        fs::create_dir(&git_dir).expect("git dir should be created");
1267        fs::write(git_dir.join("CHERRY_PICK_HEAD"), "cherry-pick")
1268            .expect("cherry-pick metadata should be created");
1269
1270        // Act
1271        let operation =
1272            in_progress_operation_sync(temp_dir.path()).expect("operation should be detected");
1273
1274        // Assert
1275        assert_eq!(operation, Some(InProgressGitOperation::CherryPick));
1276    }
1277
1278    #[test]
1279    fn test_in_progress_operation_detects_revert_metadata() {
1280        // Arrange
1281        let temp_dir = tempdir().expect("tempdir should be created");
1282        let git_dir = temp_dir.path().join(".git");
1283        fs::create_dir(&git_dir).expect("git dir should be created");
1284        fs::write(git_dir.join("REVERT_HEAD"), "revert")
1285            .expect("revert metadata should be created");
1286
1287        // Act
1288        let operation =
1289            in_progress_operation_sync(temp_dir.path()).expect("operation should be detected");
1290
1291        // Assert
1292        assert_eq!(operation, Some(InProgressGitOperation::Revert));
1293    }
1294
1295    #[test]
1296    fn test_in_progress_operation_returns_none_for_clean_git_dir() {
1297        // Arrange
1298        let temp_dir = tempdir().expect("tempdir should be created");
1299        fs::create_dir(temp_dir.path().join(".git")).expect("git dir should be created");
1300
1301        // Act
1302        let operation =
1303            in_progress_operation_sync(temp_dir.path()).expect("operation should be detected");
1304
1305        // Assert
1306        assert_eq!(operation, None);
1307    }
1308
1309    /// Returns a successful git command output.
1310    fn success_output() -> Output {
1311        Command::new("git")
1312            .arg("--version")
1313            .output()
1314            .expect("failed to run git --version")
1315    }
1316
1317    /// Returns a failing git command output that matches index lock contention.
1318    fn git_index_lock_output() -> Output {
1319        let mut output = Command::new("git")
1320            .arg("definitely-invalid-subcommand")
1321            .output()
1322            .expect("failed to run git invalid command");
1323        output.stdout = vec![];
1324        output.stderr = b"fatal: Unable to create '.git/index.lock': File exists.".to_vec();
1325
1326        output
1327    }
1328
1329    /// Returns a failing git command output that is unrelated to index locking.
1330    fn non_lock_failure_output() -> Output {
1331        let mut output = Command::new("git")
1332            .arg("definitely-invalid-subcommand")
1333            .output()
1334            .expect("failed to run git invalid command");
1335        output.stdout = vec![];
1336        output.stderr = b"fatal: not a git repository".to_vec();
1337
1338        output
1339    }
1340
1341    /// Returns a failing git command output for known stale rebase metadata.
1342    fn stale_rebase_failure_output() -> Output {
1343        let mut output = non_lock_failure_output();
1344        output.stderr = b"fatal: No rebase in progress?".to_vec();
1345
1346        output
1347    }
1348}