fix(git): bound status payload and repo discovery; add reset escape hatch (#369)

Opening the source-control panel could freeze the whole sidebar, and the
frozen layout restored itself after every reload, leaving no way out
(issue #369). Three unbounded layers compounded:

- `git status --porcelain -z --untracked-files=all` shipped every row of a
  pathological untracked set into one response; the browser main thread
  froze on response.json() plus the unvirtualized change list, and the 2s
  poll replayed the freeze. status() now caps entries at 2000 and reports
  `truncated` (the panel shows a notice, mirroring fs.read's truncation
  semantics); worktree change counts inherit the bound for free.
- repoRoots() probed every visible child directory of a non-repo cwd
  (e.g. ~) serially, once per git.* call, each with the full 30s command
  budget. Discovery now uses a 3s probe timeout, caps probes at 200
  directories, and shares one in-flight scan plus a 60s TTL cache per cwd.
- A hung layout persisted `panelOpen: true` with the git tab active, so a
  reload remounted straight back into the freeze with no escape hatch.
  Loading with `?dsh-sidebar-reset` now drops the persisted layout (and
  the shared width) and starts from the default.

statusTruncated copy synced across all 19 shipped dictionaries.
This commit is contained in:
Menghuan1918
2026-08-25 11:04:43 +08:00
parent 6f6568d29f
commit 2cbcba8d11
26 changed files with 261 additions and 20 deletions
+67 -7
View File
@@ -26,6 +26,10 @@ export interface GitStatusResult {
isRepo: boolean
branch?: string
entries: GitStatusEntry[]
/** True when the working tree had more rows than `GIT_STATUS_LIMIT`; the
* panel shows a truncation notice instead of freezing on a huge untracked
* set (issue #369). */
truncated?: boolean
/** Selected repository root, or the discovered roots when the cwd is a container. */
root?: string
repositories?: string[]
@@ -184,10 +188,28 @@ function runGit(cwd: string, args: string[], timeoutMs = 30_000): Promise<string
})
}
/** Whether the directory is inside a git work tree (exit-0 `git rev-parse`). */
/** Cap on child directories probed by the workspace-container fallback scan.
* A home-directory cwd can hold hundreds of visible folders (Library, iCloud
* mounts…); probing them all serially is what froze the panel in #369. */
const DISCOVERY_LIMIT = 200
/** Per-probe and direct-discovery budget. `rev-parse` is millisecond-scale on
* a healthy checkout; a probe that needs longer is a stalled mount and is
* better abandoned than waited on. */
const DISCOVERY_TIMEOUT_MS = 3_000
/** Discovery results are cheap to recompute but expensive to storm: the panel
* polls every 2s and each poll fans out into several git.* calls that all
* resolve the same roots. A short TTL keeps fan-out at one scan per cwd. */
const DISCOVERY_CACHE_TTL_MS = 60_000
const repoRootsCache = new Map<string, { roots: string[]; expires: number }>()
const repoRootsInFlight = new Map<string, Promise<string[]>>()
/** Whether the directory is inside a git work tree (exit-0 `git rev-parse`).
* Probe timeout is short: a cwd on a stalled mount must not hold the panel
* hostage for the full command budget (issue #369). */
export async function isGitRepo(cwd: string): Promise<boolean> {
try {
const out = await runGit(cwd, ['rev-parse', '--is-inside-work-tree'])
const out = await runGit(cwd, ['rev-parse', '--is-inside-work-tree'], DISCOVERY_TIMEOUT_MS)
return out.trim() === 'true'
} catch {
return false
@@ -196,12 +218,34 @@ export async function isGitRepo(cwd: string): Promise<boolean> {
/** The repository top level containing `cwd` (`git rev-parse --show-toplevel`). */
async function directRepoRoot(cwd: string): Promise<string> {
const out = await runGit(cwd, ['rev-parse', '--show-toplevel'])
const out = await runGit(cwd, ['rev-parse', '--show-toplevel'], DISCOVERY_TIMEOUT_MS)
return out.trim()
}
/** Discover the current repository or direct child repositories. */
export async function repoRoots(cwd: string): Promise<string[]> {
/** Discover the current repository or direct child repositories. Results are
* cached per cwd and concurrent callers share one in-flight scan, so opening
* the panel (three parallel git.* requests) costs a single discovery pass. */
export function repoRoots(cwd: string): Promise<string[]> {
const cached = repoRootsCache.get(cwd)
if (cached !== undefined && cached.expires > Date.now()) return Promise.resolve(cached.roots)
const pending = repoRootsInFlight.get(cwd)
if (pending !== undefined) return pending
const promise = discoverRepoRoots(cwd).then(
(roots) => {
repoRootsCache.set(cwd, { roots, expires: Date.now() + DISCOVERY_CACHE_TTL_MS })
repoRootsInFlight.delete(cwd)
return roots
},
(error: unknown) => {
repoRootsInFlight.delete(cwd)
throw error
},
)
repoRootsInFlight.set(cwd, promise)
return promise
}
async function discoverRepoRoots(cwd: string): Promise<string[]> {
try {
return [await directRepoRoot(cwd)]
} catch {
@@ -209,7 +253,8 @@ export async function repoRoots(cwd: string): Promise<string[]> {
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))) {
.sort((left, right) => left.name.localeCompare(right.name))
.slice(0, DISCOVERY_LIMIT)) {
try {
const root = await directRepoRoot(join(cwd, entry.name))
if (!roots.some(existing => pathIdentity(existing) === pathIdentity(root))) roots.push(root)
@@ -241,6 +286,12 @@ export async function currentBranch(cwd: string): Promise<string> {
return out.trim()
}
/** Upper bound on status rows shipped to the client. Beyond this the result
* is truncated (with `truncated: true`) so a pathological untracked set —
* e.g. the working tree discovered under a home-directory cwd — cannot
* freeze the browser main thread on JSON parse or list render (#369). */
const GIT_STATUS_LIMIT = 2_000
/**
* Working-tree status (untracked included). `--untracked-files=all` lists
* the contents of new directories as individual entries, while preserving
@@ -254,7 +305,16 @@ export async function status(cwd: string, selected?: string): Promise<GitStatusR
currentBranch(root).catch(() => 'HEAD'),
runGit(root, ['status', '--porcelain=v1', '-z', '--untracked-files=all']),
])
return { isRepo: true, branch, entries: parsePorcelainZ(raw), root, repositories }
const parsed = parsePorcelainZ(raw)
const truncated = parsed.length > GIT_STATUS_LIMIT
return {
isRepo: true,
branch,
entries: truncated ? parsed.slice(0, GIT_STATUS_LIMIT) : parsed,
truncated,
root,
repositories,
}
}
/** Platform-aware identity used only for comparing absolute checkout roots. */