diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index 8095cfca..ce00a2e6 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -250,6 +250,9 @@ enum Commands { /// Emit machine-readable JSON instead of the human summary #[arg(long)] json: bool, + /// Process the queue, auto-rebasing clean blocked worktrees and merging what becomes ready + #[arg(long)] + apply: bool, }, /// Prune worktrees for inactive sessions and report any active sessions still holding one PruneWorktrees { @@ -844,12 +847,21 @@ async fn main() -> Result<()> { } } } - Some(Commands::MergeQueue { json }) => { - let report = session::manager::build_merge_queue(&db)?; - if json { - println!("{}", serde_json::to_string_pretty(&report)?); + Some(Commands::MergeQueue { json, apply }) => { + if apply { + let outcome = session::manager::process_merge_queue(&db).await?; + if json { + println!("{}", serde_json::to_string_pretty(&outcome)?); + } else { + println!("{}", format_bulk_worktree_merge_human(&outcome)); + } } else { - println!("{}", format_merge_queue_human(&report)); + let report = session::manager::build_merge_queue(&db)?; + if json { + println!("{}", serde_json::to_string_pretty(&report)?); + } else { + println!("{}", format_merge_queue_human(&report)); + } } } Some(Commands::PruneWorktrees { json }) => { @@ -1506,6 +1518,26 @@ fn format_bulk_worktree_merge_human( )); } + if !outcome.rebased.is_empty() { + lines.push(format!( + "Rebased {} blocked worktree(s) onto their base branch", + outcome.rebased.len() + )); + for rebased in &outcome.rebased { + lines.push(format!( + "- rebased {} onto {} for {}{}", + rebased.branch, + rebased.base_branch, + short_session(&rebased.session_id), + if rebased.already_up_to_date { + " (already up to date)" + } else { + "" + } + )); + } + } + if !outcome.active_with_worktree_ids.is_empty() { lines.push(format!( "Skipped {} active worktree session(s)", @@ -1524,6 +1556,12 @@ fn format_bulk_worktree_merge_human( outcome.dirty_worktree_ids.len() )); } + if !outcome.blocked_by_queue_session_ids.is_empty() { + lines.push(format!( + "Blocked {} worktree(s) on remaining queue conflicts", + outcome.blocked_by_queue_session_ids.len() + )); + } if !outcome.failures.is_empty() { lines.push(format!( "Encountered {} merge failure(s)", @@ -2613,7 +2651,24 @@ mod tests { .expect("merge-queue --json should parse"); match cli.command { - Some(Commands::MergeQueue { json }) => assert!(json), + Some(Commands::MergeQueue { json, apply }) => { + assert!(json); + assert!(!apply); + } + _ => panic!("expected merge-queue subcommand"), + } + } + + #[test] + fn cli_parses_merge_queue_apply_flag() { + let cli = Cli::try_parse_from(["ecc", "merge-queue", "--apply", "--json"]) + .expect("merge-queue --apply --json should parse"); + + match cli.command { + Some(Commands::MergeQueue { json, apply }) => { + assert!(json); + assert!(apply); + } _ => panic!("expected merge-queue subcommand"), } } @@ -2813,9 +2868,16 @@ mod tests { already_up_to_date: false, cleaned_worktree: true, }], + rebased: vec![session::manager::WorktreeRebaseOutcome { + session_id: "rebased12345678".to_string(), + branch: "ecc/rebased12345678".to_string(), + base_branch: "main".to_string(), + already_up_to_date: false, + }], active_with_worktree_ids: vec!["running12345678".to_string()], conflicted_session_ids: vec!["conflict123456".to_string()], dirty_worktree_ids: vec!["dirty123456789".to_string()], + blocked_by_queue_session_ids: vec!["queue123456789".to_string()], failures: vec![session::manager::WorktreeMergeFailure { session_id: "fail1234567890".to_string(), reason: "base branch not checked out".to_string(), @@ -2824,9 +2886,12 @@ mod tests { assert!(text.contains("Merged 1 ready worktree(s)")); assert!(text.contains("- merged ecc/deadbeefcafefeed -> main for deadbeef")); + assert!(text.contains("Rebased 1 blocked worktree(s) onto their base branch")); + assert!(text.contains("- rebased ecc/rebased12345678 onto main for rebased1")); assert!(text.contains("Skipped 1 active worktree session(s)")); assert!(text.contains("Skipped 1 conflicted worktree(s)")); assert!(text.contains("Skipped 1 dirty worktree(s)")); + assert!(text.contains("Blocked 1 worktree(s) on remaining queue conflicts")); assert!(text.contains("Encountered 1 merge failure(s)")); assert!(text.contains("- failed fail1234: base branch not checked out")); } diff --git a/ecc2/src/session/daemon.rs b/ecc2/src/session/daemon.rs index 47f141b8..2f5096fb 100644 --- a/ecc2/src/session/daemon.rs +++ b/ecc2/src/session/daemon.rs @@ -1202,9 +1202,11 @@ mod tests { invoked_flag.store(true, std::sync::atomic::Ordering::SeqCst); Ok(manager::WorktreeBulkMergeOutcome { merged: Vec::new(), + rebased: Vec::new(), active_with_worktree_ids: Vec::new(), conflicted_session_ids: Vec::new(), dirty_worktree_ids: Vec::new(), + blocked_by_queue_session_ids: Vec::new(), failures: Vec::new(), }) } @@ -1239,9 +1241,16 @@ mod tests { cleaned_worktree: true, }, ], + rebased: vec![manager::WorktreeRebaseOutcome { + session_id: "worker-r".to_string(), + branch: "ecc/worker-r".to_string(), + base_branch: "main".to_string(), + already_up_to_date: false, + }], active_with_worktree_ids: vec!["worker-c".to_string()], conflicted_session_ids: vec!["worker-d".to_string()], dirty_worktree_ids: vec!["worker-e".to_string()], + blocked_by_queue_session_ids: vec!["worker-f".to_string()], failures: Vec::new(), }) }) diff --git a/ecc2/src/session/manager.rs b/ecc2/src/session/manager.rs index 64a55e9b..ef96d26b 100644 --- a/ecc2/src/session/manager.rs +++ b/ecc2/src/session/manager.rs @@ -803,6 +803,14 @@ pub struct WorktreeMergeOutcome { pub cleaned_worktree: bool, } +#[derive(Debug, Clone, Serialize)] +pub struct WorktreeRebaseOutcome { + pub session_id: String, + pub branch: String, + pub base_branch: String, + pub already_up_to_date: bool, +} + pub async fn merge_session_worktree( db: &StateStore, id: &str, @@ -841,6 +849,34 @@ pub async fn merge_session_worktree( }) } +pub async fn rebase_session_worktree(db: &StateStore, id: &str) -> Result { + let session = resolve_session(db, id)?; + + if matches!( + session.state, + SessionState::Pending | SessionState::Running | SessionState::Idle | SessionState::Stale + ) { + anyhow::bail!( + "Cannot rebase active session {} while it is {}", + session.id, + session.state + ); + } + + let worktree = session + .worktree + .clone() + .ok_or_else(|| anyhow::anyhow!("Session {} has no attached worktree", session.id))?; + let outcome = crate::worktree::rebase_onto_base(&worktree)?; + + Ok(WorktreeRebaseOutcome { + session_id: session.id, + branch: outcome.branch, + base_branch: outcome.base_branch, + already_up_to_date: outcome.already_up_to_date, + }) +} + #[derive(Debug, Clone, Serialize)] pub struct WorktreeMergeFailure { pub session_id: String, @@ -850,15 +886,110 @@ pub struct WorktreeMergeFailure { #[derive(Debug, Clone, Serialize)] pub struct WorktreeBulkMergeOutcome { pub merged: Vec, + pub rebased: Vec, pub active_with_worktree_ids: Vec, pub conflicted_session_ids: Vec, pub dirty_worktree_ids: Vec, + pub blocked_by_queue_session_ids: Vec, pub failures: Vec, } pub async fn merge_ready_worktrees( db: &StateStore, cleanup_worktree: bool, +) -> Result { + if cleanup_worktree { + return process_merge_queue(db).await; + } + + merge_ready_worktrees_one_pass(db, cleanup_worktree).await +} + +pub async fn process_merge_queue(db: &StateStore) -> Result { + let mut merged = Vec::new(); + let mut rebased = Vec::new(); + let mut failures = Vec::new(); + let mut attempted_rebase_heads = BTreeMap::::new(); + + loop { + let report = build_merge_queue(db)?; + let mut merged_any = false; + + for entry in &report.ready_entries { + match merge_session_worktree(db, &entry.session_id, true).await { + Ok(outcome) => { + merged.push(outcome); + merged_any = true; + } + Err(error) => failures.push(WorktreeMergeFailure { + session_id: entry.session_id.clone(), + reason: error.to_string(), + }), + } + } + + if merged_any { + continue; + } + + let mut rebased_any = false; + for entry in &report.blocked_entries { + if !can_auto_rebase_merge_queue_entry(entry) { + continue; + } + + let session = resolve_session(db, &entry.session_id)?; + let Some(worktree) = session.worktree.clone() else { + continue; + }; + let base_head = crate::worktree::branch_head_oid(&worktree, &worktree.base_branch)?; + if attempted_rebase_heads + .get(&entry.session_id) + .is_some_and(|last_head| last_head == &base_head) + { + continue; + } + attempted_rebase_heads.insert(entry.session_id.clone(), base_head); + + match rebase_session_worktree(db, &entry.session_id).await { + Ok(outcome) => { + rebased.push(outcome); + rebased_any = true; + break; + } + Err(error) => failures.push(WorktreeMergeFailure { + session_id: entry.session_id.clone(), + reason: error.to_string(), + }), + } + } + + if rebased_any { + continue; + } + + let ( + active_with_worktree_ids, + conflicted_session_ids, + dirty_worktree_ids, + blocked_by_queue_session_ids, + ) = classify_merge_queue_report(&report); + + return Ok(WorktreeBulkMergeOutcome { + merged, + rebased, + active_with_worktree_ids, + conflicted_session_ids, + dirty_worktree_ids, + blocked_by_queue_session_ids, + failures, + }); + } +} + +async fn merge_ready_worktrees_one_pass( + db: &StateStore, + cleanup_worktree: bool, ) -> Result { let sessions = db.list_sessions()?; let mut merged = Vec::new(); @@ -926,9 +1057,11 @@ pub async fn merge_ready_worktrees( Ok(WorktreeBulkMergeOutcome { merged, + rebased: Vec::new(), active_with_worktree_ids, conflicted_session_ids, dirty_worktree_ids, + blocked_by_queue_session_ids: Vec::new(), failures, }) } @@ -1170,6 +1303,49 @@ pub fn build_merge_queue(db: &StateStore) -> Result { }) } +fn can_auto_rebase_merge_queue_entry(entry: &MergeQueueEntry) -> bool { + !entry.ready_to_merge + && !entry.dirty + && entry.worktree_health == worktree::WorktreeHealth::Conflicted + && !entry.blocked_by.is_empty() + && entry + .blocked_by + .iter() + .all(|blocker| blocker.session_id == entry.session_id) +} + +fn classify_merge_queue_report( + report: &MergeQueueReport, +) -> (Vec, Vec, Vec, Vec) { + let mut active = Vec::new(); + let mut conflicted = Vec::new(); + let mut dirty = Vec::new(); + let mut queue_blocked = Vec::new(); + + for entry in &report.blocked_entries { + if entry.blocked_by.iter().any(|blocker| { + blocker.session_id == entry.session_id + && matches!( + blocker.state, + SessionState::Pending + | SessionState::Running + | SessionState::Idle + | SessionState::Stale + ) + }) { + active.push(entry.session_id.clone()); + } else if entry.dirty { + dirty.push(entry.session_id.clone()); + } else if entry.worktree_health == worktree::WorktreeHealth::Conflicted { + conflicted.push(entry.session_id.clone()); + } else { + queue_blocked.push(entry.session_id.clone()); + } + } + + (active, conflicted, dirty, queue_blocked) +} + pub async fn delete_session(db: &StateStore, id: &str) -> Result<()> { let session = resolve_session(db, id)?; @@ -3235,6 +3411,174 @@ mod tests { Ok(()) } + #[tokio::test(flavor = "current_thread")] + async fn process_merge_queue_rebases_blocked_session_and_merges_it() -> Result<()> { + let tempdir = TestDir::new("manager-process-merge-queue-success")?; + let repo_root = tempdir.path().join("repo"); + init_git_repo(&repo_root)?; + + let cfg = build_config(tempdir.path()); + let db = StateStore::open(&cfg.db_path)?; + let now = Utc::now(); + + let alpha_worktree = worktree::create_for_session_in_repo("alpha", &cfg, &repo_root)?; + fs::write(alpha_worktree.path.join("README.md"), "hello\nalpha\n")?; + run_git(&alpha_worktree.path, ["commit", "-am", "alpha change"])?; + + let beta_worktree = worktree::create_for_session_in_repo("beta", &cfg, &repo_root)?; + fs::write(beta_worktree.path.join("README.md"), "hello\nalpha\n")?; + run_git(&beta_worktree.path, ["commit", "-am", "beta shared change"])?; + fs::write(beta_worktree.path.join("README.md"), "hello\nalpha\nbeta\n")?; + run_git(&beta_worktree.path, ["commit", "-am", "beta follow-up"])?; + + db.insert_session(&Session { + id: "alpha".to_string(), + task: "alpha merge".to_string(), + project: "ecc".to_string(), + task_group: "merge".to_string(), + agent_type: "claude".to_string(), + working_dir: alpha_worktree.path.clone(), + state: SessionState::Completed, + pid: None, + worktree: Some(alpha_worktree.clone()), + created_at: now - Duration::minutes(2), + updated_at: now - Duration::minutes(2), + last_heartbeat_at: now - Duration::minutes(2), + metrics: SessionMetrics::default(), + })?; + db.insert_session(&Session { + id: "beta".to_string(), + task: "beta merge".to_string(), + project: "ecc".to_string(), + task_group: "merge".to_string(), + agent_type: "claude".to_string(), + working_dir: beta_worktree.path.clone(), + state: SessionState::Completed, + pid: None, + worktree: Some(beta_worktree.clone()), + created_at: now - Duration::minutes(1), + updated_at: now - Duration::minutes(1), + last_heartbeat_at: now - Duration::minutes(1), + metrics: SessionMetrics::default(), + })?; + + let queue_before = build_merge_queue(&db)?; + assert_eq!(queue_before.ready_entries.len(), 1); + assert_eq!(queue_before.ready_entries[0].session_id, "alpha"); + assert_eq!(queue_before.blocked_entries.len(), 1); + assert_eq!(queue_before.blocked_entries[0].session_id, "beta"); + + let outcome = process_merge_queue(&db).await?; + + assert_eq!( + outcome + .merged + .iter() + .map(|entry| entry.session_id.as_str()) + .collect::>(), + vec!["alpha", "beta"] + ); + assert_eq!(outcome.rebased.len(), 1); + assert_eq!(outcome.rebased[0].session_id, "beta"); + assert!(outcome.active_with_worktree_ids.is_empty()); + assert!(outcome.conflicted_session_ids.is_empty()); + assert!(outcome.dirty_worktree_ids.is_empty()); + assert!(outcome.blocked_by_queue_session_ids.is_empty()); + assert!(outcome.failures.is_empty()); + assert_eq!( + fs::read_to_string(repo_root.join("README.md"))?, + "hello\nalpha\nbeta\n" + ); + assert!(db + .get_session("alpha")? + .context("alpha should still exist")? + .worktree + .is_none()); + assert!(db + .get_session("beta")? + .context("beta should still exist")? + .worktree + .is_none()); + + Ok(()) + } + + #[tokio::test(flavor = "current_thread")] + async fn process_merge_queue_records_failed_rebase_and_leaves_blocked_session() -> Result<()> { + let tempdir = TestDir::new("manager-process-merge-queue-fail")?; + let repo_root = tempdir.path().join("repo"); + init_git_repo(&repo_root)?; + + let cfg = build_config(tempdir.path()); + let db = StateStore::open(&cfg.db_path)?; + let now = Utc::now(); + + let alpha_worktree = worktree::create_for_session_in_repo("alpha", &cfg, &repo_root)?; + fs::write(alpha_worktree.path.join("README.md"), "hello\nalpha\n")?; + run_git(&alpha_worktree.path, ["commit", "-am", "alpha change"])?; + + let beta_worktree = worktree::create_for_session_in_repo("beta", &cfg, &repo_root)?; + fs::write(beta_worktree.path.join("README.md"), "hello\nbeta\n")?; + run_git(&beta_worktree.path, ["commit", "-am", "beta change"])?; + + db.insert_session(&Session { + id: "alpha".to_string(), + task: "alpha merge".to_string(), + project: "ecc".to_string(), + task_group: "merge".to_string(), + agent_type: "claude".to_string(), + working_dir: alpha_worktree.path.clone(), + state: SessionState::Completed, + pid: None, + worktree: Some(alpha_worktree.clone()), + created_at: now - Duration::minutes(2), + updated_at: now - Duration::minutes(2), + last_heartbeat_at: now - Duration::minutes(2), + metrics: SessionMetrics::default(), + })?; + db.insert_session(&Session { + id: "beta".to_string(), + task: "beta merge".to_string(), + project: "ecc".to_string(), + task_group: "merge".to_string(), + agent_type: "claude".to_string(), + working_dir: beta_worktree.path.clone(), + state: SessionState::Completed, + pid: None, + worktree: Some(beta_worktree.clone()), + created_at: now - Duration::minutes(1), + updated_at: now - Duration::minutes(1), + last_heartbeat_at: now - Duration::minutes(1), + metrics: SessionMetrics::default(), + })?; + + let outcome = process_merge_queue(&db).await?; + + assert_eq!( + outcome + .merged + .iter() + .map(|entry| entry.session_id.as_str()) + .collect::>(), + vec!["alpha"] + ); + assert!(outcome.rebased.is_empty()); + assert_eq!(outcome.conflicted_session_ids, vec!["beta".to_string()]); + assert!(outcome.active_with_worktree_ids.is_empty()); + assert!(outcome.dirty_worktree_ids.is_empty()); + assert!(outcome.blocked_by_queue_session_ids.is_empty()); + assert_eq!(outcome.failures.len(), 1); + assert_eq!(outcome.failures[0].session_id, "beta"); + assert!(outcome.failures[0].reason.contains("git rebase failed")); + assert!(db + .get_session("beta")? + .context("beta should still exist")? + .worktree + .is_some()); + + Ok(()) + } + #[tokio::test(flavor = "current_thread")] async fn build_merge_queue_orders_ready_sessions_and_blocks_conflicts() -> Result<()> { let tempdir = TestDir::new("manager-merge-queue")?; diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index ae1aa796..16b6fc2c 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -2734,9 +2734,11 @@ impl Dashboard { Ok(outcome) => { self.refresh(); if outcome.merged.is_empty() + && outcome.rebased.is_empty() && outcome.active_with_worktree_ids.is_empty() && outcome.conflicted_session_ids.is_empty() && outcome.dirty_worktree_ids.is_empty() + && outcome.blocked_by_queue_session_ids.is_empty() && outcome.failures.is_empty() { self.set_operator_note("no ready worktrees to merge".to_string()); @@ -2744,6 +2746,9 @@ impl Dashboard { } let mut parts = vec![format!("merged {} ready worktree(s)", outcome.merged.len())]; + if !outcome.rebased.is_empty() { + parts.push(format!("rebased {}", outcome.rebased.len())); + } if !outcome.active_with_worktree_ids.is_empty() { parts.push(format!( "skipped {} active", @@ -2762,6 +2767,12 @@ impl Dashboard { outcome.dirty_worktree_ids.len() )); } + if !outcome.blocked_by_queue_session_ids.is_empty() { + parts.push(format!( + "blocked {} in queue", + outcome.blocked_by_queue_session_ids.len() + )); + } if !outcome.failures.is_empty() { parts.push(format!("{} failed", outcome.failures.len())); } diff --git a/ecc2/src/worktree/mod.rs b/ecc2/src/worktree/mod.rs index 3173662f..6165a559 100644 --- a/ecc2/src/worktree/mod.rs +++ b/ecc2/src/worktree/mod.rs @@ -35,6 +35,13 @@ pub struct MergeOutcome { pub already_up_to_date: bool, } +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct RebaseOutcome { + pub branch: String, + pub base_branch: String, + pub already_up_to_date: bool, +} + #[derive(Debug, Clone, Serialize, PartialEq, Eq)] pub struct BranchConflictPreview { pub left_branch: String, @@ -741,6 +748,65 @@ pub fn merge_into_base(worktree: &WorktreeInfo) -> Result { }) } +pub fn rebase_onto_base(worktree: &WorktreeInfo) -> Result { + if has_uncommitted_changes(worktree)? { + anyhow::bail!( + "Worktree {} has uncommitted changes; commit or discard them before rebasing", + worktree.branch + ); + } + + let repo_root = base_checkout_path(worktree)?; + let before_head = branch_head_oid_in_repo(&repo_root, &worktree.branch)?; + let output = Command::new("git") + .arg("-C") + .arg(&worktree.path) + .args(["rebase", &worktree.base_branch]) + .output() + .context("Failed to rebase worktree branch onto base")?; + + if !output.status.success() { + let abort_output = Command::new("git") + .arg("-C") + .arg(&worktree.path) + .args(["rebase", "--abort"]) + .output() + .context("Failed to abort unsuccessful rebase")?; + let abort_warning = if abort_output.status.success() { + String::new() + } else { + format!( + " (rebase abort warning: {})", + String::from_utf8_lossy(&abort_output.stderr).trim() + ) + }; + let stderr = format!( + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + anyhow::bail!("git rebase failed: {}{}", stderr.trim(), abort_warning); + } + + let after_head = branch_head_oid_in_repo(&repo_root, &worktree.branch)?; + let rebase_output = format!( + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + Ok(RebaseOutcome { + branch: worktree.branch.clone(), + base_branch: worktree.base_branch.clone(), + already_up_to_date: before_head == after_head || rebase_output.contains("up to date"), + }) +} + +pub fn branch_head_oid(worktree: &WorktreeInfo, branch: &str) -> Result { + let repo_root = base_checkout_path(worktree)?; + branch_head_oid_in_repo(&repo_root, branch) +} + fn git_diff_shortstat(worktree_path: &Path, extra_args: &[&str]) -> Result> { let mut command = Command::new("git"); command @@ -1113,6 +1179,22 @@ fn git_status_short(worktree_path: &Path) -> Result> { Ok(parse_nonempty_lines(&output.stdout)) } +fn branch_head_oid_in_repo(repo_root: &Path, branch: &str) -> Result { + let output = Command::new("git") + .arg("-C") + .arg(repo_root) + .args(["rev-parse", branch]) + .output() + .context("Failed to resolve branch head")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("git rev-parse failed: {stderr}"); + } + + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + fn validate_branch_name(repo_root: &Path, branch: &str) -> Result<()> { let output = Command::new("git") .arg("-C") @@ -1567,6 +1649,130 @@ mod tests { Ok(()) } + #[test] + fn rebase_onto_base_replays_simple_branch_after_base_advances() -> Result<()> { + let root = + std::env::temp_dir().join(format!("ecc2-worktree-rebase-success-{}", Uuid::new_v4())); + let repo = init_repo(&root)?; + + let alpha_dir = root.join("wt-alpha"); + run_git( + &repo, + &[ + "worktree", + "add", + "-b", + "ecc/alpha", + alpha_dir.to_str().expect("utf8 path"), + "HEAD", + ], + )?; + fs::write(alpha_dir.join("README.md"), "hello\nalpha\n")?; + run_git(&alpha_dir, &["commit", "-am", "alpha change"])?; + + let beta_dir = root.join("wt-beta"); + run_git( + &repo, + &[ + "worktree", + "add", + "-b", + "ecc/beta", + beta_dir.to_str().expect("utf8 path"), + "HEAD", + ], + )?; + fs::write(beta_dir.join("README.md"), "hello\nalpha\n")?; + run_git(&beta_dir, &["commit", "-am", "beta shared change"])?; + fs::write(beta_dir.join("README.md"), "hello\nalpha\nbeta\n")?; + run_git(&beta_dir, &["commit", "-am", "beta follow-up"])?; + + run_git(&repo, &["merge", "--no-edit", "ecc/alpha"])?; + + let beta = WorktreeInfo { + path: beta_dir.clone(), + branch: "ecc/beta".to_string(), + base_branch: "main".to_string(), + }; + let readiness_before = merge_readiness(&beta)?; + assert_eq!(readiness_before.status, MergeReadinessStatus::Conflicted); + + let outcome = rebase_onto_base(&beta)?; + assert_eq!(outcome.branch, "ecc/beta"); + assert_eq!(outcome.base_branch, "main"); + assert!(!outcome.already_up_to_date); + + let readiness_after = merge_readiness(&beta)?; + assert_eq!(readiness_after.status, MergeReadinessStatus::Ready); + assert_eq!( + fs::read_to_string(beta_dir.join("README.md"))?, + "hello\nalpha\nbeta\n" + ); + + let _ = Command::new("git") + .arg("-C") + .arg(&repo) + .args(["worktree", "remove", "--force"]) + .arg(&alpha_dir) + .output(); + let _ = Command::new("git") + .arg("-C") + .arg(&repo) + .args(["worktree", "remove", "--force"]) + .arg(&beta_dir) + .output(); + let _ = fs::remove_dir_all(root); + Ok(()) + } + + #[test] + fn rebase_onto_base_aborts_failed_rebase() -> Result<()> { + let root = + std::env::temp_dir().join(format!("ecc2-worktree-rebase-fail-{}", Uuid::new_v4())); + let repo = init_repo(&root)?; + + let worktree_dir = root.join("wt-conflict"); + run_git( + &repo, + &[ + "worktree", + "add", + "-b", + "ecc/conflict", + worktree_dir.to_str().expect("utf8 path"), + "HEAD", + ], + )?; + + fs::write(worktree_dir.join("README.md"), "hello\nbranch\n")?; + run_git(&worktree_dir, &["commit", "-am", "branch change"])?; + fs::write(repo.join("README.md"), "hello\nmain\n")?; + run_git(&repo, &["commit", "-am", "main change"])?; + + let info = WorktreeInfo { + path: worktree_dir.clone(), + branch: "ecc/conflict".to_string(), + base_branch: "main".to_string(), + }; + + let error = rebase_onto_base(&info).expect_err("rebase should fail"); + assert!(error.to_string().contains("git rebase failed")); + assert!(git_status_short(&worktree_dir)?.is_empty()); + assert_eq!( + merge_readiness(&info)?.status, + MergeReadinessStatus::Conflicted + ); + + let _ = Command::new("git") + .arg("-C") + .arg(&repo) + .args(["worktree", "remove", "--force"]) + .arg(&worktree_dir) + .output(); + let _ = fs::remove_dir_all(root); + Ok(()) + } + #[test] fn branch_conflict_preview_reports_conflicting_branches() -> Result<()> { let root = std::env::temp_dir().join(format!(