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
+80
View File
@@ -10,6 +10,7 @@
* user.name/user.email).
*/
import { spawn } from 'node:child_process'
import { resolve } from 'node:path'
/** A parsed `git status --porcelain=v1 -z` entry. */
export interface GitStatusEntry {
@@ -25,6 +26,18 @@ export interface GitStatusResult {
entries: GitStatusEntry[]
}
/** One linked checkout returned by `git worktree list --porcelain`. */
export interface GitWorktree {
/** Absolute checkout root. */
path: string
/** Branch name without `refs/heads/`, or `HEAD` when detached. */
branch: string
/** Whether this checkout contains the session cwd. */
current: boolean
/** Number of staged + unstaged status rows (a file changed on both sides counts once). */
changes: number
}
/** One `git log` row. */
export interface GitLogEntry {
/** Short hash (7+ chars, display). */
@@ -71,6 +84,32 @@ export function parsePorcelainZ(output: string): GitStatusEntry[] {
return entries
}
/** Parse `git worktree list --porcelain` records. Production requests use
* `-z` so even newlines and non-ASCII bytes in checkout paths stay lossless;
* newline framing remains accepted for small fixtures and older Git output. */
export function parseWorktreeList(output: string): Array<{ path: string; branch: string }> {
const rows: Array<{ path: string; branch: string }> = []
let path: string | undefined
let branch = 'HEAD'
const flush = (): void => {
if (path !== undefined) rows.push({ path, branch })
path = undefined
branch = 'HEAD'
}
const sep = output.includes('\0') ? '\0' : '\n'
const framed = output.endsWith(sep) ? output : `${output}${sep}`
for (const line of framed.split(sep)) {
if (line === '') {
flush()
} else if (line.startsWith('worktree ')) {
path = line.slice('worktree '.length)
} else if (line.startsWith('branch refs/heads/')) {
branch = line.slice('branch refs/heads/'.length)
}
}
return rows
}
/** Parse `git log --pretty=format:%h%x1f%s%x1f%an%x1f%ai%x1f%H%x1f%D` rows. */
export function parseLogLines(output: string): GitLogEntry[] {
const rows: GitLogEntry[] = []
@@ -154,6 +193,47 @@ export async function status(cwd: string): Promise<GitStatusResult> {
return { isRepo: true, branch, entries: parsePorcelainZ(raw) }
}
/** Platform-aware identity used only for comparing absolute checkout roots. */
function pathIdentity(path: string): string {
const absolute = resolve(path).replace(/[\\/]+$/, '')
return process.platform === 'win32' ? absolute.toLowerCase() : absolute
}
/** Raw linked checkout records, shared by inventory and target validation. */
async function listedWorktrees(cwd: string): Promise<Array<{ path: string; branch: string }>> {
const raw = await runGit(cwd, ['worktree', 'list', '--porcelain', '-z'])
return parseWorktreeList(raw)
}
/** All linked checkouts of the repository containing `cwd`, enriched with a
* live change count. The current checkout is first so a single-worktree repo
* preserves the old UI ordering. */
export async function worktrees(cwd: string): Promise<GitWorktree[]> {
if (!await isGitRepo(cwd)) return []
const currentRoot = await repoRoot(cwd)
const listed = await listedWorktrees(cwd)
const rows = await Promise.all(listed.map(async (entry): Promise<GitWorktree> => ({
path: entry.path,
branch: entry.branch,
current: pathIdentity(entry.path) === pathIdentity(currentRoot),
changes: (await status(entry.path)).entries.length,
})))
return rows.sort((left, right) => Number(right.current) - Number(left.current))
}
/** Resolve an optional client-selected linked checkout. A caller may never use
* this seam to point Git operations at an unrelated repository: the target
* must occur in the authoritative session repository's worktree list. */
export async function resolveWorktree(cwd: string, requested?: string): Promise<string> {
if (requested === undefined || requested === '') return cwd
const identity = pathIdentity(requested)
const match = (await listedWorktrees(cwd)).find(entry => pathIdentity(entry.path) === identity)
if (match === undefined) {
throw new GitCommandError(`unknown linked worktree: ${requested}`, 'git-worktree', 'worktree list')
}
return match.path
}
/** Diff text of the worktree (unstaged) or the index (staged). */
export async function diff(cwd: string, path: string | undefined, staged: boolean): Promise<string> {
const args = ['diff', '--no-ext-diff', '--no-color', '-U3']