use std::path::{Path, PathBuf};
use std::process::Command;
use crate::engine::git::{
current_branch, get_default_branch, is_clean, land as git_land, LandStrategy,
};
use crate::engine::naming::parse_branch_name;
use crate::engine::worktrees::{
create_with_schema, main_repo_root, preserve_worktree, push_branch_with_upstream,
wave_name_from_worktree_and_main, worktree_path,
};
use crate::engine::command::run_command;
use crate::ops::commit::{commit_workflow, CommitOptions};
use crate::ops::error::{OpsError, OpsResult};
use crate::ops::pr::{generate_pr_copy, PrInfo};
use crate::ops::progress::Progress;
#[derive(Debug, Clone)]
pub struct LandOptions {
pub strict: bool,
pub local: bool,
pub create_pr: bool,
pub worktree: Option<String>,
pub commit_message: Option<String>,
pub pr_title: Option<String>,
pub pr_body: Option<String>,
}
#[derive(Debug, Clone)]
pub struct LandResult {
pub merged: bool,
pub rotation: Option<RotationResult>,
pub pr: Option<PrInfo>,
}
#[derive(Debug, Clone)]
pub enum RotationResult {
Advanced {
preserved: PathBuf,
new_path: PathBuf,
},
Complete { preserved: PathBuf },
}
pub fn land(repo: &Path, options: &LandOptions, progress: &impl Progress) -> OpsResult<LandResult> {
let (repo_root, main_repo) = resolve_repos(repo, options.worktree.as_deref())?;
let feature_branch = current_branch(&repo_root)?
.ok_or_else(|| OpsError::Message("not on a branch".to_string()))?;
prepare_land(&repo_root, options, progress)?;
let main_branch = rebase_land(&repo_root, &main_repo, progress)?;
let pr_exists = if options.local {
false
} else {
crate::ops::pr::pr_exists_for_current_branch(&repo_root)?
};
if !options.local && !pr_exists && !options.create_pr {
return Err(OpsError::Message(format!(
"no open PR found for branch '{feature_branch}'; run lf op pr or use --create-pr"
)));
}
let (pr_title, pr_body) = resolve_pr_copy(&repo_root, options, progress)?;
clear_scratch(&repo_root, progress)?;
let pr = if options.local {
finalize_local(&repo_root, &main_branch, &feature_branch, progress)?;
None
} else {
ensure_pr(
&repo_root,
pr_exists,
&feature_branch,
options.create_pr,
pr_title.as_deref(),
pr_body.as_deref(),
progress,
)?;
finalize_remote(
&repo_root,
pr_title.as_deref(),
pr_body.as_deref(),
progress,
)?;
crate::ops::pr::current_pr(&repo_root).ok().flatten()
};
let rotation = rotate_worktree(&repo_root, &main_repo, &feature_branch, progress)?;
Ok(LandResult {
merged: true,
rotation,
pr,
})
}
fn resolve_pr_copy(
repo_root: &Path,
options: &LandOptions,
progress: &impl Progress,
) -> OpsResult<(Option<String>, Option<String>)> {
if options.local {
return Ok((options.pr_title.clone(), options.pr_body.clone()));
}
let mut pr_title = options.pr_title.clone();
let mut pr_body = options.pr_body.clone();
if pr_title.is_some() {
return Ok((pr_title, pr_body));
}
if let Some((title, body)) = read_cached_pr_copy(repo_root, progress)? {
progress.status("Using cached PR copy from scratch/");
pr_title = Some(title);
if pr_body.is_none() {
pr_body = Some(body);
}
return Ok((pr_title, pr_body));
}
let generated = generate_pr_copy(repo_root, progress)?;
pr_title = Some(generated.title);
if pr_body.is_none() {
pr_body = Some(generated.body);
}
Ok((pr_title, pr_body))
}
fn read_cached_pr_copy(
repo_root: &Path,
progress: &impl Progress,
) -> OpsResult<Option<(String, String)>> {
let title_path = repo_root.join("scratch/pr-title.txt");
let body_path = repo_root.join("scratch/pr-body.md");
let ref_path = repo_root.join("scratch/.pr-copy-ref");
if !title_path.exists() || !body_path.exists() {
return Ok(None);
}
let title = std::fs::read_to_string(&title_path)?.trim().to_string();
if title.is_empty() {
return Ok(None);
}
let copied_for = match std::fs::read_to_string(&ref_path) {
Ok(value) => value.trim().to_string(),
Err(_) => {
progress.status("Ignoring cached PR copy: scratch/.pr-copy-ref is missing");
return Ok(None);
}
};
if !is_recent_ancestor(repo_root, &copied_for, 1)? {
progress.status("Ignoring cached PR copy: branch changed since gate output");
return Ok(None);
}
let body = std::fs::read_to_string(body_path)?;
Ok(Some((title, body)))
}
fn is_recent_ancestor(repo_root: &Path, commit: &str, max_ahead: u32) -> OpsResult<bool> {
let output = Command::new("git")
.args(["rev-list", "--count", &format!("{commit}..HEAD")])
.current_dir(repo_root)
.output()?;
if !output.status.success() {
return Ok(false);
}
let ahead = String::from_utf8_lossy(&output.stdout)
.trim()
.parse::<u32>()
.unwrap_or(u32::MAX);
Ok(ahead <= max_ahead)
}
fn prepare_land(
repo_root: &Path,
options: &LandOptions,
progress: &impl Progress,
) -> OpsResult<()> {
if options.strict && !is_clean(repo_root)? {
return Err(OpsError::Message(
"uncommitted changes; commit, stash, or rerun without --strict".to_string(),
));
}
if !options.strict {
let message = options
.commit_message
.clone()
.unwrap_or_else(|| "lf land: stage uncommitted changes".to_string());
let commit_options = CommitOptions {
add: true,
push: true,
create_draft_pr: true,
message: Some(message),
..CommitOptions::for_task("land")
};
let _ = commit_workflow(repo_root, &commit_options, progress)?;
}
Ok(())
}
fn rebase_land(repo_root: &Path, main_repo: &Path, progress: &impl Progress) -> OpsResult<String> {
let main_branch = get_default_branch(main_repo)?;
crate::ops::rebase::rebase_with_recovery(
repo_root,
&crate::ops::rebase::RebaseOptions {
onto: format!("origin/{main_branch}"),
push: true,
},
progress,
)?;
Ok(main_branch)
}
fn finalize_local(
repo_root: &Path,
main_branch: &str,
feature_branch: &str,
progress: &impl Progress,
) -> OpsResult<()> {
progress.status("Merging locally...");
let _ = git_land(repo_root, LandStrategy::LocalMerge, main_branch)?;
delete_remote_branch(repo_root, feature_branch)?;
Ok(())
}
fn ensure_pr(
repo_root: &Path,
pr_exists: bool,
feature_branch: &str,
create_pr: bool,
pr_title: Option<&str>,
pr_body: Option<&str>,
progress: &impl Progress,
) -> OpsResult<()> {
if !crate::ops::pr::gh_available() {
return Err(OpsError::Message("gh CLI not found".to_string()));
}
if !pr_exists {
if create_pr {
let title = pr_title.ok_or_else(|| {
OpsError::Message(
"no PR title provided; run `lf gate` or pass --title/--body".to_string(),
)
})?;
let body = pr_body.unwrap_or("");
let _ = crate::ops::pr::create_or_update_pr(
repo_root,
&crate::ops::pr::PrOptions {
title: Some(title.to_string()),
body: Some(body.to_string()),
},
progress,
)?;
} else {
return Err(OpsError::Message(format!(
"no open PR found for branch '{feature_branch}'; run lf op pr or use --create-pr"
)));
}
}
Ok(())
}
fn finalize_remote(
repo_root: &Path,
pr_title: Option<&str>,
pr_body: Option<&str>,
progress: &impl Progress,
) -> OpsResult<()> {
if let Some(title) = pr_title {
let body = pr_body.unwrap_or("");
progress.status("Updating PR...");
update_pr_message(repo_root, title, body)?;
} else {
progress.status("Enabling auto-merge...");
}
mark_ready(repo_root)?;
enable_auto_merge(repo_root, pr_title, pr_body)?;
if let Some(url) = current_pr_url(repo_root)? {
progress.status(&format!("\n{url}\n"));
open_url(&url);
}
Ok(())
}
fn resolve_repos(repo: &Path, worktree: Option<&str>) -> OpsResult<(PathBuf, PathBuf)> {
let main_repo = main_repo_root(repo).unwrap_or_else(|_| repo.to_path_buf());
let repo_root = if let Some(worktree) = worktree {
let candidate = Path::new(worktree);
if candidate.exists() {
candidate.to_path_buf()
} else {
let path = worktree_path(&main_repo, worktree);
if path.exists() {
path
} else {
return Err(OpsError::Message(format!(
"worktree not found: {}",
worktree
)));
}
}
} else {
repo.to_path_buf()
};
Ok((repo_root, main_repo))
}
fn clear_scratch(repo: &Path, progress: &impl Progress) -> OpsResult<()> {
let scratch = repo.join("scratch");
let gitkeep = scratch.join(".gitkeep");
if !scratch.exists() {
std::fs::create_dir_all(&scratch)?;
}
let mut removed = false;
for entry in std::fs::read_dir(&scratch)? {
let entry = entry?;
let path = entry.path();
if path == gitkeep {
continue;
}
if path.is_dir() {
std::fs::remove_dir_all(&path)?;
} else {
std::fs::remove_file(&path)?;
}
removed = true;
}
if !gitkeep.exists() {
std::fs::write(&gitkeep, "")?;
removed = true; }
if !removed {
return Ok(());
}
progress.status("Clearing scratch/...");
crate::engine::git::stage_all(repo)?;
if has_staged_changes(repo)? {
crate::engine::git::commit(repo, "lf land: clear scratch/")?;
crate::ops::commit::push_with_upstream_if_needed(repo)?;
}
Ok(())
}
fn has_staged_changes(repo: &Path) -> OpsResult<bool> {
let status = Command::new("git")
.arg("diff")
.arg("--cached")
.arg("--quiet")
.current_dir(repo)
.status()?;
Ok(!status.success())
}
fn update_pr_message(repo: &Path, title: &str, body: &str) -> OpsResult<()> {
let mut cmd = Command::new("gh");
cmd.arg("pr")
.arg("edit")
.arg("--title")
.arg(title)
.arg("--body")
.arg(body)
.current_dir(repo);
if let Err(err) = run_command(&mut cmd) {
return Err(OpsError::CommandFailed {
command: err.command_line(),
stderr: err.stderr,
});
}
Ok(())
}
pub fn mark_ready(repo: &Path) -> OpsResult<()> {
let mut cmd = Command::new("gh");
cmd.arg("pr").arg("ready").current_dir(repo);
let _ = run_command(&mut cmd);
Ok(())
}
fn enable_auto_merge(repo: &Path, title: Option<&str>, body: Option<&str>) -> OpsResult<()> {
let mut cmd = Command::new("gh");
cmd.arg("pr").arg("merge").arg("--squash").arg("--auto");
if let Some(title) = title {
cmd.arg("--subject").arg(title);
}
if let Some(body) = body.filter(|b| !b.trim().is_empty()) {
cmd.arg("--body").arg(body);
}
cmd.current_dir(repo);
if let Err(err) = run_command(&mut cmd) {
return Err(OpsError::CommandFailed {
command: err.command_line(),
stderr: err.stderr,
});
}
Ok(())
}
fn delete_remote_branch(repo: &Path, branch: &str) -> OpsResult<()> {
let mut cmd = Command::new("git");
cmd.args(["push", "origin", "--delete", branch])
.current_dir(repo);
if let Err(err) = run_command(&mut cmd) {
return Err(OpsError::CommandFailed {
command: err.command_line(),
stderr: err.stderr,
});
}
Ok(())
}
fn current_pr_url(repo: &Path) -> OpsResult<Option<String>> {
let mut cmd = Command::new("gh");
cmd.arg("pr")
.arg("view")
.arg("--json")
.arg("url")
.arg("-q")
.arg(".url")
.current_dir(repo);
let output = match run_command(&mut cmd) {
Ok(output) => output,
Err(_) => return Ok(None),
};
let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
if url.is_empty() {
Ok(None)
} else {
Ok(Some(url))
}
}
fn open_url(url: &str) {
crate::engine::platform::open_url(url);
}
fn rotate_worktree(
repo_root: &Path,
main_repo: &Path,
feature_branch: &str,
progress: &impl Progress,
) -> OpsResult<Option<RotationResult>> {
let wave_name = match wave_name_from_worktree_and_main(repo_root, main_repo) {
Some(name) => name,
None => return Ok(None),
};
if wave_name.contains('.') {
return Ok(None);
}
let wave_dir = repo_root.join("wave").join(&wave_name);
let has_items = wave_dir.exists() && has_wave_items(&wave_dir)?;
let branch_suffix = parse_branch_name(feature_branch, None).and_then(|parts| parts.timestamp);
let preserved = preserve_worktree(main_repo, repo_root, branch_suffix.as_deref())?;
progress.status(&format!(
"Preserved {} → {}",
repo_root.display(),
preserved.display()
));
if has_items {
let result = create_with_schema(main_repo, &wave_name, Some(feature_branch), None)?;
let _ = push_branch_with_upstream(&result.path, &result.branch);
progress.status(&format!(
"Created new worktree at {}",
result.path.display()
));
Ok(Some(RotationResult::Advanced {
preserved,
new_path: result.path,
}))
} else {
progress.status("Wave complete — no more items");
Ok(Some(RotationResult::Complete { preserved }))
}
}
fn has_wave_items(wave_dir: &Path) -> OpsResult<bool> {
for entry in std::fs::read_dir(wave_dir)? {
let entry = entry?;
let path = entry.path();
if !path.is_file() {
continue;
}
let ext = path.extension().and_then(|e| e.to_str());
if ext != Some("md") {
continue;
}
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if name.eq_ignore_ascii_case("readme.md") {
continue;
}
return Ok(true);
}
Ok(false)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ops::NullProgress;
use std::process::Command;
use tempfile::TempDir;
fn git(repo: &Path, args: &[&str]) -> String {
let output = Command::new("git")
.args(args)
.current_dir(repo)
.output()
.unwrap();
assert!(
output.status.success(),
"git {:?} failed: {}",
args,
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8_lossy(&output.stdout).trim().to_string()
}
#[test]
fn rotate_worktree_bases_on_feature_branch() {
let dir = TempDir::new().unwrap();
let main_repo = dir.path().join("repo");
std::fs::create_dir(&main_repo).unwrap();
git(&main_repo, &["init", "-b", "main"]);
git(&main_repo, &["config", "user.email", "test@test.com"]);
git(&main_repo, &["config", "user.name", "test"]);
std::fs::write(main_repo.join("file.txt"), "initial").unwrap();
git(&main_repo, &["add", "."]);
git(&main_repo, &["commit", "-m", "initial"]);
let main_commit = git(&main_repo, &["rev-parse", "HEAD"]);
let feature_branch = "test.mywave.20260228_1500";
git(&main_repo, &["checkout", "-b", feature_branch]);
std::fs::write(main_repo.join("feature.txt"), "feature work").unwrap();
git(&main_repo, &["add", "."]);
git(&main_repo, &["commit", "-m", "feature"]);
let feature_commit = git(&main_repo, &["rev-parse", "HEAD"]);
git(&main_repo, &["checkout", "main"]);
assert_ne!(main_commit, feature_commit);
let wt_path = dir.path().join("repo.mywave");
git(
&main_repo,
&[
"worktree",
"add",
"-b",
"wt-temp",
wt_path.to_str().unwrap(),
"main",
],
);
let wave_dir = wt_path.join("wave").join("mywave");
std::fs::create_dir_all(&wave_dir).unwrap();
std::fs::write(wave_dir.join("01-next-item.md"), "# Next").unwrap();
let result = rotate_worktree(&wt_path, &main_repo, feature_branch, &NullProgress)
.unwrap()
.expect("should produce a rotation");
let new_path = match result {
RotationResult::Advanced { new_path, .. } => new_path,
other => panic!("expected Advanced, got {:?}", other),
};
let new_head = git(&new_path, &["rev-parse", "HEAD"]);
assert_eq!(
new_head, feature_commit,
"new worktree should be based on the feature branch"
);
assert_ne!(
new_head, main_commit,
"new worktree should NOT be based on main"
);
}
#[test]
fn rotate_worktree_preserves_with_branch_timestamp() {
let dir = TempDir::new().unwrap();
let main_repo = dir.path().join("repo");
std::fs::create_dir(&main_repo).unwrap();
git(&main_repo, &["init", "-b", "main"]);
git(&main_repo, &["config", "user.email", "test@test.com"]);
git(&main_repo, &["config", "user.name", "test"]);
std::fs::write(main_repo.join("file.txt"), "initial").unwrap();
git(&main_repo, &["add", "."]);
git(&main_repo, &["commit", "-m", "initial"]);
let feature_branch = "test.mywave.20260228_1500";
git(&main_repo, &["checkout", "-b", feature_branch]);
std::fs::write(main_repo.join("feature.txt"), "work").unwrap();
git(&main_repo, &["add", "."]);
git(&main_repo, &["commit", "-m", "feature"]);
git(&main_repo, &["checkout", "main"]);
let wt_path = dir.path().join("repo.mywave");
git(
&main_repo,
&[
"worktree",
"add",
"-b",
"wt-temp",
wt_path.to_str().unwrap(),
"main",
],
);
let result = rotate_worktree(&wt_path, &main_repo, feature_branch, &NullProgress)
.unwrap()
.expect("should produce a rotation");
let preserved = match result {
RotationResult::Complete { preserved } => preserved,
other => panic!("expected Complete (no wave items), got {:?}", other),
};
let dir_name = preserved.file_name().unwrap().to_string_lossy().to_string();
assert!(
dir_name.ends_with(".20260228_1500"),
"preserved dir should use the branch timestamp, got: {dir_name}"
);
}
}