feat(desktop): detect and surface gone branches for cleanup

Add `gone` branch detection (upstream tracking ref deleted on remote) to
the composer coding rail and the sidebar project tree, plus a bulk archive
action for sessions on gone branches.

Part 1 — composer "gone" indicator:
- Add `_branch_gone()` to `web_git.py` (remote/REST path) and
  `branchGone()` to `git-review-ops.ts` (Electron local path), both using
  `git for-each-ref --format=%(upstream:track)`.
- Add `gone: boolean` to `HermesRepoStatus` type.
- Show amber "gone" badge in `CodingStatusRow` next to the branch name.
- Rides existing repo-status refresh edges (cwd change, workspace change
  tick, busy→idle, window focus) — no new polling.

Part 2a — session DB staleness fix:
- Re-probe `git_branch`/`git_repo_root` at turn-complete (gateway
  `_run_prompt_submit` finally block) and on session resume (both
  deferred and eager paths), using the existing `_persist_session_git_meta`
  daemon-thread helper. Keeps the session DB's branch column fresh so the
  sidebar tree and gone-branch cleanup are accurate.

Part 2b — sidebar gone-branch detection + cleanup UI:
- Add `gone_branches()` to `git_probe.py` (one `for-each-ref` per repo).
- Add `gone_fn` parameter to `project_tree.build_tree()` + `_annotate_gone()`
  helper that marks branch lanes with `gone: True`.
- Wire `_gone_branches_for_repo` into the gateway's `_build_project_tree`.
- Add `gone?: boolean` to `SidebarSessionGroup` (renderer type).
- Show amber "gone" badge on sidebar branch lanes in `WorkspaceHeader`.
- Add bulk "Archive sessions" action to `WorkspaceMenu` for gone lanes:
  confirms, optimistically tombstones, calls `setSessionArchived` per
  session, surfaces success/failure toasts.
- i18n strings for all 4 locales (en, ja, zh, zh-hant).

Tests:
- `test_status_gone_after_remote_branch_deleted` — E2E with bare remote,
  push, delete, fetch --prune, verify gone=true.
- `test_gone_fn_annotates_branch_lanes` — project tree annotation.
- `test_gone_fn_absent_leaves_no_gone_field` — no spurious gone field.
- Updated `sampleStatus` in coding-status.test.ts with `gone: false`.
This commit is contained in:
ethernet 2026-07-15 17:41:31 -04:00
parent b80b52aa46
commit db5cbff884
18 changed files with 315 additions and 17 deletions

View file

@ -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,

View file

@ -268,6 +268,15 @@ export const CodingStatusRow = memo(function CodingStatusRow({
{branchLabel}
</span>
{status.gone && !status.detached && (
<span
className="shrink-0 text-[0.625rem] font-medium text-amber-500/90"
title={s.gone}
>
{s.gone}
</span>
)}
{/* 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

View file

@ -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 (
<SidebarRowStack>
<WorkspaceHeader
action={
(onNewSession || isProfileGroup || onRemove) && (
(onNewSession || isProfileGroup || onRemove || group.gone) && (
<div className="flex items-center">
{(onNewSession || isProfileGroup) && (
<WorkspaceAddButton
@ -109,11 +148,19 @@ export function SidebarWorkspaceGroup({ group, renderRows, onNewSession, onRemov
onClick={() => void handleNewSession()}
/>
)}
{group.gone && (
<WorkspaceMenu
onArchive={() => void handleArchiveGone()}
onArchiveLabel={p.archiveSessions}
path={group.path}
/>
)}
{onRemove && <WorkspaceMenu onRemove={onRemove} path={group.path} />}
</div>
)
}
count={isProfileGroup ? countLabel(visibleSessions.length, totalCount) : group.sessions.length}
gone={group.gone}
icon={leadingIcon}
label={group.label}
onToggle={toggleOpen}

View file

@ -26,6 +26,9 @@ export interface SidebarSessionGroup {
// worktrees (`<repo>/.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

View file

@ -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
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48" sideOffset={6}>
<DropdownMenuItem disabled={!path} onSelect={() => void revealPath(path)}>
<Codicon name="folder-opened" size="0.875rem" />
<span>{p.reveal}</span>
</DropdownMenuItem>
<DropdownMenuItem disabled={!path} onSelect={() => void copyPath(path)}>
<Codicon name="copy" size="0.875rem" />
<span>{p.copyPath}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={onRemove} variant="destructive">
<Codicon name="trash" size="0.875rem" />
<span>{`${p.removeWorktree}`}</span>
</DropdownMenuItem>
{onArchive && (
<DropdownMenuItem onSelect={onArchive}>
<Codicon name="archive" size="0.875rem" />
<span>{onArchiveLabel ?? p.archiveSessions}</span>
</DropdownMenuItem>
)}
{path && (
<>
<DropdownMenuItem onSelect={() => void revealPath(path)}>
<Codicon name="folder-opened" size="0.875rem" />
<span>{p.reveal}</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => void copyPath(path)}>
<Codicon name="copy" size="0.875rem" />
<span>{p.copyPath}</span>
</DropdownMenuItem>
</>
)}
{onRemove && (
<>
{(onArchive || path) && <DropdownMenuSeparator />}
<DropdownMenuItem onSelect={onRemove} variant="destructive">
<Codicon name="trash" size="0.875rem" />
<span>{`${p.removeWorktree}`}</span>
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)
@ -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({
>
<SidebarRowLead>{icon}</SidebarRowLead>
<LaneLabel label={label} title={title ? `${label}\n${title}` : label} />
{gone && (
<span
className="shrink-0 text-[0.625rem] font-medium text-amber-500/90"
title={label}
>
gone
</span>
)}
<span className="shrink-0">
<SidebarCount>{count}</SidebarCount>
</span>

View file

@ -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

View file

@ -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`,

View file

@ -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} 先行`,

View file

@ -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

View file

@ -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}`,

View file

@ -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}`,

View file

@ -9,6 +9,7 @@ const sampleStatus: HermesRepoStatus = {
branch: 'feature/login',
defaultBranch: 'main',
detached: false,
gone: false,
ahead: 1,
behind: 0,
staged: 1,

View file

@ -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),

View file

@ -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()

View file

@ -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"])

View file

@ -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

View file

@ -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

View file

@ -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