diff --git a/apps/desktop/electron/git-review-ops.ts b/apps/desktop/electron/git-review-ops.ts index 1baf26d2fca..4515644fe2d 100644 --- a/apps/desktop/electron/git-review-ops.ts +++ b/apps/desktop/electron/git-review-ops.ts @@ -204,6 +204,25 @@ async function defaultBranchName(git) { return null } +// True when the current branch's upstream tracking ref has been deleted on the +// remote — the "[gone]" state. Returns false for detached HEAD, no upstream, +// or a live upstream. +async function branchGone(git, branch) { + if (!branch) { + return false + } + + try { + const track = (await git.raw([ + 'for-each-ref', '--format=%(upstream:track)', `refs/heads/${branch}` + ])).trim() + + return track.includes('[gone]') + } catch { + return false + } +} + // A status file's single-letter classification, preferring the staged (index) // code over the worktree code; untracked wins (simple-git marks both '?'). function statusLetter(file) { @@ -630,6 +649,7 @@ async function repoStatus(repoPath, gitBin) { branch: detached ? null : status.current || null, defaultBranch: await defaultBranchName(git), detached, + gone: await branchGone(git, detached ? null : status.current || null), ahead: status.ahead || 0, behind: status.behind || 0, staged: files.filter(f => f.staged).length, diff --git a/apps/desktop/src/app/chat/composer/status-stack/coding-row.tsx b/apps/desktop/src/app/chat/composer/status-stack/coding-row.tsx index 02f41e2605c..92cd0ef8b21 100644 --- a/apps/desktop/src/app/chat/composer/status-stack/coding-row.tsx +++ b/apps/desktop/src/app/chat/composer/status-stack/coding-row.tsx @@ -268,6 +268,15 @@ export const CodingStatusRow = memo(function CodingStatusRow({ {branchLabel} + {status.gone && !status.detached && ( + + {s.gone} + + )} + {/* Branch actions kebab — same pattern as the session/worktree rows. ALWAYS laid out; only its opacity flips on hover/focus/open, so revealing it never reflows the row (no layout shift). pointer-events diff --git a/apps/desktop/src/app/chat/sidebar/projects/workspace-group.tsx b/apps/desktop/src/app/chat/sidebar/projects/workspace-group.tsx index 911aaaeecb2..2165ec11241 100644 --- a/apps/desktop/src/app/chat/sidebar/projects/workspace-group.tsx +++ b/apps/desktop/src/app/chat/sidebar/projects/workspace-group.tsx @@ -3,10 +3,12 @@ import { useState } from 'react' import { Codicon } from '@/components/ui/codicon' import type { SessionInfo } from '@/hermes' +import { setSessionArchived } from '@/hermes' import { useI18n } from '@/i18n' -import { notifyError } from '@/store/notifications' +import { triggerHaptic } from '@/lib/haptics' +import { notify, notifyError } from '@/store/notifications' import { newSessionInProfile } from '@/store/profile' -import { switchBranchInRepo } from '@/store/projects' +import { switchBranchInRepo, tombstoneSessions } from '@/store/projects' import { countLabel, SidebarRowStack } from '../chrome' import { SidebarLoadMoreRow } from '../load-more-row' @@ -27,6 +29,7 @@ interface SidebarWorkspaceGroupProps { export function SidebarWorkspaceGroup({ group, renderRows, onNewSession, onRemove }: SidebarWorkspaceGroupProps) { const { t } = useI18n() const s = t.sidebar + const p = s.projects const isProfileGroup = group.mode === 'profile' // Empty worktree/branch lanes start collapsed — they only show a "No sessions // yet" placeholder, so defaulting them open just adds noise. Profile lanes and @@ -94,11 +97,47 @@ export function SidebarWorkspaceGroup({ group, renderRows, onNewSession, onRemov onNewSession(group.path) } + const handleArchiveGone = async () => { + const sessions = group.sessions + + if (!sessions.length) { + return + } + + if (!window.confirm(p.archiveSessionsConfirm(sessions.length))) { + return + } + + // Optimistically tombstone so the sidebar drops them immediately. + tombstoneSessions(sessions.map(s => s.id)) + + let ok = 0 + let failed = 0 + + for (const session of sessions) { + try { + await setSessionArchived(session.id, true, session.profile) + ok++ + } catch { + failed++ + } + } + + if (ok > 0) { + triggerHaptic('selection') + notify({ durationMs: 3_000, kind: 'success', message: p.archiveSessionsDone(ok) }) + } + + if (failed > 0) { + notifyError(new Error(`${failed} session(s) could not be archived`), p.archiveSessionsFailed) + } + } + return ( {(onNewSession || isProfileGroup) && ( void handleNewSession()} /> )} + {group.gone && ( + void handleArchiveGone()} + onArchiveLabel={p.archiveSessions} + path={group.path} + /> + )} {onRemove && } ) } count={isProfileGroup ? countLabel(visibleSessions.length, totalCount) : group.sessions.length} + gone={group.gone} icon={leadingIcon} label={group.label} onToggle={toggleOpen} diff --git a/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.ts b/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.ts index 04e18f7c7b9..00868bfd783 100644 --- a/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.ts +++ b/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.ts @@ -26,6 +26,9 @@ export interface SidebarSessionGroup { // worktrees (`/.worktrees/t_*`) into one row, so a heavy board doesn't // spray hundreds of throwaway branch lanes across the sidebar. isKanban?: boolean + // True when this branch lane's upstream tracking ref has been deleted on the + // remote (merged or manually deleted). The sidebar can flag it for cleanup. + gone?: boolean loadingMore?: boolean mode?: 'profile' | 'source' | 'workspace' onLoadMore?: () => void diff --git a/apps/desktop/src/app/chat/sidebar/projects/workspace-header.tsx b/apps/desktop/src/app/chat/sidebar/projects/workspace-header.tsx index e184ac871a0..adb7614061a 100644 --- a/apps/desktop/src/app/chat/sidebar/projects/workspace-header.tsx +++ b/apps/desktop/src/app/chat/sidebar/projects/workspace-header.tsx @@ -106,7 +106,18 @@ export function WorkspaceShowMoreButton({ // Per-worktree actions (linked worktree lanes only), mirroring the session row // and ProjectMenu kebab: reveal in the file manager, copy path, and remove the // worktree (runs a real `git worktree remove` via the caller's confirm dialog). -export function WorkspaceMenu({ path, onRemove }: { path: null | string; onRemove: () => void }) { +// When `onArchive` is set (gone branch lanes), adds an "Archive sessions" item. +export function WorkspaceMenu({ + onArchive, + onArchiveLabel, + onRemove, + path +}: { + onArchive?: () => void + onArchiveLabel?: string + onRemove?: () => void + path: null | string +}) { const { t } = useI18n() const p = t.sidebar.projects @@ -123,19 +134,33 @@ export function WorkspaceMenu({ path, onRemove }: { path: null | string; onRemov - void revealPath(path)}> - - {p.reveal} - - void copyPath(path)}> - - {p.copyPath} - - - - - {`${p.removeWorktree}…`} - + {onArchive && ( + + + {onArchiveLabel ?? p.archiveSessions} + + )} + {path && ( + <> + void revealPath(path)}> + + {p.reveal} + + void copyPath(path)}> + + {p.copyPath} + + + )} + {onRemove && ( + <> + {(onArchive || path) && } + + + {`${p.removeWorktree}…`} + + + )} ) @@ -355,6 +380,7 @@ export function WorkspaceHeader({ action, count, emphasis = false, + gone = false, icon, label, onToggle, @@ -364,6 +390,7 @@ export function WorkspaceHeader({ action?: React.ReactNode count: React.ReactNode emphasis?: boolean + gone?: boolean icon: React.ReactNode label: string onToggle: () => void @@ -388,6 +415,14 @@ export function WorkspaceHeader({ > {icon} + {gone && ( + + gone + + )} {count} diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index a37091ceeb4..b2595cfb000 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -706,6 +706,10 @@ export interface HermesRepoStatus { // the default" from anywhere. Null when no trunk is detected. defaultBranch: null | string detached: boolean + // True when the current branch's upstream tracking ref has been deleted on + // the remote (merged or manually deleted) — the "[gone]" state. Lets the + // coding rail flag stale branches for cleanup. + gone: boolean ahead: number behind: number staged: number diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 31c0a734f30..381376ee1ae 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -1627,6 +1627,11 @@ export const en: Translations = { removeWorktreeDirty: 'This worktree has uncommitted changes. Force-remove it (discards those changes), or just hide the lane and keep it on disk.', forceRemove: 'Force remove', + archiveSessions: 'Archive sessions', + archiveSessionsConfirm: count => + `Archive ${count} session${count === 1 ? '' : 's'} on this branch? Archived chats are hidden from the sidebar but keep all their messages.`, + archiveSessionsDone: count => `${count} session${count === 1 ? '' : 's'} archived`, + archiveSessionsFailed: 'Could not archive sessions', enter: label => `Open ${label}`, reorder: label => `Reorder ${label}`, toggle: label => `Toggle ${label} sessions`, @@ -1815,6 +1820,7 @@ export const en: Translations = { title: 'Working tree', noBranch: 'No branch', detached: 'detached', + gone: 'gone', clean: 'Clean', changed: count => `${count} changed`, ahead: count => `${count} ahead`, diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index f6bb2c0d7b3..76c659e5376 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -1551,6 +1551,11 @@ export const ja = defineLocale({ removeWorktreeDirty: 'このワークツリーにはコミットされていない変更があります。強制削除(変更を破棄)するか、レーンを隠してディスク上に残します。', forceRemove: '強制削除', + archiveSessions: 'セッションをアーカイブ', + archiveSessionsConfirm: count => + `このブランチの ${count} 件のセッションをアーカイブしますか?アーカイブされたチャットはサイドバーから非表示になりますが、メッセージはすべて保持されます。`, + archiveSessionsDone: count => `${count} 件のセッションをアーカイブしました`, + archiveSessionsFailed: 'セッションをアーカイブできませんでした', enter: label => `${label} を開く` }, newSessionIn: label => `${label} で新しいセッション`, @@ -1733,6 +1738,7 @@ export const ja = defineLocale({ title: 'ワークツリー', noBranch: 'ブランチなし', detached: 'デタッチ', + gone: 'gone', clean: 'クリーン', changed: count => `${count} 件変更`, ahead: count => `${count} 先行`, diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 5962d317221..9e7fbc08f86 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -1350,6 +1350,10 @@ export interface Translations { removeWorktreeConfirm: string removeWorktreeDirty: string forceRemove: string + archiveSessions: string + archiveSessionsConfirm: (count: number) => string + archiveSessionsDone: (count: number) => string + archiveSessionsFailed: string enter: (label: string) => string reorder: (label: string) => string toggle: (label: string) => string @@ -1490,6 +1494,7 @@ export interface Translations { title: string noBranch: string detached: string + gone: string clean: string changed: (count: number) => string ahead: (count: number) => string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index a02abba62e0..8eb2d3d87ed 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -1501,6 +1501,11 @@ export const zhHant = defineLocale({ '從 git 中移除(刪除工作樹目錄,但保留分支),或僅從側邊欄隱藏該軌道並將工作樹保留在磁碟上。', removeWorktreeDirty: '此工作樹有未提交的變更。強制移除(捨棄這些變更),或僅隱藏軌道並保留在磁碟上。', forceRemove: '強制移除', + archiveSessions: '封存工作階段', + archiveSessionsConfirm: count => + `封存此分支上的 ${count} 個工作階段?已封存的聊天會從側邊欄隱藏,但保留所有訊息。`, + archiveSessionsDone: count => `已封存 ${count} 個工作階段`, + archiveSessionsFailed: '無法封存工作階段', enter: label => `開啟 ${label}` }, newSessionIn: label => `在 ${label} 中新建工作階段`, @@ -1682,6 +1687,7 @@ export const zhHant = defineLocale({ title: '工作區', noBranch: '無分支', detached: '分離 HEAD', + gone: 'gone', clean: '乾淨', changed: count => `${count} 處變更`, ahead: count => `領先 ${count}`, diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 1422fb886d7..d265bda1530 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -1803,6 +1803,11 @@ export const zh: Translations = { '从 git 中移除(删除工作树目录,但保留分支),或仅从侧边栏隐藏该泳道并将工作树保留在磁盘上。', removeWorktreeDirty: '此工作树有未提交的更改。强制移除(丢弃这些更改),或仅隐藏泳道并保留在磁盘上。', forceRemove: '强制移除', + archiveSessions: '归档会话', + archiveSessionsConfirm: count => + `归档此分支上的 ${count} 个会话?已归档的聊天会从侧边栏隐藏,但保留所有消息。`, + archiveSessionsDone: count => `已归档 ${count} 个会话`, + archiveSessionsFailed: '无法归档会话', enter: label => `打开 ${label}`, reorder: label => `重新排序 ${label}`, toggle: label => `展开/收起 ${label} 会话`, @@ -1991,6 +1996,7 @@ export const zh: Translations = { title: '工作区', noBranch: '无分支', detached: '分离头指针', + gone: 'gone', clean: '干净', changed: count => `${count} 处更改`, ahead: count => `领先 ${count}`, diff --git a/apps/desktop/src/store/coding-status.test.ts b/apps/desktop/src/store/coding-status.test.ts index 02d9e843bb6..16e1d611652 100644 --- a/apps/desktop/src/store/coding-status.test.ts +++ b/apps/desktop/src/store/coding-status.test.ts @@ -9,6 +9,7 @@ const sampleStatus: HermesRepoStatus = { branch: 'feature/login', defaultBranch: 'main', detached: false, + gone: false, ahead: 1, behind: 0, staged: 1, diff --git a/hermes_cli/web_git.py b/hermes_cli/web_git.py index 0af12106745..54664caafd8 100644 --- a/hermes_cli/web_git.py +++ b/hermes_cli/web_git.py @@ -150,6 +150,20 @@ def _default_branch_name(cwd: str) -> str | None: return None +def _branch_gone(cwd: str, branch: str | None) -> bool: + """True when ``branch``'s upstream tracking ref has been deleted on the remote. + + This is the signal that a branch was merged (or deleted) remotely and the + local branch is now stale — the exact state ``git branch --list`` shows as + ``[gone]``. Returns False for detached HEAD, no upstream, or a live + upstream. + """ + if not branch: + return False + track = _git_out(cwd, ["for-each-ref", "--format=%(upstream:track)", f"refs/heads/{branch}"]) + return "[gone]" in track + + # ── porcelain v2 status parsing ────────────────────────────────────────────── @@ -239,6 +253,7 @@ def repo_status(cwd: str) -> dict | None: "branch": branch, "defaultBranch": _default_branch_name(cwd), "detached": detached, + "gone": _branch_gone(cwd, branch), "ahead": ahead, "behind": behind, "staged": sum(f["staged"] for f in files), diff --git a/tests/hermes_cli/test_web_server_git.py b/tests/hermes_cli/test_web_server_git.py index 31b73b3f283..24ee1e81b46 100644 --- a/tests/hermes_cli/test_web_server_git.py +++ b/tests/hermes_cli/test_web_server_git.py @@ -54,6 +54,7 @@ def test_status_reports_branch_and_change_counts(client, repo): assert body["branch"] == body["defaultBranch"] assert body["branch"] assert body["detached"] is False + assert body["gone"] is False # 1 tracked-modified + 1 untracked = 2 changed paths. assert body["changed"] == 2 assert body["untracked"] == 1 @@ -69,6 +70,30 @@ def test_status_returns_null_outside_repo(client, tmp_path): assert client.get("/api/git/status", params={"path": str(plain)}).json() is None +def test_status_gone_after_remote_branch_deleted(client, repo, tmp_path): + """A branch whose upstream was deleted on the remote reports ``gone: true``.""" + # Set up a bare remote and push a feature branch to it. + remote = tmp_path / "remote.git" + remote.mkdir() + _git(remote, "init", "--bare", "-q") + _git(repo, "remote", "add", "origin", str(remote)) + _git(repo, "checkout", "-q", "-b", "feature/merged") + _git(repo, "push", "-q", "-u", "origin", "feature/merged") + + # Branch is live on the remote — not gone. + body = client.get("/api/git/status", params={"path": str(repo)}).json() + assert body["branch"] == "feature/merged" + assert body["gone"] is False + + # Delete the remote branch; `git fetch --prune` updates the tracking ref. + _git(repo, "push", "-q", "origin", "--delete", "feature/merged") + _git(repo, "fetch", "-q", "--prune", "origin") + + body = client.get("/api/git/status", params={"path": str(repo)}).json() + assert body["branch"] == "feature/merged" + assert body["gone"] is True + + def test_review_list_classifies_modified_and_untracked(client, repo): body = client.get("/api/git/review/list", params={"path": str(repo)}).json() diff --git a/tests/tui_gateway/test_project_tree.py b/tests/tui_gateway/test_project_tree.py index cd0424c5d5a..047b3fd01a6 100644 --- a/tests/tui_gateway/test_project_tree.py +++ b/tests/tui_gateway/test_project_tree.py @@ -479,3 +479,39 @@ def test_colliding_repo_basenames_disambiguate_labels(): labels = sorted(p["label"] for p in tree["projects"]) assert labels == ["x/proj", "y/proj"] + + +def test_gone_fn_annotates_branch_lanes(): + """A gone_fn marks branch lanes whose label is in the repo's gone set.""" + resolve = _resolver({"/repo": ("/repo", "/repo")}) + sessions = [ + _session("/repo", branch="main"), + _session("/repo", branch="feature/merged"), + _session("/repo", branch="feature/live"), + ] + + tree = pt.build_tree( + [], + sessions, + [], + resolve, + hydrate=True, + gone_fn=lambda root: {"feature/merged"} if root == "/repo" else set(), + ) + project = next(p for p in tree["projects"] if p["id"] == "/repo") + lanes = {g["label"]: g for repo in project["repos"] for g in repo["groups"]} + + assert lanes["main"].get("gone") is not True + assert lanes["feature/merged"].get("gone") is True + assert lanes["feature/live"].get("gone") is not True + + +def test_gone_fn_absent_leaves_no_gone_field(): + """Without a gone_fn, no lane carries a gone field.""" + resolve = _resolver({"/repo": ("/repo", "/repo")}) + sessions = [_session("/repo", branch="feature/merged")] + + tree = pt.build_tree([], sessions, [], resolve, hydrate=True) + project = next(p for p in tree["projects"] if p["id"] == "/repo") + + assert all("gone" not in g for repo in project["repos"] for g in repo["groups"]) diff --git a/tui_gateway/git_probe.py b/tui_gateway/git_probe.py index 72582ebf5dc..220748558b0 100644 --- a/tui_gateway/git_probe.py +++ b/tui_gateway/git_probe.py @@ -69,6 +69,25 @@ def branch(cwd: str) -> str: return run_git(cwd, "branch", "--show-current") or run_git(cwd, "rev-parse", "--short", "HEAD") +def gone_branches(cwd: str) -> set[str]: + """Local branches whose upstream tracking ref was deleted on the remote. + + One ``git for-each-ref`` call returns all gone branches for the repo at + ``cwd``. Returns an empty set for non-repos, bare repos, or repos with + no upstream tracking. Used by the project tree to annotate branch lanes + whose sessions sit on merged/deleted branches. + """ + out = run_git(cwd, "for-each-ref", "--format=%(refname:short) %(upstream:track)", "refs/heads/") + if not out: + return set() + gone = set() + for line in out.splitlines(): + parts = line.split(None, 1) + if len(parts) >= 2 and "[gone]" in parts[1]: + gone.add(parts[0].strip()) + return gone + + class _RootCache: """Thread-safe, single-flight cache of git-root probes. Positive results are cached for the process lifetime; negative ("not a repo") results are cached diff --git a/tui_gateway/project_tree.py b/tui_gateway/project_tree.py index 3f243153964..da2ba7bac54 100644 --- a/tui_gateway/project_tree.py +++ b/tui_gateway/project_tree.py @@ -438,6 +438,26 @@ def _project_for_session(session: dict, index: _FolderIndex, resolve: Optional[R # --------------------------------------------------------------------------- +def _annotate_gone(repos: list[dict], gone_fn: Optional[Callable[[str], frozenset[str]]]) -> None: + """Annotate each branch lane in ``repos`` with ``gone: True`` when the + lane's branch label is in the repo's gone set. + + Only applies to main-checkout branch lanes (``isMain`` and not ``isKanban``): + linked worktree lanes and kanban lanes are labeled by path, not branch name. + Mutates group dicts in place. + """ + if not gone_fn: + return + for repo in repos: + gone = gone_fn(repo.get("path") or "") + if not gone: + continue + for group in repo.get("groups") or []: + if group.get("isMain") and not group.get("isKanban"): + if group.get("label", "").strip() in gone: + group["gone"] = True + + def _project_node( *, pid: str, @@ -475,6 +495,7 @@ def build_tree( hydrate: bool = False, is_junk_root: Optional[Callable[[str], bool]] = None, is_junk_cwd: Optional[Callable[[str], bool]] = None, + gone_fn: Optional[Callable[[str], frozenset[str]]] = None, ) -> dict: """Build the authoritative project tree. @@ -487,6 +508,11 @@ def build_tree( policy for non-git session folders: selected descendants may be intentional workspaces even when their parent tree contains Hermes state. User-created projects are honored regardless. + ``gone_fn`` is an optional callable that takes a repo root path and returns + the set of branch names whose upstream tracking ref has been deleted on the + remote (the ``[gone]`` state). When provided, each branch lane whose label + is in the set is annotated with ``gone: True`` so the UI can flag stale + branches for cleanup. Returns ``{"projects": [...], "scoped_session_ids": [...]}``. When ``hydrate`` is False (overview), lane ``sessions`` arrays are emptied but @@ -527,6 +553,7 @@ def build_tree( repos = _seed_folder_repos( _build_repos(psessions, resolve, hydrate), project.get("folders") or [], resolve ) + _annotate_gone(repos, gone_fn) result.append( _project_node( pid=project["id"], @@ -581,6 +608,7 @@ def build_tree( auto_sessions = bucket["sessions"] auto_key = _path_key(auto_root) repos = _build_repos(auto_sessions, resolve, hydrate) + _annotate_gone(repos, gone_fn) repo_node = next( ( repo diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 2f6e833934f..166c9a67f6e 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -1567,6 +1567,19 @@ _git_branch_for_cwd = git_probe.branch _git_repo_root_for_cwd = git_probe.repo_root _git_common_repo_root_for_cwd = git_probe.common_repo_root _resolve_cwd_git = git_probe.resolve +_git_gone_branches = git_probe.gone_branches + + +def _gone_branches_for_repo(root: str) -> frozenset[str]: + """Cached gone-branch set for a repo root. + + ``git for-each-ref`` is cheap (~1ms) but the project tree is rebuilt on + several structural edges, so cache per-root with a short TTL. The cache + lives in the same process as the git probe's own root cache. + """ + if not root: + return frozenset() + return frozenset(_git_gone_branches(root)) def _session_cwd(session: dict | None) -> str: @@ -5796,6 +5809,9 @@ def _(rid, params: dict) -> dict: _schedule_agent_build(sid) _schedule_session_cap_enforcement() # trim detached idle sessions over the cap + # Re-probe git branch/repo-root: the branch may have changed since + # this session was last active (merged, switched in another worktree). + _persist_session_git_meta(record, cwd) messages = _history_to_messages(display_history) return _ok( @@ -5937,6 +5953,10 @@ def _(rid, params: dict) -> dict: lease.release() return _err(rid, 5000, f"resume failed: {e}") session = _sessions.get(sid) or {} + # Re-probe git branch/repo-root: the branch may have changed since + # this session was last active (merged, switched in another worktree). + if session.get("session_key"): + _persist_session_git_meta(session, profile_resume_cwd) return _ok( rid, { @@ -9376,6 +9396,12 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None: session["last_active"] = time.time() _clear_inflight_turn(session) _emit("session.info", sid, _session_info(agent, session)) + # Re-probe the session's git branch / repo root on a daemon thread. + # The agent may have switched branches via `git checkout` in the + # terminal during the turn; persisting the fresh metadata keeps the + # session DB's `git_branch` column from going stale, which the + # sidebar project tree + gone-branch cleanup rely on. + _persist_session_git_meta(session, _display_session_cwd(session)) # A user prompt that arrived mid-turn (interrupt + queue) wins over # every auto follow-up below — drain it first and skip them this cycle; @@ -11231,6 +11257,7 @@ def _build_project_tree( hydrate=hydrate, is_junk_root=_is_repo_junk, is_junk_cwd=_is_session_cwd_junk, + gone_fn=_gone_branches_for_repo, ) return tree, active_id