mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-04-09 19:03:28 +08:00
feat: add ecc2 bulk worktree merge actions
This commit is contained in:
143
ecc2/src/main.rs
143
ecc2/src/main.rs
@@ -207,6 +207,9 @@ enum Commands {
|
|||||||
MergeWorktree {
|
MergeWorktree {
|
||||||
/// Session ID or alias
|
/// Session ID or alias
|
||||||
session_id: Option<String>,
|
session_id: Option<String>,
|
||||||
|
/// Merge all ready inactive worktrees
|
||||||
|
#[arg(long)]
|
||||||
|
all: bool,
|
||||||
/// 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,
|
||||||
@@ -703,17 +706,36 @@ async fn main() -> Result<()> {
|
|||||||
}
|
}
|
||||||
Some(Commands::MergeWorktree {
|
Some(Commands::MergeWorktree {
|
||||||
session_id,
|
session_id,
|
||||||
|
all,
|
||||||
json,
|
json,
|
||||||
keep_worktree,
|
keep_worktree,
|
||||||
}) => {
|
}) => {
|
||||||
let id = session_id.unwrap_or_else(|| "latest".to_string());
|
if all && session_id.is_some() {
|
||||||
let resolved_id = resolve_session_id(&db, &id)?;
|
return Err(anyhow::anyhow!(
|
||||||
let outcome =
|
"merge-worktree does not accept a session ID when --all is set"
|
||||||
session::manager::merge_session_worktree(&db, &resolved_id, !keep_worktree).await?;
|
));
|
||||||
if json {
|
}
|
||||||
println!("{}", serde_json::to_string_pretty(&outcome)?);
|
if all {
|
||||||
|
let outcome = session::manager::merge_ready_worktrees(&db, !keep_worktree).await?;
|
||||||
|
if json {
|
||||||
|
println!("{}", serde_json::to_string_pretty(&outcome)?);
|
||||||
|
} else {
|
||||||
|
println!("{}", format_bulk_worktree_merge_human(&outcome));
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
println!("{}", format_worktree_merge_human(&outcome));
|
let id = session_id.unwrap_or_else(|| "latest".to_string());
|
||||||
|
let resolved_id = resolve_session_id(&db, &id)?;
|
||||||
|
let outcome = session::manager::merge_session_worktree(
|
||||||
|
&db,
|
||||||
|
&resolved_id,
|
||||||
|
!keep_worktree,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
if json {
|
||||||
|
println!("{}", serde_json::to_string_pretty(&outcome)?);
|
||||||
|
} else {
|
||||||
|
println!("{}", format_worktree_merge_human(&outcome));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some(Commands::PruneWorktrees { json }) => {
|
Some(Commands::PruneWorktrees { json }) => {
|
||||||
@@ -1102,6 +1124,62 @@ fn format_worktree_merge_human(outcome: &session::manager::WorktreeMergeOutcome)
|
|||||||
lines.join("\n")
|
lines.join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn format_bulk_worktree_merge_human(outcome: &session::manager::WorktreeBulkMergeOutcome) -> String {
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
lines.push(format!(
|
||||||
|
"Merged {} ready worktree(s)",
|
||||||
|
outcome.merged.len()
|
||||||
|
));
|
||||||
|
|
||||||
|
for merged in &outcome.merged {
|
||||||
|
lines.push(format!(
|
||||||
|
"- merged {} -> {} for {}{}",
|
||||||
|
merged.branch,
|
||||||
|
merged.base_branch,
|
||||||
|
short_session(&merged.session_id),
|
||||||
|
if merged.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)",
|
||||||
|
outcome.active_with_worktree_ids.len()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !outcome.conflicted_session_ids.is_empty() {
|
||||||
|
lines.push(format!(
|
||||||
|
"Skipped {} conflicted worktree(s)",
|
||||||
|
outcome.conflicted_session_ids.len()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !outcome.dirty_worktree_ids.is_empty() {
|
||||||
|
lines.push(format!(
|
||||||
|
"Skipped {} dirty worktree(s)",
|
||||||
|
outcome.dirty_worktree_ids.len()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !outcome.failures.is_empty() {
|
||||||
|
lines.push(format!(
|
||||||
|
"Encountered {} merge failure(s)",
|
||||||
|
outcome.failures.len()
|
||||||
|
));
|
||||||
|
for failure in &outcome.failures {
|
||||||
|
lines.push(format!(
|
||||||
|
"- failed {}: {}",
|
||||||
|
short_session(&failure.session_id),
|
||||||
|
failure.reason
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
fn worktree_status_exit_code(report: &WorktreeStatusReport) -> i32 {
|
fn worktree_status_exit_code(report: &WorktreeStatusReport) -> i32 {
|
||||||
report.check_exit_code
|
report.check_exit_code
|
||||||
}
|
}
|
||||||
@@ -1575,10 +1653,12 @@ mod tests {
|
|||||||
match cli.command {
|
match cli.command {
|
||||||
Some(Commands::MergeWorktree {
|
Some(Commands::MergeWorktree {
|
||||||
session_id,
|
session_id,
|
||||||
|
all,
|
||||||
json,
|
json,
|
||||||
keep_worktree,
|
keep_worktree,
|
||||||
}) => {
|
}) => {
|
||||||
assert_eq!(session_id.as_deref(), Some("deadbeef"));
|
assert_eq!(session_id.as_deref(), Some("deadbeef"));
|
||||||
|
assert!(!all);
|
||||||
assert!(json);
|
assert!(json);
|
||||||
assert!(keep_worktree);
|
assert!(keep_worktree);
|
||||||
}
|
}
|
||||||
@@ -1586,6 +1666,27 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cli_parses_merge_worktree_all_flags() {
|
||||||
|
let cli = Cli::try_parse_from(["ecc", "merge-worktree", "--all", "--json"])
|
||||||
|
.expect("merge-worktree --all --json should parse");
|
||||||
|
|
||||||
|
match cli.command {
|
||||||
|
Some(Commands::MergeWorktree {
|
||||||
|
session_id,
|
||||||
|
all,
|
||||||
|
json,
|
||||||
|
keep_worktree,
|
||||||
|
}) => {
|
||||||
|
assert!(session_id.is_none());
|
||||||
|
assert!(all);
|
||||||
|
assert!(json);
|
||||||
|
assert!(!keep_worktree);
|
||||||
|
}
|
||||||
|
_ => panic!("expected merge-worktree subcommand"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn format_worktree_status_human_includes_readiness_and_conflicts() {
|
fn format_worktree_status_human_includes_readiness_and_conflicts() {
|
||||||
let report = WorktreeStatusReport {
|
let report = WorktreeStatusReport {
|
||||||
@@ -1649,6 +1750,34 @@ mod tests {
|
|||||||
assert!(text.contains("Cleanup removed worktree and branch"));
|
assert!(text.contains("Cleanup removed worktree and branch"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_bulk_worktree_merge_human_reports_summary_and_skips() {
|
||||||
|
let text = format_bulk_worktree_merge_human(&session::manager::WorktreeBulkMergeOutcome {
|
||||||
|
merged: vec![session::manager::WorktreeMergeOutcome {
|
||||||
|
session_id: "deadbeefcafefeed".to_string(),
|
||||||
|
branch: "ecc/deadbeefcafefeed".to_string(),
|
||||||
|
base_branch: "main".to_string(),
|
||||||
|
already_up_to_date: false,
|
||||||
|
cleaned_worktree: true,
|
||||||
|
}],
|
||||||
|
active_with_worktree_ids: vec!["running12345678".to_string()],
|
||||||
|
conflicted_session_ids: vec!["conflict123456".to_string()],
|
||||||
|
dirty_worktree_ids: vec!["dirty123456789".to_string()],
|
||||||
|
failures: vec![session::manager::WorktreeMergeFailure {
|
||||||
|
session_id: "fail1234567890".to_string(),
|
||||||
|
reason: "base branch not checked out".to_string(),
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(text.contains("Merged 1 ready worktree(s)"));
|
||||||
|
assert!(text.contains("- merged ecc/deadbeefcafefeed -> main for deadbeef"));
|
||||||
|
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("Encountered 1 merge failure(s)"));
|
||||||
|
assert!(text.contains("- failed fail1234: base branch not checked out"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn format_worktree_status_human_handles_missing_worktree() {
|
fn format_worktree_status_human_handles_missing_worktree() {
|
||||||
let report = WorktreeStatusReport {
|
let report = WorktreeStatusReport {
|
||||||
|
|||||||
@@ -626,7 +626,10 @@ pub async fn merge_session_worktree(
|
|||||||
) -> Result<WorktreeMergeOutcome> {
|
) -> Result<WorktreeMergeOutcome> {
|
||||||
let session = resolve_session(db, id)?;
|
let session = resolve_session(db, id)?;
|
||||||
|
|
||||||
if matches!(session.state, SessionState::Pending | SessionState::Running) {
|
if matches!(
|
||||||
|
session.state,
|
||||||
|
SessionState::Pending | SessionState::Running | SessionState::Idle
|
||||||
|
) {
|
||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
"Cannot merge active session {} while it is {}",
|
"Cannot merge active session {} while it is {}",
|
||||||
session.id,
|
session.id,
|
||||||
@@ -654,6 +657,95 @@ pub async fn merge_session_worktree(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct WorktreeMergeFailure {
|
||||||
|
pub session_id: String,
|
||||||
|
pub reason: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct WorktreeBulkMergeOutcome {
|
||||||
|
pub merged: Vec<WorktreeMergeOutcome>,
|
||||||
|
pub active_with_worktree_ids: Vec<String>,
|
||||||
|
pub conflicted_session_ids: Vec<String>,
|
||||||
|
pub dirty_worktree_ids: Vec<String>,
|
||||||
|
pub failures: Vec<WorktreeMergeFailure>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn merge_ready_worktrees(
|
||||||
|
db: &StateStore,
|
||||||
|
cleanup_worktree: bool,
|
||||||
|
) -> Result<WorktreeBulkMergeOutcome> {
|
||||||
|
let sessions = db.list_sessions()?;
|
||||||
|
let mut merged = Vec::new();
|
||||||
|
let mut active_with_worktree_ids = Vec::new();
|
||||||
|
let mut conflicted_session_ids = Vec::new();
|
||||||
|
let mut dirty_worktree_ids = Vec::new();
|
||||||
|
let mut failures = Vec::new();
|
||||||
|
|
||||||
|
for session in sessions {
|
||||||
|
let Some(worktree) = session.worktree.clone() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
if matches!(
|
||||||
|
session.state,
|
||||||
|
SessionState::Pending | SessionState::Running | SessionState::Idle
|
||||||
|
) {
|
||||||
|
active_with_worktree_ids.push(session.id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
match crate::worktree::merge_readiness(&worktree) {
|
||||||
|
Ok(readiness)
|
||||||
|
if readiness.status == crate::worktree::MergeReadinessStatus::Conflicted =>
|
||||||
|
{
|
||||||
|
conflicted_session_ids.push(session.id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(error) => {
|
||||||
|
failures.push(WorktreeMergeFailure {
|
||||||
|
session_id: session.id,
|
||||||
|
reason: error.to_string(),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match crate::worktree::has_uncommitted_changes(&worktree) {
|
||||||
|
Ok(true) => {
|
||||||
|
dirty_worktree_ids.push(session.id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Ok(false) => {}
|
||||||
|
Err(error) => {
|
||||||
|
failures.push(WorktreeMergeFailure {
|
||||||
|
session_id: session.id,
|
||||||
|
reason: error.to_string(),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match merge_session_worktree(db, &session.id, cleanup_worktree).await {
|
||||||
|
Ok(outcome) => merged.push(outcome),
|
||||||
|
Err(error) => failures.push(WorktreeMergeFailure {
|
||||||
|
session_id: session.id,
|
||||||
|
reason: error.to_string(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(WorktreeBulkMergeOutcome {
|
||||||
|
merged,
|
||||||
|
active_with_worktree_ids,
|
||||||
|
conflicted_session_ids,
|
||||||
|
dirty_worktree_ids,
|
||||||
|
failures,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
pub struct WorktreePruneOutcome {
|
pub struct WorktreePruneOutcome {
|
||||||
pub cleaned_session_ids: Vec<String>,
|
pub cleaned_session_ids: Vec<String>,
|
||||||
@@ -1960,6 +2052,104 @@ mod tests {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
async fn merge_ready_worktrees_merges_ready_sessions_and_skips_active_and_dirty() -> Result<()>
|
||||||
|
{
|
||||||
|
let tempdir = TestDir::new("manager-merge-ready-worktrees")?;
|
||||||
|
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 merged_worktree =
|
||||||
|
crate::worktree::create_for_session_in_repo("merge-ready", &cfg, &repo_root)?;
|
||||||
|
fs::write(merged_worktree.path.join("merged.txt"), "bulk merge\n")?;
|
||||||
|
run_git(&merged_worktree.path, ["add", "merged.txt"])?;
|
||||||
|
run_git(&merged_worktree.path, ["commit", "-qm", "merge ready"])?;
|
||||||
|
db.insert_session(&Session {
|
||||||
|
id: "merge-ready".to_string(),
|
||||||
|
task: "merge me".to_string(),
|
||||||
|
agent_type: "claude".to_string(),
|
||||||
|
working_dir: merged_worktree.path.clone(),
|
||||||
|
state: SessionState::Completed,
|
||||||
|
pid: None,
|
||||||
|
worktree: Some(merged_worktree.clone()),
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
metrics: SessionMetrics::default(),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let active_worktree =
|
||||||
|
crate::worktree::create_for_session_in_repo("active-worktree", &cfg, &repo_root)?;
|
||||||
|
db.insert_session(&Session {
|
||||||
|
id: "active-worktree".to_string(),
|
||||||
|
task: "still running".to_string(),
|
||||||
|
agent_type: "claude".to_string(),
|
||||||
|
working_dir: active_worktree.path.clone(),
|
||||||
|
state: SessionState::Running,
|
||||||
|
pid: Some(12345),
|
||||||
|
worktree: Some(active_worktree.clone()),
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
metrics: SessionMetrics::default(),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let dirty_worktree =
|
||||||
|
crate::worktree::create_for_session_in_repo("dirty-worktree", &cfg, &repo_root)?;
|
||||||
|
fs::write(dirty_worktree.path.join("dirty.txt"), "not committed yet\n")?;
|
||||||
|
db.insert_session(&Session {
|
||||||
|
id: "dirty-worktree".to_string(),
|
||||||
|
task: "needs commit".to_string(),
|
||||||
|
agent_type: "claude".to_string(),
|
||||||
|
working_dir: dirty_worktree.path.clone(),
|
||||||
|
state: SessionState::Stopped,
|
||||||
|
pid: None,
|
||||||
|
worktree: Some(dirty_worktree.clone()),
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
metrics: SessionMetrics::default(),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let outcome = merge_ready_worktrees(&db, true).await?;
|
||||||
|
|
||||||
|
assert_eq!(outcome.merged.len(), 1);
|
||||||
|
assert_eq!(outcome.merged[0].session_id, "merge-ready");
|
||||||
|
assert_eq!(outcome.active_with_worktree_ids, vec!["active-worktree".to_string()]);
|
||||||
|
assert_eq!(outcome.dirty_worktree_ids, vec!["dirty-worktree".to_string()]);
|
||||||
|
assert!(outcome.conflicted_session_ids.is_empty());
|
||||||
|
assert!(outcome.failures.is_empty());
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
fs::read_to_string(repo_root.join("merged.txt"))?,
|
||||||
|
"bulk merge\n"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
db.get_session("merge-ready")?
|
||||||
|
.context("merged session should still exist")?
|
||||||
|
.worktree
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
db.get_session("active-worktree")?
|
||||||
|
.context("active session should still exist")?
|
||||||
|
.worktree
|
||||||
|
.is_some()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
db.get_session("dirty-worktree")?
|
||||||
|
.context("dirty session should still exist")?
|
||||||
|
.worktree
|
||||||
|
.is_some()
|
||||||
|
);
|
||||||
|
assert!(!merged_worktree.path.exists());
|
||||||
|
assert!(active_worktree.path.exists());
|
||||||
|
assert!(dirty_worktree.path.exists());
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "current_thread")]
|
#[tokio::test(flavor = "current_thread")]
|
||||||
async fn delete_session_removes_inactive_session_and_worktree() -> Result<()> {
|
async fn delete_session_removes_inactive_session_and_worktree() -> Result<()> {
|
||||||
let tempdir = TestDir::new("manager-delete-session")?;
|
let tempdir = TestDir::new("manager-delete-session")?;
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> {
|
|||||||
(_, KeyCode::Char('G')) => dashboard.coordinate_backlog().await,
|
(_, KeyCode::Char('G')) => dashboard.coordinate_backlog().await,
|
||||||
(_, KeyCode::Char('v')) => dashboard.toggle_output_mode(),
|
(_, KeyCode::Char('v')) => dashboard.toggle_output_mode(),
|
||||||
(_, KeyCode::Char('m')) => dashboard.merge_selected_worktree().await,
|
(_, KeyCode::Char('m')) => dashboard.merge_selected_worktree().await,
|
||||||
|
(_, KeyCode::Char('M')) => dashboard.merge_ready_worktrees().await,
|
||||||
(_, KeyCode::Char('p')) => dashboard.toggle_auto_dispatch_policy(),
|
(_, KeyCode::Char('p')) => dashboard.toggle_auto_dispatch_policy(),
|
||||||
(_, KeyCode::Char(',')) => dashboard.adjust_auto_dispatch_limit(-1),
|
(_, KeyCode::Char(',')) => dashboard.adjust_auto_dispatch_limit(-1),
|
||||||
(_, KeyCode::Char('.')) => dashboard.adjust_auto_dispatch_limit(1),
|
(_, KeyCode::Char('.')) => dashboard.adjust_auto_dispatch_limit(1),
|
||||||
|
|||||||
@@ -462,7 +462,7 @@ impl Dashboard {
|
|||||||
|
|
||||||
fn render_status_bar(&self, frame: &mut Frame, area: Rect) {
|
fn render_status_bar(&self, frame: &mut Frame, area: Rect) {
|
||||||
let text = format!(
|
let text = format!(
|
||||||
" [n]ew session [a]ssign re[b]alance global re[B]alance dra[i]n inbox [g]lobal dispatch coordinate [G]lobal [v]iew diff toggle [p]olicy [,/.] dispatch limit [s]top [u]resume [x]cleanup prune inactive [X] [d]elete [r]efresh [Tab] switch pane [j/k] scroll [+/-] resize [{}] layout [?] help [q]uit ",
|
" [n]ew session [a]ssign re[b]alance global re[B]alance dra[i]n inbox [g]lobal dispatch coordinate [G]lobal [v]iew diff [m]erge merge ready [M] toggle [p]olicy [,/.] dispatch limit [s]top [u]resume [x]cleanup prune inactive [X] [d]elete [r]efresh [Tab] switch pane [j/k] scroll [+/-] resize [{}] layout [?] help [q]uit ",
|
||||||
self.layout_label()
|
self.layout_label()
|
||||||
);
|
);
|
||||||
let text = if let Some(note) = self.operator_note.as_ref() {
|
let text = if let Some(note) = self.operator_note.as_ref() {
|
||||||
@@ -515,6 +515,7 @@ impl Dashboard {
|
|||||||
" G Dispatch then rebalance backlog across lead teams",
|
" G Dispatch then rebalance backlog across lead teams",
|
||||||
" v Toggle selected worktree diff in output pane",
|
" v Toggle selected worktree diff in output pane",
|
||||||
" m Merge selected ready worktree into base and clean it up",
|
" m Merge selected ready worktree into base and clean it up",
|
||||||
|
" M Merge all ready inactive worktrees and clean them up",
|
||||||
" p Toggle daemon auto-dispatch policy and persist config",
|
" p Toggle daemon auto-dispatch policy and persist config",
|
||||||
" ,/. Decrease/increase auto-dispatch limit per lead",
|
" ,/. Decrease/increase auto-dispatch limit per lead",
|
||||||
" s Stop selected session",
|
" s Stop selected session",
|
||||||
@@ -1151,6 +1152,51 @@ impl Dashboard {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn merge_ready_worktrees(&mut self) {
|
||||||
|
match manager::merge_ready_worktrees(&self.db, true).await {
|
||||||
|
Ok(outcome) => {
|
||||||
|
self.refresh();
|
||||||
|
if outcome.merged.is_empty()
|
||||||
|
&& outcome.active_with_worktree_ids.is_empty()
|
||||||
|
&& outcome.conflicted_session_ids.is_empty()
|
||||||
|
&& outcome.dirty_worktree_ids.is_empty()
|
||||||
|
&& outcome.failures.is_empty()
|
||||||
|
{
|
||||||
|
self.set_operator_note("no ready worktrees to merge".to_string());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut parts = vec![format!("merged {} ready worktree(s)", outcome.merged.len())];
|
||||||
|
if !outcome.active_with_worktree_ids.is_empty() {
|
||||||
|
parts.push(format!(
|
||||||
|
"skipped {} active",
|
||||||
|
outcome.active_with_worktree_ids.len()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !outcome.conflicted_session_ids.is_empty() {
|
||||||
|
parts.push(format!(
|
||||||
|
"skipped {} conflicted",
|
||||||
|
outcome.conflicted_session_ids.len()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !outcome.dirty_worktree_ids.is_empty() {
|
||||||
|
parts.push(format!(
|
||||||
|
"skipped {} dirty",
|
||||||
|
outcome.dirty_worktree_ids.len()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !outcome.failures.is_empty() {
|
||||||
|
parts.push(format!("{} failed", outcome.failures.len()));
|
||||||
|
}
|
||||||
|
self.set_operator_note(parts.join("; "));
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!("Failed to merge ready worktrees: {error}");
|
||||||
|
self.set_operator_note(format!("merge ready worktrees failed: {error}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn prune_inactive_worktrees(&mut self) {
|
pub async fn prune_inactive_worktrees(&mut self) {
|
||||||
match manager::prune_inactive_worktrees(&self.db).await {
|
match manager::prune_inactive_worktrees(&self.db).await {
|
||||||
Ok(outcome) => {
|
Ok(outcome) => {
|
||||||
@@ -3335,6 +3381,83 @@ mod tests {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
async fn merge_ready_worktrees_sets_operator_note_with_skip_summary() -> Result<()> {
|
||||||
|
let tempdir =
|
||||||
|
std::env::temp_dir().join(format!("dashboard-merge-ready-{}", Uuid::new_v4()));
|
||||||
|
let repo_root = tempdir.join("repo");
|
||||||
|
init_git_repo(&repo_root)?;
|
||||||
|
|
||||||
|
let cfg = build_config(&tempdir);
|
||||||
|
let db = StateStore::open(&cfg.db_path)?;
|
||||||
|
let now = Utc::now();
|
||||||
|
|
||||||
|
let merged_worktree = worktree::create_for_session_in_repo("merge-ready", &cfg, &repo_root)?;
|
||||||
|
std::fs::write(merged_worktree.path.join("merged.txt"), "dashboard bulk merge\n")?;
|
||||||
|
Command::new("git")
|
||||||
|
.arg("-C")
|
||||||
|
.arg(&merged_worktree.path)
|
||||||
|
.args(["add", "merged.txt"])
|
||||||
|
.status()?;
|
||||||
|
Command::new("git")
|
||||||
|
.arg("-C")
|
||||||
|
.arg(&merged_worktree.path)
|
||||||
|
.args(["commit", "-qm", "dashboard bulk merge"])
|
||||||
|
.status()?;
|
||||||
|
db.insert_session(&Session {
|
||||||
|
id: "merge-ready".to_string(),
|
||||||
|
task: "merge via dashboard".to_string(),
|
||||||
|
agent_type: "claude".to_string(),
|
||||||
|
working_dir: merged_worktree.path.clone(),
|
||||||
|
state: SessionState::Completed,
|
||||||
|
pid: None,
|
||||||
|
worktree: Some(merged_worktree.clone()),
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
metrics: SessionMetrics::default(),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let active_worktree =
|
||||||
|
worktree::create_for_session_in_repo("active-ready", &cfg, &repo_root)?;
|
||||||
|
db.insert_session(&Session {
|
||||||
|
id: "active-ready".to_string(),
|
||||||
|
task: "still active".to_string(),
|
||||||
|
agent_type: "claude".to_string(),
|
||||||
|
working_dir: active_worktree.path.clone(),
|
||||||
|
state: SessionState::Running,
|
||||||
|
pid: Some(999),
|
||||||
|
worktree: Some(active_worktree.clone()),
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
metrics: SessionMetrics::default(),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mut dashboard = Dashboard::new(db, cfg);
|
||||||
|
dashboard.merge_ready_worktrees().await;
|
||||||
|
|
||||||
|
let note = dashboard
|
||||||
|
.operator_note
|
||||||
|
.clone()
|
||||||
|
.context("operator note should be set")?;
|
||||||
|
assert!(note.contains("merged 1 ready worktree(s)"));
|
||||||
|
assert!(note.contains("skipped 1 active"));
|
||||||
|
assert!(
|
||||||
|
dashboard
|
||||||
|
.db
|
||||||
|
.get_session("merge-ready")?
|
||||||
|
.context("merged session should still exist")?
|
||||||
|
.worktree
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_to_string(repo_root.join("merged.txt"))?,
|
||||||
|
"dashboard bulk merge\n"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&tempdir);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn delete_selected_session_removes_inactive_session() -> Result<()> {
|
async fn delete_selected_session_removes_inactive_session() -> Result<()> {
|
||||||
let db_path = std::env::temp_dir().join(format!("ecc2-dashboard-{}.db", Uuid::new_v4()));
|
let db_path = std::env::temp_dir().join(format!("ecc2-dashboard-{}.db", Uuid::new_v4()));
|
||||||
|
|||||||
@@ -297,13 +297,17 @@ pub fn health(worktree: &WorktreeInfo) -> Result<WorktreeHealth> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn has_uncommitted_changes(worktree: &WorktreeInfo) -> Result<bool> {
|
||||||
|
Ok(!git_status_short(&worktree.path)?.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn merge_into_base(worktree: &WorktreeInfo) -> Result<MergeOutcome> {
|
pub fn merge_into_base(worktree: &WorktreeInfo) -> Result<MergeOutcome> {
|
||||||
let readiness = merge_readiness(worktree)?;
|
let readiness = merge_readiness(worktree)?;
|
||||||
if readiness.status == MergeReadinessStatus::Conflicted {
|
if readiness.status == MergeReadinessStatus::Conflicted {
|
||||||
anyhow::bail!(readiness.summary);
|
anyhow::bail!(readiness.summary);
|
||||||
}
|
}
|
||||||
|
|
||||||
if !git_status_short(&worktree.path)?.is_empty() {
|
if has_uncommitted_changes(worktree)? {
|
||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
"Worktree {} has uncommitted changes; commit or discard them before merging",
|
"Worktree {} has uncommitted changes; commit or discard them before merging",
|
||||||
worktree.branch
|
worktree.branch
|
||||||
|
|||||||
Reference in New Issue
Block a user