@@ -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.
13681478pub 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
14131523mod 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