integrate train/git: worktree RC8 forward-port + multi-repo selector

# Conflicts:
#	src/index.ts
This commit is contained in:
HuanLinOTO
2026-08-23 20:28:17 +08:00
17 changed files with 770 additions and 137 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

+1 -1
View File
@@ -72,7 +72,7 @@
"LICENSE"
],
"scripts": {
"build": "rm -rf lib && tsc -p tsconfig.build.json && tsdown",
"build": "node -e \"require('node:fs').rmSync('lib',{recursive:true,force:true})\" && tsc -p tsconfig.build.json && tsdown",
"prepublishOnly": "pnpm build",
"typecheck": "tsc --noEmit",
"bundle": "tsdown",
+6 -5
View File
@@ -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). */
@@ -33,24 +34,24 @@ export function DiffTab(props: { sessionId: string; cwd: string | undefined; dif
useEffect(() => {
let cancelled = false
const scope: SessionScope = { sessionId, cwd }
const scope: SessionScope = { sessionId, cwd, ...(diff.repoRoot !== undefined ? { repoRoot: diff.repoRoot } : {}) }
setLoading(true)
setError(null)
setData(null)
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: '' })
}
+177 -32
View File
@@ -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,14 @@ 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 [repoRoot, setRepoRoot] = useState<string | undefined>(undefined)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [branchNames, setBranchNames] = useState<string[]>([])
@@ -104,70 +111,179 @@ 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)
/** Monotonic request id: a manual worktree switch invalidates any older poll
* before it can publish state from the previous checkout. */
const refreshGeneration = useRef(0)
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 gitScope: SessionScope = repoRoot === undefined ? scope : { ...scope, repoRoot }
/** Publish a complete checkout-derived view. Status, branch choices and
* history are one consistency unit: never mix rows from two worktrees. */
const refreshTarget = useCallback(async (
target: string | undefined,
options: { loading: boolean; generation: number },
): Promise<void> => {
if (options.loading) setLoading(true)
setError(null)
try {
const [statusResult, branchResult, logResult] = await Promise.all([
api.gitStatus(scope),
api.gitBranch(scope).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.gitStatus(gitScope, target),
api.gitBranch(gitScope, target).catch(() => ({ current: '', names: [] as string[] })),
api.gitLog(gitScope, LOG_BATCH, 0, target).catch(() => [] as GitLogEntry[]),
])
if (options.generation !== refreshGeneration.current) return
setStatus(statusResult)
if (statusResult.root !== undefined && statusResult.root !== repoRoot) setRepoRoot(statusResult.root)
setBranchNames(branchResult.names)
setLogEntries(logResult)
setLogEnded(logResult.length < LOG_BATCH)
} catch (reason) {
setError(reason instanceof Error ? reason.message : String(reason))
if (options.generation === refreshGeneration.current) {
setError(reason instanceof Error ? reason.message : String(reason))
}
} finally {
setLoading(false)
if (options.loading && options.generation === refreshGeneration.current) setLoading(false)
}
}, [scope.sessionId, scope.cwd])
}, [scope.sessionId, scope.cwd, repoRoot])
const refresh = useCallback(async (silent = false): Promise<void> => {
if (refreshInFlight.current) return
refreshInFlight.current = true
let generation = refreshGeneration.current
try {
const listed = await api.gitWorktrees(scope)
if (generation !== refreshGeneration.current) return
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
}
const targetChanged = target !== selectedRef.current
if (targetChanged) {
// Changing the automatically selected checkout invalidates any direct
// target refresh that may still be resolving for the previous one.
generation = refreshGeneration.current += 1
selectedRef.current = target
setSelectedWorktree(target)
// Remove rows owned by the previous checkout immediately: keeping them
// interactive while the target changes could apply a destructive action
// to the new checkout with stale history from the old one.
setStatus(null)
setBranchNames([])
setLogEntries([])
setLogEnded(false)
setLogLoadingMore(false)
}
// A poll may update status alone only while staying on the same checkout.
// Any automatic selection change refreshes the complete derived view.
if (silent && !targetChanged) {
const statusResult = await api.gitStatus(gitScope, target)
if (generation === refreshGeneration.current) setStatus(statusResult)
return
}
await refreshTarget(target, { loading: !silent, generation })
} catch (reason) {
if (generation === refreshGeneration.current) {
setError(reason instanceof Error ? reason.message : String(reason))
if (!silent) setLoading(false)
}
} finally {
refreshInFlight.current = false
}
}, [scope.sessionId, scope.cwd, refreshTarget])
useEffect(() => {
refreshGeneration.current += 1
refreshInFlight.current = false
worktreeChosenByUser.current = false
selectedRef.current = undefined
setSelectedWorktree(undefined)
}, [scope.sessionId, scope.cwd])
useEffect(() => { void refresh() }, [refresh])
/** A user choice invalidates any older poll and atomically refreshes every
* checkout-derived surface before destructive history actions can run. */
const chooseWorktree = (target: string): void => {
worktreeChosenByUser.current = true
selectedRef.current = target
setSelectedWorktree(target)
setStatus(null)
setBranchNames([])
setLogEntries([])
setLogEnded(false)
setLogLoadingMore(false)
const generation = refreshGeneration.current += 1
void refreshTarget(target, { loading: true, generation })
}
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
const generation = refreshGeneration.current
const target = selectedRef.current
setLogLoadingMore(true)
try {
const next = await api.gitLog(scope, LOG_BATCH, logEntries.length)
const next = await api.gitLog(gitScope, LOG_BATCH, logEntries.length, target)
// A worktree switch clears the old history and increments generation.
// Never append a late page from that checkout into the new one.
if (generation !== refreshGeneration.current || target !== selectedRef.current) return
setLogEntries(entries => [...entries, ...next])
if (next.length < LOG_BATCH) setLogEnded(true)
} catch (reason) {
setCommitError(`${t('historyLoadError')}: ${reason instanceof Error ? reason.message : String(reason)}`)
if (generation === refreshGeneration.current && target === selectedRef.current) {
setCommitError(`${t('historyLoadError')}: ${reason instanceof Error ? reason.message : String(reason)}`)
}
} finally {
setLogLoadingMore(false)
if (generation === refreshGeneration.current && target === selectedRef.current) setLogLoadingMore(false)
}
}
/** 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, repoRoot },
})
}
/** 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, repoRoot },
})
}
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(gitScope, entry.path, selectedWorktree)
else await api.gitStage(gitScope, entry.path, selectedWorktree)
await refresh()
} finally {
setBusy(false)
@@ -177,8 +293,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(gitScope, undefined, selectedWorktree)
else await api.gitStage(gitScope, undefined, selectedWorktree)
await refresh()
} finally {
setBusy(false)
@@ -191,7 +307,7 @@ export function GitView(props: {
setBusy(true)
setCommitError(null)
try {
await api.gitCommit(scope, message)
await api.gitCommit(gitScope, message, selectedWorktree)
setCommitMsg('')
await refresh()
} catch (reason) {
@@ -206,7 +322,7 @@ export function GitView(props: {
setBusy(true)
setCommitError(null)
try {
await api.gitCheckout(scope, branch)
await api.gitCheckout(gitScope, branch, selectedWorktree)
await refresh()
} catch (reason) {
setCommitError(`${t('checkoutError')}: ${reason instanceof Error ? reason.message : String(reason)}`)
@@ -280,7 +396,36 @@ 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) => { chooseWorktree(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}>
{(status?.repositories?.length ?? 0) > 1 && (
<select
className={css.gitBranchSelect}
value={repoRoot ?? ''}
title={repoRoot}
onChange={(event) => { setRepoRoot(event.target.value) }}
disabled={busy}
>
{status!.repositories!.map(root => <option key={root} value={root}>{baseName(root)}</option>)}
</select>
)}
<select
className={css.gitBranchSelect}
value={status?.branch ?? ''}
@@ -422,7 +567,7 @@ export function GitView(props: {
if (target === null) return
setFileMenu(null)
if (id === 'open') {
onOpenFile(target.entry.path)
onOpenFile(resolveSidebarPath(repoRoot ?? selectedWorktree ?? scope.cwd, target.entry.path))
return
}
if (id === 'stage') {
@@ -434,15 +579,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(gitScope, target.entry.path, selectedWorktree),
})
return
}
if (id === 'relative') {
copy(relativeTo(scope.cwd ?? '', target.entry.path))
copy(relativeTo(repoRoot ?? selectedWorktree ?? scope.cwd ?? '', target.entry.path))
return
}
if (id === 'absolute') copy(target.entry.path)
if (id === 'absolute') copy(resolveSidebarPath(repoRoot ?? selectedWorktree ?? scope.cwd, target.entry.path))
}}
portal
align="start"
@@ -488,7 +633,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(gitScope, target.entry.hashFull, selectedWorktree),
})
return
}
@@ -497,7 +642,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(gitScope, target.entry.hashFull, selectedWorktree),
})
}
}}
+50 -25
View File
@@ -44,6 +44,16 @@ export interface GitStatusResult {
isRepo: boolean
branch?: string
entries: GitStatusEntry[]
root?: string
repositories?: string[]
}
/** One linked Git checkout. */
export interface GitWorktree {
path: string
branch: string
current: boolean
changes: number
}
/** One git log row. */
@@ -166,11 +176,24 @@ export interface SessionScope {
sessionId: string
/** The session's working directory from the client list summary (optional). */
cwd?: string
/** Selected Git repository when cwd is a workspace container. */
repoRoot?: string
}
/** Fold a scope into a JSON payload ({cwd} only when present). */
function scopePayload(scope: SessionScope, extra: Record<string, unknown>): Record<string, unknown> {
return { sessionId: scope.sessionId, ...(scope.cwd !== undefined && scope.cwd !== '' ? { cwd: scope.cwd } : {}), ...extra }
return {
sessionId: scope.sessionId,
...(scope.cwd !== undefined && scope.cwd !== '' ? { cwd: scope.cwd } : {}),
...(scope.repoRoot !== undefined && scope.repoRoot !== '' ? { repoRoot: scope.repoRoot } : {}),
...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). */
@@ -191,38 +214,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). */
+2 -1
View File
@@ -137,9 +137,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 */ })}
/>
+2
View File
@@ -113,6 +113,7 @@ export const zh = {
commit: '提交',
commitError: '提交失败',
branch: '分支',
worktree: '工作树',
checkoutError: '切换分支失败',
history: '历史',
changes: '变更',
@@ -443,6 +444,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',
+14
View File
@@ -1996,6 +1996,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
View File
@@ -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; repoRoot?: string }
| { kind: 'commit'; hash: string; hashFull: string; subject: string; worktree?: string; repoRoot?: 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
+173 -37
View File
@@ -9,7 +9,10 @@
* Commits use the user's git global identity untouched (never sets
* user.name/user.email).
*/
import { readdir } from 'node:fs/promises'
import { join } from 'node:path'
import { spawn } from 'node:child_process'
import { resolve } from 'node:path'
/** A parsed `git status --porcelain=v1 -z` entry. */
export interface GitStatusEntry {
@@ -23,6 +26,21 @@ export interface GitStatusResult {
isRepo: boolean
branch?: string
entries: GitStatusEntry[]
/** Selected repository root, or the discovered roots when the cwd is a container. */
root?: string
repositories?: string[]
}
/** 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. */
@@ -71,6 +89,50 @@ export function parsePorcelainZ(output: string): GitStatusEntry[] {
return entries
}
/** One raw porcelain worktree record. Prunable checkouts are retained by
* Git's administrative metadata after their directory disappears and must not
* become selectable command targets. Locked checkouts remain usable. */
export interface GitWorktreeRecord {
path: string
branch: string
locked: boolean
prunable: boolean
}
/** 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): GitWorktreeRecord[] {
const rows: GitWorktreeRecord[] = []
let path: string | undefined
let branch = 'HEAD'
let locked = false
let prunable = false
const flush = (): void => {
if (path !== undefined) rows.push({ path, branch, locked, prunable })
path = undefined
branch = 'HEAD'
locked = false
prunable = false
}
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)
} else if (line === 'locked' || line.startsWith('locked ')) {
locked = true
} else if (line === 'prunable' || line.startsWith('prunable ')) {
prunable = true
}
}
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[] = []
@@ -133,11 +195,40 @@ export async function isGitRepo(cwd: string): Promise<boolean> {
}
/** The repository top level containing `cwd` (`git rev-parse --show-toplevel`). */
export async function repoRoot(cwd: string): Promise<string> {
async function directRepoRoot(cwd: string): Promise<string> {
const out = await runGit(cwd, ['rev-parse', '--show-toplevel'])
return out.trim()
}
/** Discover the current repository or direct child repositories. */
export async function repoRoots(cwd: string): Promise<string[]> {
try {
return [await directRepoRoot(cwd)]
} catch {
const entries = await readdir(cwd, { withFileTypes: true }).catch(() => [])
const roots: string[] = []
for (const entry of entries
.filter(entry => entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules')
.sort((left, right) => left.name.localeCompare(right.name))) {
try {
const root = await directRepoRoot(join(cwd, entry.name))
if (!roots.includes(root)) roots.push(root)
} catch {
// Ordinary child directory; keep discovering sibling repositories.
}
}
return roots
}
}
/** Resolve the selected repository, defaulting to the first discovered root. */
export async function repoRoot(cwd: string, selected?: string): Promise<string> {
const roots = await repoRoots(cwd)
if (roots.length === 0) throw new GitCommandError('not a git repository', 'not-repo', 'rev-parse')
if (selected !== undefined && roots.includes(selected)) return selected
return roots[0]!
}
/** The current branch name (`git rev-parse --abbrev-ref HEAD`; 'HEAD' when detached). */
export async function currentBranch(cwd: string): Promise<string> {
const out = await runGit(cwd, ['rev-parse', '--abbrev-ref', 'HEAD'])
@@ -146,63 +237,108 @@ export async function currentBranch(cwd: string): Promise<string> {
/**
* Working-tree status (untracked included). `--untracked-files=all` lists
* the CONTENTS of new directories as individual entries (`?? newdir/a.ts`
* rather than a collapsed `?? newdir/`), so every row in the source-control
* panel is a real file whose diff tab can load. With `=normal`, git folds a
* new folder into one trailing-slash entry that has no diff output and
* cannot be read as a file.
* the contents of new directories as individual entries, while preserving
* repository discovery and explicit repository selection for workspace roots.
*/
export async function status(cwd: string): Promise<GitStatusResult> {
const repo = await isGitRepo(cwd)
if (!repo) return { isRepo: false, entries: [] }
export async function status(cwd: string, selected?: string): Promise<GitStatusResult> {
const repositories = await repoRoots(cwd)
if (repositories.length === 0) return { isRepo: false, entries: [], repositories: [] }
const root = await repoRoot(cwd, selected)
const [branch, raw] = await Promise.all([
currentBranch(cwd).catch(() => 'HEAD'),
runGit(cwd, ['status', '--porcelain=v1', '-z', '--untracked-files=all']),
currentBranch(root).catch(() => 'HEAD'),
runGit(root, ['status', '--porcelain=v1', '-z', '--untracked-files=all']),
])
return { isRepo: true, branch, entries: parsePorcelainZ(raw) }
return { isRepo: true, branch, entries: parsePorcelainZ(raw), root, repositories }
}
/** 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 usable checkout records, shared by inventory and target validation.
* Prunable records point at missing paths and are deliberately excluded from
* both the selector and the command-target allowlist. */
async function listedWorktrees(cwd: string): Promise<GitWorktreeRecord[]> {
const raw = await runGit(cwd, ['worktree', 'list', '--porcelain', '-z'])
return parseWorktreeList(raw).filter(entry => !entry.prunable)
}
/** 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),
// One stale/permission-raced linked checkout must not hide the valid
// current repository from the panel. Targeted operations still fail loud.
changes: await status(entry.path).then(result => result.entries.length, () => 0),
})))
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> {
export async function diff(cwd: string, path: string | undefined, staged: boolean, selected?: string): Promise<string> {
const root = await repoRoot(cwd, selected)
const args = ['diff', '--no-ext-diff', '--no-color', '-U3']
if (staged) args.push('--cached')
if (path !== undefined) args.push('--', path)
return runGit(cwd, args)
return runGit(root, args)
}
/** Stage paths (all when path is undefined). */
export async function stage(cwd: string, path: string | undefined): Promise<void> {
await runGit(cwd, ['add', '-A', ...(path !== undefined ? ['--', path] : [])])
export async function stage(cwd: string, path: string | undefined, selected?: string): Promise<void> {
await runGit(await repoRoot(cwd, selected), ['add', '-A', ...(path !== undefined ? ['--', path] : [])])
}
/** Unstage paths (all when path is undefined). */
export async function unstage(cwd: string, path: string | undefined): Promise<void> {
await runGit(cwd, ['reset', '-q', ...(path !== undefined ? ['--', path] : [])])
export async function unstage(cwd: string, path: string | undefined, selected?: string): Promise<void> {
await runGit(await repoRoot(cwd, selected), ['reset', '-q', ...(path !== undefined ? ['--', path] : [])])
}
/** Commit the staged changes with a message (global identity untouched). */
export async function commit(cwd: string, message: string): Promise<void> {
await runGit(cwd, ['commit', '-m', message])
export async function commit(cwd: string, message: string, selected?: string): Promise<void> {
await runGit(await repoRoot(cwd, selected), ['commit', '-m', message])
}
/** Branch names (current first). */
export async function branches(cwd: string): Promise<{ current: string; names: string[] }> {
export async function branches(cwd: string, selected?: string): Promise<{ current: string; names: string[] }> {
const root = await repoRoot(cwd, selected)
const [current, raw] = await Promise.all([
currentBranch(cwd).catch(() => 'HEAD'),
runGit(cwd, ['for-each-ref', '--format=%(refname:short)', 'refs/heads']),
currentBranch(root).catch(() => 'HEAD'),
runGit(root, ['for-each-ref', '--format=%(refname:short)', 'refs/heads']),
])
const names = raw.split('\n').filter(line => line !== '')
return { current, names: names.includes(current) ? names : [current, ...names] }
}
/** Switch to an existing branch. */
export async function checkout(cwd: string, branch: string): Promise<void> {
await runGit(cwd, ['checkout', branch])
export async function checkout(cwd: string, branch: string, selected?: string): Promise<void> {
await runGit(await repoRoot(cwd, selected), ['checkout', branch])
}
/** Recent commit history (newest first), lazily pageable via skip/count. */
export async function log(cwd: string, count = 30, skip = 0): Promise<GitLogEntry[]> {
const raw = await runGit(cwd, [
export async function log(cwd: string, count = 30, skip = 0, selected?: string): Promise<GitLogEntry[]> {
const raw = await runGit(await repoRoot(cwd, selected), [
'log', '-n', String(count), '--skip', String(skip), '--decorate=short',
'--pretty=format:%h%x1f%s%x1f%an%x1f%ai%x1f%H%x1f%D',
])
@@ -213,9 +349,9 @@ export async function log(cwd: string, count = 30, skip = 0): Promise<GitLogEntr
* Content of a file at a revision (`git show <rev>:<path>`), or null when the
* revision has no such path (a new/untracked file has no HEAD side).
*/
export async function show(cwd: string, rev: string, path: string): Promise<string | null> {
export async function show(cwd: string, rev: string, path: string, selected?: string): Promise<string | null> {
try {
return await runGit(cwd, ['show', `${rev}:${path}`])
return await runGit(await repoRoot(cwd, selected), ['show', `${rev}:${path}`])
} catch {
return null
}
@@ -224,21 +360,21 @@ export async function show(cwd: string, rev: string, path: string): Promise<stri
/** Full patch text of one commit (`git show` with the commit header suppressed).
* Merge commits show their diff against the first parent (`-m --first-parent`
* is a no-op for regular commits), so a history click always has content. */
export async function commitDiff(cwd: string, hash: string): Promise<string> {
return runGit(cwd, ['show', '--no-ext-diff', '--no-color', '--format=', '-m', '--first-parent', hash])
export async function commitDiff(cwd: string, hash: string, selected?: string): Promise<string> {
return runGit(await repoRoot(cwd, selected), ['show', '--no-ext-diff', '--no-color', '--format=', '-m', '--first-parent', hash])
}
/** Discard the worktree changes of one path (`git checkout -- <path>`; the index is untouched). */
export async function discard(cwd: string, path: string): Promise<void> {
await runGit(cwd, ['checkout', '--', path])
export async function discard(cwd: string, path: string, selected?: string): Promise<void> {
await runGit(await repoRoot(cwd, selected), ['checkout', '--', path])
}
/** Revert one commit onto the current branch with an auto-generated message. */
export async function revert(cwd: string, hash: string): Promise<void> {
await runGit(cwd, ['revert', '--no-edit', hash])
export async function revert(cwd: string, hash: string, selected?: string): Promise<void> {
await runGit(await repoRoot(cwd, selected), ['revert', '--no-edit', hash])
}
/** Cherry-pick one commit onto the current branch. */
export async function cherryPick(cwd: string, hash: string): Promise<void> {
await runGit(cwd, ['cherry-pick', hash])
export async function cherryPick(cwd: string, hash: string, selected?: string): Promise<void> {
await runGit(await repoRoot(cwd, selected), ['cherry-pick', hash])
}
+52 -30
View File
@@ -122,6 +122,13 @@ function sessionCwdOf(ctx: Context, sessionId: string, clientCwd?: string): stri
return process.cwd()
}
/** Optional repository selected by the Git panel when cwd is a container. */
function selectedRepoOf(payload: unknown): string | undefined {
const record = payload as { repoRoot?: unknown }
if (record.repoRoot === undefined) return undefined
return requireAbsolute(requireString(payload, 'repoRoot'))
}
/**
* Resolve a path that a git command reported — `git status`/`git diff`
* print paths RELATIVE TO THE REPO TOP LEVEL, which may sit above the
@@ -129,7 +136,7 @@ function sessionCwdOf(ctx: Context, sessionId: string, clientCwd?: string): stri
* paths pass through; relative ones join the repo root (falling back to the
* cwd when the root cannot be resolved, e.g. a bare directory).
*/
async function resolveGitPath(cwd: string, raw: string): Promise<string> {
async function resolveGitPath(cwd: string, raw: string, selected?: string): Promise<string> {
if (isAbsolute(raw)) return requireAbsolute(raw)
// Prefer the session-relative interpretation when it names an existing
// path. Git status reports repository-root-relative names, but the sidebar
@@ -137,7 +144,7 @@ async function resolveGitPath(cwd: string, raw: string): Promise<string> {
// inside a nested session readable without reopening the repository root.
const sessionPath = requireAbsolute(join(cwd, raw))
if (await stat(sessionPath).then(() => true).catch(() => false)) return sessionPath
const root = await git.repoRoot(cwd).catch(() => cwd)
const root = await git.repoRoot(cwd, selected).catch(() => cwd)
return requireAbsolute(join(root, raw))
}
@@ -239,6 +246,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
@@ -292,47 +307,52 @@ function buildApi(
}
return { ok: true }
},
'git.status': async (payload) => {
'git.worktrees': async (payload) => {
const { cwd } = cwdOf(payload)
return git.status(cwd)
return git.worktrees(cwd)
},
'git.status': async (payload) => {
const { cwd } = await gitCwdOf(payload)
return git.status(cwd, selectedRepoOf(payload))
},
'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) }
const repoRoot = selectedRepoOf(payload)
const path = record.path === undefined ? undefined : await resolveGitPath(cwd, requireString(payload, 'path'), repoRoot)
return { diff: await git.diff(cwd, path, record.staged === true, repoRoot) }
},
'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)
await git.stage(cwd, path, selectedRepoOf(payload))
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)
await git.unstage(cwd, path, selectedRepoOf(payload))
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)
await git.commit(cwd, message, selectedRepoOf(payload))
return { ok: true }
},
'git.branch': async (payload) => {
const { cwd } = cwdOf(payload)
return git.branches(cwd)
const { cwd } = await gitCwdOf(payload)
return git.branches(cwd, selectedRepoOf(payload))
},
'git.checkout': async (payload) => {
const { cwd } = cwdOf(payload)
await git.checkout(cwd, requireString(payload, 'branch'))
const { cwd } = await gitCwdOf(payload)
await git.checkout(cwd, requireString(payload, 'branch'), selectedRepoOf(payload))
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
@@ -340,32 +360,34 @@ function buildApi(
const skip = typeof record.skip === 'number' && Number.isInteger(record.skip) && record.skip >= 0
? record.skip
: undefined
return git.log(cwd, count, skip)
return git.log(cwd, count, skip, selectedRepoOf(payload))
},
'git.commit-diff': async (payload) => {
const { cwd } = cwdOf(payload)
return { diff: await git.commitDiff(cwd, requireString(payload, 'hash')) }
const { cwd } = await gitCwdOf(payload)
return { diff: await git.commitDiff(cwd, requireString(payload, 'hash'), selectedRepoOf(payload)) }
},
'git.discard': async (payload) => {
const { cwd } = cwdOf(payload)
await git.discard(cwd, await resolveGitPath(cwd, requireString(payload, 'path')))
const { cwd } = await gitCwdOf(payload)
const repoRoot = selectedRepoOf(payload)
await git.discard(cwd, await resolveGitPath(cwd, requireString(payload, 'path'), repoRoot), repoRoot)
return { ok: true }
},
'git.revert': async (payload) => {
const { cwd } = cwdOf(payload)
await git.revert(cwd, requireString(payload, 'hash'))
const { cwd } = await gitCwdOf(payload)
await git.revert(cwd, requireString(payload, 'hash'), selectedRepoOf(payload))
return { ok: true }
},
'git.cherry-pick': async (payload) => {
const { cwd } = cwdOf(payload)
await git.cherryPick(cwd, requireString(payload, 'hash'))
const { cwd } = await gitCwdOf(payload)
await git.cherryPick(cwd, requireString(payload, 'hash'), selectedRepoOf(payload))
return { ok: true }
},
'git.show': async (payload) => {
const { cwd } = cwdOf(payload)
const path = await resolveGitPath(cwd, requireString(payload, 'path'))
const { cwd } = await gitCwdOf(payload)
const repoRoot = selectedRepoOf(payload)
const path = await resolveGitPath(cwd, requireString(payload, 'path'), repoRoot)
const rev = requireString(payload, 'rev')
return { content: await git.show(cwd, rev, path) }
return { content: await git.show(cwd, rev, path, repoRoot) }
},
// Release a terminal immediately. The WebSocket close frame already does
// this while the socket is open; this route covers the tab-close that
+161
View File
@@ -0,0 +1,161 @@
/**
* Git worktree selection is an atomic view boundary: status, branch choices
* and history must always come from the same checkout. A stale history row is
* especially dangerous because its revert/cherry-pick action targets the
* currently selected checkout.
*/
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createElement } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { act } from 'react-dom/test-utils'
import { GitView } from '../src/client/GitView.tsx'
import { api, type GitLogEntry, type GitStatusResult, type GitWorktree } from '../src/client/api.ts'
;(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true
const MAIN = 'C:/repo/main'
const AGENT = 'C:/repo/agent'
const inventories: GitWorktree[] = [
{ path: MAIN, branch: 'main', current: true, changes: 0 },
{ path: AGENT, branch: 'agent', current: false, changes: 1 },
]
function statusFor(target?: string): GitStatusResult {
return target === AGENT
? { isRepo: true, branch: 'agent', entries: [{ path: 'agent-change.ts', xy: ' M' }] }
: { isRepo: true, branch: 'main', entries: [{ path: 'main-change.ts', xy: ' M' }] }
}
function logFor(target?: string, index = 0): GitLogEntry[] {
const agent = target === AGENT
const digit = agent ? 'a' : 'b'
const suffix = index.toString(16).padStart(8, '0')
return [{
hash: `${digit.repeat(6)}${suffix.slice(-1)}`,
hashFull: `${digit.repeat(32)}${suffix}`,
subject: agent ? `Agent checkout commit ${index}` : `Main checkout commit ${index}`,
author: 'Test',
date: '2026-08-20 00:00:00 +0800',
refs: agent ? 'HEAD -> agent' : 'HEAD -> main',
}]
}
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
let resolvePromise!: (value: T) => void
const promise = new Promise<T>((resolve) => { resolvePromise = resolve })
return { promise, resolve: resolvePromise }
}
async function flushEffects(): Promise<void> {
await act(async () => { await Promise.resolve() })
await act(async () => { await Promise.resolve() })
}
afterEach(() => { vi.restoreAllMocks() })
describe('GitView linked-worktree consistency', () => {
it('refreshes status, branches and history together on auto and manual selection', async () => {
vi.spyOn(api, 'gitWorktrees').mockResolvedValue(inventories)
vi.spyOn(api, 'gitStatus').mockImplementation(async (_scope, target) => statusFor(target))
const branch = vi.spyOn(api, 'gitBranch').mockImplementation(async (_scope, target) => ({
current: target === AGENT ? 'agent' : 'main',
names: target === AGENT ? ['agent'] : ['main'],
}))
const log = vi.spyOn(api, 'gitLog').mockImplementation(async (_scope, _count, _skip, target) => logFor(target))
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
try {
await act(async () => {
root.render(createElement(GitView, {
scope: { sessionId: 'session', cwd: MAIN },
onOpenFile: () => {},
onOpenDiff: () => {},
visible: false,
}))
})
await flushEffects()
const selects = container.querySelectorAll<HTMLSelectElement>('select')
const worktreeSelect = selects[0]!
// A clean primary + exactly one dirty linked checkout auto-selects the
// linked checkout and loads every target-derived surface from it.
expect(worktreeSelect.value).toBe(AGENT)
expect(container.textContent).toContain('agent-change.ts')
expect(container.textContent).toContain('Agent checkout commit')
expect(container.textContent).not.toContain('Main checkout commit')
expect(branch).toHaveBeenCalledWith(expect.anything(), AGENT)
expect(log).toHaveBeenCalledWith(expect.anything(), 20, 0, AGENT)
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value')!.set!.call(worktreeSelect, MAIN)
worktreeSelect.dispatchEvent(new Event('change', { bubbles: true }))
})
await flushEffects()
expect(worktreeSelect.value).toBe(MAIN)
expect(container.textContent).toContain('main-change.ts')
expect(container.textContent).toContain('Main checkout commit')
expect(container.textContent).not.toContain('Agent checkout commit')
expect(branch).toHaveBeenLastCalledWith(expect.anything(), MAIN)
expect(log).toHaveBeenLastCalledWith(expect.anything(), 20, 0, MAIN)
} finally {
act(() => { root.unmount() })
container.remove()
}
})
it('drops a late history page when the selected worktree changes', async () => {
const lateAgentPage = deferred<GitLogEntry[]>()
vi.spyOn(api, 'gitWorktrees').mockResolvedValue(inventories)
vi.spyOn(api, 'gitStatus').mockImplementation(async (_scope, target) => statusFor(target))
vi.spyOn(api, 'gitBranch').mockImplementation(async (_scope, target) => ({
current: target === AGENT ? 'agent' : 'main',
names: target === AGENT ? ['agent'] : ['main'],
}))
vi.spyOn(api, 'gitLog').mockImplementation(async (_scope, _count, skip, target) => {
if (target === AGENT && skip === 20) return lateAgentPage.promise
if (target === AGENT) return Array.from({ length: 20 }, (_value, index) => logFor(AGENT, index)[0]!)
return logFor(MAIN)
})
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
try {
await act(async () => {
root.render(createElement(GitView, {
scope: { sessionId: 'session', cwd: MAIN },
onOpenFile: () => {},
onOpenDiff: () => {},
visible: false,
}))
})
await flushEffects()
const worktreeSelect = container.querySelectorAll<HTMLSelectElement>('select')[0]!
const loadMore = [...container.querySelectorAll<HTMLButtonElement>('button')]
.find(button => /Load more|加载更多/.test(button.textContent ?? ''))
expect(loadMore).not.toBeUndefined()
await act(async () => { loadMore!.click() })
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value')!.set!.call(worktreeSelect, MAIN)
worktreeSelect.dispatchEvent(new Event('change', { bubbles: true }))
})
await flushEffects()
expect(container.textContent).toContain('Main checkout commit 0')
lateAgentPage.resolve(logFor(AGENT, 99))
await flushEffects()
expect(container.textContent).toContain('Main checkout commit 0')
expect(container.textContent).not.toContain('Agent checkout commit 99')
} finally {
act(() => { root.unmount() })
container.remove()
}
})
})
+95
View File
@@ -0,0 +1,95 @@
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', locked: false, prunable: false },
{ path: 'C:/repo/agent checkout', branch: 'HEAD', locked: false, prunable: false },
])
})
it('preserves locked metadata and marks stale records prunable', () => {
expect(parseWorktreeList([
'worktree C:/repo/locked',
'HEAD abc',
'branch refs/heads/locked',
'locked in use',
'',
'worktree C:/repo/missing',
'HEAD def',
'prunable gitdir file points to non-existent location',
'',
].join('\0'))).toEqual([
{ path: 'C:/repo/locked', branch: 'locked', locked: true, prunable: false },
{ path: 'C:/repo/missing', branch: 'HEAD', locked: false, prunable: true },
])
})
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')
// Git retains administrative metadata after a checkout directory is
// deleted, reporting the record as prunable. It must disappear from both
// the selector inventory and the accepted command-target allowlist.
rmSync(agent, { recursive: true, force: true })
const remaining = await worktrees(main)
expect(remaining).toHaveLength(1)
expect(resolve(remaining[0]!.path)).toBe(resolve(main))
expect(remaining[0]!.current).toBe(true)
await expect(resolveWorktree(main, agent)).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 })
}
})
})
+29 -2
View File
@@ -1,12 +1,39 @@
import { execFileSync } from 'node:child_process'
import { execFile, execFileSync } from 'node:child_process'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { mkdir, mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { promisify } from 'node:util'
import { describe, expect, it } from 'vitest'
import { parseUnifiedDiff } from '../src/client/DiffView.tsx'
import { parseLogLines, parsePorcelainZ, status } from '../src/git.ts'
import { parseLogLines, parsePorcelainZ, repoRoots, status } from '../src/git.ts'
const execFileAsync = promisify(execFile)
const normalizePath = (path: string): string => path.replaceAll('\\', '/')
describe('git parsing', () => {
it('discovers and selects direct child repositories under a workspace directory', async () => {
const workspace = await mkdtemp(join(tmpdir(), 'dsh-better-sidebar-git-'))
const first = join(workspace, 'first-repo')
const second = join(workspace, 'second-repo')
try {
await Promise.all([mkdir(first), mkdir(second)])
await Promise.all([
execFileAsync('git', ['-C', first, 'init']),
execFileAsync('git', ['-C', second, 'init']),
])
await expect(repoRoots(workspace)).resolves.toEqual([normalizePath(first), normalizePath(second)])
await expect(status(workspace, normalizePath(second))).resolves.toMatchObject({
isRepo: true,
root: normalizePath(second),
repositories: [normalizePath(first), normalizePath(second)],
})
} finally {
await rm(workspace, { recursive: true, force: true })
}
})
it('parses porcelain -z entries including renames', () => {
const output = ['M src/a.ts', ' M src/b.ts', '?? src/c.ts', 'R src/new.ts', 'src/old.ts', ''].join('\0')
const entries = parsePorcelainZ(output)
+6 -2
View File
@@ -168,6 +168,7 @@ describe('depsStatus', () => {
writeFileSync(join(root, 'profiles', 'web', 'pnpm-workspace.yaml'), '')
writeFileSync(join(pkgRoot, 'package.json'), JSON.stringify({ name: 'dsh-better-sidebar' }))
writeFileSync(join(pkgRoot, 'scripts', 'install.sh'), '#!/usr/bin/env bash\n')
writeFileSync(join(pkgRoot, 'scripts', 'install.ps1'), '# repair fixture\n')
moduleFile = join(pkgRoot, 'lib', 'index.js')
writeFileSync(moduleFile, '')
resetNodePtyCache()
@@ -188,8 +189,11 @@ describe('depsStatus', () => {
expect(status.ok).toBe(false)
if (status.ok) throw new Error('unreachable')
expect(status.cause).toBe('Cannot find package node-pty')
const script = realpathSync(join(root, 'profiles', 'web', 'node_modules', '.pnpm', 'dsh-better-sidebar@0.0.0', 'node_modules', 'dsh-better-sidebar', 'scripts', 'install.sh'))
expect(status.command).toBe(`bash "${script}" --repair --profile "web"`)
const scriptName = process.platform === 'win32' ? 'install.ps1' : 'install.sh'
const script = realpathSync(join(root, 'profiles', 'web', 'node_modules', '.pnpm', 'dsh-better-sidebar@0.0.0', 'node_modules', 'dsh-better-sidebar', 'scripts', scriptName))
expect(status.command).toBe(process.platform === 'win32'
? `powershell -ExecutionPolicy Bypass -File "${script}" -Repair -Profile "web"`
: `bash "${script}" --repair --profile "web"`)
expect(status.profile).toBe('web')
})
})