use std::path::{Path, PathBuf};
use std::process::Command;
use crate::engine::git::{
current_branch, fetch, get_default_branch, rebase, squash_merge_fork_point, sync_main,
};
use crate::engine::worktrees::StackBranch;
use crate::ops::error::{OpsError, OpsResult};
use crate::ops::progress::Progress;
#[derive(Debug, Clone)]
pub struct RebaseOptions {
pub onto: String,
pub push: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RebaseClass {
Current,
StaleEmpty,
ScratchOnly,
GeneratedOnly,
CleanAuthored,
StackParentOpen,
Protected,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RebaseStrategy {
Noop,
ResetToBase,
DirectRebase,
RebaseOntoParent,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RebasePlan {
pub branch: String,
pub base_ref: String,
pub stack_parent: Option<String>,
pub class: RebaseClass,
pub strategy: RebaseStrategy,
pub unique_commits: usize,
pub changed_files: Vec<PathBuf>,
pub protected: bool,
pub scratch_stashed: bool,
}
pub fn plan_rebase(repo: &Path, onto: Option<&str>) -> OpsResult<RebasePlan> {
let default_branch = get_default_branch(repo)?;
let branch = current_branch(repo)?.unwrap_or_else(|| "HEAD".to_string());
let stack = StackBranch::parse(&branch, &default_branch);
let stack_parent = stack.as_ref().and_then(StackBranch::parent);
let parent_base_ref = stack_parent.as_deref().and_then(|parent| {
if ref_exists(repo, parent) {
Some(parent.to_string())
} else {
let remote_parent = format!("origin/{parent}");
ref_exists(repo, &remote_parent).then_some(remote_parent)
}
});
let base_ref = if let Some(onto) = onto {
onto.to_string()
} else if let Some(parent_base_ref) = parent_base_ref.as_ref() {
parent_base_ref.clone()
} else {
format!("origin/{default_branch}")
};
let unique_commits = count_unique_commits(repo, &base_ref).unwrap_or(0);
let mut changed_files = diff_names(repo, &base_ref).unwrap_or_default();
for path in dirty_paths(repo).unwrap_or_default() {
if !changed_files.contains(&path) {
changed_files.push(path);
}
}
changed_files.sort();
let protected = changed_files.iter().any(|path| is_protected_path(path));
let scratch_only = !changed_files.is_empty()
&& changed_files
.iter()
.all(|path| path.starts_with(Path::new("scratch")));
let has_non_scratch_changes = changed_files
.iter()
.any(|path| !path.starts_with(Path::new("scratch")));
let (class, strategy) = if branch == default_branch {
(RebaseClass::Protected, RebaseStrategy::Noop)
} else if protected {
(RebaseClass::Protected, RebaseStrategy::DirectRebase)
} else if parent_base_ref.is_some() && onto.is_none() {
(
RebaseClass::StackParentOpen,
RebaseStrategy::RebaseOntoParent,
)
} else if unique_commits == 0 && scratch_only {
(RebaseClass::ScratchOnly, RebaseStrategy::ResetToBase)
} else if unique_commits == 0 && changed_files.is_empty() {
(RebaseClass::StaleEmpty, RebaseStrategy::ResetToBase)
} else if has_non_scratch_changes {
(RebaseClass::CleanAuthored, RebaseStrategy::DirectRebase)
} else if unique_commits == 0 {
(RebaseClass::Current, RebaseStrategy::Noop)
} else if scratch_only || generated_only(repo, &base_ref).unwrap_or(false) {
(RebaseClass::GeneratedOnly, RebaseStrategy::ResetToBase)
} else {
(RebaseClass::CleanAuthored, RebaseStrategy::DirectRebase)
};
let scratch_stashed =
matches!(strategy, RebaseStrategy::ResetToBase) && repo.join("scratch").exists();
Ok(RebasePlan {
branch,
base_ref,
stack_parent: parent_base_ref.as_ref().and(stack_parent),
class,
strategy,
unique_commits,
changed_files,
protected,
scratch_stashed,
})
}
pub fn rebase_with_recovery(
repo: &Path,
options: &RebaseOptions,
progress: &impl Progress,
) -> OpsResult<()> {
let plan = plan_rebase(repo, Some(&options.onto))?;
if matches!(plan.strategy, RebaseStrategy::ResetToBase) && !plan.protected {
return reset_to_base(repo, &plan, options.push, progress);
}
if let Some(branch) = options.onto.strip_prefix("origin/") {
let _ = fetch(repo, "origin", branch);
let _ = sync_main(repo, branch);
}
let fork_point = squash_merge_fork_point(repo, &options.onto).unwrap_or(None);
progress.status(&format!("Rebasing onto {}...", options.onto));
let result = rebase(repo, &options.onto, fork_point.as_deref())?;
if result.success {
if fork_point.is_some() {
progress.status("Skipped squash-merged parent commits");
}
if options.push {
crate::ops::commit::push_with_upstream_if_needed(repo)?;
}
return Ok(());
}
let detail = result
.conflicts
.filter(|conflicts| !conflicts.is_empty())
.map(|conflicts| {
let conflict_paths = conflicts
.iter()
.map(|path| path.display().to_string())
.collect::<Vec<_>>()
.join(", ");
format!("conflicts: {conflict_paths}")
})
.unwrap_or_else(|| "manual resolution required".to_string());
Err(OpsError::RebaseConflict {
onto: options.onto.clone(),
detail,
})
}
fn reset_to_base(
repo: &Path,
plan: &RebasePlan,
push: bool,
progress: &impl Progress,
) -> OpsResult<()> {
let stash_path = if repo.join("scratch").exists() {
let path = scratch_stash_path(repo, &plan.branch);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
if path.exists() {
std::fs::remove_dir_all(&path)?;
}
copy_dir(&repo.join("scratch"), &path)?;
Some(path)
} else {
None
};
progress.status(&format!(
"Resetting {} to {}...",
plan.branch, plan.base_ref
));
git(repo, &["reset", "--hard", &plan.base_ref])?;
if let Some(path) = stash_path {
let scratch = repo.join("scratch");
if scratch.exists() {
std::fs::remove_dir_all(&scratch)?;
}
copy_dir(&path, &scratch)?;
progress.status(&format!("Restored scratch from {}", path.display()));
}
if push {
push_reset(repo)?;
}
Ok(())
}
fn push_reset(repo: &Path) -> OpsResult<()> {
let upstream = Command::new("git")
.arg("-C")
.arg(repo)
.args([
"rev-parse",
"--abbrev-ref",
"--symbolic-full-name",
"@{upstream}",
])
.output()?;
if upstream.status.success() {
crate::engine::git::push(repo, true)?;
return Ok(());
}
let branch =
current_branch(repo)?.ok_or_else(|| OpsError::Message("not on a branch".into()))?;
crate::engine::git::push_with_upstream(repo, "origin", &branch)?;
Ok(())
}
fn scratch_stash_path(repo: &Path, branch: &str) -> PathBuf {
let ts = chrono::Utc::now().format("%Y%m%dT%H%M%SZ");
let safe_branch = branch.replace(['/', '.'], "-");
repo.join(".lf")
.join("scratch-stash")
.join(format!("{safe_branch}-{ts}"))
}
fn copy_dir(from: &Path, to: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(to)?;
for entry in std::fs::read_dir(from)? {
let entry = entry?;
let source = entry.path();
let target = to.join(entry.file_name());
if entry.file_type()?.is_dir() {
copy_dir(&source, &target)?;
} else {
std::fs::copy(&source, &target)?;
}
}
Ok(())
}
fn git(repo: &Path, args: &[&str]) -> OpsResult<String> {
let output = Command::new("git")
.arg("-C")
.arg(repo)
.args(args)
.output()?;
if !output.status.success() {
return Err(OpsError::Git(crate::engine::GitError::CommandFailed {
command: format!("git {}", args.join(" ")),
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
}));
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
fn ref_exists(repo: &Path, ref_name: &str) -> bool {
Command::new("git")
.arg("-C")
.arg(repo)
.args(["rev-parse", "--verify", "--quiet", ref_name])
.output()
.map(|output| output.status.success())
.unwrap_or(false)
}
fn count_unique_commits(repo: &Path, base_ref: &str) -> OpsResult<usize> {
let stdout = git(repo, &["rev-list", "--count", &format!("{base_ref}..HEAD")])?;
Ok(stdout.trim().parse().unwrap_or(0))
}
fn diff_names(repo: &Path, base_ref: &str) -> OpsResult<Vec<PathBuf>> {
let stdout = git(
repo,
&["diff", "--name-only", &format!("{base_ref}...HEAD")],
)?;
Ok(stdout
.lines()
.filter(|line| !line.trim().is_empty())
.map(PathBuf::from)
.collect())
}
fn dirty_paths(repo: &Path) -> OpsResult<Vec<PathBuf>> {
let output = Command::new("git")
.arg("-C")
.arg(repo)
.args(["status", "--porcelain"])
.output()?;
if !output.status.success() {
return Err(OpsError::Git(crate::engine::GitError::CommandFailed {
command: "git status --porcelain".to_string(),
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
}));
}
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(stdout
.lines()
.filter_map(|line| {
if line.len() < 4 {
return None;
}
let path = &line[3..];
let path = path
.split_once(" -> ")
.map(|(_, right)| right)
.unwrap_or(path);
Some(PathBuf::from(path))
})
.collect())
}
fn generated_only(repo: &Path, base_ref: &str) -> OpsResult<bool> {
let stdout = git(repo, &["log", "--format=%s", &format!("{base_ref}..HEAD")])?;
let subjects = stdout
.lines()
.filter(|line| !line.trim().is_empty())
.collect::<Vec<_>>();
Ok(!subjects.is_empty()
&& subjects.iter().all(|subject| {
subject.starts_with("checkpoint:")
|| subject.starts_with("wip:")
|| subject.contains("generated")
}))
}
fn is_protected_path(path: &Path) -> bool {
path.starts_with(Path::new("wave"))
|| path.starts_with(Path::new(".lf/steps"))
|| path.starts_with(Path::new(".lf/flows"))
|| path.starts_with(Path::new(".lf/directions"))
|| path == Path::new(".lf/config.yaml")
}
pub fn rebase_class_name(class: &RebaseClass) -> &'static str {
match class {
RebaseClass::Current => "current",
RebaseClass::StaleEmpty => "stale_empty",
RebaseClass::ScratchOnly => "scratch_only",
RebaseClass::GeneratedOnly => "generated_only",
RebaseClass::CleanAuthored => "clean_authored",
RebaseClass::StackParentOpen => "stack_parent_open",
RebaseClass::Protected => "protected",
}
}
pub fn rebase_strategy_name(strategy: &RebaseStrategy) -> &'static str {
match strategy {
RebaseStrategy::Noop => "noop",
RebaseStrategy::ResetToBase => "reset_to_base",
RebaseStrategy::DirectRebase => "direct_rebase",
RebaseStrategy::RebaseOntoParent => "rebase_onto_parent",
}
}