mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-04-10 03:13:29 +08:00
feat: add ecc2 worktree merge actions
This commit is contained in:
@@ -203,6 +203,17 @@ enum Commands {
|
|||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
check: bool,
|
check: bool,
|
||||||
},
|
},
|
||||||
|
/// Merge a session worktree branch into its base branch
|
||||||
|
MergeWorktree {
|
||||||
|
/// Session ID or alias
|
||||||
|
session_id: Option<String>,
|
||||||
|
/// Emit machine-readable JSON instead of the human summary
|
||||||
|
#[arg(long)]
|
||||||
|
json: bool,
|
||||||
|
/// Keep the worktree attached after a successful merge
|
||||||
|
#[arg(long)]
|
||||||
|
keep_worktree: 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 {
|
||||||
/// Emit machine-readable JSON instead of the human summary
|
/// Emit machine-readable JSON instead of the human summary
|
||||||
@@ -690,6 +701,21 @@ async fn main() -> Result<()> {
|
|||||||
std::process::exit(worktree_status_reports_exit_code(&reports));
|
std::process::exit(worktree_status_reports_exit_code(&reports));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Some(Commands::MergeWorktree {
|
||||||
|
session_id,
|
||||||
|
json,
|
||||||
|
keep_worktree,
|
||||||
|
}) => {
|
||||||
|
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 }) => {
|
||||||
let outcome = session::manager::prune_inactive_worktrees(&db).await?;
|
let outcome = session::manager::prune_inactive_worktrees(&db).await?;
|
||||||
if json {
|
if json {
|
||||||
@@ -1054,6 +1080,28 @@ fn format_worktree_status_reports_human(reports: &[WorktreeStatusReport]) -> Str
|
|||||||
.join("\n\n")
|
.join("\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn format_worktree_merge_human(outcome: &session::manager::WorktreeMergeOutcome) -> String {
|
||||||
|
let mut lines = vec![format!(
|
||||||
|
"Merged worktree for {}",
|
||||||
|
short_session(&outcome.session_id)
|
||||||
|
)];
|
||||||
|
lines.push(format!(
|
||||||
|
"Branch {} -> {}",
|
||||||
|
outcome.branch, outcome.base_branch
|
||||||
|
));
|
||||||
|
lines.push(if outcome.already_up_to_date {
|
||||||
|
"Result already up to date".to_string()
|
||||||
|
} else {
|
||||||
|
"Result merged into base".to_string()
|
||||||
|
});
|
||||||
|
lines.push(if outcome.cleaned_worktree {
|
||||||
|
"Cleanup removed worktree and branch".to_string()
|
||||||
|
} else {
|
||||||
|
"Cleanup kept worktree attached".to_string()
|
||||||
|
});
|
||||||
|
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
|
||||||
}
|
}
|
||||||
@@ -1513,6 +1561,31 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cli_parses_merge_worktree_flags() {
|
||||||
|
let cli = Cli::try_parse_from([
|
||||||
|
"ecc",
|
||||||
|
"merge-worktree",
|
||||||
|
"deadbeef",
|
||||||
|
"--json",
|
||||||
|
"--keep-worktree",
|
||||||
|
])
|
||||||
|
.expect("merge-worktree flags should parse");
|
||||||
|
|
||||||
|
match cli.command {
|
||||||
|
Some(Commands::MergeWorktree {
|
||||||
|
session_id,
|
||||||
|
json,
|
||||||
|
keep_worktree,
|
||||||
|
}) => {
|
||||||
|
assert_eq!(session_id.as_deref(), Some("deadbeef"));
|
||||||
|
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 {
|
||||||
@@ -1560,6 +1633,22 @@ mod tests {
|
|||||||
assert!(text.contains("- active facefeed"));
|
assert!(text.contains("- active facefeed"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_worktree_merge_human_reports_merge_and_cleanup() {
|
||||||
|
let text = format_worktree_merge_human(&session::manager::WorktreeMergeOutcome {
|
||||||
|
session_id: "deadbeefcafefeed".to_string(),
|
||||||
|
branch: "ecc/deadbeef".to_string(),
|
||||||
|
base_branch: "main".to_string(),
|
||||||
|
already_up_to_date: false,
|
||||||
|
cleaned_worktree: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(text.contains("Merged worktree for deadbeef"));
|
||||||
|
assert!(text.contains("Branch ecc/deadbeef -> main"));
|
||||||
|
assert!(text.contains("Result merged into base"));
|
||||||
|
assert!(text.contains("Cleanup removed worktree and branch"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn format_worktree_status_human_handles_missing_worktree() {
|
fn format_worktree_status_human_handles_missing_worktree() {
|
||||||
let report = WorktreeStatusReport {
|
let report = WorktreeStatusReport {
|
||||||
|
|||||||
@@ -603,13 +603,57 @@ pub async fn cleanup_session_worktree(db: &StateStore, id: &str) -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(worktree) = session.worktree.as_ref() {
|
if let Some(worktree) = session.worktree.as_ref() {
|
||||||
crate::worktree::remove(&worktree.path)?;
|
crate::worktree::remove(worktree)?;
|
||||||
db.clear_worktree(&session.id)?;
|
db.clear_worktree(&session.id)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct WorktreeMergeOutcome {
|
||||||
|
pub session_id: String,
|
||||||
|
pub branch: String,
|
||||||
|
pub base_branch: String,
|
||||||
|
pub already_up_to_date: bool,
|
||||||
|
pub cleaned_worktree: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn merge_session_worktree(
|
||||||
|
db: &StateStore,
|
||||||
|
id: &str,
|
||||||
|
cleanup_worktree: bool,
|
||||||
|
) -> Result<WorktreeMergeOutcome> {
|
||||||
|
let session = resolve_session(db, id)?;
|
||||||
|
|
||||||
|
if matches!(session.state, SessionState::Pending | SessionState::Running) {
|
||||||
|
anyhow::bail!(
|
||||||
|
"Cannot merge 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::merge_into_base(&worktree)?;
|
||||||
|
|
||||||
|
if cleanup_worktree {
|
||||||
|
crate::worktree::remove(&worktree)?;
|
||||||
|
db.clear_worktree(&session.id)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(WorktreeMergeOutcome {
|
||||||
|
session_id: session.id,
|
||||||
|
branch: outcome.branch,
|
||||||
|
base_branch: outcome.base_branch,
|
||||||
|
already_up_to_date: outcome.already_up_to_date,
|
||||||
|
cleaned_worktree: cleanup_worktree,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
#[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>,
|
||||||
@@ -659,7 +703,7 @@ pub async fn delete_session(db: &StateStore, id: &str) -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(worktree) = session.worktree.as_ref() {
|
if let Some(worktree) = session.worktree.as_ref() {
|
||||||
let _ = crate::worktree::remove(&worktree.path);
|
let _ = crate::worktree::remove(worktree);
|
||||||
}
|
}
|
||||||
|
|
||||||
db.delete_session(&session.id)?;
|
db.delete_session(&session.id)?;
|
||||||
@@ -758,7 +802,7 @@ async fn queue_session_in_dir_with_runner_program(
|
|||||||
db.update_state(&session.id, &SessionState::Failed)?;
|
db.update_state(&session.id, &SessionState::Failed)?;
|
||||||
|
|
||||||
if let Some(worktree) = session.worktree.as_ref() {
|
if let Some(worktree) = session.worktree.as_ref() {
|
||||||
let _ = crate::worktree::remove(&worktree.path);
|
let _ = crate::worktree::remove(worktree);
|
||||||
}
|
}
|
||||||
|
|
||||||
Err(error.context(format!("Failed to queue session {}", session.id)))
|
Err(error.context(format!("Failed to queue session {}", session.id)))
|
||||||
@@ -829,7 +873,7 @@ async fn create_session_in_dir(
|
|||||||
db.update_state(&session.id, &SessionState::Failed)?;
|
db.update_state(&session.id, &SessionState::Failed)?;
|
||||||
|
|
||||||
if let Some(worktree) = session.worktree.as_ref() {
|
if let Some(worktree) = session.worktree.as_ref() {
|
||||||
let _ = crate::worktree::remove(&worktree.path);
|
let _ = crate::worktree::remove(worktree);
|
||||||
}
|
}
|
||||||
|
|
||||||
Err(error.context(format!("Failed to start session {}", session.id)))
|
Err(error.context(format!("Failed to start session {}", session.id)))
|
||||||
@@ -1029,7 +1073,7 @@ async fn stop_session_with_options(
|
|||||||
|
|
||||||
if cleanup_worktree {
|
if cleanup_worktree {
|
||||||
if let Some(worktree) = session.worktree.as_ref() {
|
if let Some(worktree) = session.worktree.as_ref() {
|
||||||
crate::worktree::remove(&worktree.path)?;
|
crate::worktree::remove(worktree)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1525,15 +1569,13 @@ mod tests {
|
|||||||
fn init_git_repo(path: &Path) -> Result<()> {
|
fn init_git_repo(path: &Path) -> Result<()> {
|
||||||
fs::create_dir_all(path)?;
|
fs::create_dir_all(path)?;
|
||||||
run_git(path, ["init", "-q"])?;
|
run_git(path, ["init", "-q"])?;
|
||||||
|
run_git(path, ["config", "user.name", "ECC Tests"])?;
|
||||||
|
run_git(path, ["config", "user.email", "ecc-tests@example.com"])?;
|
||||||
fs::write(path.join("README.md"), "hello\n")?;
|
fs::write(path.join("README.md"), "hello\n")?;
|
||||||
run_git(path, ["add", "README.md"])?;
|
run_git(path, ["add", "README.md"])?;
|
||||||
run_git(
|
run_git(
|
||||||
path,
|
path,
|
||||||
[
|
[
|
||||||
"-c",
|
|
||||||
"user.name=ECC Tests",
|
|
||||||
"-c",
|
|
||||||
"user.email=ecc-tests@example.com",
|
|
||||||
"commit",
|
"commit",
|
||||||
"-qm",
|
"-qm",
|
||||||
"init",
|
"init",
|
||||||
@@ -1856,6 +1898,68 @@ mod tests {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
async fn merge_session_worktree_merges_branch_and_cleans_worktree() -> Result<()> {
|
||||||
|
let tempdir = TestDir::new("manager-merge-worktree")?;
|
||||||
|
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 (fake_claude, _) = write_fake_claude(tempdir.path())?;
|
||||||
|
|
||||||
|
let session_id = create_session_in_dir(
|
||||||
|
&db,
|
||||||
|
&cfg,
|
||||||
|
"merge later",
|
||||||
|
"claude",
|
||||||
|
true,
|
||||||
|
&repo_root,
|
||||||
|
&fake_claude,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
stop_session_with_options(&db, &session_id, false).await?;
|
||||||
|
let stopped = db
|
||||||
|
.get_session(&session_id)?
|
||||||
|
.context("stopped session should exist")?;
|
||||||
|
let worktree = stopped
|
||||||
|
.worktree
|
||||||
|
.clone()
|
||||||
|
.context("stopped session worktree missing")?;
|
||||||
|
|
||||||
|
fs::write(worktree.path.join("feature.txt"), "ready to merge\n")?;
|
||||||
|
run_git(&worktree.path, ["add", "feature.txt"])?;
|
||||||
|
run_git(&worktree.path, ["commit", "-qm", "feature work"])?;
|
||||||
|
|
||||||
|
let outcome = merge_session_worktree(&db, &session_id, true).await?;
|
||||||
|
|
||||||
|
assert_eq!(outcome.session_id, session_id);
|
||||||
|
assert_eq!(outcome.branch, worktree.branch);
|
||||||
|
assert_eq!(outcome.base_branch, worktree.base_branch);
|
||||||
|
assert!(outcome.cleaned_worktree);
|
||||||
|
assert!(!outcome.already_up_to_date);
|
||||||
|
assert_eq!(fs::read_to_string(repo_root.join("feature.txt"))?, "ready to merge\n");
|
||||||
|
|
||||||
|
let merged = db
|
||||||
|
.get_session(&outcome.session_id)?
|
||||||
|
.context("merged session should still exist")?;
|
||||||
|
assert!(merged.worktree.is_none(), "worktree metadata should be cleared");
|
||||||
|
assert!(!worktree.path.exists(), "worktree path should be removed");
|
||||||
|
|
||||||
|
let branch_output = StdCommand::new("git")
|
||||||
|
.arg("-C")
|
||||||
|
.arg(&repo_root)
|
||||||
|
.args(["branch", "--list", &worktree.branch])
|
||||||
|
.output()?;
|
||||||
|
assert!(
|
||||||
|
String::from_utf8_lossy(&branch_output.stdout).trim().is_empty(),
|
||||||
|
"merged worktree branch should be deleted"
|
||||||
|
);
|
||||||
|
|
||||||
|
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")?;
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> {
|
|||||||
(_, KeyCode::Char('g')) => dashboard.auto_dispatch_backlog().await,
|
(_, KeyCode::Char('g')) => dashboard.auto_dispatch_backlog().await,
|
||||||
(_, 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('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),
|
||||||
|
|||||||
@@ -21,8 +21,6 @@ use crate::worktree;
|
|||||||
use crate::session::output::OutputStream;
|
use crate::session::output::OutputStream;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use crate::session::{SessionMetrics, WorktreeInfo};
|
use crate::session::{SessionMetrics, WorktreeInfo};
|
||||||
#[cfg(test)]
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
const DEFAULT_PANE_SIZE_PERCENT: u16 = 35;
|
const DEFAULT_PANE_SIZE_PERCENT: u16 = 35;
|
||||||
const DEFAULT_GRID_SIZE_PERCENT: u16 = 50;
|
const DEFAULT_GRID_SIZE_PERCENT: u16 = 50;
|
||||||
@@ -516,6 +514,7 @@ impl Dashboard {
|
|||||||
" g Auto-dispatch unread handoffs across lead sessions",
|
" g Auto-dispatch unread handoffs across lead sessions",
|
||||||
" 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",
|
||||||
" 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",
|
||||||
@@ -1115,6 +1114,43 @@ impl Dashboard {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn merge_selected_worktree(&mut self) {
|
||||||
|
let Some(session) = self.sessions.get(self.selected_session) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
if session.worktree.is_none() {
|
||||||
|
self.set_operator_note("selected session has no worktree to merge".to_string());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let session_id = session.id.clone();
|
||||||
|
let outcome = match manager::merge_session_worktree(&self.db, &session_id, true).await {
|
||||||
|
Ok(outcome) => outcome,
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!("Failed to merge session {} worktree: {error}", session.id);
|
||||||
|
self.set_operator_note(format!(
|
||||||
|
"merge failed for {}: {error}",
|
||||||
|
format_session_id(&session_id)
|
||||||
|
));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
self.refresh();
|
||||||
|
self.set_operator_note(format!(
|
||||||
|
"merged {} into {} for {}{}",
|
||||||
|
outcome.branch,
|
||||||
|
outcome.base_branch,
|
||||||
|
format_session_id(&session_id),
|
||||||
|
if outcome.already_up_to_date {
|
||||||
|
" (already up to date)"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
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) => {
|
||||||
@@ -2327,14 +2363,16 @@ fn format_duration(duration_secs: u64) -> String {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use anyhow::Result;
|
use anyhow::{Context, Result};
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use ratatui::{Terminal, backend::TestBackend};
|
use ratatui::{Terminal, backend::TestBackend};
|
||||||
use std::path::PathBuf;
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::Command;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::config::PaneLayout;
|
use crate::config::{Config, PaneLayout, Theme};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn render_sessions_shows_summary_headers_and_selected_row() {
|
fn render_sessions_shows_summary_headers_and_selected_row() {
|
||||||
@@ -3235,6 +3273,68 @@ mod tests {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
async fn merge_selected_worktree_sets_operator_note_when_ready() -> Result<()> {
|
||||||
|
let tempdir = std::env::temp_dir().join(format!("dashboard-merge-{}", 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 worktree = worktree::create_for_session_in_repo("merge1234", &cfg, &repo_root)?;
|
||||||
|
let session_id = "merge1234".to_string();
|
||||||
|
let now = Utc::now();
|
||||||
|
db.insert_session(&Session {
|
||||||
|
id: session_id.clone(),
|
||||||
|
task: "merge via dashboard".to_string(),
|
||||||
|
agent_type: "claude".to_string(),
|
||||||
|
working_dir: worktree.path.clone(),
|
||||||
|
state: SessionState::Completed,
|
||||||
|
pid: None,
|
||||||
|
worktree: Some(worktree.clone()),
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
metrics: SessionMetrics::default(),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
std::fs::write(worktree.path.join("dashboard.txt"), "dashboard merge\n")?;
|
||||||
|
Command::new("git")
|
||||||
|
.arg("-C")
|
||||||
|
.arg(&worktree.path)
|
||||||
|
.args(["add", "dashboard.txt"])
|
||||||
|
.status()?;
|
||||||
|
Command::new("git")
|
||||||
|
.arg("-C")
|
||||||
|
.arg(&worktree.path)
|
||||||
|
.args(["commit", "-qm", "dashboard work"])
|
||||||
|
.status()?;
|
||||||
|
|
||||||
|
let mut dashboard = Dashboard::new(db, cfg);
|
||||||
|
dashboard.sync_selection_by_id(Some(&session_id));
|
||||||
|
dashboard.merge_selected_worktree().await;
|
||||||
|
|
||||||
|
let note = dashboard
|
||||||
|
.operator_note
|
||||||
|
.clone()
|
||||||
|
.context("operator note should be set")?;
|
||||||
|
assert!(note.contains("merged ecc/merge1234 into"));
|
||||||
|
assert!(note.contains(&format!("for {}", format_session_id(&session_id))));
|
||||||
|
|
||||||
|
let session = dashboard
|
||||||
|
.db
|
||||||
|
.get_session(&session_id)?
|
||||||
|
.context("merged session should still exist")?;
|
||||||
|
assert!(session.worktree.is_none(), "worktree metadata should be cleared");
|
||||||
|
assert!(!worktree.path.exists(), "worktree path should be removed");
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_to_string(repo_root.join("dashboard.txt"))?,
|
||||||
|
"dashboard 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()));
|
||||||
@@ -3501,6 +3601,48 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn build_config(root: &Path) -> Config {
|
||||||
|
Config {
|
||||||
|
db_path: root.join("state.db"),
|
||||||
|
worktree_root: root.join("worktrees"),
|
||||||
|
max_parallel_sessions: 4,
|
||||||
|
max_parallel_worktrees: 4,
|
||||||
|
session_timeout_secs: 60,
|
||||||
|
heartbeat_interval_secs: 5,
|
||||||
|
default_agent: "claude".to_string(),
|
||||||
|
auto_dispatch_unread_handoffs: false,
|
||||||
|
auto_dispatch_limit_per_session: 5,
|
||||||
|
cost_budget_usd: 10.0,
|
||||||
|
token_budget: 500_000,
|
||||||
|
theme: Theme::Dark,
|
||||||
|
pane_layout: PaneLayout::Horizontal,
|
||||||
|
risk_thresholds: Config::RISK_THRESHOLDS,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init_git_repo(path: &Path) -> Result<()> {
|
||||||
|
fs::create_dir_all(path)?;
|
||||||
|
run_git(path, &["init", "-q"])?;
|
||||||
|
run_git(path, &["config", "user.name", "ECC Tests"])?;
|
||||||
|
run_git(path, &["config", "user.email", "ecc-tests@example.com"])?;
|
||||||
|
fs::write(path.join("README.md"), "hello\n")?;
|
||||||
|
run_git(path, &["add", "README.md"])?;
|
||||||
|
run_git(path, &["commit", "-qm", "init"])?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_git(path: &Path, args: &[&str]) -> Result<()> {
|
||||||
|
let output = Command::new("git")
|
||||||
|
.arg("-C")
|
||||||
|
.arg(path)
|
||||||
|
.args(args)
|
||||||
|
.output()?;
|
||||||
|
if !output.status.success() {
|
||||||
|
anyhow::bail!("{}", String::from_utf8_lossy(&output.stderr));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn sample_session(
|
fn sample_session(
|
||||||
id: &str,
|
id: &str,
|
||||||
agent_type: &str,
|
agent_type: &str,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use std::path::Path;
|
use serde::Serialize;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
@@ -25,6 +26,13 @@ pub enum WorktreeHealth {
|
|||||||
Conflicted,
|
Conflicted,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||||
|
pub struct MergeOutcome {
|
||||||
|
pub branch: String,
|
||||||
|
pub base_branch: String,
|
||||||
|
pub already_up_to_date: bool,
|
||||||
|
}
|
||||||
|
|
||||||
/// Create a new git worktree for an agent session.
|
/// Create a new git worktree for an agent session.
|
||||||
pub fn create_for_session(session_id: &str, cfg: &Config) -> Result<WorktreeInfo> {
|
pub fn create_for_session(session_id: &str, cfg: &Config) -> Result<WorktreeInfo> {
|
||||||
let repo_root = std::env::current_dir().context("Failed to resolve repository root")?;
|
let repo_root = std::env::current_dir().context("Failed to resolve repository root")?;
|
||||||
@@ -73,18 +81,59 @@ pub(crate) fn create_for_session_in_repo(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Remove a worktree and its branch.
|
/// Remove a worktree and its branch.
|
||||||
pub fn remove(path: &Path) -> Result<()> {
|
pub fn remove(worktree: &WorktreeInfo) -> Result<()> {
|
||||||
|
let repo_root = match base_checkout_path(worktree) {
|
||||||
|
Ok(path) => path,
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!(
|
||||||
|
"Falling back to filesystem-only cleanup for {}: {error}",
|
||||||
|
worktree.path.display()
|
||||||
|
);
|
||||||
|
if worktree.path.exists() {
|
||||||
|
if let Err(remove_error) = std::fs::remove_dir_all(&worktree.path) {
|
||||||
|
tracing::warn!(
|
||||||
|
"Fallback worktree directory cleanup warning for {}: {remove_error}",
|
||||||
|
worktree.path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
let output = Command::new("git")
|
let output = Command::new("git")
|
||||||
.arg("-C")
|
.arg("-C")
|
||||||
.arg(path)
|
.arg(&repo_root)
|
||||||
.args(["worktree", "remove", "--force"])
|
.args(["worktree", "remove", "--force"])
|
||||||
.arg(path)
|
.arg(&worktree.path)
|
||||||
.output()
|
.output()
|
||||||
.context("Failed to remove worktree")?;
|
.context("Failed to remove worktree")?;
|
||||||
|
|
||||||
if !output.status.success() {
|
if !output.status.success() {
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
tracing::warn!("Worktree removal warning: {stderr}");
|
tracing::warn!("Worktree removal warning: {stderr}");
|
||||||
|
if worktree.path.exists() {
|
||||||
|
if let Err(remove_error) = std::fs::remove_dir_all(&worktree.path) {
|
||||||
|
tracing::warn!(
|
||||||
|
"Fallback worktree directory cleanup warning for {}: {remove_error}",
|
||||||
|
worktree.path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let branch_output = Command::new("git")
|
||||||
|
.arg("-C")
|
||||||
|
.arg(&repo_root)
|
||||||
|
.args(["branch", "-D", &worktree.branch])
|
||||||
|
.output()
|
||||||
|
.context("Failed to delete worktree branch")?;
|
||||||
|
|
||||||
|
if !branch_output.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&branch_output.stderr);
|
||||||
|
tracing::warn!(
|
||||||
|
"Worktree branch deletion warning for {}: {stderr}",
|
||||||
|
worktree.branch
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -248,6 +297,61 @@ pub fn health(worktree: &WorktreeInfo) -> Result<WorktreeHealth> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn merge_into_base(worktree: &WorktreeInfo) -> Result<MergeOutcome> {
|
||||||
|
let readiness = merge_readiness(worktree)?;
|
||||||
|
if readiness.status == MergeReadinessStatus::Conflicted {
|
||||||
|
anyhow::bail!(readiness.summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !git_status_short(&worktree.path)?.is_empty() {
|
||||||
|
anyhow::bail!(
|
||||||
|
"Worktree {} has uncommitted changes; commit or discard them before merging",
|
||||||
|
worktree.branch
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let repo_root = base_checkout_path(worktree)?;
|
||||||
|
let current_branch = get_current_branch(&repo_root)?;
|
||||||
|
if current_branch != worktree.base_branch {
|
||||||
|
anyhow::bail!(
|
||||||
|
"Base branch {} is not checked out in repo root (currently {})",
|
||||||
|
worktree.base_branch,
|
||||||
|
current_branch
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !git_status_short(&repo_root)?.is_empty() {
|
||||||
|
anyhow::bail!(
|
||||||
|
"Repository root {} has uncommitted changes; commit or stash them before merging",
|
||||||
|
repo_root.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let output = Command::new("git")
|
||||||
|
.arg("-C")
|
||||||
|
.arg(&repo_root)
|
||||||
|
.args(["merge", "--no-edit", &worktree.branch])
|
||||||
|
.output()
|
||||||
|
.context("Failed to merge worktree branch into base")?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
anyhow::bail!("git merge failed: {stderr}");
|
||||||
|
}
|
||||||
|
|
||||||
|
let merged_output = format!(
|
||||||
|
"{}\n{}",
|
||||||
|
String::from_utf8_lossy(&output.stdout),
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(MergeOutcome {
|
||||||
|
branch: worktree.branch.clone(),
|
||||||
|
base_branch: worktree.base_branch.clone(),
|
||||||
|
already_up_to_date: merged_output.contains("Already up to date."),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
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
|
||||||
@@ -387,6 +491,61 @@ fn get_current_branch(repo_root: &Path) -> Result<String> {
|
|||||||
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn base_checkout_path(worktree: &WorktreeInfo) -> Result<PathBuf> {
|
||||||
|
let output = Command::new("git")
|
||||||
|
.arg("-C")
|
||||||
|
.arg(&worktree.path)
|
||||||
|
.args(["worktree", "list", "--porcelain"])
|
||||||
|
.output()
|
||||||
|
.context("Failed to resolve git worktree list")?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
anyhow::bail!("git worktree list --porcelain failed: {stderr}");
|
||||||
|
}
|
||||||
|
|
||||||
|
let target_branch = format!("refs/heads/{}", worktree.base_branch);
|
||||||
|
let mut current_path: Option<PathBuf> = None;
|
||||||
|
let mut current_branch: Option<String> = None;
|
||||||
|
let mut fallback: Option<PathBuf> = None;
|
||||||
|
|
||||||
|
for line in String::from_utf8_lossy(&output.stdout).lines() {
|
||||||
|
if line.is_empty() {
|
||||||
|
if let Some(path) = current_path.take() {
|
||||||
|
if fallback.is_none() && path != worktree.path {
|
||||||
|
fallback = Some(path.clone());
|
||||||
|
}
|
||||||
|
if current_branch.as_deref() == Some(target_branch.as_str()) && path != worktree.path
|
||||||
|
{
|
||||||
|
return Ok(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
current_branch = None;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(path) = line.strip_prefix("worktree ") {
|
||||||
|
current_path = Some(PathBuf::from(path.trim()));
|
||||||
|
} else if let Some(branch) = line.strip_prefix("branch ") {
|
||||||
|
current_branch = Some(branch.trim().to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(path) = current_path.take() {
|
||||||
|
if fallback.is_none() && path != worktree.path {
|
||||||
|
fallback = Some(path.clone());
|
||||||
|
}
|
||||||
|
if current_branch.as_deref() == Some(target_branch.as_str()) && path != worktree.path {
|
||||||
|
return Ok(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fallback.context(format!(
|
||||||
|
"Failed to locate base checkout for {} from git worktree list",
|
||||||
|
worktree.base_branch
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
Reference in New Issue
Block a user