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:
@@ -14,6 +14,7 @@ import { api } from './api.ts'
|
||||
import type { SidebarDiffRef } from './state.ts'
|
||||
import { DiffView } from './DiffView.tsx'
|
||||
import { t } from './locales.ts'
|
||||
import { resolveSidebarPath } from './produced-files.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
/** The loaded diff surface (untracked content rendered as a full addition). */
|
||||
@@ -40,17 +41,17 @@ export function DiffTab(props: { sessionId: string; cwd: string | undefined; dif
|
||||
const load = async (): Promise<void> => {
|
||||
try {
|
||||
if (diff.kind === 'commit') {
|
||||
const result = await api.gitCommitDiff(scope, diff.hashFull)
|
||||
const result = await api.gitCommitDiff(scope, diff.hashFull, diff.worktree)
|
||||
if (!cancelled) setData({ diff: result.diff })
|
||||
return
|
||||
}
|
||||
let result = await api.gitDiff(scope, diff.path, diff.staged)
|
||||
let result = await api.gitDiff(scope, diff.path, diff.staged, diff.worktree)
|
||||
if (result.diff === '') {
|
||||
// The requested side is empty — try the OTHER side once: the ref
|
||||
// may predate the staged-flag fix, or the change moved sides (a
|
||||
// file staged after its tab opened). Both sides empty means the
|
||||
// file genuinely has no text changes.
|
||||
const other = await api.gitDiff(scope, diff.path, !diff.staged)
|
||||
const other = await api.gitDiff(scope, diff.path, !diff.staged, diff.worktree)
|
||||
if (other.diff !== '') result = other
|
||||
}
|
||||
if (result.diff !== '') {
|
||||
@@ -60,7 +61,7 @@ export function DiffTab(props: { sessionId: string; cwd: string | undefined; dif
|
||||
// Empty diff: an untracked file (git diff never lists it) falls back
|
||||
// to a full-file addition; anything else is a genuine no-text-change.
|
||||
if (diff.untracked === true && !diff.staged) {
|
||||
const text = await api.fsRead(scope, diff.path)
|
||||
const text = await api.fsRead(scope, resolveSidebarPath(diff.worktree ?? cwd, diff.path))
|
||||
if (!cancelled) {
|
||||
setData(text.kind === 'text' ? { diff: '', untracked: text.content } : { diff: '' })
|
||||
}
|
||||
|
||||
+101
-27
@@ -6,16 +6,18 @@
|
||||
* placed below the git pane on first use. File rows and history rows open a
|
||||
* right-click context menu with advanced operations (open in editor, discard,
|
||||
* revert, cherry-pick, copy paths/hashes). Refresh is manual + on mount/
|
||||
* focus (no file watcher — KISS).
|
||||
* focus. While visible it polls lightweight porcelain state so model-authored
|
||||
* file changes appear without a manual refresh.
|
||||
*/
|
||||
import { useCallback, useEffect, useState, type MouseEvent, type ReactNode } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState, type MouseEvent, type ReactNode } from 'react'
|
||||
import {
|
||||
Button, IconBranchOutline16, IconCodeOutline16, IconCopyOutline16, IconRefreshOutline16,
|
||||
IconTrashOutline16, Input, Menu, Modal, writeClipboard,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { GitLogEntry, GitStatusEntry, GitStatusResult, SessionScope } from './api.ts'
|
||||
import type { GitLogEntry, GitStatusEntry, GitStatusResult, GitWorktree, SessionScope } from './api.ts'
|
||||
import { api } from './api.ts'
|
||||
import { relativeTo } from './paths.ts'
|
||||
import { resolveSidebarPath } from './produced-files.ts'
|
||||
import { relativeTime, t } from './locales.ts'
|
||||
import type { SidebarTab } from './state.ts'
|
||||
import css from './sidebar.module.css'
|
||||
@@ -84,9 +86,13 @@ export function GitView(props: {
|
||||
onOpenFile: (path: string) => void
|
||||
/** Open a diff tab (the shell places it below the git pane on first use). */
|
||||
onOpenDiff: (tab: SidebarTab) => void
|
||||
/** Poll only while the tab is actually visible. */
|
||||
visible: boolean
|
||||
}) {
|
||||
const { scope, onOpenFile, onOpenDiff } = props
|
||||
const { scope, onOpenFile, onOpenDiff, visible } = props
|
||||
const [status, setStatus] = useState<GitStatusResult | null>(null)
|
||||
const [worktrees, setWorktrees] = useState<GitWorktree[]>([])
|
||||
const [selectedWorktree, setSelectedWorktree] = useState<string | undefined>()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [branchNames, setBranchNames] = useState<string[]>([])
|
||||
@@ -104,16 +110,45 @@ export function GitView(props: {
|
||||
const [historyMenu, setHistoryMenu] = useState<{ entry: GitLogEntry; x: number; y: number } | null>(null)
|
||||
/** The pending destructive action awaiting confirmation. */
|
||||
const [confirm, setConfirm] = useState<ConfirmState | null>(null)
|
||||
const refreshInFlight = useRef(false)
|
||||
const worktreeChosenByUser = useRef(false)
|
||||
/** selectedWorktree read inside refresh without re-creating the callback:
|
||||
* avoids a spurious full refresh on every auto-select (the very state
|
||||
* change refresh writes back via setSelectedWorktree would recreate the
|
||||
* callback and re-trigger the mount effect — an N→N+1 fetch loop). */
|
||||
const selectedRef = useRef<string | undefined>(undefined)
|
||||
useEffect(() => { selectedRef.current = selectedWorktree }, [selectedWorktree])
|
||||
|
||||
const refresh = useCallback(async (): Promise<void> => {
|
||||
setLoading(true)
|
||||
const refresh = useCallback(async (silent = false): Promise<void> => {
|
||||
if (refreshInFlight.current) return
|
||||
refreshInFlight.current = true
|
||||
if (!silent) setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const listed = await api.gitWorktrees(scope)
|
||||
setWorktrees(listed)
|
||||
const selectedStillExists = listed.some(entry => entry.path === selectedRef.current)
|
||||
let target = selectedStillExists ? selectedRef.current : listed.find(entry => entry.current)?.path
|
||||
// DSH and other coding agents commonly create one linked checkout while
|
||||
// the session remains rooted at the clean primary checkout. Select that
|
||||
// checkout automatically only when the choice is unambiguous.
|
||||
const current = listed.find(entry => entry.current)
|
||||
const dirtyLinked = listed.filter(entry => !entry.current && entry.changes > 0)
|
||||
if (!worktreeChosenByUser.current) {
|
||||
target = (current?.changes ?? 0) === 0 && dirtyLinked.length === 1
|
||||
? dirtyLinked[0]!.path
|
||||
: current?.path
|
||||
}
|
||||
if (target !== selectedRef.current) setSelectedWorktree(target)
|
||||
if (silent) {
|
||||
setStatus(await api.gitStatus(scope, target))
|
||||
return
|
||||
}
|
||||
const [statusResult, branchResult, logResult] = await Promise.all([
|
||||
api.gitStatus(scope),
|
||||
api.gitBranch(scope).catch(() => ({ current: '', names: [] as string[] })),
|
||||
api.gitStatus(scope, target),
|
||||
api.gitBranch(scope, target).catch(() => ({ current: '', names: [] as string[] })),
|
||||
// The first history page only; the rest arrives via "load more".
|
||||
api.gitLog(scope, LOG_BATCH, 0).catch(() => [] as GitLogEntry[]),
|
||||
api.gitLog(scope, LOG_BATCH, 0, target).catch(() => [] as GitLogEntry[]),
|
||||
])
|
||||
setStatus(statusResult)
|
||||
setBranchNames(branchResult.names)
|
||||
@@ -122,18 +157,36 @@ export function GitView(props: {
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : String(reason))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
refreshInFlight.current = false
|
||||
if (!silent) setLoading(false)
|
||||
}
|
||||
}, [scope.sessionId, scope.cwd])
|
||||
|
||||
useEffect(() => {
|
||||
worktreeChosenByUser.current = false
|
||||
setSelectedWorktree(undefined)
|
||||
}, [scope.sessionId, scope.cwd])
|
||||
useEffect(() => { void refresh() }, [refresh])
|
||||
// When the user picks a worktree from the dropdown, fetch its status
|
||||
// immediately instead of waiting for the next 2s poll tick. Gated on
|
||||
// worktreeChosenByUser so the auto-select inside refresh() doesn't
|
||||
// trigger a redundant fetch (that was the N→N+1 loop the ref fix avoids).
|
||||
useEffect(() => {
|
||||
if (!worktreeChosenByUser.current) return
|
||||
void refresh(true)
|
||||
}, [selectedWorktree, refresh])
|
||||
useEffect(() => {
|
||||
if (!visible) return
|
||||
const timer = window.setInterval(() => { void refresh(true) }, 2_000)
|
||||
return () => { window.clearInterval(timer) }
|
||||
}, [visible, refresh])
|
||||
|
||||
/** Append the next history page (lazy: only when the user asks for more). */
|
||||
const loadMoreLog = async (): Promise<void> => {
|
||||
if (logLoadingMore || logEnded) return
|
||||
setLogLoadingMore(true)
|
||||
try {
|
||||
const next = await api.gitLog(scope, LOG_BATCH, logEntries.length)
|
||||
const next = await api.gitLog(scope, LOG_BATCH, logEntries.length, selectedWorktree)
|
||||
setLogEntries(entries => [...entries, ...next])
|
||||
if (next.length < LOG_BATCH) setLogEnded(true)
|
||||
} catch (reason) {
|
||||
@@ -146,28 +199,28 @@ export function GitView(props: {
|
||||
/** The diff tab for one changed file (one tab per path+side; same id = focused). */
|
||||
const openWorktreeDiff = (entry: GitStatusEntry, staged: boolean): void => {
|
||||
onOpenDiff({
|
||||
id: `diff:w:${staged ? 's' : 'u'}:${entry.path}`,
|
||||
id: `diff:w:${encodeURIComponent(selectedWorktree ?? '')}:${staged ? 's' : 'u'}:${entry.path}`,
|
||||
type: 'diff',
|
||||
title: baseName(entry.path),
|
||||
diff: { kind: 'worktree', path: entry.path, staged, untracked: isUntracked(entry) },
|
||||
diff: { kind: 'worktree', path: entry.path, staged, untracked: isUntracked(entry), worktree: selectedWorktree },
|
||||
})
|
||||
}
|
||||
|
||||
/** The diff tab for one commit (one tab per commit). */
|
||||
const openCommitDiff = (entry: GitLogEntry): void => {
|
||||
onOpenDiff({
|
||||
id: `diff:c:${entry.hashFull}`,
|
||||
id: `diff:c:${encodeURIComponent(selectedWorktree ?? '')}:${entry.hashFull}`,
|
||||
type: 'diff',
|
||||
title: `${entry.hash} ${entry.subject}`,
|
||||
diff: { kind: 'commit', hash: entry.hash, hashFull: entry.hashFull, subject: entry.subject },
|
||||
diff: { kind: 'commit', hash: entry.hash, hashFull: entry.hashFull, subject: entry.subject, worktree: selectedWorktree },
|
||||
})
|
||||
}
|
||||
|
||||
const stageEntry = async (entry: GitStatusEntry, staged: boolean): Promise<void> => {
|
||||
setBusy(true)
|
||||
try {
|
||||
if (staged) await api.gitUnstage(scope, entry.path)
|
||||
else await api.gitStage(scope, entry.path)
|
||||
if (staged) await api.gitUnstage(scope, entry.path, selectedWorktree)
|
||||
else await api.gitStage(scope, entry.path, selectedWorktree)
|
||||
await refresh()
|
||||
} finally {
|
||||
setBusy(false)
|
||||
@@ -177,8 +230,8 @@ export function GitView(props: {
|
||||
const stageAll = async (staged: boolean): Promise<void> => {
|
||||
setBusy(true)
|
||||
try {
|
||||
if (staged) await api.gitUnstage(scope)
|
||||
else await api.gitStage(scope)
|
||||
if (staged) await api.gitUnstage(scope, undefined, selectedWorktree)
|
||||
else await api.gitStage(scope, undefined, selectedWorktree)
|
||||
await refresh()
|
||||
} finally {
|
||||
setBusy(false)
|
||||
@@ -191,7 +244,7 @@ export function GitView(props: {
|
||||
setBusy(true)
|
||||
setCommitError(null)
|
||||
try {
|
||||
await api.gitCommit(scope, message)
|
||||
await api.gitCommit(scope, message, selectedWorktree)
|
||||
setCommitMsg('')
|
||||
await refresh()
|
||||
} catch (reason) {
|
||||
@@ -206,7 +259,7 @@ export function GitView(props: {
|
||||
setBusy(true)
|
||||
setCommitError(null)
|
||||
try {
|
||||
await api.gitCheckout(scope, branch)
|
||||
await api.gitCheckout(scope, branch, selectedWorktree)
|
||||
await refresh()
|
||||
} catch (reason) {
|
||||
setCommitError(`${t('checkoutError')}: ${reason instanceof Error ? reason.message : String(reason)}`)
|
||||
@@ -280,6 +333,27 @@ export function GitView(props: {
|
||||
|
||||
return (
|
||||
<div className={css.git}>
|
||||
{worktrees.length > 1 && (
|
||||
<div className={css.gitWorktreeRow}>
|
||||
<span className={css.gitWorktreeLabel}>{t('worktree')}</span>
|
||||
<select
|
||||
className={css.gitBranchSelect}
|
||||
value={selectedWorktree ?? ''}
|
||||
title={selectedWorktree}
|
||||
disabled={busy}
|
||||
onChange={(event) => {
|
||||
worktreeChosenByUser.current = true
|
||||
setSelectedWorktree(event.target.value)
|
||||
}}
|
||||
>
|
||||
{worktrees.map(entry => (
|
||||
<option key={entry.path} value={entry.path}>
|
||||
{entry.branch} · {baseName(entry.path)} ({entry.changes})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div className={css.gitHeader}>
|
||||
<select
|
||||
className={css.gitBranchSelect}
|
||||
@@ -422,7 +496,7 @@ export function GitView(props: {
|
||||
if (target === null) return
|
||||
setFileMenu(null)
|
||||
if (id === 'open') {
|
||||
onOpenFile(target.entry.path)
|
||||
onOpenFile(resolveSidebarPath(selectedWorktree ?? scope.cwd, target.entry.path))
|
||||
return
|
||||
}
|
||||
if (id === 'stage') {
|
||||
@@ -434,15 +508,15 @@ export function GitView(props: {
|
||||
title: t('discardTitle'),
|
||||
description: t('discardDesc', { path: target.entry.path }),
|
||||
confirmLabel: t('discard'),
|
||||
onConfirm: () => api.gitDiscard(scope, target.entry.path),
|
||||
onConfirm: () => api.gitDiscard(scope, target.entry.path, selectedWorktree),
|
||||
})
|
||||
return
|
||||
}
|
||||
if (id === 'relative') {
|
||||
copy(relativeTo(scope.cwd ?? '', target.entry.path))
|
||||
copy(relativeTo(selectedWorktree ?? scope.cwd ?? '', target.entry.path))
|
||||
return
|
||||
}
|
||||
if (id === 'absolute') copy(target.entry.path)
|
||||
if (id === 'absolute') copy(resolveSidebarPath(selectedWorktree ?? scope.cwd, target.entry.path))
|
||||
}}
|
||||
portal
|
||||
align="start"
|
||||
@@ -488,7 +562,7 @@ export function GitView(props: {
|
||||
title: t('revertTitle'),
|
||||
description: t('revertDesc', { subject: target.entry.subject }),
|
||||
confirmLabel: t('revertCommit'),
|
||||
onConfirm: () => api.gitRevert(scope, target.entry.hashFull),
|
||||
onConfirm: () => api.gitRevert(scope, target.entry.hashFull, selectedWorktree),
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -497,7 +571,7 @@ export function GitView(props: {
|
||||
title: t('cherryPickTitle'),
|
||||
description: t('cherryPickDesc', { subject: target.entry.subject }),
|
||||
confirmLabel: t('cherryPickCommit'),
|
||||
onConfirm: () => api.gitCherryPick(scope, target.entry.hashFull),
|
||||
onConfirm: () => api.gitCherryPick(scope, target.entry.hashFull, selectedWorktree),
|
||||
})
|
||||
}
|
||||
}}
|
||||
|
||||
+40
-24
@@ -44,6 +44,14 @@ export interface GitStatusResult {
|
||||
entries: GitStatusEntry[]
|
||||
}
|
||||
|
||||
/** One linked Git checkout. */
|
||||
export interface GitWorktree {
|
||||
path: string
|
||||
branch: string
|
||||
current: boolean
|
||||
changes: number
|
||||
}
|
||||
|
||||
/** One git log row. */
|
||||
export interface GitLogEntry {
|
||||
/** Short hash (7+ chars, display). */
|
||||
@@ -168,6 +176,12 @@ function scopePayload(scope: SessionScope, extra: Record<string, unknown>): Reco
|
||||
return { sessionId: scope.sessionId, ...(scope.cwd !== undefined && scope.cwd !== '' ? { cwd: scope.cwd } : {}), ...extra }
|
||||
}
|
||||
|
||||
/** Add a linked-worktree selection to a scoped Git request. The host validates
|
||||
* membership before using it as a command cwd. */
|
||||
function gitPayload(scope: SessionScope, worktree: string | undefined, extra: Record<string, unknown>): Record<string, unknown> {
|
||||
return scopePayload(scope, { ...(worktree !== undefined && worktree !== '' ? { worktree } : {}), ...extra })
|
||||
}
|
||||
|
||||
/** The sidebar API surface (session scope threaded through every call). */
|
||||
export const api = {
|
||||
sessionCwd: (scope: SessionScope, signal?: AbortSignal) =>
|
||||
@@ -186,38 +200,40 @@ export const api = {
|
||||
* `relativePath`); the host streams it under the session workspace. */
|
||||
uploadFile: (scope: SessionScope, dir: string, relativePath: string, body: Blob, signal?: AbortSignal) =>
|
||||
fetchUpload<{ path: string; size: number }>(scope, dir, relativePath, body, signal),
|
||||
gitStatus: (scope: SessionScope, signal?: AbortSignal) =>
|
||||
call<GitStatusResult>('git.status', scopePayload(scope, {}), signal),
|
||||
gitDiff: (scope: SessionScope, path: string | undefined, staged: boolean, signal?: AbortSignal) =>
|
||||
call<{ diff: string }>('git.diff', scopePayload(scope, { ...(path !== undefined ? { path } : {}), staged }), signal),
|
||||
gitStage: (scope: SessionScope, path?: string) =>
|
||||
call<{ ok: true }>('git.stage', scopePayload(scope, { ...(path !== undefined ? { path } : {}) })),
|
||||
gitUnstage: (scope: SessionScope, path?: string) =>
|
||||
call<{ ok: true }>('git.unstage', scopePayload(scope, { ...(path !== undefined ? { path } : {}) })),
|
||||
gitCommit: (scope: SessionScope, message: string) =>
|
||||
call<{ ok: true }>('git.commit', scopePayload(scope, { message })),
|
||||
gitBranch: (scope: SessionScope, signal?: AbortSignal) =>
|
||||
call<{ current: string; names: string[] }>('git.branch', scopePayload(scope, {}), signal),
|
||||
gitCheckout: (scope: SessionScope, branch: string) =>
|
||||
call<{ ok: true }>('git.checkout', scopePayload(scope, { branch })),
|
||||
gitWorktrees: (scope: SessionScope, signal?: AbortSignal) =>
|
||||
call<GitWorktree[]>('git.worktrees', scopePayload(scope, {}), signal),
|
||||
gitStatus: (scope: SessionScope, worktree?: string, signal?: AbortSignal) =>
|
||||
call<GitStatusResult>('git.status', gitPayload(scope, worktree, {}), signal),
|
||||
gitDiff: (scope: SessionScope, path: string | undefined, staged: boolean, worktree?: string, signal?: AbortSignal) =>
|
||||
call<{ diff: string }>('git.diff', gitPayload(scope, worktree, { ...(path !== undefined ? { path } : {}), staged }), signal),
|
||||
gitStage: (scope: SessionScope, path?: string, worktree?: string) =>
|
||||
call<{ ok: true }>('git.stage', gitPayload(scope, worktree, { ...(path !== undefined ? { path } : {}) })),
|
||||
gitUnstage: (scope: SessionScope, path?: string, worktree?: string) =>
|
||||
call<{ ok: true }>('git.unstage', gitPayload(scope, worktree, { ...(path !== undefined ? { path } : {}) })),
|
||||
gitCommit: (scope: SessionScope, message: string, worktree?: string) =>
|
||||
call<{ ok: true }>('git.commit', gitPayload(scope, worktree, { message })),
|
||||
gitBranch: (scope: SessionScope, worktree?: string, signal?: AbortSignal) =>
|
||||
call<{ current: string; names: string[] }>('git.branch', gitPayload(scope, worktree, {}), signal),
|
||||
gitCheckout: (scope: SessionScope, branch: string, worktree?: string) =>
|
||||
call<{ ok: true }>('git.checkout', gitPayload(scope, worktree, { branch })),
|
||||
/** Recent commit history, lazily pageable (skip/count; defaults 0/30). */
|
||||
gitLog: (scope: SessionScope, count?: number, skip?: number, signal?: AbortSignal) =>
|
||||
call<GitLogEntry[]>('git.log', scopePayload(scope, {
|
||||
gitLog: (scope: SessionScope, count?: number, skip?: number, worktree?: string, signal?: AbortSignal) =>
|
||||
call<GitLogEntry[]>('git.log', gitPayload(scope, worktree, {
|
||||
...(count !== undefined ? { count } : {}),
|
||||
...(skip !== undefined ? { skip } : {}),
|
||||
}), signal),
|
||||
/** Full patch text of one commit (diff display for the history rows). */
|
||||
gitCommitDiff: (scope: SessionScope, hash: string, signal?: AbortSignal) =>
|
||||
call<{ diff: string }>('git.commit-diff', scopePayload(scope, { hash }), signal),
|
||||
gitCommitDiff: (scope: SessionScope, hash: string, worktree?: string, signal?: AbortSignal) =>
|
||||
call<{ diff: string }>('git.commit-diff', gitPayload(scope, worktree, { hash }), signal),
|
||||
/** Discard the worktree changes of one file (the index is untouched). */
|
||||
gitDiscard: (scope: SessionScope, path: string) =>
|
||||
call<{ ok: true }>('git.discard', scopePayload(scope, { path })),
|
||||
gitDiscard: (scope: SessionScope, path: string, worktree?: string) =>
|
||||
call<{ ok: true }>('git.discard', gitPayload(scope, worktree, { path })),
|
||||
/** Revert one commit onto the current branch. */
|
||||
gitRevert: (scope: SessionScope, hash: string) =>
|
||||
call<{ ok: true }>('git.revert', scopePayload(scope, { hash })),
|
||||
gitRevert: (scope: SessionScope, hash: string, worktree?: string) =>
|
||||
call<{ ok: true }>('git.revert', gitPayload(scope, worktree, { hash })),
|
||||
/** Cherry-pick one commit onto the current branch. */
|
||||
gitCherryPick: (scope: SessionScope, hash: string) =>
|
||||
call<{ ok: true }>('git.cherry-pick', scopePayload(scope, { hash })),
|
||||
gitCherryPick: (scope: SessionScope, hash: string, worktree?: string) =>
|
||||
call<{ ok: true }>('git.cherry-pick', gitPayload(scope, worktree, { hash })),
|
||||
/** Release a terminal's process immediately (tab closed; the WS close frame
|
||||
* may be unreachable while the socket is down, so the host also accepts
|
||||
* this explicit route). */
|
||||
|
||||
@@ -129,9 +129,10 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read
|
||||
icon: (size: number) => <IconBranchOutline16 size={size} />,
|
||||
order: 20,
|
||||
single: true,
|
||||
component: ({ ctx, store, scope, onOpenDiff }) => (
|
||||
component: ({ ctx, store, scope, visible, onOpenDiff }) => (
|
||||
<GitView
|
||||
scope={scope}
|
||||
visible={visible}
|
||||
onOpenFile={(path) => { openSidebarFile(ctx, store, scope.sessionId, path) }}
|
||||
onOpenDiff={onOpenDiff ?? (() => { /* no-op */ })}
|
||||
/>
|
||||
|
||||
@@ -78,6 +78,7 @@ export const zh = {
|
||||
commit: '提交',
|
||||
commitError: '提交失败',
|
||||
branch: '分支',
|
||||
worktree: '工作树',
|
||||
checkoutError: '切换分支失败',
|
||||
history: '历史',
|
||||
changes: '变更',
|
||||
@@ -346,6 +347,7 @@ export const en: Record<keyof typeof zh, string> = {
|
||||
commit: 'Commit',
|
||||
commitError: 'Commit failed',
|
||||
branch: 'Branch',
|
||||
worktree: 'Worktree',
|
||||
checkoutError: 'Branch switch failed',
|
||||
history: 'History',
|
||||
changes: 'Changes',
|
||||
|
||||
@@ -1959,6 +1959,20 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
|
||||
padding: 0 8px 0 12px;
|
||||
}
|
||||
|
||||
.gitWorktreeRow {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px 0 12px;
|
||||
}
|
||||
|
||||
.gitWorktreeLabel {
|
||||
flex: none;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font: var(--dsw-font-xxxs-11);
|
||||
}
|
||||
|
||||
.gitBranchSelect {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
+2
-2
@@ -22,8 +22,8 @@ export type TabType = string
|
||||
|
||||
/** What a diff tab shows: a worktree/index change of one path, or one commit's full patch. */
|
||||
export type SidebarDiffRef =
|
||||
| { kind: 'worktree'; path: string; staged: boolean; untracked?: boolean }
|
||||
| { kind: 'commit'; hash: string; hashFull: string; subject: string }
|
||||
| { kind: 'worktree'; path: string; staged: boolean; untracked?: boolean; worktree?: string }
|
||||
| { kind: 'commit'; hash: string; hashFull: string; subject: string; worktree?: string }
|
||||
|
||||
/** One open tab. `path` carries the file (editor) or is absent (git/terminal);
|
||||
* `diff` carries the change a diff tab shows; `meta` (v0.12.0+) carries
|
||||
|
||||
+80
@@ -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']
|
||||
|
||||
+25
-13
@@ -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) }
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { parseWorktreeList, resolveWorktree, status, worktrees } from '../src/git.ts'
|
||||
|
||||
const IDENTITY = {
|
||||
GIT_AUTHOR_NAME: 'dsh-better-sidebar-test',
|
||||
GIT_AUTHOR_EMAIL: 'test@dsh.invalid',
|
||||
GIT_COMMITTER_NAME: 'dsh-better-sidebar-test',
|
||||
GIT_COMMITTER_EMAIL: 'test@dsh.invalid',
|
||||
}
|
||||
|
||||
function git(cwd: string, args: string[]): string {
|
||||
const result = spawnSync('git', ['-C', cwd, ...args], {
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, ...IDENTITY },
|
||||
})
|
||||
if (result.status !== 0) throw new Error(result.stderr || `git ${args.join(' ')} failed`)
|
||||
return result.stdout
|
||||
}
|
||||
|
||||
describe('linked Git worktrees', () => {
|
||||
it('parses porcelain records with spaces in checkout paths', () => {
|
||||
expect(parseWorktreeList([
|
||||
'worktree C:/repo/main checkout',
|
||||
'HEAD abc',
|
||||
'branch refs/heads/main',
|
||||
'',
|
||||
'worktree C:/repo/agent checkout',
|
||||
'HEAD def',
|
||||
'detached',
|
||||
'',
|
||||
].join('\n'))).toEqual([
|
||||
{ path: 'C:/repo/main checkout', branch: 'main' },
|
||||
{ path: 'C:/repo/agent checkout', branch: 'HEAD' },
|
||||
])
|
||||
})
|
||||
|
||||
it('discovers dirty linked checkouts and fences selected targets', async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-sidebar-worktrees-'))
|
||||
const main = join(root, 'main')
|
||||
const agent = join(root, 'agent worktree')
|
||||
try {
|
||||
git(root, ['init', '-q', main])
|
||||
git(main, ['checkout', '-q', '-b', 'main'])
|
||||
writeFileSync(join(main, 'tracked.txt'), 'base\n')
|
||||
git(main, ['add', '-A'])
|
||||
git(main, ['commit', '-q', '-m', 'base'])
|
||||
git(main, ['worktree', 'add', '-q', '-b', 'agent', agent])
|
||||
writeFileSync(join(agent, 'tracked.txt'), 'changed by agent\n')
|
||||
|
||||
const listed = await worktrees(main)
|
||||
expect(listed).toHaveLength(2)
|
||||
expect(listed.find(entry => entry.current)).toMatchObject({ branch: 'main', changes: 0 })
|
||||
expect(listed.find(entry => !entry.current)).toMatchObject({ branch: 'agent', changes: 1 })
|
||||
expect(resolve(await resolveWorktree(main, agent))).toBe(resolve(agent))
|
||||
expect((await status(await resolveWorktree(main, agent))).entries).toEqual([
|
||||
{ path: 'tracked.txt', xy: ' M' },
|
||||
])
|
||||
await expect(resolveWorktree(main, root)).rejects.toThrow('unknown linked worktree')
|
||||
} finally {
|
||||
try { git(main, ['worktree', 'remove', '--force', agent]) } catch { /* fixture may not be fully initialized */ }
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user