mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
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:
parent
b80b52aa46
commit
db5cbff884
18 changed files with 315 additions and 17 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
4
apps/desktop/src/global.d.ts
vendored
4
apps/desktop/src/global.d.ts
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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} 先行`,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ const sampleStatus: HermesRepoStatus = {
|
|||
branch: 'feature/login',
|
||||
defaultBranch: 'main',
|
||||
detached: false,
|
||||
gone: false,
|
||||
ahead: 1,
|
||||
behind: 0,
|
||||
staged: 1,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue