Skip to main content

ag_git/
rebase.rs

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