Skip to content

Commit 7e9c5c7

Browse files
author
zedmor
committed
team: refresh engineer worktrees before assignment
1 parent 12e2b43 commit 7e9c5c7

1 file changed

Lines changed: 250 additions & 0 deletions

File tree

src/team/daemon.rs

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -618,6 +618,20 @@ impl TeamDaemon {
618618
};
619619

620620
let team_config_dir = self.config.project_root.join(".batty").join("team_config");
621+
if use_worktrees
622+
&& let Err(e) = refresh_engineer_worktree(
623+
&self.config.project_root,
624+
&work_dir,
625+
engineer,
626+
&team_config_dir,
627+
)
628+
{
629+
warn!(
630+
engineer,
631+
error = %e,
632+
"worktree refresh failed, proceeding with existing"
633+
);
634+
}
621635
let role_context =
622636
member.map(|m| strip_nudge_section(&self.load_prompt(m, &team_config_dir)));
623637

@@ -1364,6 +1378,102 @@ fn setup_engineer_worktree(
13641378
Ok(worktree_dir.to_path_buf())
13651379
}
13661380

1381+
fn refresh_engineer_worktree(
1382+
project_root: &Path,
1383+
worktree_dir: &Path,
1384+
branch_name: &str,
1385+
team_config_dir: &Path,
1386+
) -> Result<()> {
1387+
if !worktree_dir.exists() {
1388+
return Ok(());
1389+
}
1390+
1391+
let status = std::process::Command::new("git")
1392+
.args(["status", "--porcelain"])
1393+
.current_dir(worktree_dir)
1394+
.output()
1395+
.context("failed to inspect worktree status")?;
1396+
if !status.status.success() {
1397+
let stderr = String::from_utf8_lossy(&status.stderr);
1398+
bail!("git status --porcelain failed: {stderr}");
1399+
}
1400+
1401+
let dirty = String::from_utf8_lossy(&status.stdout)
1402+
.lines()
1403+
.any(|line| !line.starts_with("?? .batty/"));
1404+
if dirty {
1405+
warn!(
1406+
worktree = %worktree_dir.display(),
1407+
branch = branch_name,
1408+
"skipping worktree refresh because worktree is dirty"
1409+
);
1410+
return Ok(());
1411+
}
1412+
1413+
let up_to_date = std::process::Command::new("git")
1414+
.args(["merge-base", "--is-ancestor", "main", branch_name])
1415+
.current_dir(project_root)
1416+
.output()
1417+
.context("failed to compare worktree branch with main")?;
1418+
if up_to_date.status.success() {
1419+
return Ok(());
1420+
}
1421+
1422+
let rebase = std::process::Command::new("git")
1423+
.args(["rebase", "main"])
1424+
.current_dir(worktree_dir)
1425+
.output()
1426+
.context("failed to rebase engineer worktree")?;
1427+
if rebase.status.success() {
1428+
info!(
1429+
worktree = %worktree_dir.display(),
1430+
branch = branch_name,
1431+
"refreshed engineer worktree"
1432+
);
1433+
return Ok(());
1434+
}
1435+
1436+
let stderr = String::from_utf8_lossy(&rebase.stderr).trim().to_string();
1437+
let _ = std::process::Command::new("git")
1438+
.args(["rebase", "--abort"])
1439+
.current_dir(worktree_dir)
1440+
.output();
1441+
1442+
let remove = std::process::Command::new("git")
1443+
.args([
1444+
"worktree",
1445+
"remove",
1446+
"--force",
1447+
&worktree_dir.to_string_lossy(),
1448+
])
1449+
.current_dir(project_root)
1450+
.output()
1451+
.context("failed to remove conflicted worktree")?;
1452+
if !remove.status.success() {
1453+
let remove_stderr = String::from_utf8_lossy(&remove.stderr);
1454+
bail!("git worktree remove --force failed after rebase error '{stderr}': {remove_stderr}");
1455+
}
1456+
1457+
let delete = std::process::Command::new("git")
1458+
.args(["branch", "-D", branch_name])
1459+
.current_dir(project_root)
1460+
.output()
1461+
.context("failed to delete conflicted worktree branch")?;
1462+
if !delete.status.success() {
1463+
let delete_stderr = String::from_utf8_lossy(&delete.stderr);
1464+
bail!("git branch -D failed after rebase error '{stderr}': {delete_stderr}");
1465+
}
1466+
1467+
warn!(
1468+
worktree = %worktree_dir.display(),
1469+
branch = branch_name,
1470+
rebase_error = %stderr,
1471+
"recreating engineer worktree after rebase conflict"
1472+
);
1473+
setup_engineer_worktree(project_root, worktree_dir, branch_name, team_config_dir)?;
1474+
Ok(())
1475+
}
1476+
13671477
/// Merge an engineer's worktree branch into main.
13681478
pub fn merge_engineer_branch(project_root: &Path, engineer_name: &str) -> Result<()> {
13691479
let worktree_dir = project_root
@@ -1413,11 +1523,55 @@ pub fn merge_engineer_branch(project_root: &Path, engineer_name: &str) -> Result
14131523
mod tests {
14141524
use super::*;
14151525
use std::collections::HashMap;
1526+
use std::process::{Command, Output};
14161527

14171528
use crate::team::config::{BoardConfig, ChannelConfig, RoleDef, StandupConfig};
14181529
use crate::team::events::EventSink;
14191530
use crate::team::watcher::WatcherState;
14201531

1532+
fn git(dir: &Path, args: &[&str]) -> Output {
1533+
Command::new("git")
1534+
.args(args)
1535+
.current_dir(dir)
1536+
.output()
1537+
.unwrap_or_else(|e| panic!("git {:?} failed to run: {e}", args))
1538+
}
1539+
1540+
fn git_ok(dir: &Path, args: &[&str]) {
1541+
let output = git(dir, args);
1542+
assert!(
1543+
output.status.success(),
1544+
"git {:?} failed: stdout={} stderr={}",
1545+
args,
1546+
String::from_utf8_lossy(&output.stdout),
1547+
String::from_utf8_lossy(&output.stderr)
1548+
);
1549+
}
1550+
1551+
fn git_stdout(dir: &Path, args: &[&str]) -> String {
1552+
let output = git(dir, args);
1553+
assert!(
1554+
output.status.success(),
1555+
"git {:?} failed: stdout={} stderr={}",
1556+
args,
1557+
String::from_utf8_lossy(&output.stdout),
1558+
String::from_utf8_lossy(&output.stderr)
1559+
);
1560+
String::from_utf8_lossy(&output.stdout).trim().to_string()
1561+
}
1562+
1563+
fn init_git_repo(tmp: &tempfile::TempDir) -> PathBuf {
1564+
let repo = tmp.path();
1565+
git_ok(repo, &["init", "-b", "main"]);
1566+
git_ok(repo, &["config", "user.email", "batty-test@example.com"]);
1567+
git_ok(repo, &["config", "user.name", "Batty Test"]);
1568+
std::fs::create_dir_all(repo.join(".batty").join("team_config")).unwrap();
1569+
std::fs::write(repo.join("README.md"), "initial\n").unwrap();
1570+
git_ok(repo, &["add", "README.md", ".batty/team_config"]);
1571+
git_ok(repo, &["commit", "-m", "initial"]);
1572+
repo.to_path_buf()
1573+
}
1574+
14211575
#[test]
14221576
fn launch_script_active_sends_prompt_as_user_message() {
14231577
let cmd = write_launch_script(
@@ -1591,6 +1745,102 @@ mod tests {
15911745
assert!(err.to_string().contains("no worktree found"));
15921746
}
15931747

1748+
#[test]
1749+
fn test_refresh_worktree_rebases_behind_main() {
1750+
let tmp = tempfile::tempdir().unwrap();
1751+
let repo = init_git_repo(&tmp);
1752+
let worktree_dir = repo.join(".batty").join("worktrees").join("eng-1");
1753+
let team_config_dir = repo.join(".batty").join("team_config");
1754+
1755+
setup_engineer_worktree(&repo, &worktree_dir, "eng-1", &team_config_dir).unwrap();
1756+
1757+
std::fs::write(repo.join("main.txt"), "new main content\n").unwrap();
1758+
git_ok(&repo, &["add", "main.txt"]);
1759+
git_ok(&repo, &["commit", "-m", "advance main"]);
1760+
1761+
refresh_engineer_worktree(&repo, &worktree_dir, "eng-1", &team_config_dir).unwrap();
1762+
1763+
assert!(worktree_dir.join("main.txt").exists());
1764+
assert_eq!(
1765+
git_stdout(&repo, &["rev-parse", "main"]),
1766+
git_stdout(&worktree_dir, &["rev-parse", "HEAD"])
1767+
);
1768+
}
1769+
1770+
#[test]
1771+
fn test_refresh_worktree_recreates_on_conflict() {
1772+
let tmp = tempfile::tempdir().unwrap();
1773+
let repo = init_git_repo(&tmp);
1774+
let worktree_dir = repo.join(".batty").join("worktrees").join("eng-2");
1775+
let team_config_dir = repo.join(".batty").join("team_config");
1776+
1777+
std::fs::write(repo.join("file.txt"), "A\n").unwrap();
1778+
git_ok(&repo, &["add", "file.txt"]);
1779+
git_ok(&repo, &["commit", "-m", "add file"]);
1780+
1781+
setup_engineer_worktree(&repo, &worktree_dir, "eng-2", &team_config_dir).unwrap();
1782+
1783+
std::fs::write(worktree_dir.join("file.txt"), "B\n").unwrap();
1784+
git_ok(&worktree_dir, &["add", "file.txt"]);
1785+
git_ok(&worktree_dir, &["commit", "-m", "engineer change"]);
1786+
1787+
std::fs::write(repo.join("file.txt"), "C\n").unwrap();
1788+
git_ok(&repo, &["add", "file.txt"]);
1789+
git_ok(&repo, &["commit", "-m", "main change"]);
1790+
1791+
refresh_engineer_worktree(&repo, &worktree_dir, "eng-2", &team_config_dir).unwrap();
1792+
1793+
assert!(worktree_dir.exists());
1794+
assert_eq!(
1795+
std::fs::read_to_string(worktree_dir.join("file.txt")).unwrap(),
1796+
"C\n"
1797+
);
1798+
assert_eq!(
1799+
git_stdout(&repo, &["rev-parse", "main"]),
1800+
git_stdout(&worktree_dir, &["rev-parse", "HEAD"])
1801+
);
1802+
}
1803+
1804+
#[test]
1805+
fn test_refresh_worktree_skips_dirty() {
1806+
let tmp = tempfile::tempdir().unwrap();
1807+
let repo = init_git_repo(&tmp);
1808+
let worktree_dir = repo.join(".batty").join("worktrees").join("eng-3");
1809+
let team_config_dir = repo.join(".batty").join("team_config");
1810+
1811+
setup_engineer_worktree(&repo, &worktree_dir, "eng-3", &team_config_dir).unwrap();
1812+
std::fs::write(worktree_dir.join("scratch.txt"), "uncommitted\n").unwrap();
1813+
1814+
std::fs::write(repo.join("main.txt"), "new main content\n").unwrap();
1815+
git_ok(&repo, &["add", "main.txt"]);
1816+
git_ok(&repo, &["commit", "-m", "advance main"]);
1817+
1818+
refresh_engineer_worktree(&repo, &worktree_dir, "eng-3", &team_config_dir).unwrap();
1819+
1820+
assert!(!worktree_dir.join("main.txt").exists());
1821+
assert_eq!(
1822+
std::fs::read_to_string(worktree_dir.join("scratch.txt")).unwrap(),
1823+
"uncommitted\n"
1824+
);
1825+
}
1826+
1827+
#[test]
1828+
fn test_refresh_worktree_noop_when_current() {
1829+
let tmp = tempfile::tempdir().unwrap();
1830+
let repo = init_git_repo(&tmp);
1831+
let worktree_dir = repo.join(".batty").join("worktrees").join("eng-4");
1832+
let team_config_dir = repo.join(".batty").join("team_config");
1833+
1834+
setup_engineer_worktree(&repo, &worktree_dir, "eng-4", &team_config_dir).unwrap();
1835+
let before = git_stdout(&worktree_dir, &["rev-parse", "HEAD"]);
1836+
1837+
refresh_engineer_worktree(&repo, &worktree_dir, "eng-4", &team_config_dir).unwrap();
1838+
1839+
let after = git_stdout(&worktree_dir, &["rev-parse", "HEAD"]);
1840+
assert_eq!(before, after);
1841+
assert!(worktree_dir.exists());
1842+
}
1843+
15941844
#[test]
15951845
fn mark_member_working_updates_state_and_watcher() {
15961846
let tmp = tempfile::tempdir().unwrap();

0 commit comments

Comments
 (0)