merge #326: support selecting child git repositories

# Conflicts:
#	src/client/GitView.tsx
#	src/client/state.ts
#	src/index.ts
This commit is contained in:
HuanLinOTO
2026-08-23 20:25:52 +08:00
11 changed files with 176 additions and 81 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",
+1 -1
View File
@@ -34,7 +34,7 @@ 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)
+35 -20
View File
@@ -93,6 +93,7 @@ export function GitView(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[]>([])
@@ -122,6 +123,8 @@ export function GitView(props: {
const selectedRef = useRef<string | undefined>(undefined)
useEffect(() => { selectedRef.current = selectedWorktree }, [selectedWorktree])
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 (
@@ -132,12 +135,13 @@ export function GitView(props: {
setError(null)
try {
const [statusResult, branchResult, logResult] = await Promise.all([
api.gitStatus(scope, target),
api.gitBranch(scope, target).catch(() => ({ current: '', names: [] as string[] })),
api.gitLog(scope, LOG_BATCH, 0, target).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)
@@ -148,7 +152,7 @@ export function GitView(props: {
} finally {
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
@@ -189,7 +193,7 @@ export function GitView(props: {
// 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(scope, target)
const statusResult = await api.gitStatus(gitScope, target)
if (generation === refreshGeneration.current) setStatus(statusResult)
return
}
@@ -240,7 +244,7 @@ export function GitView(props: {
const target = selectedRef.current
setLogLoadingMore(true)
try {
const next = await api.gitLog(scope, LOG_BATCH, logEntries.length, target)
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
@@ -261,7 +265,7 @@ export function GitView(props: {
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), worktree: selectedWorktree },
diff: { kind: 'worktree', path: entry.path, staged, untracked: isUntracked(entry), worktree: selectedWorktree, repoRoot },
})
}
@@ -271,15 +275,15 @@ export function GitView(props: {
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, worktree: selectedWorktree },
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, selectedWorktree)
else await api.gitStage(scope, entry.path, selectedWorktree)
if (staged) await api.gitUnstage(gitScope, entry.path, selectedWorktree)
else await api.gitStage(gitScope, entry.path, selectedWorktree)
await refresh()
} finally {
setBusy(false)
@@ -289,8 +293,8 @@ export function GitView(props: {
const stageAll = async (staged: boolean): Promise<void> => {
setBusy(true)
try {
if (staged) await api.gitUnstage(scope, undefined, selectedWorktree)
else await api.gitStage(scope, undefined, selectedWorktree)
if (staged) await api.gitUnstage(gitScope, undefined, selectedWorktree)
else await api.gitStage(gitScope, undefined, selectedWorktree)
await refresh()
} finally {
setBusy(false)
@@ -303,7 +307,7 @@ export function GitView(props: {
setBusy(true)
setCommitError(null)
try {
await api.gitCommit(scope, message, selectedWorktree)
await api.gitCommit(gitScope, message, selectedWorktree)
setCommitMsg('')
await refresh()
} catch (reason) {
@@ -318,7 +322,7 @@ export function GitView(props: {
setBusy(true)
setCommitError(null)
try {
await api.gitCheckout(scope, branch, selectedWorktree)
await api.gitCheckout(gitScope, branch, selectedWorktree)
await refresh()
} catch (reason) {
setCommitError(`${t('checkoutError')}: ${reason instanceof Error ? reason.message : String(reason)}`)
@@ -411,6 +415,17 @@ export function GitView(props: {
</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 ?? ''}
@@ -552,7 +567,7 @@ export function GitView(props: {
if (target === null) return
setFileMenu(null)
if (id === 'open') {
onOpenFile(resolveSidebarPath(selectedWorktree ?? scope.cwd, target.entry.path))
onOpenFile(resolveSidebarPath(repoRoot ?? selectedWorktree ?? scope.cwd, target.entry.path))
return
}
if (id === 'stage') {
@@ -564,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, selectedWorktree),
onConfirm: () => api.gitDiscard(gitScope, target.entry.path, selectedWorktree),
})
return
}
if (id === 'relative') {
copy(relativeTo(selectedWorktree ?? scope.cwd ?? '', target.entry.path))
copy(relativeTo(repoRoot ?? selectedWorktree ?? scope.cwd ?? '', target.entry.path))
return
}
if (id === 'absolute') copy(resolveSidebarPath(selectedWorktree ?? scope.cwd, target.entry.path))
if (id === 'absolute') copy(resolveSidebarPath(repoRoot ?? selectedWorktree ?? scope.cwd, target.entry.path))
}}
portal
align="start"
@@ -618,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, selectedWorktree),
onConfirm: () => api.gitRevert(gitScope, target.entry.hashFull, selectedWorktree),
})
return
}
@@ -627,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, selectedWorktree),
onConfirm: () => api.gitCherryPick(gitScope, target.entry.hashFull, selectedWorktree),
})
}
}}
+10 -1
View File
@@ -44,6 +44,8 @@ export interface GitStatusResult {
isRepo: boolean
branch?: string
entries: GitStatusEntry[]
root?: string
repositories?: string[]
}
/** One linked Git checkout. */
@@ -174,11 +176,18 @@ 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
+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; worktree?: string }
| { kind: 'commit'; hash: string; hashFull: string; subject: string; worktree?: 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
+71 -37
View File
@@ -9,6 +9,8 @@
* 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'
@@ -24,6 +26,9 @@ 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`. */
@@ -190,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'])
@@ -203,20 +237,18 @@ 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. */
@@ -265,46 +297,48 @@ export async function resolveWorktree(cwd: string, requested?: string): Promise<
}
/** 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',
])
@@ -315,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
}
@@ -326,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])
}
+27 -17
View File
@@ -119,6 +119,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
@@ -126,9 +133,9 @@ 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)
const root = await git.repoRoot(cwd).catch(() => cwd)
const root = await git.repoRoot(cwd, selected).catch(() => cwd)
return requireAbsolute(join(root, raw))
}
@@ -297,41 +304,42 @@ function buildApi(
},
'git.status': async (payload) => {
const { cwd } = await gitCwdOf(payload)
return git.status(cwd)
return git.status(cwd, selectedRepoOf(payload))
},
'git.diff': async (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 } = 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 } = 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 } = 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 } = await gitCwdOf(payload)
return git.branches(cwd)
return git.branches(cwd, selectedRepoOf(payload))
},
'git.checkout': async (payload) => {
const { cwd } = await gitCwdOf(payload)
await git.checkout(cwd, requireString(payload, 'branch'))
await git.checkout(cwd, requireString(payload, 'branch'), selectedRepoOf(payload))
return { ok: true }
},
'git.log': async (payload) => {
@@ -343,32 +351,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 } = await gitCwdOf(payload)
return { diff: await git.commitDiff(cwd, requireString(payload, 'hash')) }
return { diff: await git.commitDiff(cwd, requireString(payload, 'hash'), selectedRepoOf(payload)) }
},
'git.discard': async (payload) => {
const { cwd } = await gitCwdOf(payload)
await git.discard(cwd, await resolveGitPath(cwd, requireString(payload, 'path')))
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 } = await gitCwdOf(payload)
await git.revert(cwd, requireString(payload, 'hash'))
await git.revert(cwd, requireString(payload, 'hash'), selectedRepoOf(payload))
return { ok: true }
},
'git.cherry-pick': async (payload) => {
const { cwd } = await gitCwdOf(payload)
await git.cherryPick(cwd, requireString(payload, 'hash'))
await git.cherryPick(cwd, requireString(payload, 'hash'), selectedRepoOf(payload))
return { ok: true }
},
'git.show': async (payload) => {
const { cwd } = await gitCwdOf(payload)
const path = await resolveGitPath(cwd, requireString(payload, 'path'))
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
+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)