diff --git a/crates/goat-tui/src/app/mod.rs b/crates/goat-tui/src/app/mod.rs index 1f9a723..4b9b607 100644 --- a/crates/goat-tui/src/app/mod.rs +++ b/crates/goat-tui/src/app/mod.rs @@ -66,6 +66,7 @@ pub(crate) enum Overlay { const TICK: Duration = Duration::from_millis(120); const QUIT_ARM_TICKS: u16 = 25; const CLEAR_ARM_TICKS: u16 = 25; +const BRANCH_POLL_TICKS: u16 = 8; pub(crate) enum AppEvent { Input(CtEvent), @@ -94,6 +95,7 @@ pub struct App { pub(crate) spinner: usize, pub(crate) quit_arm: Option, pub(crate) clear_arm: Option, + branch_poll: u16, pub(crate) queued: Vec<( TaskId, String, @@ -180,6 +182,7 @@ impl App { spinner: 0, quit_arm: None, clear_arm: None, + branch_poll: BRANCH_POLL_TICKS, queued: Vec::new(), should_quit: false, dirty: true, @@ -232,6 +235,11 @@ impl App { if crate::toast::tick(&mut self.toasts) { self.dirty = true; } + self.branch_poll = self.branch_poll.saturating_sub(1); + if self.branch_poll == 0 { + self.branch_poll = BRANCH_POLL_TICKS; + self.refresh_git_branch(); + } Vec::new() } AppEvent::Input(CtEvent::Key(key)) if key.kind == KeyEventKind::Press => { @@ -827,6 +835,21 @@ impl App { pub(crate) fn workspace_snapshot(&self) -> Option<&goat_worktree::Workspace> { self.git_workspace.as_ref() } + fn refresh_git_branch(&mut self) { + let Some(ws) = self.git_workspace.as_ref() else { + return; + }; + let Some(branch) = ws.head_branch() else { + return; + }; + if branch == ws.git_branch { + return; + } + if let Some(ws) = self.git_workspace.as_mut() { + ws.git_branch = branch; + } + self.dirty = true; + } pub(crate) fn quit_armed(&self) -> bool { self.quit_arm.is_some() } diff --git a/crates/goat-worktree/src/lib.rs b/crates/goat-worktree/src/lib.rs index 563292a..b7c4203 100644 --- a/crates/goat-worktree/src/lib.rs +++ b/crates/goat-worktree/src/lib.rs @@ -604,6 +604,18 @@ mod tests { assert_eq!(ws.git_branch, "main"); } + #[test] + fn head_branch_tracks_checkout() { + let Some(dir) = git_repo() else { + return; + }; + let repo = dir.path().join("repo"); + let ws = workspace(&repo).unwrap(); + assert_eq!(ws.head_branch().as_deref(), Some("main")); + run(&repo, &["checkout", "-b", "feature-x"]); + assert_eq!(ws.head_branch().as_deref(), Some("feature-x")); + } + #[test] fn workspace_managed_from_worktree_path() { let Some(dir) = git_repo_with_origin() else { diff --git a/crates/goat-worktree/src/workspace.rs b/crates/goat-worktree/src/workspace.rs index abdd933..479b357 100644 --- a/crates/goat-worktree/src/workspace.rs +++ b/crates/goat-worktree/src/workspace.rs @@ -45,6 +45,24 @@ pub fn workspace(cwd: &Path) -> Result { }) } +impl Workspace { + pub fn head_branch(&self) -> Option { + let head = self.repo_root.join(".git").join("HEAD"); + parse_head(&std::fs::read_to_string(head).ok()?) + } +} + +fn parse_head(content: &str) -> Option { + let content = content.trim(); + if let Some(branch) = content.strip_prefix("ref: refs/heads/") { + return Some(branch.to_owned()); + } + if !content.is_empty() && content.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Some(content.chars().take(7).collect()); + } + None +} + fn owner_root(current_root: &Path, worktrees: &[crate::git::GitWorktree]) -> PathBuf { for worktree in worktrees { let bucket = worktree.path.join(GOAT_DIR).join(WORKTREES_DIR); @@ -94,4 +112,21 @@ mod tests { let plan = PathBuf::from("/repo/.goat/worktrees/plan"); assert_eq!(owner_root(&plan, &worktrees), PathBuf::from("/repo")); } + + #[test] + fn parse_head_symbolic_and_detached() { + assert_eq!( + parse_head("ref: refs/heads/main\n").as_deref(), + Some("main") + ); + assert_eq!( + parse_head("ref: refs/heads/feature/foo\n").as_deref(), + Some("feature/foo") + ); + assert_eq!( + parse_head("5894a7d0deadbeef1234\n").as_deref(), + Some("5894a7d") + ); + assert_eq!(parse_head("\n"), None); + } }