diff --git a/src/commands/merge.rs b/src/commands/merge.rs index 8e9b9f0..555f8fd 100644 --- a/src/commands/merge.rs +++ b/src/commands/merge.rs @@ -6,20 +6,7 @@ use crate::error::{BrdError, Result}; use crate::git; use crate::repo::RepoPaths; -/// check if we're in an agent worktree (has .braid/agent.toml). -fn is_agent_worktree(paths: &RepoPaths) -> bool { - paths.worktree_root.join(".braid/agent.toml").exists() -} - pub fn cmd_merge(cli: &Cli, paths: &RepoPaths) -> Result<()> { - // step 0: check we're in an agent worktree - if !is_agent_worktree(paths) { - return Err(BrdError::Other( - "not in an agent worktree - brd agent merge only works from agent worktrees" - .to_string(), - )); - } - // step 1: check for clean working tree if !git::is_clean(&paths.worktree_root)? { return Err(BrdError::Other( @@ -44,8 +31,18 @@ pub fn cmd_merge(cli: &Cli, paths: &RepoPaths) -> Result<()> { return Ok(()); } + let (display_ref, push_ref) = if branch == "HEAD" { + let short_head = git::output(&["rev-parse", "--short", "HEAD"], &paths.worktree_root)?; + ( + format!("detached HEAD {}", short_head), + "HEAD:main".to_string(), + ) + } else { + (branch.clone(), format!("{}:main", branch)) + }; + if !cli.json { - println!("merging {} to main...", branch); + println!("merging {} to main...", display_ref); } // step 2: fetch origin main @@ -75,7 +72,6 @@ pub fn cmd_merge(cli: &Cli, paths: &RepoPaths) -> Result<()> { if !cli.json { println!(" pushing to main..."); } - let push_ref = format!("{}:main", branch); let push_output = git::run_full(&["push", "origin", &push_ref], &paths.worktree_root)?; if !push_output.status.success() { let stderr = String::from_utf8_lossy(&push_output.stderr); @@ -109,11 +105,12 @@ pub fn cmd_merge(cli: &Cli, paths: &RepoPaths) -> Result<()> { let json = serde_json::json!({ "ok": true, "branch": branch, + "source": display_ref, "action": "merged", }); println!("{}", serde_json::to_string_pretty(&json).unwrap()); } else { - println!("merged {} to main", branch); + println!("merged {} to main", display_ref); if manual_sync { println!(); println!("hint: run `brd sync` to push any pending issue changes"); @@ -165,6 +162,8 @@ mod tests { "schema_version = 6\nid_prefix = \"tst\"\nid_len = 4\n", ) .unwrap(); + git_ok(repo_path, &["add", ".braid/config.toml"]); + git_ok(repo_path, &["commit", "-m", "init braid"]); let paths = RepoPaths { worktree_root: repo_path.to_path_buf(), @@ -186,45 +185,11 @@ mod tests { } } - #[test] - fn test_is_agent_worktree_true() { - let (dir, paths) = create_repo(); - let braid_dir = dir.path().join(".braid"); - std::fs::write(braid_dir.join("agent.toml"), "agent_id = \"test\"\n").unwrap(); - - assert!(is_agent_worktree(&paths)); - } - - #[test] - fn test_is_agent_worktree_false() { - let (_dir, paths) = create_repo(); - // No agent.toml created - assert!(!is_agent_worktree(&paths)); - } - - #[test] - fn test_merge_rejects_non_agent_worktree() { - let (_dir, paths) = create_repo(); - let cli = make_cli(); - - // No agent.toml, should reject - let err = cmd_merge(&cli, &paths).unwrap_err(); - assert!(err.to_string().contains("not in an agent worktree")); - } - #[test] fn test_merge_rejects_dirty_worktree() { let (dir, paths) = create_repo(); let cli = make_cli(); - // Create agent.toml - std::fs::write( - dir.path().join(".braid/agent.toml"), - "agent_id = \"test\"\n", - ) - .unwrap(); - - // Create uncommitted changes std::fs::write(dir.path().join("dirty.txt"), "dirty\n").unwrap(); let err = cmd_merge(&cli, &paths).unwrap_err(); @@ -233,19 +198,9 @@ mod tests { #[test] fn test_merge_on_main_branch() { - let (dir, paths) = create_repo(); + let (_dir, paths) = create_repo(); let cli = make_cli(); - // Create agent.toml and commit it - std::fs::write( - dir.path().join(".braid/agent.toml"), - "agent_id = \"test\"\n", - ) - .unwrap(); - git_ok(dir.path(), &["add", "."]); - git_ok(dir.path(), &["commit", "-m", "add agent.toml"]); - - // We're on main branch, should return Ok but print message let result = cmd_merge(&cli, &paths); assert!(result.is_ok(), "expected Ok, got: {:?}", result); } diff --git a/src/session.rs b/src/session.rs index 2d2efb1..00a2757 100644 --- a/src/session.rs +++ b/src/session.rs @@ -1,5 +1,6 @@ //! agent session management for tracking spawned claude agents. +use std::cmp::Reverse; use std::fs; use std::path::{Path, PathBuf}; @@ -210,7 +211,7 @@ pub fn load_all_sessions(sessions_dir: &Path) -> Result> { } // sort by started_at, newest first - sessions.sort_by(|a, b| b.started_at.cmp(&a.started_at)); + sessions.sort_by_key(|session| Reverse(session.started_at)); Ok(sessions) } diff --git a/src/tui/event.rs b/src/tui/event.rs index 66b74fe..4bc4dd2 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -96,13 +96,11 @@ fn handle_key_event(app: &mut App, paths: &RepoPaths, key: KeyEvent) -> Result { - if *selected < 3 { - app.input_mode = InputMode::Priority { - title: title.clone(), - selected: selected + 1, - }; - } + KeyCode::Down | KeyCode::Char('j') if *selected < 3 => { + app.input_mode = InputMode::Priority { + title: title.clone(), + selected: selected + 1, + }; } _ => {} } @@ -125,15 +123,13 @@ fn handle_key_event(app: &mut App, paths: &RepoPaths, key: KeyEvent) -> Result { - if *selected < 2 { - // 3 options: (none), design, meta - app.input_mode = InputMode::Type { - title: title.clone(), - priority: *priority, - selected: selected + 1, - }; - } + KeyCode::Down | KeyCode::Char('j') if *selected < 2 => { + // 3 options: (none), design, meta + app.input_mode = InputMode::Type { + title: title.clone(), + priority: *priority, + selected: selected + 1, + }; } _ => {} } @@ -167,16 +163,14 @@ fn handle_key_event(app: &mut App, paths: &RepoPaths, key: KeyEvent) -> Result { - if *cursor < max_cursor { - app.input_mode = InputMode::Deps { - title: title.clone(), - priority: *priority, - type_idx: *type_idx, - selected_deps: selected_deps.clone(), - cursor: cursor + 1, - }; - } + KeyCode::Down | KeyCode::Char('j') if *cursor < max_cursor => { + app.input_mode = InputMode::Deps { + title: title.clone(), + priority: *priority, + type_idx: *type_idx, + selected_deps: selected_deps.clone(), + cursor: cursor + 1, + }; } _ => {} } diff --git a/tests/merge.rs b/tests/merge.rs index 66d389b..7fa259b 100644 --- a/tests/merge.rs +++ b/tests/merge.rs @@ -32,6 +32,10 @@ impl MergeEnv { run_git_in(&self.path(), args) } + fn git_in(&self, path: &Path, args: &[&str]) -> Output { + run_git_in(path, args) + } + fn git_ok(&self, args: &[&str]) -> Output { let output = self.git(args); assert!( @@ -43,11 +47,27 @@ impl MergeEnv { output } + fn git_ok_in(&self, path: &Path, args: &[&str]) -> Output { + let output = self.git_in(path, args); + assert!( + output.status.success(), + "git {:?} failed: {}", + args, + stderr(&output) + ); + output + } + fn git_stdout(&self, args: &[&str]) -> String { let output = self.git_ok(args); String::from_utf8_lossy(&output.stdout).trim().to_string() } + fn git_stdout_in(&self, path: &Path, args: &[&str]) -> String { + let output = self.git_ok_in(path, args); + String::from_utf8_lossy(&output.stdout).trim().to_string() + } + fn git_remote_stdout(&self, args: &[&str]) -> String { let output = run_git_in_bare(&self.remote_path(), args); assert!( @@ -60,9 +80,13 @@ impl MergeEnv { } fn brd(&self, args: &[&str]) -> Output { + self.brd_in(&self.path(), args) + } + + fn brd_in(&self, path: &Path, args: &[&str]) -> Output { Command::new(env!("CARGO_BIN_EXE_brd")) .args(args) - .current_dir(self.path()) + .current_dir(path) .output() .expect("failed to run brd") } @@ -72,8 +96,12 @@ impl MergeEnv { } fn commit_all(&self, message: &str) { - self.git_ok(&["add", "-A"]); - self.git_ok(&["commit", "-m", message]); + self.commit_all_in(&self.path(), message); + } + + fn commit_all_in(&self, path: &Path, message: &str) { + self.git_ok_in(path, &["add", "-A"]); + self.git_ok_in(path, &["commit", "-m", message]); } fn init_repo(&self) { @@ -168,6 +196,67 @@ fn test_merge_success_flow() { assert_eq!(origin_main, remote_main); } +#[test] +fn test_merge_from_plain_linked_worktree() { + let env = MergeEnv::new(); + let worktree = tempfile::tempdir().expect("failed to create worktree dir"); + let worktree_path = worktree.path().join("plain-agent"); + let worktree_str = worktree_path.to_str().expect("worktree path is not utf-8"); + + env.git_ok(&[ + "worktree", + "add", + "-b", + "plain-agent", + worktree_str, + "origin/main", + ]); + assert!(!worktree_path.join(".braid/agent.toml").exists()); + + fs::write(worktree_path.join("work.txt"), "agent work\n").expect("failed to write work file"); + env.commit_all_in(&worktree_path, "agent work"); + + let output = env.brd_in(&worktree_path, &["agent", "merge"]); + assert!(output.status.success(), "merge failed: {}", stderr(&output)); + + let branch = env.git_stdout_in(&worktree_path, &["rev-parse", "--abbrev-ref", "HEAD"]); + assert_eq!(branch, "plain-agent"); + + let head = env.git_stdout_in(&worktree_path, &["rev-parse", "HEAD"]); + let origin_main = env.git_stdout_in(&worktree_path, &["rev-parse", "origin/main"]); + assert_eq!(head, origin_main); + + let remote_main = env.git_remote_stdout(&["rev-parse", "main"]); + assert_eq!(origin_main, remote_main); +} + +#[test] +fn test_merge_from_detached_worktree() { + let env = MergeEnv::new(); + let worktree = tempfile::tempdir().expect("failed to create worktree dir"); + let worktree_path = worktree.path().join("detached-agent"); + let worktree_str = worktree_path.to_str().expect("worktree path is not utf-8"); + + env.git_ok(&["worktree", "add", "--detach", worktree_str, "origin/main"]); + assert!(!worktree_path.join(".braid/agent.toml").exists()); + + fs::write(worktree_path.join("work.txt"), "agent work\n").expect("failed to write work file"); + env.commit_all_in(&worktree_path, "detached agent work"); + + let output = env.brd_in(&worktree_path, &["agent", "merge"]); + assert!(output.status.success(), "merge failed: {}", stderr(&output)); + + let branch = env.git_stdout_in(&worktree_path, &["rev-parse", "--abbrev-ref", "HEAD"]); + assert_eq!(branch, "HEAD"); + + let head = env.git_stdout_in(&worktree_path, &["rev-parse", "HEAD"]); + let origin_main = env.git_stdout_in(&worktree_path, &["rev-parse", "origin/main"]); + assert_eq!(head, origin_main); + + let remote_main = env.git_remote_stdout(&["rev-parse", "main"]); + assert_eq!(origin_main, remote_main); +} + #[test] fn test_merge_dirty_worktree() { let env = MergeEnv::new();