fix(git): show changes across linked worktrees

The Git panel only ran git status against the session's own worktree,
so when DSH (or any coding agent) created a linked worktree via
'git worktree add' and modified files there, the sidebar's Git panel
stayed empty - no changed files, no diff, nothing to commit.

Enumerate all linked checkouts of the session repository and let the
user select which one the panel targets. Auto-select the dirty linked
worktree when the primary checkout is clean and exactly one linked
worktree has changes (the common DSH agent pattern). Poll status every
2s while the panel is visible so model-authored edits appear without
a manual refresh.

Security: resolveWorktree validates the client-supplied worktree path
against 'git worktree list' before using it as a git -C cwd. Uses -z
porcelain framing, which disables core.quotePath C-style quoting so
non-ASCII checkout paths round-trip losslessly.
This commit is contained in:
dsh-better-sidebar-fix
2026-08-21 00:45:24 +08:00
parent 4e52923970
commit 6d6d582996
10 changed files with 339 additions and 71 deletions
+25 -13
View File
@@ -227,6 +227,14 @@ function buildApi(
const clientCwd = typeof record?.cwd === 'string' && record.cwd !== '' ? record.cwd : undefined
return { sessionId, cwd: sessionCwdOf(ctx, sessionId, clientCwd) }
}
/** Resolve the optional Git-panel checkout selector against the authoritative
* session repository. Unlike `cwd`, `worktree` is never trusted directly. */
const gitCwdOf = async (payload: unknown): Promise<{ sessionId: string; cwd: string }> => {
const base = cwdOf(payload)
const record = payload as { worktree?: unknown } | null
const requested = typeof record?.worktree === 'string' && record.worktree !== '' ? record.worktree : undefined
return { sessionId: base.sessionId, cwd: await git.resolveWorktree(base.cwd, requested) }
}
// Background jobs: the LIST rides the harness's `session/jobs` push
// mirror, so these routes only replay output the model has read (from the
// session's own event log — no DSH source is touched, the model's
@@ -276,47 +284,51 @@ function buildApi(
}
return { ok: true }
},
'git.status': async (payload) => {
'git.worktrees': async (payload) => {
const { cwd } = cwdOf(payload)
return git.worktrees(cwd)
},
'git.status': async (payload) => {
const { cwd } = await gitCwdOf(payload)
return git.status(cwd)
},
'git.diff': async (payload) => {
const { cwd } = cwdOf(payload)
const { cwd } = await gitCwdOf(payload)
const record = payload as { path?: unknown; staged?: unknown }
const path = record.path === undefined ? undefined : await resolveGitPath(cwd, requireString(payload, 'path'))
return { diff: await git.diff(cwd, path, record.staged === true) }
},
'git.stage': async (payload) => {
const { cwd } = cwdOf(payload)
const { cwd } = await gitCwdOf(payload)
const record = payload as { path?: unknown }
const path = record.path === undefined ? undefined : requireString(payload, 'path')
await git.stage(cwd, path)
return { ok: true }
},
'git.unstage': async (payload) => {
const { cwd } = cwdOf(payload)
const { cwd } = await gitCwdOf(payload)
const record = payload as { path?: unknown }
const path = record.path === undefined ? undefined : requireString(payload, 'path')
await git.unstage(cwd, path)
return { ok: true }
},
'git.commit': async (payload) => {
const { cwd } = cwdOf(payload)
const { cwd } = await gitCwdOf(payload)
const message = requireString(payload, 'message')
await git.commit(cwd, message)
return { ok: true }
},
'git.branch': async (payload) => {
const { cwd } = cwdOf(payload)
const { cwd } = await gitCwdOf(payload)
return git.branches(cwd)
},
'git.checkout': async (payload) => {
const { cwd } = cwdOf(payload)
const { cwd } = await gitCwdOf(payload)
await git.checkout(cwd, requireString(payload, 'branch'))
return { ok: true }
},
'git.log': async (payload) => {
const { cwd } = cwdOf(payload)
const { cwd } = await gitCwdOf(payload)
const record = payload as { count?: unknown; skip?: unknown }
const count = typeof record.count === 'number' && Number.isInteger(record.count) && record.count > 0
? record.count
@@ -327,26 +339,26 @@ function buildApi(
return git.log(cwd, count, skip)
},
'git.commit-diff': async (payload) => {
const { cwd } = cwdOf(payload)
const { cwd } = await gitCwdOf(payload)
return { diff: await git.commitDiff(cwd, requireString(payload, 'hash')) }
},
'git.discard': async (payload) => {
const { cwd } = cwdOf(payload)
const { cwd } = await gitCwdOf(payload)
await git.discard(cwd, await resolveGitPath(cwd, requireString(payload, 'path')))
return { ok: true }
},
'git.revert': async (payload) => {
const { cwd } = cwdOf(payload)
const { cwd } = await gitCwdOf(payload)
await git.revert(cwd, requireString(payload, 'hash'))
return { ok: true }
},
'git.cherry-pick': async (payload) => {
const { cwd } = cwdOf(payload)
const { cwd } = await gitCwdOf(payload)
await git.cherryPick(cwd, requireString(payload, 'hash'))
return { ok: true }
},
'git.show': async (payload) => {
const { cwd } = cwdOf(payload)
const { cwd } = await gitCwdOf(payload)
const path = await resolveGitPath(cwd, requireString(payload, 'path'))
const rev = requireString(payload, 'rev')
return { content: await git.show(cwd, rev, path) }