mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-04-12 04:33:29 +08:00
feat: auto-rebase blocked merge queue worktrees
This commit is contained in:
@@ -250,6 +250,9 @@ enum Commands {
|
|||||||
/// Emit machine-readable JSON instead of the human summary
|
/// Emit machine-readable JSON instead of the human summary
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
json: bool,
|
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
|
/// Prune worktrees for inactive sessions and report any active sessions still holding one
|
||||||
PruneWorktrees {
|
PruneWorktrees {
|
||||||
@@ -844,7 +847,15 @@ async fn main() -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some(Commands::MergeQueue { json }) => {
|
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 {
|
||||||
let report = session::manager::build_merge_queue(&db)?;
|
let report = session::manager::build_merge_queue(&db)?;
|
||||||
if json {
|
if json {
|
||||||
println!("{}", serde_json::to_string_pretty(&report)?);
|
println!("{}", serde_json::to_string_pretty(&report)?);
|
||||||
@@ -852,6 +863,7 @@ async fn main() -> Result<()> {
|
|||||||
println!("{}", format_merge_queue_human(&report));
|
println!("{}", format_merge_queue_human(&report));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Some(Commands::PruneWorktrees { json }) => {
|
Some(Commands::PruneWorktrees { json }) => {
|
||||||
let outcome = session::manager::prune_inactive_worktrees(&db, &cfg).await?;
|
let outcome = session::manager::prune_inactive_worktrees(&db, &cfg).await?;
|
||||||
if json {
|
if 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() {
|
if !outcome.active_with_worktree_ids.is_empty() {
|
||||||
lines.push(format!(
|
lines.push(format!(
|
||||||
"Skipped {} active worktree session(s)",
|
"Skipped {} active worktree session(s)",
|
||||||
@@ -1524,6 +1556,12 @@ fn format_bulk_worktree_merge_human(
|
|||||||
outcome.dirty_worktree_ids.len()
|
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() {
|
if !outcome.failures.is_empty() {
|
||||||
lines.push(format!(
|
lines.push(format!(
|
||||||
"Encountered {} merge failure(s)",
|
"Encountered {} merge failure(s)",
|
||||||
@@ -2613,7 +2651,24 @@ mod tests {
|
|||||||
.expect("merge-queue --json should parse");
|
.expect("merge-queue --json should parse");
|
||||||
|
|
||||||
match cli.command {
|
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"),
|
_ => panic!("expected merge-queue subcommand"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2813,9 +2868,16 @@ mod tests {
|
|||||||
already_up_to_date: false,
|
already_up_to_date: false,
|
||||||
cleaned_worktree: true,
|
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()],
|
active_with_worktree_ids: vec!["running12345678".to_string()],
|
||||||
conflicted_session_ids: vec!["conflict123456".to_string()],
|
conflicted_session_ids: vec!["conflict123456".to_string()],
|
||||||
dirty_worktree_ids: vec!["dirty123456789".to_string()],
|
dirty_worktree_ids: vec!["dirty123456789".to_string()],
|
||||||
|
blocked_by_queue_session_ids: vec!["queue123456789".to_string()],
|
||||||
failures: vec![session::manager::WorktreeMergeFailure {
|
failures: vec![session::manager::WorktreeMergeFailure {
|
||||||
session_id: "fail1234567890".to_string(),
|
session_id: "fail1234567890".to_string(),
|
||||||
reason: "base branch not checked out".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 1 ready worktree(s)"));
|
||||||
assert!(text.contains("- merged ecc/deadbeefcafefeed -> main for deadbeef"));
|
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 active worktree session(s)"));
|
||||||
assert!(text.contains("Skipped 1 conflicted worktree(s)"));
|
assert!(text.contains("Skipped 1 conflicted worktree(s)"));
|
||||||
assert!(text.contains("Skipped 1 dirty 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("Encountered 1 merge failure(s)"));
|
||||||
assert!(text.contains("- failed fail1234: base branch not checked out"));
|
assert!(text.contains("- failed fail1234: base branch not checked out"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1202,9 +1202,11 @@ mod tests {
|
|||||||
invoked_flag.store(true, std::sync::atomic::Ordering::SeqCst);
|
invoked_flag.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||||
Ok(manager::WorktreeBulkMergeOutcome {
|
Ok(manager::WorktreeBulkMergeOutcome {
|
||||||
merged: Vec::new(),
|
merged: Vec::new(),
|
||||||
|
rebased: Vec::new(),
|
||||||
active_with_worktree_ids: Vec::new(),
|
active_with_worktree_ids: Vec::new(),
|
||||||
conflicted_session_ids: Vec::new(),
|
conflicted_session_ids: Vec::new(),
|
||||||
dirty_worktree_ids: Vec::new(),
|
dirty_worktree_ids: Vec::new(),
|
||||||
|
blocked_by_queue_session_ids: Vec::new(),
|
||||||
failures: Vec::new(),
|
failures: Vec::new(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1239,9 +1241,16 @@ mod tests {
|
|||||||
cleaned_worktree: true,
|
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()],
|
active_with_worktree_ids: vec!["worker-c".to_string()],
|
||||||
conflicted_session_ids: vec!["worker-d".to_string()],
|
conflicted_session_ids: vec!["worker-d".to_string()],
|
||||||
dirty_worktree_ids: vec!["worker-e".to_string()],
|
dirty_worktree_ids: vec!["worker-e".to_string()],
|
||||||
|
blocked_by_queue_session_ids: vec!["worker-f".to_string()],
|
||||||
failures: Vec::new(),
|
failures: Vec::new(),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -803,6 +803,14 @@ pub struct WorktreeMergeOutcome {
|
|||||||
pub cleaned_worktree: bool,
|
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(
|
pub async fn merge_session_worktree(
|
||||||
db: &StateStore,
|
db: &StateStore,
|
||||||
id: &str,
|
id: &str,
|
||||||
@@ -841,6 +849,34 @@ pub async fn merge_session_worktree(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn rebase_session_worktree(db: &StateStore, id: &str) -> Result<WorktreeRebaseOutcome> {
|
||||||
|
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)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
pub struct WorktreeMergeFailure {
|
pub struct WorktreeMergeFailure {
|
||||||
pub session_id: String,
|
pub session_id: String,
|
||||||
@@ -850,15 +886,110 @@ pub struct WorktreeMergeFailure {
|
|||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
pub struct WorktreeBulkMergeOutcome {
|
pub struct WorktreeBulkMergeOutcome {
|
||||||
pub merged: Vec<WorktreeMergeOutcome>,
|
pub merged: Vec<WorktreeMergeOutcome>,
|
||||||
|
pub rebased: Vec<WorktreeRebaseOutcome>,
|
||||||
pub active_with_worktree_ids: Vec<String>,
|
pub active_with_worktree_ids: Vec<String>,
|
||||||
pub conflicted_session_ids: Vec<String>,
|
pub conflicted_session_ids: Vec<String>,
|
||||||
pub dirty_worktree_ids: Vec<String>,
|
pub dirty_worktree_ids: Vec<String>,
|
||||||
|
pub blocked_by_queue_session_ids: Vec<String>,
|
||||||
pub failures: Vec<WorktreeMergeFailure>,
|
pub failures: Vec<WorktreeMergeFailure>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn merge_ready_worktrees(
|
pub async fn merge_ready_worktrees(
|
||||||
db: &StateStore,
|
db: &StateStore,
|
||||||
cleanup_worktree: bool,
|
cleanup_worktree: bool,
|
||||||
|
) -> Result<WorktreeBulkMergeOutcome> {
|
||||||
|
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<WorktreeBulkMergeOutcome> {
|
||||||
|
let mut merged = Vec::new();
|
||||||
|
let mut rebased = Vec::new();
|
||||||
|
let mut failures = Vec::new();
|
||||||
|
let mut attempted_rebase_heads = BTreeMap::<String, String>::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<WorktreeBulkMergeOutcome> {
|
) -> Result<WorktreeBulkMergeOutcome> {
|
||||||
let sessions = db.list_sessions()?;
|
let sessions = db.list_sessions()?;
|
||||||
let mut merged = Vec::new();
|
let mut merged = Vec::new();
|
||||||
@@ -926,9 +1057,11 @@ pub async fn merge_ready_worktrees(
|
|||||||
|
|
||||||
Ok(WorktreeBulkMergeOutcome {
|
Ok(WorktreeBulkMergeOutcome {
|
||||||
merged,
|
merged,
|
||||||
|
rebased: Vec::new(),
|
||||||
active_with_worktree_ids,
|
active_with_worktree_ids,
|
||||||
conflicted_session_ids,
|
conflicted_session_ids,
|
||||||
dirty_worktree_ids,
|
dirty_worktree_ids,
|
||||||
|
blocked_by_queue_session_ids: Vec::new(),
|
||||||
failures,
|
failures,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1170,6 +1303,49 @@ pub fn build_merge_queue(db: &StateStore) -> Result<MergeQueueReport> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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<String>, Vec<String>, Vec<String>, Vec<String>) {
|
||||||
|
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<()> {
|
pub async fn delete_session(db: &StateStore, id: &str) -> Result<()> {
|
||||||
let session = resolve_session(db, id)?;
|
let session = resolve_session(db, id)?;
|
||||||
|
|
||||||
@@ -3235,6 +3411,174 @@ mod tests {
|
|||||||
Ok(())
|
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<_>>(),
|
||||||
|
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<_>>(),
|
||||||
|
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")]
|
#[tokio::test(flavor = "current_thread")]
|
||||||
async fn build_merge_queue_orders_ready_sessions_and_blocks_conflicts() -> Result<()> {
|
async fn build_merge_queue_orders_ready_sessions_and_blocks_conflicts() -> Result<()> {
|
||||||
let tempdir = TestDir::new("manager-merge-queue")?;
|
let tempdir = TestDir::new("manager-merge-queue")?;
|
||||||
|
|||||||
@@ -2734,9 +2734,11 @@ impl Dashboard {
|
|||||||
Ok(outcome) => {
|
Ok(outcome) => {
|
||||||
self.refresh();
|
self.refresh();
|
||||||
if outcome.merged.is_empty()
|
if outcome.merged.is_empty()
|
||||||
|
&& outcome.rebased.is_empty()
|
||||||
&& outcome.active_with_worktree_ids.is_empty()
|
&& outcome.active_with_worktree_ids.is_empty()
|
||||||
&& outcome.conflicted_session_ids.is_empty()
|
&& outcome.conflicted_session_ids.is_empty()
|
||||||
&& outcome.dirty_worktree_ids.is_empty()
|
&& outcome.dirty_worktree_ids.is_empty()
|
||||||
|
&& outcome.blocked_by_queue_session_ids.is_empty()
|
||||||
&& outcome.failures.is_empty()
|
&& outcome.failures.is_empty()
|
||||||
{
|
{
|
||||||
self.set_operator_note("no ready worktrees to merge".to_string());
|
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())];
|
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() {
|
if !outcome.active_with_worktree_ids.is_empty() {
|
||||||
parts.push(format!(
|
parts.push(format!(
|
||||||
"skipped {} active",
|
"skipped {} active",
|
||||||
@@ -2762,6 +2767,12 @@ impl Dashboard {
|
|||||||
outcome.dirty_worktree_ids.len()
|
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() {
|
if !outcome.failures.is_empty() {
|
||||||
parts.push(format!("{} failed", outcome.failures.len()));
|
parts.push(format!("{} failed", outcome.failures.len()));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,13 @@ pub struct MergeOutcome {
|
|||||||
pub already_up_to_date: bool,
|
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)]
|
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||||
pub struct BranchConflictPreview {
|
pub struct BranchConflictPreview {
|
||||||
pub left_branch: String,
|
pub left_branch: String,
|
||||||
@@ -741,6 +748,65 @@ pub fn merge_into_base(worktree: &WorktreeInfo) -> Result<MergeOutcome> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn rebase_onto_base(worktree: &WorktreeInfo) -> Result<RebaseOutcome> {
|
||||||
|
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<String> {
|
||||||
|
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<Option<String>> {
|
fn git_diff_shortstat(worktree_path: &Path, extra_args: &[&str]) -> Result<Option<String>> {
|
||||||
let mut command = Command::new("git");
|
let mut command = Command::new("git");
|
||||||
command
|
command
|
||||||
@@ -1113,6 +1179,22 @@ fn git_status_short(worktree_path: &Path) -> Result<Vec<String>> {
|
|||||||
Ok(parse_nonempty_lines(&output.stdout))
|
Ok(parse_nonempty_lines(&output.stdout))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn branch_head_oid_in_repo(repo_root: &Path, branch: &str) -> Result<String> {
|
||||||
|
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<()> {
|
fn validate_branch_name(repo_root: &Path, branch: &str) -> Result<()> {
|
||||||
let output = Command::new("git")
|
let output = Command::new("git")
|
||||||
.arg("-C")
|
.arg("-C")
|
||||||
@@ -1567,6 +1649,130 @@ mod tests {
|
|||||||
Ok(())
|
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]
|
#[test]
|
||||||
fn branch_conflict_preview_reports_conflicting_branches() -> Result<()> {
|
fn branch_conflict_preview_reports_conflicting_branches() -> Result<()> {
|
||||||
let root = std::env::temp_dir().join(format!(
|
let root = std::env::temp_dir().join(format!(
|
||||||
|
|||||||
Reference in New Issue
Block a user