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 = 21;
18pub(super) const GIT_INDEX_LOCK_RETRY_DELAY: Duration = Duration::from_millis(250);
19
20#[cfg_attr(test, mockall::automock)]
22trait GitCommandRunner: Send + Sync {
23 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#[cfg_attr(test, mockall::automock)]
34trait RebaseMetadataCleaner: Send + Sync {
35 fn clean_stale_metadata(&self, repo_path: &Path) -> Result<bool, GitError>;
37}
38
39struct 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
48struct 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#[derive(Clone, Debug, Eq, PartialEq)]
70pub enum RebaseStepResult {
71 Completed,
73 Conflict {
75 detail: String,
77 },
78}
79
80#[derive(Clone, Copy, Debug, Eq, PartialEq)]
82pub enum InProgressGitOperation {
83 CherryPick,
85 Merge,
87 Rebase,
89 Revert,
91}
92
93impl InProgressGitOperation {
94 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 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
116pub(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
148pub(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
175pub(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
205pub(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
241pub(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
266pub(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
302pub(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
346pub(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
362pub(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 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
422pub(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
452fn 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
476fn 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
517fn 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
527fn 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
537fn 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
554pub(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
572fn 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
609pub(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
623pub(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)]
633#[path = "rebase_test.rs"]
634mod tests;