merge #285: forward-port linked worktrees to RC8

Co-authored-by: xxxg0001 <xxxg0001@users.noreply.github.com>
This commit is contained in:
HuanLinOTO
2026-08-23 20:17:14 +08:00
co-authored by xxxg0001
12 changed files with 615 additions and 77 deletions
+102
View File
@@ -10,6 +10,7 @@
* user.name/user.email).
*/
import { spawn } from 'node:child_process'
import { resolve } from 'node:path'
/** A parsed `git status --porcelain=v1 -z` entry. */
export interface GitStatusEntry {
@@ -25,6 +26,18 @@ export interface GitStatusResult {
entries: GitStatusEntry[]
}
/** One linked checkout returned by `git worktree list --porcelain`. */
export interface GitWorktree {
/** Absolute checkout root. */
path: string
/** Branch name without `refs/heads/`, or `HEAD` when detached. */
branch: string
/** Whether this checkout contains the session cwd. */
current: boolean
/** Number of staged + unstaged status rows (a file changed on both sides counts once). */
changes: number
}
/** One `git log` row. */
export interface GitLogEntry {
/** Short hash (7+ chars, display). */
@@ -71,6 +84,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[] = []
@@ -162,6 +219,51 @@ 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 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> {
const args = ['diff', '--no-ext-diff', '--no-color', '-U3']