dsh-taskboard 0.6.2 → 0.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/host/git.js CHANGED
@@ -31,6 +31,28 @@ const QUICK_TIMEOUT_MS = 2e3;
31
31
  const HEAVY_TIMEOUT_MS = 15e3;
32
32
  /** Directory under a workspace where task worktrees live. */
33
33
  const WORKTREE_DIR = ".dsh-worktrees";
34
+ /**
35
+ * The path segment of one `status --porcelain` line. Shape-aware: the RAW
36
+ * line is `XY path` (path at index 3) while the plugin's trimmed evidence
37
+ * lines collapse a leading-space status to `X path` (path at index 2) —
38
+ * slicing a fixed 3 misparses exactly the gitlink/unstaged shapes a
39
+ * multi-repo mirror produces (` M sub-repo`), 0.6.3 review fix.
40
+ */
41
+ function statusLinePath(line) {
42
+ if (line.length >= 3 && line[2] === " ") return line.slice(3);
43
+ if (line.length >= 2 && line[1] === " ") return line.slice(2);
44
+ return line;
45
+ }
46
+ /**
47
+ * Whether a `status --porcelain` line targets one of `rels` (a repo-relative
48
+ * path) or anything under it. Porcelain prints untracked directories with a
49
+ * trailing slash (`?? sub/`), so all three shapes match. Empty rels never
50
+ * match (0.6.3 review fix).
51
+ */
52
+ function statusLineUnder(line, rels) {
53
+ const p = statusLinePath(line);
54
+ return rels.some((rel) => rel.length > 0 && (p === rel || p === rel + "/" || p.startsWith(rel + "/")));
55
+ }
34
56
  /** Diff viewer caps (0.4.0): raw text kept per view. */
35
57
  const MAX_DIFF_BYTES = 128 * 1024;
36
58
  /** Diff viewer caps: lines kept per view. */
@@ -189,7 +211,7 @@ function createGitFace(exec = realExec) {
189
211
  baseCommit
190
212
  };
191
213
  }),
192
- async collect(worktreePath, baseCommit) {
214
+ async collect(worktreePath, baseCommit, excludeRelPaths) {
193
215
  const facts = {
194
216
  commits: [],
195
217
  commitsTotal: 0,
@@ -221,7 +243,7 @@ function createGitFace(exec = realExec) {
221
243
  }
222
244
  const status = await quick(["status", "--porcelain"], worktreePath);
223
245
  if (status.ok) {
224
- const dirty = status.stdout.split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
246
+ const dirty = status.stdout.split("\n").filter((l) => l.trim().length > 0).filter((l) => !statusLineUnder(l, excludeRelPaths ?? [])).map((l) => l.trim());
225
247
  facts.dirtyFilesTotal = dirty.length;
226
248
  facts.dirtyFiles = dirty.slice(0, 100);
227
249
  }
@@ -239,14 +261,15 @@ function createGitFace(exec = realExec) {
239
261
  if (names.ok) facts.changedFiles = names.stdout.split("\n").filter((l) => l.trim().length > 0).length;
240
262
  return facts;
241
263
  },
242
- merge: (root, branch) => withRootLock(root, async () => {
264
+ async dirtyLines(cwd) {
265
+ const status = await quick(["status", "--porcelain"], cwd);
266
+ if (!status.ok) return void 0;
267
+ return status.stdout.split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
268
+ },
269
+ merge: (root, branch, exemptRelPaths) => withRootLock(root, async () => {
243
270
  const status = await quick(["status", "--porcelain"], root);
244
271
  if (status.ok) {
245
- const dirtyLines = status.stdout.split("\n").map((l) => l.trim()).filter((l) => {
246
- if (l.length === 0) return false;
247
- const path = l.slice(3);
248
- return path !== ".dsh-worktrees" && !path.startsWith(`.dsh-worktrees/`);
249
- });
272
+ const dirtyLines = status.stdout.split("\n").filter((l) => l.trim().length > 0).filter((l) => !statusLineUnder(l, [WORKTREE_DIR, ...exemptRelPaths ?? []])).map((l) => l.trim());
250
273
  if (dirtyLines.length > 0) throw Object.assign(/* @__PURE__ */ new Error(`主工作区有 ${dirtyLines.length} 处未提交修改,请先提交或暂存后再合并`), { code: "dirty-tree" });
251
274
  }
252
275
  const merged = await heavy([
@@ -268,13 +291,18 @@ function createGitFace(exec = realExec) {
268
291
  "HEAD"
269
292
  ], root)).ok;
270
293
  },
271
- removeWorktree: (root, worktreePath) => withRootLock(root, async () => {
294
+ removeWorktree: (root, worktreePath, opts) => withRootLock(root, async () => {
272
295
  const status = await quick(["status", "--porcelain"], worktreePath);
273
296
  if (status.ok && status.stdout.trim().length > 0) {
274
- const lines = status.stdout.split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
275
- throw Object.assign(/* @__PURE__ */ new Error(`worktree 有 ${lines.length} 处未提交修改,拒绝删除:\n${lines.slice(0, 10).join("\n")}`), { code: "dirty-worktree" });
297
+ const lines = status.stdout.split("\n").filter((l) => l.trim().length > 0).filter((l) => !statusLineUnder(l, opts?.exempt ?? [])).map((l) => l.trim());
298
+ if (lines.length > 0) throw Object.assign(/* @__PURE__ */ new Error(`worktree 有 ${lines.length} 处未提交修改,拒绝删除:\n${lines.slice(0, 10).join("\n")}`), { code: "dirty-worktree" });
276
299
  }
277
- const removed = await heavy([
300
+ const removed = await heavy(opts?.force === true ? [
301
+ "worktree",
302
+ "remove",
303
+ "--force",
304
+ worktreePath
305
+ ] : [
278
306
  "worktree",
279
307
  "remove",
280
308
  worktreePath
@@ -366,6 +394,6 @@ function createGitFace(exec = realExec) {
366
394
  };
367
395
  }
368
396
  //#endregion
369
- export { MAX_DIFF_BYTES, MAX_DIFF_LINES, WORKTREE_DIR, createGitFace, sanitizeBranchName, worktreePathOf };
397
+ export { MAX_DIFF_BYTES, MAX_DIFF_LINES, WORKTREE_DIR, createGitFace, sanitizeBranchName, statusLinePath, statusLineUnder, worktreePathOf };
370
398
 
371
399
  //# sourceMappingURL=git.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"git.js","names":[],"sources":["../../src/host/git.ts"],"sourcesContent":["/**\n * Host git face (0.3.0): the ONLY place dsh-taskboard shells out to git.\n * 0.3.1: per-repo serialization of structural operations, binary probing,\n * no-op merge detection, worktree REUSE mode, and evidence size caps.\n *\n * Design invariants (plan §3.4/§3.5):\n * - NARROW interface: detect / binaryAvailable / prepareWorktree / collect /\n * merge / isAncestor / removeWorktree / deleteBranch — nothing else leaks\n * into the plugin.\n * - FAIL-SOFT: every call has a timeout and resolves to a benign result\n * (false / undefined / empty facts) on ANY git failure — a missing git,\n * a locked worktree, or a damaged repo degrades execution to the original\n * directory and NEVER fails the ledger or the run pipeline. Only the\n * explicit user actions (merge / remove / deleteBranch) throw, with a\n * readable message the GUI surfaces as-is.\n * - SERIALIZED structural ops: concurrent isolated executions on the SAME\n * repository would race on git's index/worktree locks, so every structural\n * operation (prepareWorktree / merge / removeWorktree / deleteBranch) runs\n * inside a per-root in-process mutex. Read-only collects stay concurrent.\n * - INJECTABLE runner: the exec layer is a single function so unit tests\n * script every path without a real git.\n *\n * @module dsh-taskboard/host/git\n */\nimport { resolve } from 'node:path'\nimport { isValidTaskId, type CommitInfo } from '../shared/protocol.ts'\n\n/** Timeout for quick read-only queries (rev-parse / status / log / diff). */\nconst QUICK_TIMEOUT_MS = 2_000\n\n/** Timeout for structural operations (worktree add/remove, merge, branch). */\nconst HEAVY_TIMEOUT_MS = 15_000\n\n/** Directory under a workspace where task worktrees live. */\nexport const WORKTREE_DIR = '.dsh-worktrees'\n\n/** Evidence caps: commits kept per execution record (newest first). */\nexport const MAX_COMMIT_EVIDENCE = 50\n\n/** Evidence caps: uncommitted-change lines kept per execution record. */\nexport const MAX_DIRTY_EVIDENCE = 100\n\n/** Diff viewer caps (0.4.0): raw text kept per view. */\nexport const MAX_DIFF_BYTES = 128 * 1024\n\n/** Diff viewer caps: lines kept per view. */\nexport const MAX_DIFF_LINES = 2_000\n\n/** One capped, read-only diff view (diff viewer, 0.4.0). */\nexport interface DiffResult {\n text: string\n truncated: boolean\n}\n\n/** Cap one diff payload by bytes and lines (in order, marking truncation). */\nfunction capDiff(out: string): DiffResult {\n let text = out\n let truncated = false\n if (text.length > MAX_DIFF_BYTES) {\n text = text.slice(0, MAX_DIFF_BYTES)\n truncated = true\n }\n const lines = text.split('\\n')\n if (lines.length > MAX_DIFF_LINES) {\n text = lines.slice(0, MAX_DIFF_LINES).join('\\n')\n truncated = true\n }\n return { text, truncated }\n}\n\n/** A plausible git object hash (defense against option injection). */\nfunction isHash(hash: string): boolean {\n return /^[0-9a-f]{4,64}$/i.test(hash)\n}\n\n/** Result of one underlying exec: `ok` is exit-0, output never null. */\nexport interface ExecResult { ok: boolean; stdout: string; stderr: string }\n\n/** The injectable exec layer: run `git <args>` under a cwd with a timeout. */\nexport type ExecFn = (args: string[], options: { cwd?: string; timeout?: number }) => Promise<ExecResult>\n\n/** Facts needed to open an isolated execution. */\nexport interface WorktreeInfo {\n /** Absolute worktree path (the session's cwd). */\n path: string\n /** The task branch checked out there. */\n branch: string\n /** Baseline for evidence collection: main HEAD (fresh) or worktree HEAD (reuse). */\n baseCommit: string\n /** True when an existing live worktree was kept as-is (续跑). */\n reused?: boolean\n}\n\n/** Settlement facts collected from a worktree (partial on best-effort basis). */\nexport interface SettlementFacts {\n headCommit?: string\n commits: CommitInfo[]\n /** Total commits before capping (equals commits.length when under the cap). */\n commitsTotal: number\n dirtyFiles: string[]\n /** Total uncommitted lines before capping. */\n dirtyFilesTotal: number\n diffStat?: string\n changedFiles: number\n}\n\n/** The narrow git face the rest of the plugin depends on. */\nexport interface GitFace {\n /** Whether `root` sits inside a usable git work tree (fail-soft → false). */\n detect(root: string): Promise<boolean>\n /** Whether a usable git binary answers at all (distinguishes 未装 git vs 非 git 仓库). */\n binaryAvailable(): Promise<boolean>\n /**\n * Ensure a worktree at `path` on `branch`. Default mode `'fresh'` resets to\n * the main worktree's current HEAD (每次全新); mode `'reuse'` keeps a live\n * worktree exactly as-is (续跑 — agent's commits and uncommitted changes\n * survive) and falls back to a fresh creation when none is alive. Resolves\n * undefined on any failure — callers degrade to the original directory.\n */\n prepareWorktree(root: string, path: string, branch: string, mode?: 'fresh' | 'reuse'): Promise<WorktreeInfo | undefined>\n /** Collect settlement facts (never throws; missing pieces stay unset). */\n collect(worktreePath: string, baseCommit: string): Promise<SettlementFacts>\n /** Merge `branch` into the main worktree (`--no-ff`); THROWS with a readable reason. */\n merge(root: string, branch: string): Promise<void>\n /** Whether `branch` is already an ancestor of HEAD (a merge would be a no-op). */\n isAncestor(root: string, branch: string): Promise<boolean>\n /**\n * Remove a worktree. Resolves 'removed' on success, 'unregistered' when git\n * no longer knows the path (an orphaned directory). THROWS when it still\n * has uncommitted changes, or on any other git failure (readable reason).\n */\n removeWorktree(root: string, worktreePath: string): Promise<'removed' | 'unregistered'>\n /** Delete a branch; THROWS (e.g. still checked out in a worktree). */\n deleteBranch(root: string, branch: string): Promise<void>\n /**\n * Show one commit (message + patch) — diff viewer (0.4.0). Fail-soft:\n * undefined on any git failure; the payload is capped.\n */\n showCommit(cwd: string, hash: string): Promise<DiffResult | undefined>\n /**\n * Show the diff of one path — diff viewer (0.4.0). Without `baseCommit`:\n * the working-tree view (staged + unstaged vs HEAD, e.g. uncommitted\n * changes in a live worktree); with `baseCommit`: the range\n * base..HEAD restricted to the path. Fail-soft: undefined on failure.\n */\n showPathDiff(cwd: string, path: string, baseCommit?: string): Promise<DiffResult | undefined>\n}\n\n/**\n * Build the task branch name `task/<标题>+<taskId>` (plan §9 拍板).\n *\n * Title sanitizing: whitespace runs collapse to `-`; git-illegal characters\n * (`~ ^ : ? * [ \\ / @ { }` and friends) are stripped; `..` collapses; the\n * segment is trimmed of leading/trailing `.-` and truncated to ~20 code\n * points; an empty result falls back to the bare `task/<taskId>`.\n * @param title - the task title (already normalized 1..200 chars).\n * @param taskId - the task id (stable suffix).\n * @returns the branch name.\n */\nexport function sanitizeBranchName(title: string, taskId: string): string {\n const segment = title.trim()\n .replace(/\\s+/g, '-')\n .replace(/[/\\\\~^:?*[\\]@{}\"'<>|#%&;$!`'=,;()]+/g, '')\n .replace(/\\.\\.+/g, '.')\n .replace(/^[-.\\s]+|[-.\\s]+$/g, '')\n const head = Array.from(segment).slice(0, 20).join('').replace(/^[-.]+|[-.]+$/g, '')\n return head.length === 0 ? `task/${taskId}` : `task/${head}+${taskId}`\n}\n\n/**\n * The canonical worktree path of a task inside its workspace (forward\n * slashes). R4②: the id is validated HERE so every present and future call\n * site is covered — a traversal-shaped id must never ride into a filesystem\n * path (the cleanup/purge flows `rm -rf` what this returns).\n */\nexport function worktreePathOf(workspacePath: string, taskId: string): string {\n if (!isValidTaskId(taskId)) {\n throw new Error(`Error: invalid_input: illegal task id ${JSON.stringify(taskId.slice(0, 40))}`)\n }\n const root = workspacePath.replace(/[\\\\/]+$/, '').replaceAll('\\\\', '/')\n return `${root}/${WORKTREE_DIR}/${taskId}`\n}\n\n/** Real exec layer over child_process.execFile (windowsHide, timeout, maxBuffer). */\nconst realExec: ExecFn = (args, options) => new Promise(resolve => {\n void (async () => {\n const { execFile } = await import('node:child_process')\n execFile('git', args, {\n cwd: options.cwd,\n timeout: options.timeout ?? QUICK_TIMEOUT_MS,\n windowsHide: true,\n maxBuffer: 4 * 1024 * 1024,\n encoding: 'utf8',\n }, (error, stdout, stderr) => {\n resolve({ ok: error === null, stdout: String(stdout ?? ''), stderr: String(stderr ?? '') })\n })\n })().catch(() => resolve({ ok: false, stdout: '', stderr: 'exec unavailable' }))\n})\n\n/**\n * Build a {@link GitFace} over an injectable exec layer.\n * @param exec - the exec function (real `git` when omitted).\n */\nexport function createGitFace(exec: ExecFn = realExec): GitFace {\n const quick = (args: string[], cwd?: string): Promise<ExecResult> => exec(args, { cwd, timeout: QUICK_TIMEOUT_MS })\n const heavy = (args: string[], cwd?: string): Promise<ExecResult> => exec(args, { cwd, timeout: HEAVY_TIMEOUT_MS })\n\n // Per-root mutex (0.3.1): structural git ops on the SAME repository run one\n // at a time — concurrent isolated executions must not race on git's locks.\n const locks = new Map<string, Promise<unknown>>()\n const withRootLock = <T>(root: string, fn: () => Promise<T>): Promise<T> => {\n const prev = locks.get(root) ?? Promise.resolve()\n const next = prev.then(fn, fn)\n locks.set(root, next.catch(() => { /* the chain never blocks later ops */ }))\n return next\n }\n\n return {\n async detect(root) {\n const r = await quick(['rev-parse', '--is-inside-work-tree'], root)\n return r.ok && r.stdout.trim() === 'true'\n },\n\n async binaryAvailable() {\n const r = await quick(['--version'])\n return r.ok && r.stdout.startsWith('git version')\n },\n\n prepareWorktree: (root, path, branch, mode = 'fresh') => withRootLock(root, async () => {\n // 续跑: a live worktree at the path is kept EXACTLY as-is — the agent's\n // commits and uncommitted changes survive; the baseline becomes the\n // worktree's own HEAD so evidence covers only the new run.\n if (mode === 'reuse') {\n const wtHead = await quick(['rev-parse', 'HEAD'], path)\n // S14: a readable HEAD is not enough — the worktree must be on OUR\n // branch, otherwise a user-created repo at the path would be silently\n // taken over. Foreign or detached → fall through to fresh preparation.\n const wtBranch = wtHead.ok ? await quick(['rev-parse', '--abbrev-ref', 'HEAD'], path) : undefined\n if (wtHead.ok && wtHead.stdout.trim().length > 0\n && wtBranch !== undefined && wtBranch.ok && wtBranch.stdout.trim() === branch) {\n return { path, branch, baseCommit: wtHead.stdout.trim(), reused: true }\n }\n // No live worktree on our branch → fall through to a fresh preparation.\n }\n\n // Baseline: the main worktree's current HEAD (also validates the repo).\n const head = await quick(['rev-parse', 'HEAD'], root)\n if (!head.ok) return undefined\n const baseCommit = head.stdout.trim()\n\n const exists = await quick(['show-ref', '--verify', `refs/heads/${branch}`], root)\n if (exists.ok) {\n // Reuse the fixed branch name, but guarantee a FRESH baseline: drop\n // any stale worktree at the path, move the branch to the current\n // HEAD, then check the branch out again (每次全新,复用仅作选项保留).\n await heavy(['worktree', 'remove', '--force', path], root)\n await heavy(['worktree', 'prune'], root)\n const moved = await heavy(['branch', '-f', branch, 'HEAD'], root)\n if (!moved.ok) return undefined\n const added = await heavy(['worktree', 'add', path, branch], root)\n if (!added.ok) return undefined\n } else {\n const added = await heavy(['worktree', 'add', '-b', branch, path], root)\n if (!added.ok) return undefined\n }\n return { path, branch, baseCommit }\n }),\n\n async collect(worktreePath, baseCommit) {\n const facts: SettlementFacts = { commits: [], commitsTotal: 0, dirtyFiles: [], dirtyFilesTotal: 0, changedFiles: 0 }\n const range = `${baseCommit}..HEAD`\n\n const head = await quick(['rev-parse', 'HEAD'], worktreePath)\n if (head.ok) facts.headCommit = head.stdout.trim()\n\n const log = await quick(['log', '--pretty=format:%h %s', range], worktreePath)\n if (log.ok) {\n const commits = log.stdout.split('\\n')\n .map(line => line.trim())\n .filter(line => line.length > 0)\n .map(line => {\n const space = line.indexOf(' ')\n return space === -1\n ? { hash: line, subject: '' }\n : { hash: line.slice(0, space), subject: line.slice(space + 1) }\n })\n // Evidence caps (0.3.1): the ledger is rewritten whole on every\n // mutation — cap what a huge branch/status dump can add to it.\n facts.commitsTotal = commits.length\n facts.commits = commits.slice(0, MAX_COMMIT_EVIDENCE)\n }\n\n const status = await quick(['status', '--porcelain'], worktreePath)\n if (status.ok) {\n const dirty = status.stdout.split('\\n').map(l => l.trim()).filter(l => l.length > 0)\n facts.dirtyFilesTotal = dirty.length\n facts.dirtyFiles = dirty.slice(0, MAX_DIRTY_EVIDENCE)\n }\n\n const shortstat = await quick(['diff', '--shortstat', range], worktreePath)\n if (shortstat.ok && shortstat.stdout.trim().length > 0) facts.diffStat = shortstat.stdout.trim()\n\n const names = await quick(['diff', '--name-only', range], worktreePath)\n if (names.ok) facts.changedFiles = names.stdout.split('\\n').filter(l => l.trim().length > 0).length\n\n return facts\n },\n\n merge: (root, branch) => withRootLock(root, async () => {\n // Main-clean check. The plugin's own worktree directory\n // (<root>/.dsh-worktrees) shows up as untracked noise and is EXEMPT —\n // otherwise merging would be impossible without gitignoring it first.\n const status = await quick(['status', '--porcelain'], root)\n if (status.ok) {\n const dirtyLines = status.stdout.split('\\n')\n .map(l => l.trim())\n .filter(l => {\n if (l.length === 0) return false\n const path = l.slice(3)\n return path !== WORKTREE_DIR && !path.startsWith(`${WORKTREE_DIR}/`)\n })\n if (dirtyLines.length > 0) {\n // Machine-readable tag: callers classify without parsing zh-CN text.\n throw Object.assign(new Error(`主工作区有 ${dirtyLines.length} 处未提交修改,请先提交或暂存后再合并`), { code: 'dirty-tree' })\n }\n }\n const merged = await heavy(['merge', '--no-ff', '--no-edit', branch], root)\n if (!merged.ok) {\n // Roll the half-finished merge back so the main worktree stays usable;\n // report the ORIGINAL failure verbatim (不自动解决冲突).\n await heavy(['merge', '--abort'], root)\n throw new Error(`合并失败:${merged.stderr.trim().slice(0, 300)}`)\n }\n }),\n\n async isAncestor(root, branch) {\n // exit 0 = branch is an ancestor of (or equal to) HEAD → merge no-op.\n const r = await quick(['merge-base', '--is-ancestor', branch, 'HEAD'], root)\n return r.ok\n },\n\n removeWorktree: (root, worktreePath) => withRootLock(root, async (): Promise<'removed' | 'unregistered'> => {\n const status = await quick(['status', '--porcelain'], worktreePath)\n if (status.ok && status.stdout.trim().length > 0) {\n const lines = status.stdout.split('\\n').map(l => l.trim()).filter(l => l.length > 0)\n // Machine-readable tag: purge flows classify without parsing zh-CN text.\n throw Object.assign(new Error(`worktree 有 ${lines.length} 处未提交修改,拒绝删除:\\n${lines.slice(0, 10).join('\\n')}`), { code: 'dirty-worktree' })\n }\n const removed = await heavy(['worktree', 'remove', worktreePath], root)\n if (removed.ok) return 'removed'\n // S3: classify the failure WITHOUT parsing git's (localizable) stderr —\n // a path absent from `worktree list` is an unregistered leftover, not\n // an error the caller should relay verbatim.\n const list = await quick(['worktree', 'list', '--porcelain'], root)\n const registered = list.ok && list.stdout.split('\\n')\n .some(l => l.startsWith('worktree ')\n && resolve(l.slice('worktree '.length).trim()).toLowerCase() === resolve(worktreePath).toLowerCase())\n if (!registered) return 'unregistered'\n throw new Error(`删除 worktree 失败:${(removed.stderr.trim() || removed.stdout.trim()).slice(0, 300)}`)\n }),\n\n deleteBranch: (root, branch) => withRootLock(root, async () => {\n const deleted = await heavy(['branch', '-D', branch], root)\n if (!deleted.ok) throw new Error(`删除分支失败:${deleted.stderr.trim().slice(0, 300)}`)\n }),\n\n async showCommit(cwd, hash) {\n if (!isHash(hash)) return undefined\n const r = await quick(['show', '--no-color', '--format=medium', hash], cwd)\n if (!r.ok || r.stdout.trim().length === 0) return undefined\n return capDiff(r.stdout)\n },\n\n async showPathDiff(cwd, path, baseCommit) {\n const target = path.trim()\n if (target.length === 0) return undefined\n if (baseCommit !== undefined && isHash(baseCommit)) {\n const r = await quick(['diff', '--no-color', `${baseCommit}..HEAD`, '--', target], cwd)\n if (!r.ok) return undefined\n if (r.stdout.trim().length === 0) return { text: '(该文件无差异)', truncated: false }\n return capDiff(r.stdout)\n }\n // Working-tree view: staged + unstaged vs HEAD.\n const r = await quick(['diff', '--no-color', 'HEAD', '--', target], cwd)\n if (r.ok && r.stdout.trim().length > 0) return capDiff(r.stdout)\n // Untracked files never appear in `git diff` — detect one and synthesize\n // its new-file patch via --no-index (which exits 1 on differences, so\n // its stdout is trusted whenever it carries a diff header).\n const st = await quick(['status', '--porcelain', '--', target], cwd)\n if (r.ok && st.ok && st.stdout.trim().startsWith('??')) {\n const ni = await exec(['diff', '--no-color', '--no-index', '--', '/dev/null', target], { cwd, timeout: QUICK_TIMEOUT_MS })\n if (ni.stdout.includes('diff --git')) return capDiff(ni.stdout)\n return { text: `(未跟踪新文件:${target})`, truncated: false }\n }\n if (!r.ok) return undefined\n return { text: '(该文件无差异)', truncated: false }\n },\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"git.js","names":[],"sources":["../../src/host/git.ts"],"sourcesContent":["/**\n * Host git face (0.3.0): the ONLY place dsh-taskboard shells out to git.\n * 0.3.1: per-repo serialization of structural operations, binary probing,\n * no-op merge detection, worktree REUSE mode, and evidence size caps.\n *\n * Design invariants (plan §3.4/§3.5):\n * - NARROW interface: detect / binaryAvailable / prepareWorktree / collect /\n * merge / isAncestor / removeWorktree / deleteBranch — nothing else leaks\n * into the plugin.\n * - FAIL-SOFT: every call has a timeout and resolves to a benign result\n * (false / undefined / empty facts) on ANY git failure — a missing git,\n * a locked worktree, or a damaged repo degrades execution to the original\n * directory and NEVER fails the ledger or the run pipeline. Only the\n * explicit user actions (merge / remove / deleteBranch) throw, with a\n * readable message the GUI surfaces as-is.\n * - SERIALIZED structural ops: concurrent isolated executions on the SAME\n * repository would race on git's index/worktree locks, so every structural\n * operation (prepareWorktree / merge / removeWorktree / deleteBranch) runs\n * inside a per-root in-process mutex. Read-only collects stay concurrent.\n * - INJECTABLE runner: the exec layer is a single function so unit tests\n * script every path without a real git.\n *\n * @module dsh-taskboard/host/git\n */\nimport { resolve } from 'node:path'\nimport { isValidTaskId, type CommitInfo } from '../shared/protocol.ts'\n\n/** Timeout for quick read-only queries (rev-parse / status / log / diff). */\nconst QUICK_TIMEOUT_MS = 2_000\n\n/** Timeout for structural operations (worktree add/remove, merge, branch). */\nconst HEAVY_TIMEOUT_MS = 15_000\n\n/** Directory under a workspace where task worktrees live. */\nexport const WORKTREE_DIR = '.dsh-worktrees'\n\n/**\n * The path segment of one `status --porcelain` line. Shape-aware: the RAW\n * line is `XY path` (path at index 3) while the plugin's trimmed evidence\n * lines collapse a leading-space status to `X path` (path at index 2) —\n * slicing a fixed 3 misparses exactly the gitlink/unstaged shapes a\n * multi-repo mirror produces (` M sub-repo`), 0.6.3 review fix.\n */\nexport function statusLinePath(line: string): string {\n if (line.length >= 3 && line[2] === ' ') return line.slice(3)\n if (line.length >= 2 && line[1] === ' ') return line.slice(2)\n return line\n}\n\n/**\n * Whether a `status --porcelain` line targets one of `rels` (a repo-relative\n * path) or anything under it. Porcelain prints untracked directories with a\n * trailing slash (`?? sub/`), so all three shapes match. Empty rels never\n * match (0.6.3 review fix).\n */\nexport function statusLineUnder(line: string, rels: readonly string[]): boolean {\n const p = statusLinePath(line)\n return rels.some(rel => rel.length > 0 && (p === rel || p === rel + '/' || p.startsWith(rel + '/')))\n}\n\n/** Evidence caps: commits kept per execution record (newest first). */\nexport const MAX_COMMIT_EVIDENCE = 50\n\n/** Evidence caps: uncommitted-change lines kept per execution record. */\nexport const MAX_DIRTY_EVIDENCE = 100\n\n/** Diff viewer caps (0.4.0): raw text kept per view. */\nexport const MAX_DIFF_BYTES = 128 * 1024\n\n/** Diff viewer caps: lines kept per view. */\nexport const MAX_DIFF_LINES = 2_000\n\n/** One capped, read-only diff view (diff viewer, 0.4.0). */\nexport interface DiffResult {\n text: string\n truncated: boolean\n}\n\n/** Cap one diff payload by bytes and lines (in order, marking truncation). */\nfunction capDiff(out: string): DiffResult {\n let text = out\n let truncated = false\n if (text.length > MAX_DIFF_BYTES) {\n text = text.slice(0, MAX_DIFF_BYTES)\n truncated = true\n }\n const lines = text.split('\\n')\n if (lines.length > MAX_DIFF_LINES) {\n text = lines.slice(0, MAX_DIFF_LINES).join('\\n')\n truncated = true\n }\n return { text, truncated }\n}\n\n/** A plausible git object hash (defense against option injection). */\nfunction isHash(hash: string): boolean {\n return /^[0-9a-f]{4,64}$/i.test(hash)\n}\n\n/** Result of one underlying exec: `ok` is exit-0, output never null. */\nexport interface ExecResult { ok: boolean; stdout: string; stderr: string }\n\n/** The injectable exec layer: run `git <args>` under a cwd with a timeout. */\nexport type ExecFn = (args: string[], options: { cwd?: string; timeout?: number }) => Promise<ExecResult>\n\n/** Facts needed to open an isolated execution. */\nexport interface WorktreeInfo {\n /** Absolute worktree path (the session's cwd). */\n path: string\n /** The task branch checked out there. */\n branch: string\n /** Baseline for evidence collection: main HEAD (fresh) or worktree HEAD (reuse). */\n baseCommit: string\n /** True when an existing live worktree was kept as-is (续跑). */\n reused?: boolean\n}\n\n/** Settlement facts collected from a worktree (partial on best-effort basis). */\nexport interface SettlementFacts {\n headCommit?: string\n commits: CommitInfo[]\n /** Total commits before capping (equals commits.length when under the cap). */\n commitsTotal: number\n dirtyFiles: string[]\n /** Total uncommitted lines before capping. */\n dirtyFilesTotal: number\n diffStat?: string\n changedFiles: number\n}\n\n/** The narrow git face the rest of the plugin depends on. */\nexport interface GitFace {\n /** Whether `root` sits inside a usable git work tree (fail-soft → false). */\n detect(root: string): Promise<boolean>\n /** Whether a usable git binary answers at all (distinguishes 未装 git vs 非 git 仓库). */\n binaryAvailable(): Promise<boolean>\n /**\n * Ensure a worktree at `path` on `branch`. Default mode `'fresh'` resets to\n * the main worktree's current HEAD (每次全新); mode `'reuse'` keeps a live\n * worktree exactly as-is (续跑 — agent's commits and uncommitted changes\n * survive) and falls back to a fresh creation when none is alive. Resolves\n * undefined on any failure — callers degrade to the original directory.\n */\n prepareWorktree(root: string, path: string, branch: string, mode?: 'fresh' | 'reuse'): Promise<WorktreeInfo | undefined>\n /**\n * Collect settlement facts (never throws; missing pieces stay unset).\n * `excludeRelPaths` drops `status --porcelain` lines targeting those\n * repo-relative paths (or anything under them) — the mirror's NESTED repo\n * worktrees read as untracked noise in the root worktree's status and are\n * not this repo's uncommitted changes (0.6.3 review fix).\n */\n collect(worktreePath: string, baseCommit: string, excludeRelPaths?: readonly string[]): Promise<SettlementFacts>\n /**\n * Uncommitted-change lines of a working tree ([] = clean; undefined on git\n * failure). Mirror removal pre-checks EVERY repo worktree before deleting\n * any, so one dirty repo refuses the whole mirror (0.6.3).\n */\n dirtyLines(cwd: string): Promise<string[] | undefined>\n /**\n * Merge `branch` into the main worktree (`--no-ff`); THROWS with a readable\n * reason. `exemptRelPaths` extends the main-clean exemption beyond\n * `.dsh-worktrees`: uncommitted-shape noise under those repo-relative paths\n * (parallel repos are self-governing — their content AND their gitlink\n * entries don't gate the root repo's merge, 0.6.3 review fix).\n */\n merge(root: string, branch: string, exemptRelPaths?: readonly string[]): Promise<void>\n /** Whether `branch` is already an ancestor of HEAD (a merge would be a no-op). */\n isAncestor(root: string, branch: string): Promise<boolean>\n /**\n * Remove a worktree. Resolves 'removed' on success, 'unregistered' when git\n * no longer knows the path (an orphaned directory). THROWS when it still\n * has uncommitted changes, or on any other git failure (readable reason).\n * `opts.exempt` drops mirror-structural noise from the dirty check and\n * `opts.force` lets `git worktree remove` accept it — for callers that have\n * ALREADY aggregated the real-dirty check themselves (the mirror removal:\n * noise-exempt clean → force is safe; real dirt never gets here).\n */\n removeWorktree(root: string, worktreePath: string, opts?: { exempt?: readonly string[]; force?: boolean }): Promise<'removed' | 'unregistered'>\n /** Delete a branch; THROWS (e.g. still checked out in a worktree). */\n deleteBranch(root: string, branch: string): Promise<void>\n /**\n * Show one commit (message + patch) — diff viewer (0.4.0). Fail-soft:\n * undefined on any git failure; the payload is capped.\n */\n showCommit(cwd: string, hash: string): Promise<DiffResult | undefined>\n /**\n * Show the diff of one path — diff viewer (0.4.0). Without `baseCommit`:\n * the working-tree view (staged + unstaged vs HEAD, e.g. uncommitted\n * changes in a live worktree); with `baseCommit`: the range\n * base..HEAD restricted to the path. Fail-soft: undefined on failure.\n */\n showPathDiff(cwd: string, path: string, baseCommit?: string): Promise<DiffResult | undefined>\n}\n\n/**\n * Build the task branch name `task/<标题>+<taskId>` (plan §9 拍板).\n *\n * Title sanitizing: whitespace runs collapse to `-`; git-illegal characters\n * (`~ ^ : ? * [ \\ / @ { }` and friends) are stripped; `..` collapses; the\n * segment is trimmed of leading/trailing `.-` and truncated to ~20 code\n * points; an empty result falls back to the bare `task/<taskId>`.\n * @param title - the task title (already normalized 1..200 chars).\n * @param taskId - the task id (stable suffix).\n * @returns the branch name.\n */\nexport function sanitizeBranchName(title: string, taskId: string): string {\n const segment = title.trim()\n .replace(/\\s+/g, '-')\n .replace(/[/\\\\~^:?*[\\]@{}\"'<>|#%&;$!`'=,;()]+/g, '')\n .replace(/\\.\\.+/g, '.')\n .replace(/^[-.\\s]+|[-.\\s]+$/g, '')\n const head = Array.from(segment).slice(0, 20).join('').replace(/^[-.]+|[-.]+$/g, '')\n return head.length === 0 ? `task/${taskId}` : `task/${head}+${taskId}`\n}\n\n/**\n * The canonical worktree path of a task inside its workspace (forward\n * slashes). R4②: the id is validated HERE so every present and future call\n * site is covered — a traversal-shaped id must never ride into a filesystem\n * path (the cleanup/purge flows `rm -rf` what this returns).\n */\nexport function worktreePathOf(workspacePath: string, taskId: string): string {\n if (!isValidTaskId(taskId)) {\n throw new Error(`Error: invalid_input: illegal task id ${JSON.stringify(taskId.slice(0, 40))}`)\n }\n const root = workspacePath.replace(/[\\\\/]+$/, '').replaceAll('\\\\', '/')\n return `${root}/${WORKTREE_DIR}/${taskId}`\n}\n\n/** Real exec layer over child_process.execFile (windowsHide, timeout, maxBuffer). */\nconst realExec: ExecFn = (args, options) => new Promise(resolve => {\n void (async () => {\n const { execFile } = await import('node:child_process')\n execFile('git', args, {\n cwd: options.cwd,\n timeout: options.timeout ?? QUICK_TIMEOUT_MS,\n windowsHide: true,\n maxBuffer: 4 * 1024 * 1024,\n encoding: 'utf8',\n }, (error, stdout, stderr) => {\n resolve({ ok: error === null, stdout: String(stdout ?? ''), stderr: String(stderr ?? '') })\n })\n })().catch(() => resolve({ ok: false, stdout: '', stderr: 'exec unavailable' }))\n})\n\n/**\n * Build a {@link GitFace} over an injectable exec layer.\n * @param exec - the exec function (real `git` when omitted).\n */\nexport function createGitFace(exec: ExecFn = realExec): GitFace {\n const quick = (args: string[], cwd?: string): Promise<ExecResult> => exec(args, { cwd, timeout: QUICK_TIMEOUT_MS })\n const heavy = (args: string[], cwd?: string): Promise<ExecResult> => exec(args, { cwd, timeout: HEAVY_TIMEOUT_MS })\n\n // Per-root mutex (0.3.1): structural git ops on the SAME repository run one\n // at a time — concurrent isolated executions must not race on git's locks.\n const locks = new Map<string, Promise<unknown>>()\n const withRootLock = <T>(root: string, fn: () => Promise<T>): Promise<T> => {\n const prev = locks.get(root) ?? Promise.resolve()\n const next = prev.then(fn, fn)\n locks.set(root, next.catch(() => { /* the chain never blocks later ops */ }))\n return next\n }\n\n return {\n async detect(root) {\n const r = await quick(['rev-parse', '--is-inside-work-tree'], root)\n return r.ok && r.stdout.trim() === 'true'\n },\n\n async binaryAvailable() {\n const r = await quick(['--version'])\n return r.ok && r.stdout.startsWith('git version')\n },\n\n prepareWorktree: (root, path, branch, mode = 'fresh') => withRootLock(root, async () => {\n // 续跑: a live worktree at the path is kept EXACTLY as-is — the agent's\n // commits and uncommitted changes survive; the baseline becomes the\n // worktree's own HEAD so evidence covers only the new run.\n if (mode === 'reuse') {\n const wtHead = await quick(['rev-parse', 'HEAD'], path)\n // S14: a readable HEAD is not enough — the worktree must be on OUR\n // branch, otherwise a user-created repo at the path would be silently\n // taken over. Foreign or detached → fall through to fresh preparation.\n const wtBranch = wtHead.ok ? await quick(['rev-parse', '--abbrev-ref', 'HEAD'], path) : undefined\n if (wtHead.ok && wtHead.stdout.trim().length > 0\n && wtBranch !== undefined && wtBranch.ok && wtBranch.stdout.trim() === branch) {\n return { path, branch, baseCommit: wtHead.stdout.trim(), reused: true }\n }\n // No live worktree on our branch → fall through to a fresh preparation.\n }\n\n // Baseline: the main worktree's current HEAD (also validates the repo).\n const head = await quick(['rev-parse', 'HEAD'], root)\n if (!head.ok) return undefined\n const baseCommit = head.stdout.trim()\n\n const exists = await quick(['show-ref', '--verify', `refs/heads/${branch}`], root)\n if (exists.ok) {\n // Reuse the fixed branch name, but guarantee a FRESH baseline: drop\n // any stale worktree at the path, move the branch to the current\n // HEAD, then check the branch out again (每次全新,复用仅作选项保留).\n await heavy(['worktree', 'remove', '--force', path], root)\n await heavy(['worktree', 'prune'], root)\n const moved = await heavy(['branch', '-f', branch, 'HEAD'], root)\n if (!moved.ok) return undefined\n const added = await heavy(['worktree', 'add', path, branch], root)\n if (!added.ok) return undefined\n } else {\n const added = await heavy(['worktree', 'add', '-b', branch, path], root)\n if (!added.ok) return undefined\n }\n return { path, branch, baseCommit }\n }),\n\n async collect(worktreePath, baseCommit, excludeRelPaths) {\n const facts: SettlementFacts = { commits: [], commitsTotal: 0, dirtyFiles: [], dirtyFilesTotal: 0, changedFiles: 0 }\n const range = `${baseCommit}..HEAD`\n\n const head = await quick(['rev-parse', 'HEAD'], worktreePath)\n if (head.ok) facts.headCommit = head.stdout.trim()\n\n const log = await quick(['log', '--pretty=format:%h %s', range], worktreePath)\n if (log.ok) {\n const commits = log.stdout.split('\\n')\n .map(line => line.trim())\n .filter(line => line.length > 0)\n .map(line => {\n const space = line.indexOf(' ')\n return space === -1\n ? { hash: line, subject: '' }\n : { hash: line.slice(0, space), subject: line.slice(space + 1) }\n })\n // Evidence caps (0.3.1): the ledger is rewritten whole on every\n // mutation — cap what a huge branch/status dump can add to it.\n facts.commitsTotal = commits.length\n facts.commits = commits.slice(0, MAX_COMMIT_EVIDENCE)\n }\n\n const status = await quick(['status', '--porcelain'], worktreePath)\n if (status.ok) {\n // excludeRelPaths drops the mirror's nested repo worktrees — they\n // read as untracked noise / gitlink drift here, not as this repo's\n // uncommitted changes. Excluded on the RAW line (path extraction is\n // shape-aware); the stored evidence keeps its trimmed 0.3.x shape.\n const dirty = status.stdout.split('\\n')\n .filter(l => l.trim().length > 0)\n .filter(l => !statusLineUnder(l, excludeRelPaths ?? []))\n .map(l => l.trim())\n facts.dirtyFilesTotal = dirty.length\n facts.dirtyFiles = dirty.slice(0, MAX_DIRTY_EVIDENCE)\n }\n\n const shortstat = await quick(['diff', '--shortstat', range], worktreePath)\n if (shortstat.ok && shortstat.stdout.trim().length > 0) facts.diffStat = shortstat.stdout.trim()\n\n const names = await quick(['diff', '--name-only', range], worktreePath)\n if (names.ok) facts.changedFiles = names.stdout.split('\\n').filter(l => l.trim().length > 0).length\n\n return facts\n },\n\n async dirtyLines(cwd) {\n const status = await quick(['status', '--porcelain'], cwd)\n if (!status.ok) return undefined\n return status.stdout.split('\\n').map(l => l.trim()).filter(l => l.length > 0)\n },\n\n merge: (root, branch, exemptRelPaths) => withRootLock(root, async () => {\n // Main-clean check. The plugin's own worktree directory\n // (<root>/.dsh-worktrees) shows up as untracked noise and is EXEMPT —\n // otherwise merging would be impossible without gitignoring it first.\n const status = await quick(['status', '--porcelain'], root)\n if (status.ok) {\n const dirtyLines = status.stdout.split('\\n')\n .filter(l => l.trim().length > 0)\n .filter(l => !statusLineUnder(l, [WORKTREE_DIR, ...(exemptRelPaths ?? [])]))\n .map(l => l.trim())\n if (dirtyLines.length > 0) {\n // Machine-readable tag: callers classify without parsing zh-CN text.\n throw Object.assign(new Error(`主工作区有 ${dirtyLines.length} 处未提交修改,请先提交或暂存后再合并`), { code: 'dirty-tree' })\n }\n }\n const merged = await heavy(['merge', '--no-ff', '--no-edit', branch], root)\n if (!merged.ok) {\n // Roll the half-finished merge back so the main worktree stays usable;\n // report the ORIGINAL failure verbatim (不自动解决冲突).\n await heavy(['merge', '--abort'], root)\n throw new Error(`合并失败:${merged.stderr.trim().slice(0, 300)}`)\n }\n }),\n\n async isAncestor(root, branch) {\n // exit 0 = branch is an ancestor of (or equal to) HEAD → merge no-op.\n const r = await quick(['merge-base', '--is-ancestor', branch, 'HEAD'], root)\n return r.ok\n },\n\n removeWorktree: (root, worktreePath, opts) => withRootLock(root, async (): Promise<'removed' | 'unregistered'> => {\n const status = await quick(['status', '--porcelain'], worktreePath)\n if (status.ok && status.stdout.trim().length > 0) {\n // opts.exempt drops mirror-structural noise (nested child worktrees /\n // gitlink drift) — the caller's aggregated check already refused real\n // dirt before reaching here.\n const lines = status.stdout.split('\\n')\n .filter(l => l.trim().length > 0)\n .filter(l => !statusLineUnder(l, opts?.exempt ?? []))\n .map(l => l.trim())\n if (lines.length > 0) {\n // Machine-readable tag: purge flows classify without parsing zh-CN text.\n throw Object.assign(new Error(`worktree 有 ${lines.length} 处未提交修改,拒绝删除:\\n${lines.slice(0, 10).join('\\n')}`), { code: 'dirty-worktree' })\n }\n }\n const removed = await heavy(opts?.force === true\n ? ['worktree', 'remove', '--force', worktreePath]\n : ['worktree', 'remove', worktreePath], root)\n if (removed.ok) return 'removed'\n // S3: classify the failure WITHOUT parsing git's (localizable) stderr —\n // a path absent from `worktree list` is an unregistered leftover, not\n // an error the caller should relay verbatim.\n const list = await quick(['worktree', 'list', '--porcelain'], root)\n const registered = list.ok && list.stdout.split('\\n')\n .some(l => l.startsWith('worktree ')\n && resolve(l.slice('worktree '.length).trim()).toLowerCase() === resolve(worktreePath).toLowerCase())\n if (!registered) return 'unregistered'\n throw new Error(`删除 worktree 失败:${(removed.stderr.trim() || removed.stdout.trim()).slice(0, 300)}`)\n }),\n\n deleteBranch: (root, branch) => withRootLock(root, async () => {\n const deleted = await heavy(['branch', '-D', branch], root)\n if (!deleted.ok) throw new Error(`删除分支失败:${deleted.stderr.trim().slice(0, 300)}`)\n }),\n\n async showCommit(cwd, hash) {\n if (!isHash(hash)) return undefined\n const r = await quick(['show', '--no-color', '--format=medium', hash], cwd)\n if (!r.ok || r.stdout.trim().length === 0) return undefined\n return capDiff(r.stdout)\n },\n\n async showPathDiff(cwd, path, baseCommit) {\n const target = path.trim()\n if (target.length === 0) return undefined\n if (baseCommit !== undefined && isHash(baseCommit)) {\n const r = await quick(['diff', '--no-color', `${baseCommit}..HEAD`, '--', target], cwd)\n if (!r.ok) return undefined\n if (r.stdout.trim().length === 0) return { text: '(该文件无差异)', truncated: false }\n return capDiff(r.stdout)\n }\n // Working-tree view: staged + unstaged vs HEAD.\n const r = await quick(['diff', '--no-color', 'HEAD', '--', target], cwd)\n if (r.ok && r.stdout.trim().length > 0) return capDiff(r.stdout)\n // Untracked files never appear in `git diff` — detect one and synthesize\n // its new-file patch via --no-index (which exits 1 on differences, so\n // its stdout is trusted whenever it carries a diff header).\n const st = await quick(['status', '--porcelain', '--', target], cwd)\n if (r.ok && st.ok && st.stdout.trim().startsWith('??')) {\n const ni = await exec(['diff', '--no-color', '--no-index', '--', '/dev/null', target], { cwd, timeout: QUICK_TIMEOUT_MS })\n if (ni.stdout.includes('diff --git')) return capDiff(ni.stdout)\n return { text: `(未跟踪新文件:${target})`, truncated: false }\n }\n if (!r.ok) return undefined\n return { text: '(该文件无差异)', truncated: false }\n },\n }\n}\n"],"mappings":"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"}
@@ -0,0 +1,192 @@
1
+ import { isValidRelRepoPath } from "../shared/protocol.js";
2
+ import { statusLineUnder, worktreePathOf } from "./git.js";
3
+ //#region src/host/isolation.ts
4
+ /**
5
+ * Worktree isolation orchestration (0.6.3): turns the single-repo worktree
6
+ * flow into a whole-workspace MIRROR when a workspace holds parallel git
7
+ * repositories — the workspace root repo plus its nested ones (plan §3/§4).
8
+ *
9
+ * Responsibilities (git.ts stays the narrow per-repo face):
10
+ * - prepareMirror: discover the repos, prepare one worktree per repo under
11
+ * the task mirror directory (root repo at the mirror root, each nested
12
+ * repo at its relative path), with per-repo reuse (续跑) and a bounded
13
+ * partial-failure policy: the FIRST repo failing degrades the whole run
14
+ * to the original directory (legacy semantics), a later repo failing
15
+ * just drops it from the mirror (framing marks it 禁改 — the isolation
16
+ * boundary never blurs).
17
+ * - removeMirror: children-first removal with an aggregated dirty
18
+ * pre-check (one dirty repo refuses the WHOLE mirror before anything is
19
+ * deleted). Children must go first: a nested worktree under the root
20
+ * worktree reads as untracked noise there, so the root worktree is only
21
+ * removable once they are gone.
22
+ *
23
+ * Every git interaction stays fail-soft at the boundaries the execution
24
+ * service already owns: this module returns outcomes, it never fails a run.
25
+ *
26
+ * @module dsh-taskboard/host/isolation
27
+ */
28
+ /** Whether this mirror is exactly the legacy single-repo shape. */
29
+ function isLegacySingle(mirror) {
30
+ return mirror.repos.length === 1 && mirror.repos[0].repo === "" && mirror.skipped.length === 0;
31
+ }
32
+ /** Whether a repo key may ride into a mirror path (defense in depth under worktreePathOf). */
33
+ function assertRepoKey(repo) {
34
+ if (!isValidRelRepoPath(repo)) throw new Error("Error: invalid_input: illegal repo path " + JSON.stringify(repo.slice(0, 80)));
35
+ }
36
+ /** Best-effort filesystem existence probe (fail-soft → false). */
37
+ async function pathExists(path) {
38
+ try {
39
+ const { stat } = await import("node:fs/promises");
40
+ return await stat(path).then(() => true, () => false);
41
+ } catch {
42
+ return false;
43
+ }
44
+ }
45
+ /** Join a repo relative path under a base path (forward slashes). */
46
+ function under(base, rel) {
47
+ return rel === "" ? base : base + "/" + rel;
48
+ }
49
+ /**
50
+ * Prepare the task mirror across every repo of the workspace.
51
+ *
52
+ * Repo list: the workspace root repo (GitFace.detect decides, whatever its
53
+ * .git shape) leads, nested parallel repos follow in path order. Each repo
54
+ * gets its own worktree on the SAME task branch name; reuse keeps live
55
+ * worktrees as-is per repo (续跑), falling back to a fresh preparation per
56
+ * repo — and a stale blocking directory gets one forced-fresh retry.
57
+ */
58
+ async function prepareMirror(deps, args) {
59
+ const { git, scanner } = deps;
60
+ const repos = [];
61
+ let inside = false;
62
+ try {
63
+ inside = await git.detect(args.workspacePath);
64
+ } catch {}
65
+ if (inside) repos.push({
66
+ relPath: "",
67
+ absPath: args.workspacePath
68
+ });
69
+ let nested = [];
70
+ try {
71
+ nested = await scanner.findNestedRepos(args.workspacePath);
72
+ } catch {
73
+ nested = [];
74
+ }
75
+ for (const repo of nested) {
76
+ assertRepoKey(repo.relPath);
77
+ repos.push(repo);
78
+ }
79
+ if (repos.length === 0) {
80
+ let hasBinary = true;
81
+ try {
82
+ hasBinary = await git.binaryAvailable();
83
+ } catch {}
84
+ return { note: hasBinary ? "当前项目不是 git 仓库,已在原目录执行" : "git 不可用(未安装或不在 PATH),已在原目录执行" };
85
+ }
86
+ if (repos.length > 8) return { note: "工作区内 git 仓库数超过镜像上限(" + repos.length + " > 8),已在原目录执行" };
87
+ const mirrorRoot = worktreePathOf(args.workspacePath, args.taskId);
88
+ const prepared = [];
89
+ const skipped = [];
90
+ for (let i = 0; i < repos.length; i++) {
91
+ const repo = repos[i];
92
+ const target = under(mirrorRoot, repo.relPath);
93
+ let info;
94
+ try {
95
+ info = await git.prepareWorktree(repo.absPath, target, args.branch, args.reuse ? "reuse" : "fresh");
96
+ } catch {}
97
+ if (info === void 0 && args.reuse) try {
98
+ info = await git.prepareWorktree(repo.absPath, target, args.branch, "fresh");
99
+ } catch {}
100
+ if (info === void 0) {
101
+ if (i === 0) return { note: "worktree 准备失败(git 报错或目录被占用),已在原目录执行" };
102
+ skipped.push({
103
+ repo: repo.relPath,
104
+ reason: "worktree 准备失败"
105
+ });
106
+ continue;
107
+ }
108
+ prepared.push({
109
+ repo: repo.relPath,
110
+ branch: info.branch,
111
+ worktreePath: info.path,
112
+ baseCommit: info.baseCommit,
113
+ ...info.reused === true ? { reused: true } : {}
114
+ });
115
+ }
116
+ return { mirror: {
117
+ root: mirrorRoot,
118
+ repos: prepared,
119
+ skipped,
120
+ allReused: prepared.length > 0 && prepared.every((p) => p.reused === true)
121
+ } };
122
+ }
123
+ /**
124
+ * Remove a task whole mirror: aggregate the dirty pre-check across EVERY
125
+ * repo worktree first (one dirty repo refuses everything, nothing is
126
+ * deleted), then remove children before the root (see module doc).
127
+ * Unknown-to-git leftovers report as unregistered — the caller fs-removes
128
+ * the mirror root afterwards (scope-verified route flows own that rm).
129
+ * @throws with code dirty-mirror when any repo worktree holds uncommitted changes.
130
+ */
131
+ async function removeMirror(deps, args) {
132
+ const { git, scanner } = deps;
133
+ const mirrorRoot = worktreePathOf(args.workspacePath, args.taskId);
134
+ scanner.clearCache();
135
+ let nested = [];
136
+ try {
137
+ nested = await scanner.findNestedRepos(args.workspacePath);
138
+ } catch {
139
+ nested = [];
140
+ }
141
+ const targets = [];
142
+ for (const repo of nested) {
143
+ assertRepoKey(repo.relPath);
144
+ const path = under(mirrorRoot, repo.relPath);
145
+ if (await pathExists(path)) targets.push({
146
+ repo: repo.relPath,
147
+ repoRoot: repo.absPath,
148
+ path
149
+ });
150
+ }
151
+ targets.push({
152
+ repo: "",
153
+ repoRoot: args.workspacePath,
154
+ path: mirrorRoot
155
+ });
156
+ const childRels = targets.filter((t) => t.repo !== "").map((t) => t.repo);
157
+ const dirty = [];
158
+ for (const target of targets) {
159
+ const raw = await git.dirtyLines(target.path).catch(() => void 0);
160
+ const lines = target.repo === "" && raw !== void 0 ? raw.filter((l) => !statusLineUnder(l, childRels)) : raw;
161
+ if (lines !== void 0 && lines.length > 0) dirty.push({
162
+ repo: target.repo,
163
+ lines
164
+ });
165
+ }
166
+ if (dirty.length > 0) {
167
+ const detail = dirty.map((d) => (d.repo === "" ? "根仓库" : d.repo) + ":\n" + d.lines.slice(0, 10).join("\n")).join("\n");
168
+ throw Object.assign(/* @__PURE__ */ new Error("镜像中 " + dirty.length + " 个仓库有未提交修改,拒绝删除:\n" + detail), { code: "dirty-mirror" });
169
+ }
170
+ const failures = [];
171
+ for (const target of targets) try {
172
+ await git.removeWorktree(target.repoRoot, target.path, target.repo === "" ? {
173
+ exempt: childRels,
174
+ force: true
175
+ } : void 0);
176
+ } catch (error) {
177
+ failures.push((target.repo === "" ? "根仓库" : target.repo) + ":" + (error instanceof Error ? error.message : String(error)));
178
+ }
179
+ if (failures.length > 0) throw new Error("删除镜像失败:\n" + failures.slice(0, 5).join("\n"));
180
+ }
181
+ /**
182
+ * Absolute path of a repo main checkout inside the workspace (the fallback
183
+ * cwd for diff views after a mirror is gone).
184
+ */
185
+ function repoMainPath(workspacePath, repo) {
186
+ assertRepoKey(repo);
187
+ return under(workspacePath, repo);
188
+ }
189
+ //#endregion
190
+ export { isLegacySingle, prepareMirror, removeMirror, repoMainPath };
191
+
192
+ //# sourceMappingURL=isolation.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"isolation.js","names":[],"sources":["../../src/host/isolation.ts"],"sourcesContent":["/**\n * Worktree isolation orchestration (0.6.3): turns the single-repo worktree\n * flow into a whole-workspace MIRROR when a workspace holds parallel git\n * repositories — the workspace root repo plus its nested ones (plan §3/§4).\n *\n * Responsibilities (git.ts stays the narrow per-repo face):\n * - prepareMirror: discover the repos, prepare one worktree per repo under\n * the task mirror directory (root repo at the mirror root, each nested\n * repo at its relative path), with per-repo reuse (续跑) and a bounded\n * partial-failure policy: the FIRST repo failing degrades the whole run\n * to the original directory (legacy semantics), a later repo failing\n * just drops it from the mirror (framing marks it 禁改 — the isolation\n * boundary never blurs).\n * - removeMirror: children-first removal with an aggregated dirty\n * pre-check (one dirty repo refuses the WHOLE mirror before anything is\n * deleted). Children must go first: a nested worktree under the root\n * worktree reads as untracked noise there, so the root worktree is only\n * removable once they are gone.\n *\n * Every git interaction stays fail-soft at the boundaries the execution\n * service already owns: this module returns outcomes, it never fails a run.\n *\n * @module dsh-taskboard/host/isolation\n */\nimport { MAX_MIRROR_REPOS, isValidRelRepoPath } from '../shared/protocol.ts'\nimport type { GitFace } from './git.ts'\nimport { statusLineUnder, worktreePathOf } from './git.ts'\nimport type { RepoRef, RepoScanner } from './repos.ts'\n\n/** One prepared repo worktree inside a task mirror. */\nexport interface PreparedMirrorRepo {\n /** Repo path relative to the workspace ('' = the workspace root repo). */\n repo: string\n /** The task branch checked out there. */\n branch: string\n /** Absolute worktree path (the working directory for this repo in the run). */\n worktreePath: string\n /** Baseline for evidence collection: main HEAD (fresh) or worktree HEAD (reuse). */\n baseCommit: string\n /** True when an existing live worktree was kept as-is (续跑). */\n reused?: boolean\n}\n\n/** The mirror of one run: prepared repos + repos deliberately left out. */\nexport interface PreparedMirror {\n /** Absolute path of the task mirror directory (the framing names it). */\n root: string\n /** Prepared worktrees in mirror order (root repo first when present). */\n repos: PreparedMirrorRepo[]\n /** Repos discovered but NOT mirrored (prepare failed); framing marks them 禁改. */\n skipped: Array<{ repo: string; reason: string }>\n /** True when EVERY prepared worktree was kept as-is (续跑 — framing picks the resume wording). */\n allReused: boolean\n}\n\n/** Outcome of a mirror preparation: either a mirror or a degrade note. */\nexport type MirrorPrepareOutcome =\n | { mirror: PreparedMirror }\n | { note: string }\n\n/** Whether this mirror is exactly the legacy single-repo shape. */\nexport function isLegacySingle(mirror: PreparedMirror): boolean {\n return mirror.repos.length === 1\n && mirror.repos[0]!.repo === ''\n && mirror.skipped.length === 0\n}\n\n/** Whether a repo key may ride into a mirror path (defense in depth under worktreePathOf). */\nfunction assertRepoKey(repo: string): void {\n if (!isValidRelRepoPath(repo)) {\n throw new Error('Error: invalid_input: illegal repo path ' + JSON.stringify(repo.slice(0, 80)))\n }\n}\n\n/** Best-effort filesystem existence probe (fail-soft → false). */\nasync function pathExists(path: string): Promise<boolean> {\n try {\n const { stat } = await import('node:fs/promises')\n return await stat(path).then(() => true, () => false)\n } catch {\n return false\n }\n}\n\n/** Join a repo relative path under a base path (forward slashes). */\nfunction under(base: string, rel: string): string {\n return rel === '' ? base : base + '/' + rel\n}\n\n/**\n * Prepare the task mirror across every repo of the workspace.\n *\n * Repo list: the workspace root repo (GitFace.detect decides, whatever its\n * .git shape) leads, nested parallel repos follow in path order. Each repo\n * gets its own worktree on the SAME task branch name; reuse keeps live\n * worktrees as-is per repo (续跑), falling back to a fresh preparation per\n * repo — and a stale blocking directory gets one forced-fresh retry.\n */\nexport async function prepareMirror(\n deps: { git: GitFace; scanner: RepoScanner },\n args: { workspacePath: string; taskId: string; branch: string; reuse: boolean },\n): Promise<MirrorPrepareOutcome> {\n const { git, scanner } = deps\n\n // 1. Discover. The root repo is probed through the git face; nested repos\n // come from the bounded scanner.\n const repos: RepoRef[] = []\n let inside = false\n try {\n inside = await git.detect(args.workspacePath)\n } catch { /* fail-soft → treated as \"root is not a repo\" */ }\n if (inside) repos.push({ relPath: '', absPath: args.workspacePath })\n let nested: RepoRef[] = []\n try {\n nested = await scanner.findNestedRepos(args.workspacePath)\n } catch { nested = [] }\n for (const repo of nested) {\n assertRepoKey(repo.relPath)\n repos.push(repo)\n }\n\n if (repos.length === 0) {\n // Distinguish 未装 git from 非 git 仓库 (0.3.1 wording preserved).\n let hasBinary = true\n try {\n hasBinary = await git.binaryAvailable()\n } catch { /* fail-soft → repo-side wording */ }\n return {\n note: hasBinary\n ? '当前项目不是 git 仓库,已在原目录执行'\n : 'git 不可用(未安装或不在 PATH),已在原目录执行',\n }\n }\n if (repos.length > MAX_MIRROR_REPOS) {\n return { note: '工作区内 git 仓库数超过镜像上限(' + repos.length + ' > ' + MAX_MIRROR_REPOS + '),已在原目录执行' }\n }\n\n // 2. Prepare per repo. FIRST repo failing → whole-run degrade (legacy\n // semantics); a later failure drops just that repo from the mirror.\n const mirrorRoot = worktreePathOf(args.workspacePath, args.taskId)\n const prepared: PreparedMirrorRepo[] = []\n const skipped: PreparedMirror['skipped'] = []\n for (let i = 0; i < repos.length; i++) {\n const repo = repos[i]!\n const target = under(mirrorRoot, repo.relPath)\n let info\n try {\n info = await git.prepareWorktree(repo.absPath, target, args.branch, args.reuse ? 'reuse' : 'fresh')\n } catch { /* fail-soft */ }\n if (info === undefined && args.reuse) {\n // A stale non-matching directory can block reuse; force one fresh\n // attempt (fresh mode already drops + prunes the stale worktree).\n try {\n info = await git.prepareWorktree(repo.absPath, target, args.branch, 'fresh')\n } catch { /* fail-soft */ }\n }\n if (info === undefined) {\n if (i === 0) return { note: 'worktree 准备失败(git 报错或目录被占用),已在原目录执行' }\n skipped.push({ repo: repo.relPath, reason: 'worktree 准备失败' })\n continue\n }\n prepared.push({\n repo: repo.relPath,\n branch: info.branch,\n worktreePath: info.path,\n baseCommit: info.baseCommit,\n ...(info.reused === true ? { reused: true } : {}),\n })\n }\n return {\n mirror: {\n root: mirrorRoot,\n repos: prepared,\n skipped,\n allReused: prepared.length > 0 && prepared.every(p => p.reused === true),\n },\n }\n}\n\n/**\n * Remove a task whole mirror: aggregate the dirty pre-check across EVERY\n * repo worktree first (one dirty repo refuses everything, nothing is\n * deleted), then remove children before the root (see module doc).\n * Unknown-to-git leftovers report as unregistered — the caller fs-removes\n * the mirror root afterwards (scope-verified route flows own that rm).\n * @throws with code dirty-mirror when any repo worktree holds uncommitted changes.\n */\nexport async function removeMirror(\n deps: { git: GitFace; scanner: RepoScanner },\n args: { workspacePath: string; taskId: string },\n): Promise<void> {\n const { git, scanner } = deps\n const mirrorRoot = worktreePathOf(args.workspacePath, args.taskId)\n // Structural teardown must target a FRESH discovery: a stale TTL cache\n // could miss a repo added since the last scan and strand its mirror\n // worktree inside the root (which then reads as unremovable noise). The\n // cache exists for the prepare/merge hot path, not for teardown.\n scanner.clearCache()\n let nested: RepoRef[] = []\n try {\n nested = await scanner.findNestedRepos(args.workspacePath)\n } catch { nested = [] }\n\n const targets: Array<{ repo: string; repoRoot: string; path: string }> = []\n for (const repo of nested) {\n assertRepoKey(repo.relPath)\n const path = under(mirrorRoot, repo.relPath)\n if (await pathExists(path)) targets.push({ repo: repo.relPath, repoRoot: repo.absPath, path })\n }\n // The mirror ROOT is always a target (the legacy flow called removeWorktree\n // unconditionally): when the workspace root is a repo this removes the root\n // worktree; when it is not (plain mirror dir, already-gone path) git side\n // reports it unregistered and the caller's fs rm cleans up.\n targets.push({ repo: '', repoRoot: args.workspacePath, path: mirrorRoot });\n\n const childRels = targets.filter(t => t.repo !== '').map(t => t.repo)\n const dirty: Array<{ repo: string; lines: string[] }> = []\n for (const target of targets) {\n const raw = await git.dirtyLines(target.path).catch(() => undefined)\n // The root worktree's status lists its nested child worktrees as untracked\n // noise (`?? sub/`) — those trees belong to the children (checked in their\n // own pass below), not to the root repo's uncommitted changes. Without\n // this exemption a fully committed mirror still reads as dirty and every\n // cleanup route refuses forever (0.6.3 review fix).\n const lines = target.repo === '' && raw !== undefined\n ? raw.filter(l => !statusLineUnder(l, childRels))\n : raw\n if (lines !== undefined && lines.length > 0) dirty.push({ repo: target.repo, lines })\n }\n if (dirty.length > 0) {\n const detail = dirty\n .map(d => (d.repo === '' ? '根仓库' : d.repo) + ':\\n' + d.lines.slice(0, 10).join('\\n'))\n .join('\\n')\n throw Object.assign(\n new Error('镜像中 ' + dirty.length + ' 个仓库有未提交修改,拒绝删除:\\n' + detail),\n { code: 'dirty-mirror' },\n )\n }\n\n // Removal walks the array FORWARD: children were pushed before the root, so\n // this IS the children-first order. (The original reverse loop removed the\n // root FIRST — its own doc said children-first; the P1 dirty refusal above\n // masked the bug because the loop never actually ran on a real mirror.)\n const failures: string[] = []\n for (const target of targets) {\n try {\n // The root's structural noise (nested child worktrees / gitlink drift)\n // survived the aggregated pre-check above — exempt + force is safe by\n // construction: real dirt never got past it.\n await git.removeWorktree(target.repoRoot, target.path,\n target.repo === '' ? { exempt: childRels, force: true } : undefined)\n } catch (error) {\n failures.push((target.repo === '' ? '根仓库' : target.repo) + ':' + (error instanceof Error ? error.message : String(error)))\n }\n }\n if (failures.length > 0) {\n throw new Error('删除镜像失败:\\n' + failures.slice(0, 5).join('\\n'))\n }\n}\n\n/**\n * Absolute path of a repo main checkout inside the workspace (the fallback\n * cwd for diff views after a mirror is gone).\n */\nexport function repoMainPath(workspacePath: string, repo: string): string {\n assertRepoKey(repo)\n return under(workspacePath, repo)\n}"],"mappings":"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"}
@@ -0,0 +1,91 @@
1
+ /** Default TTL of the per-workspace discovery cache (aligns routes' git-detect TTL). */
2
+ const CACHE_TTL_MS = 6e4;
3
+ /** Skip-listed directory names at every level of the scan. */
4
+ const SKIP_DIRS = /* @__PURE__ */ new Set([
5
+ "node_modules",
6
+ ".dsh-worktrees",
7
+ "lib",
8
+ "dist",
9
+ "build",
10
+ "out",
11
+ "coverage",
12
+ ".venv",
13
+ "venv",
14
+ "__pycache__",
15
+ "target",
16
+ ".next",
17
+ ".nuxt",
18
+ ".cache",
19
+ ".gradle",
20
+ "Pods"
21
+ ]);
22
+ /** Real IO over node:fs/promises (dynamic import like the git face). */
23
+ const realRepoIo = {
24
+ async readDir(dir) {
25
+ try {
26
+ const { readdir } = await import("node:fs/promises");
27
+ return (await readdir(dir, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => e.name);
28
+ } catch {
29
+ return [];
30
+ }
31
+ },
32
+ async hasGitDir(dir) {
33
+ try {
34
+ const { stat } = await import("node:fs/promises");
35
+ return (await stat(`${dir}/.git`)).isDirectory();
36
+ } catch {
37
+ return false;
38
+ }
39
+ }
40
+ };
41
+ /**
42
+ * Build a scanner over an injectable IO face.
43
+ * @param io - the IO face (real filesystem when omitted).
44
+ * @param ttlMs - cache lifetime; `0` disables caching (tests).
45
+ */
46
+ function createRepoScanner(io = realRepoIo, ttlMs = CACHE_TTL_MS) {
47
+ const cache = /* @__PURE__ */ new Map();
48
+ const scanDir = async (dir, rel, depth, out) => {
49
+ if (depth > 3) return;
50
+ const names = (await io.readDir(dir)).slice().sort();
51
+ for (const name of names) {
52
+ if (name.startsWith(".") || SKIP_DIRS.has(name)) continue;
53
+ const childRel = rel.length === 0 ? name : `${rel}/${name}`;
54
+ const childAbs = `${dir}/${name}`;
55
+ if (await io.hasGitDir(childAbs)) {
56
+ out.push({
57
+ relPath: childRel,
58
+ absPath: childAbs
59
+ });
60
+ continue;
61
+ }
62
+ try {
63
+ await scanDir(childAbs, childRel, depth + 1, out);
64
+ } catch {}
65
+ }
66
+ };
67
+ return {
68
+ async findNestedRepos(workspacePath) {
69
+ const root = workspacePath.replace(/[\\/]+$/, "");
70
+ const now = Date.now();
71
+ const cached = cache.get(root);
72
+ if (ttlMs > 0 && cached !== void 0 && now - cached.at < ttlMs) return cached.repos;
73
+ const out = [];
74
+ try {
75
+ await scanDir(root, "", 1, out);
76
+ } catch {}
77
+ if (ttlMs > 0) cache.set(root, {
78
+ at: now,
79
+ repos: out
80
+ });
81
+ return out;
82
+ },
83
+ clearCache() {
84
+ cache.clear();
85
+ }
86
+ };
87
+ }
88
+ //#endregion
89
+ export { createRepoScanner };
90
+
91
+ //# sourceMappingURL=repos.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"repos.js","names":[],"sources":["../../src/host/repos.ts"],"sourcesContent":["/**\n * Workspace repository discovery (0.6.3): locate the PARALLEL nested git\n * repositories inside a workspace so worktree isolation can mirror the\n * whole multi-repo layout under the task's worktree directory (plan §4.1).\n *\n * Scope rules:\n * - Only TRUE independent repositories qualify: a child directory counts\n * when it contains a `.git` DIRECTORY. A `.git` FILE (a linked worktree\n * or a submodule) is skipped — its refs live in some other repository's\n * object store, so treating it as a repo would double-register that one.\n * - Bounded scan (depth ≤ {@link MAX_SCAN_DEPTH}), never descending into a\n * discovered repo, a skip-listed directory (node_modules, build output,\n * the plugin's own .dsh-worktrees), or a dot directory.\n * - FAIL-SOFT: every IO error shrinks the result and never throws — the\n * caller degrades to single-repo (or plain no-git) behavior.\n * - Per-workspace TTL cache: the layout rarely changes mid-run.\n *\n * The workspace ROOT repo is not discovered here — the caller (isolation)\n * composes it in front of this list when `GitFace.detect` says the root is\n * a work tree, whatever `.git` shape it has.\n *\n * @module dsh-taskboard/host/repos\n */\n\n/** How deep below the workspace root nested repos are looked for. */\nexport const MAX_SCAN_DEPTH = 3\n\n/** Default TTL of the per-workspace discovery cache (aligns routes' git-detect TTL). */\nconst CACHE_TTL_MS = 60_000\n\n/** Skip-listed directory names at every level of the scan. */\nconst SKIP_DIRS: ReadonlySet<string> = new Set([\n 'node_modules',\n '.dsh-worktrees',\n 'lib',\n 'dist',\n 'build',\n 'out',\n 'coverage',\n '.venv',\n 'venv',\n '__pycache__',\n 'target',\n '.next',\n '.nuxt',\n '.cache',\n '.gradle',\n 'Pods',\n])\n\n/** One discovered repository: path relative to the workspace + absolute. */\nexport interface RepoRef {\n /**\n * Forward-slash path relative to the workspace root. `` (empty) is\n * reserved for the workspace root repo and is NEVER returned here.\n */\n relPath: string\n /** Absolute working-tree path of the repository. */\n absPath: string\n}\n\n/** Injectable IO face (unit tests script the layout without a filesystem). */\nexport interface RepoIo {\n /** Direct child directory names of `dir` ([] on any error). */\n readDir(dir: string): Promise<string[]>\n /** Whether `dir` contains a `.git` DIRECTORY (true independent repo). */\n hasGitDir(dir: string): Promise<boolean>\n}\n\n/** Real IO over node:fs/promises (dynamic import like the git face). */\nconst realRepoIo: RepoIo = {\n async readDir(dir) {\n try {\n const { readdir } = await import('node:fs/promises')\n const entries = await readdir(dir, { withFileTypes: true })\n return entries.filter(e => e.isDirectory()).map(e => e.name)\n } catch {\n return []\n }\n },\n async hasGitDir(dir) {\n try {\n const { stat } = await import('node:fs/promises')\n return (await stat(`${dir}/.git`)).isDirectory()\n } catch {\n return false\n }\n },\n}\n\n/** The scanner: discovery plus its TTL cache. */\nexport interface RepoScanner {\n /** Discover the nested parallel repos of a workspace (cache-backed). */\n findNestedRepos(workspacePath: string): Promise<RepoRef[]>\n /** Drop every cached discovery (mirror teardown re-discovers fresh — the TTL cache only serves the prepare/merge hot path). */\n clearCache(): void\n}\n\n/**\n * Build a scanner over an injectable IO face.\n * @param io - the IO face (real filesystem when omitted).\n * @param ttlMs - cache lifetime; `0` disables caching (tests).\n */\nexport function createRepoScanner(io: RepoIo = realRepoIo, ttlMs: number = CACHE_TTL_MS): RepoScanner {\n const cache = new Map<string, { at: number; repos: RepoRef[] }>()\n\n const scanDir = async (dir: string, rel: string, depth: number, out: RepoRef[]): Promise<void> => {\n if (depth > MAX_SCAN_DEPTH) return\n const names = (await io.readDir(dir)).slice().sort()\n for (const name of names) {\n if (name.startsWith('.') || SKIP_DIRS.has(name)) continue\n const childRel = rel.length === 0 ? name : `${rel}/${name}`\n const childAbs = `${dir}/${name}`\n if (await io.hasGitDir(childAbs)) {\n // A discovered repo is a LEAF: repos nested inside it (vendored\n // copies) are not part of the workspace's parallel layout.\n out.push({ relPath: childRel, absPath: childAbs })\n continue\n }\n // One unreadable subtree must not sink the whole walk (fail-soft).\n try {\n await scanDir(childAbs, childRel, depth + 1, out)\n } catch { /* skip the unreadable subtree */ }\n }\n }\n\n return {\n async findNestedRepos(workspacePath) {\n const root = workspacePath.replace(/[\\\\/]+$/, '')\n const now = Date.now()\n const cached = cache.get(root)\n if (ttlMs > 0 && cached !== undefined && now - cached.at < ttlMs) return cached.repos\n const out: RepoRef[] = []\n try {\n await scanDir(root, '', 1, out)\n } catch {\n /* fail-soft: keep whatever was found before the error */\n }\n if (ttlMs > 0) cache.set(root, { at: now, repos: out })\n return out\n },\n clearCache() {\n cache.clear()\n },\n }\n}\n"],"mappings":";AA4BA,MAAM,eAAe;;AAGrB,MAAM,4BAAiC,IAAI,IAAI;CAC7C;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;AACF,CAAC;;AAsBD,MAAM,aAAqB;CACzB,MAAM,QAAQ,KAAK;EACjB,IAAI;GACF,MAAM,EAAE,YAAY,MAAM,OAAO;GAEjC,QAAO,MADe,QAAQ,KAAK,EAAE,eAAe,KAAK,CAAC,EAAA,CAC3C,QAAO,MAAK,EAAE,YAAY,CAAC,CAAC,CAAC,KAAI,MAAK,EAAE,IAAI;EAC7D,QAAQ;GACN,OAAO,CAAC;EACV;CACF;CACA,MAAM,UAAU,KAAK;EACnB,IAAI;GACF,MAAM,EAAE,SAAS,MAAM,OAAO;GAC9B,QAAQ,MAAM,KAAK,GAAG,IAAI,MAAM,EAAA,CAAG,YAAY;EACjD,QAAQ;GACN,OAAO;EACT;CACF;AACF;;;;;;AAeA,SAAgB,kBAAkB,KAAa,YAAY,QAAgB,cAA2B;CACpG,MAAM,wBAAQ,IAAI,IAA8C;CAEhE,MAAM,UAAU,OAAO,KAAa,KAAa,OAAe,QAAkC;EAChG,IAAI,QAAA,GAAwB;EAC5B,MAAM,SAAS,MAAM,GAAG,QAAQ,GAAG,EAAA,CAAG,MAAM,CAAC,CAAC,KAAK;EACnD,KAAK,MAAM,QAAQ,OAAO;GACxB,IAAI,KAAK,WAAW,GAAG,KAAK,UAAU,IAAI,IAAI,GAAG;GACjD,MAAM,WAAW,IAAI,WAAW,IAAI,OAAO,GAAG,IAAI,GAAG;GACrD,MAAM,WAAW,GAAG,IAAI,GAAG;GAC3B,IAAI,MAAM,GAAG,UAAU,QAAQ,GAAG;IAGhC,IAAI,KAAK;KAAE,SAAS;KAAU,SAAS;IAAS,CAAC;IACjD;GACF;GAEA,IAAI;IACF,MAAM,QAAQ,UAAU,UAAU,QAAQ,GAAG,GAAG;GAClD,QAAQ,CAAoC;EAC9C;CACF;CAEA,OAAO;EACL,MAAM,gBAAgB,eAAe;GACnC,MAAM,OAAO,cAAc,QAAQ,WAAW,EAAE;GAChD,MAAM,MAAM,KAAK,IAAI;GACrB,MAAM,SAAS,MAAM,IAAI,IAAI;GAC7B,IAAI,QAAQ,KAAK,WAAW,KAAA,KAAa,MAAM,OAAO,KAAK,OAAO,OAAO,OAAO;GAChF,MAAM,MAAiB,CAAC;GACxB,IAAI;IACF,MAAM,QAAQ,MAAM,IAAI,GAAG,GAAG;GAChC,QAAQ,CAER;GACA,IAAI,QAAQ,GAAG,MAAM,IAAI,MAAM;IAAE,IAAI;IAAK,OAAO;GAAI,CAAC;GACtD,OAAO;EACT;EACA,aAAa;GACX,MAAM,MAAM;EACd;CACF;AACF"}