Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 16 additions & 61 deletions src/commands/merge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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(),
Expand All @@ -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();
Expand All @@ -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);
}
Expand Down
3 changes: 2 additions & 1 deletion src/session.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
//! agent session management for tracking spawned claude agents.

use std::cmp::Reverse;
use std::fs;
use std::path::{Path, PathBuf};

Expand Down Expand Up @@ -210,7 +211,7 @@ pub fn load_all_sessions(sessions_dir: &Path) -> Result<Vec<Session>> {
}

// 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)
}
Expand Down
46 changes: 20 additions & 26 deletions src/tui/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,13 +96,11 @@ fn handle_key_event(app: &mut App, paths: &RepoPaths, key: KeyEvent) -> Result<b
};
}
}
KeyCode::Down | KeyCode::Char('j') => {
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,
};
}
_ => {}
}
Expand All @@ -125,15 +123,13 @@ fn handle_key_event(app: &mut App, paths: &RepoPaths, key: KeyEvent) -> Result<b
};
}
}
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,
};
}
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,
};
}
_ => {}
}
Expand Down Expand Up @@ -167,16 +163,14 @@ fn handle_key_event(app: &mut App, paths: &RepoPaths, key: KeyEvent) -> Result<b
};
}
}
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,
};
}
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,
};
}
_ => {}
}
Expand Down
95 changes: 92 additions & 3 deletions tests/merge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!(
Expand All @@ -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!(
Expand All @@ -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")
}
Expand All @@ -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) {
Expand Down Expand Up @@ -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();
Expand Down
Loading