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/src/host/git.ts CHANGED
@@ -34,6 +34,30 @@ const HEAVY_TIMEOUT_MS = 15_000
34
34
  /** Directory under a workspace where task worktrees live. */
35
35
  export const WORKTREE_DIR = '.dsh-worktrees'
36
36
 
37
+ /**
38
+ * The path segment of one `status --porcelain` line. Shape-aware: the RAW
39
+ * line is `XY path` (path at index 3) while the plugin's trimmed evidence
40
+ * lines collapse a leading-space status to `X path` (path at index 2) —
41
+ * slicing a fixed 3 misparses exactly the gitlink/unstaged shapes a
42
+ * multi-repo mirror produces (` M sub-repo`), 0.6.3 review fix.
43
+ */
44
+ export function statusLinePath(line: string): string {
45
+ if (line.length >= 3 && line[2] === ' ') return line.slice(3)
46
+ if (line.length >= 2 && line[1] === ' ') return line.slice(2)
47
+ return line
48
+ }
49
+
50
+ /**
51
+ * Whether a `status --porcelain` line targets one of `rels` (a repo-relative
52
+ * path) or anything under it. Porcelain prints untracked directories with a
53
+ * trailing slash (`?? sub/`), so all three shapes match. Empty rels never
54
+ * match (0.6.3 review fix).
55
+ */
56
+ export function statusLineUnder(line: string, rels: readonly string[]): boolean {
57
+ const p = statusLinePath(line)
58
+ return rels.some(rel => rel.length > 0 && (p === rel || p === rel + '/' || p.startsWith(rel + '/')))
59
+ }
60
+
37
61
  /** Evidence caps: commits kept per execution record (newest first). */
38
62
  export const MAX_COMMIT_EVIDENCE = 50
39
63
 
@@ -118,18 +142,40 @@ export interface GitFace {
118
142
  * undefined on any failure — callers degrade to the original directory.
119
143
  */
120
144
  prepareWorktree(root: string, path: string, branch: string, mode?: 'fresh' | 'reuse'): Promise<WorktreeInfo | undefined>
121
- /** Collect settlement facts (never throws; missing pieces stay unset). */
122
- collect(worktreePath: string, baseCommit: string): Promise<SettlementFacts>
123
- /** Merge `branch` into the main worktree (`--no-ff`); THROWS with a readable reason. */
124
- merge(root: string, branch: string): Promise<void>
145
+ /**
146
+ * Collect settlement facts (never throws; missing pieces stay unset).
147
+ * `excludeRelPaths` drops `status --porcelain` lines targeting those
148
+ * repo-relative paths (or anything under them) — the mirror's NESTED repo
149
+ * worktrees read as untracked noise in the root worktree's status and are
150
+ * not this repo's uncommitted changes (0.6.3 review fix).
151
+ */
152
+ collect(worktreePath: string, baseCommit: string, excludeRelPaths?: readonly string[]): Promise<SettlementFacts>
153
+ /**
154
+ * Uncommitted-change lines of a working tree ([] = clean; undefined on git
155
+ * failure). Mirror removal pre-checks EVERY repo worktree before deleting
156
+ * any, so one dirty repo refuses the whole mirror (0.6.3).
157
+ */
158
+ dirtyLines(cwd: string): Promise<string[] | undefined>
159
+ /**
160
+ * Merge `branch` into the main worktree (`--no-ff`); THROWS with a readable
161
+ * reason. `exemptRelPaths` extends the main-clean exemption beyond
162
+ * `.dsh-worktrees`: uncommitted-shape noise under those repo-relative paths
163
+ * (parallel repos are self-governing — their content AND their gitlink
164
+ * entries don't gate the root repo's merge, 0.6.3 review fix).
165
+ */
166
+ merge(root: string, branch: string, exemptRelPaths?: readonly string[]): Promise<void>
125
167
  /** Whether `branch` is already an ancestor of HEAD (a merge would be a no-op). */
126
168
  isAncestor(root: string, branch: string): Promise<boolean>
127
169
  /**
128
170
  * Remove a worktree. Resolves 'removed' on success, 'unregistered' when git
129
171
  * no longer knows the path (an orphaned directory). THROWS when it still
130
172
  * has uncommitted changes, or on any other git failure (readable reason).
173
+ * `opts.exempt` drops mirror-structural noise from the dirty check and
174
+ * `opts.force` lets `git worktree remove` accept it — for callers that have
175
+ * ALREADY aggregated the real-dirty check themselves (the mirror removal:
176
+ * noise-exempt clean → force is safe; real dirt never gets here).
131
177
  */
132
- removeWorktree(root: string, worktreePath: string): Promise<'removed' | 'unregistered'>
178
+ removeWorktree(root: string, worktreePath: string, opts?: { exempt?: readonly string[]; force?: boolean }): Promise<'removed' | 'unregistered'>
133
179
  /** Delete a branch; THROWS (e.g. still checked out in a worktree). */
134
180
  deleteBranch(root: string, branch: string): Promise<void>
135
181
  /**
@@ -266,7 +312,7 @@ export function createGitFace(exec: ExecFn = realExec): GitFace {
266
312
  return { path, branch, baseCommit }
267
313
  }),
268
314
 
269
- async collect(worktreePath, baseCommit) {
315
+ async collect(worktreePath, baseCommit, excludeRelPaths) {
270
316
  const facts: SettlementFacts = { commits: [], commitsTotal: 0, dirtyFiles: [], dirtyFilesTotal: 0, changedFiles: 0 }
271
317
  const range = `${baseCommit}..HEAD`
272
318
 
@@ -292,7 +338,14 @@ export function createGitFace(exec: ExecFn = realExec): GitFace {
292
338
 
293
339
  const status = await quick(['status', '--porcelain'], worktreePath)
294
340
  if (status.ok) {
295
- const dirty = status.stdout.split('\n').map(l => l.trim()).filter(l => l.length > 0)
341
+ // excludeRelPaths drops the mirror's nested repo worktrees they
342
+ // read as untracked noise / gitlink drift here, not as this repo's
343
+ // uncommitted changes. Excluded on the RAW line (path extraction is
344
+ // shape-aware); the stored evidence keeps its trimmed 0.3.x shape.
345
+ const dirty = status.stdout.split('\n')
346
+ .filter(l => l.trim().length > 0)
347
+ .filter(l => !statusLineUnder(l, excludeRelPaths ?? []))
348
+ .map(l => l.trim())
296
349
  facts.dirtyFilesTotal = dirty.length
297
350
  facts.dirtyFiles = dirty.slice(0, MAX_DIRTY_EVIDENCE)
298
351
  }
@@ -306,19 +359,22 @@ export function createGitFace(exec: ExecFn = realExec): GitFace {
306
359
  return facts
307
360
  },
308
361
 
309
- merge: (root, branch) => withRootLock(root, async () => {
362
+ async dirtyLines(cwd) {
363
+ const status = await quick(['status', '--porcelain'], cwd)
364
+ if (!status.ok) return undefined
365
+ return status.stdout.split('\n').map(l => l.trim()).filter(l => l.length > 0)
366
+ },
367
+
368
+ merge: (root, branch, exemptRelPaths) => withRootLock(root, async () => {
310
369
  // Main-clean check. The plugin's own worktree directory
311
370
  // (<root>/.dsh-worktrees) shows up as untracked noise and is EXEMPT —
312
371
  // otherwise merging would be impossible without gitignoring it first.
313
372
  const status = await quick(['status', '--porcelain'], root)
314
373
  if (status.ok) {
315
374
  const dirtyLines = status.stdout.split('\n')
375
+ .filter(l => l.trim().length > 0)
376
+ .filter(l => !statusLineUnder(l, [WORKTREE_DIR, ...(exemptRelPaths ?? [])]))
316
377
  .map(l => l.trim())
317
- .filter(l => {
318
- if (l.length === 0) return false
319
- const path = l.slice(3)
320
- return path !== WORKTREE_DIR && !path.startsWith(`${WORKTREE_DIR}/`)
321
- })
322
378
  if (dirtyLines.length > 0) {
323
379
  // Machine-readable tag: callers classify without parsing zh-CN text.
324
380
  throw Object.assign(new Error(`主工作区有 ${dirtyLines.length} 处未提交修改,请先提交或暂存后再合并`), { code: 'dirty-tree' })
@@ -339,14 +395,24 @@ export function createGitFace(exec: ExecFn = realExec): GitFace {
339
395
  return r.ok
340
396
  },
341
397
 
342
- removeWorktree: (root, worktreePath) => withRootLock(root, async (): Promise<'removed' | 'unregistered'> => {
398
+ removeWorktree: (root, worktreePath, opts) => withRootLock(root, async (): Promise<'removed' | 'unregistered'> => {
343
399
  const status = await quick(['status', '--porcelain'], worktreePath)
344
400
  if (status.ok && status.stdout.trim().length > 0) {
345
- const lines = status.stdout.split('\n').map(l => l.trim()).filter(l => l.length > 0)
346
- // Machine-readable tag: purge flows classify without parsing zh-CN text.
347
- throw Object.assign(new Error(`worktree ${lines.length} 处未提交修改,拒绝删除:\n${lines.slice(0, 10).join('\n')}`), { code: 'dirty-worktree' })
401
+ // opts.exempt drops mirror-structural noise (nested child worktrees /
402
+ // gitlink drift) the caller's aggregated check already refused real
403
+ // dirt before reaching here.
404
+ const lines = status.stdout.split('\n')
405
+ .filter(l => l.trim().length > 0)
406
+ .filter(l => !statusLineUnder(l, opts?.exempt ?? []))
407
+ .map(l => l.trim())
408
+ if (lines.length > 0) {
409
+ // Machine-readable tag: purge flows classify without parsing zh-CN text.
410
+ throw Object.assign(new Error(`worktree 有 ${lines.length} 处未提交修改,拒绝删除:\n${lines.slice(0, 10).join('\n')}`), { code: 'dirty-worktree' })
411
+ }
348
412
  }
349
- const removed = await heavy(['worktree', 'remove', worktreePath], root)
413
+ const removed = await heavy(opts?.force === true
414
+ ? ['worktree', 'remove', '--force', worktreePath]
415
+ : ['worktree', 'remove', worktreePath], root)
350
416
  if (removed.ok) return 'removed'
351
417
  // S3: classify the failure WITHOUT parsing git's (localizable) stderr —
352
418
  // a path absent from `worktree list` is an unregistered leftover, not
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Worktree isolation orchestration (0.6.3): turns the single-repo worktree
3
+ * flow into a whole-workspace MIRROR when a workspace holds parallel git
4
+ * repositories — the workspace root repo plus its nested ones (plan §3/§4).
5
+ *
6
+ * Responsibilities (git.ts stays the narrow per-repo face):
7
+ * - prepareMirror: discover the repos, prepare one worktree per repo under
8
+ * the task mirror directory (root repo at the mirror root, each nested
9
+ * repo at its relative path), with per-repo reuse (续跑) and a bounded
10
+ * partial-failure policy: the FIRST repo failing degrades the whole run
11
+ * to the original directory (legacy semantics), a later repo failing
12
+ * just drops it from the mirror (framing marks it 禁改 — the isolation
13
+ * boundary never blurs).
14
+ * - removeMirror: children-first removal with an aggregated dirty
15
+ * pre-check (one dirty repo refuses the WHOLE mirror before anything is
16
+ * deleted). Children must go first: a nested worktree under the root
17
+ * worktree reads as untracked noise there, so the root worktree is only
18
+ * removable once they are gone.
19
+ *
20
+ * Every git interaction stays fail-soft at the boundaries the execution
21
+ * service already owns: this module returns outcomes, it never fails a run.
22
+ *
23
+ * @module dsh-taskboard/host/isolation
24
+ */
25
+ import { MAX_MIRROR_REPOS, isValidRelRepoPath } from '../shared/protocol.ts'
26
+ import type { GitFace } from './git.ts'
27
+ import { statusLineUnder, worktreePathOf } from './git.ts'
28
+ import type { RepoRef, RepoScanner } from './repos.ts'
29
+
30
+ /** One prepared repo worktree inside a task mirror. */
31
+ export interface PreparedMirrorRepo {
32
+ /** Repo path relative to the workspace ('' = the workspace root repo). */
33
+ repo: string
34
+ /** The task branch checked out there. */
35
+ branch: string
36
+ /** Absolute worktree path (the working directory for this repo in the run). */
37
+ worktreePath: string
38
+ /** Baseline for evidence collection: main HEAD (fresh) or worktree HEAD (reuse). */
39
+ baseCommit: string
40
+ /** True when an existing live worktree was kept as-is (续跑). */
41
+ reused?: boolean
42
+ }
43
+
44
+ /** The mirror of one run: prepared repos + repos deliberately left out. */
45
+ export interface PreparedMirror {
46
+ /** Absolute path of the task mirror directory (the framing names it). */
47
+ root: string
48
+ /** Prepared worktrees in mirror order (root repo first when present). */
49
+ repos: PreparedMirrorRepo[]
50
+ /** Repos discovered but NOT mirrored (prepare failed); framing marks them 禁改. */
51
+ skipped: Array<{ repo: string; reason: string }>
52
+ /** True when EVERY prepared worktree was kept as-is (续跑 — framing picks the resume wording). */
53
+ allReused: boolean
54
+ }
55
+
56
+ /** Outcome of a mirror preparation: either a mirror or a degrade note. */
57
+ export type MirrorPrepareOutcome =
58
+ | { mirror: PreparedMirror }
59
+ | { note: string }
60
+
61
+ /** Whether this mirror is exactly the legacy single-repo shape. */
62
+ export function isLegacySingle(mirror: PreparedMirror): boolean {
63
+ return mirror.repos.length === 1
64
+ && mirror.repos[0]!.repo === ''
65
+ && mirror.skipped.length === 0
66
+ }
67
+
68
+ /** Whether a repo key may ride into a mirror path (defense in depth under worktreePathOf). */
69
+ function assertRepoKey(repo: string): void {
70
+ if (!isValidRelRepoPath(repo)) {
71
+ throw new Error('Error: invalid_input: illegal repo path ' + JSON.stringify(repo.slice(0, 80)))
72
+ }
73
+ }
74
+
75
+ /** Best-effort filesystem existence probe (fail-soft → false). */
76
+ async function pathExists(path: string): Promise<boolean> {
77
+ try {
78
+ const { stat } = await import('node:fs/promises')
79
+ return await stat(path).then(() => true, () => false)
80
+ } catch {
81
+ return false
82
+ }
83
+ }
84
+
85
+ /** Join a repo relative path under a base path (forward slashes). */
86
+ function under(base: string, rel: string): string {
87
+ return rel === '' ? base : base + '/' + rel
88
+ }
89
+
90
+ /**
91
+ * Prepare the task mirror across every repo of the workspace.
92
+ *
93
+ * Repo list: the workspace root repo (GitFace.detect decides, whatever its
94
+ * .git shape) leads, nested parallel repos follow in path order. Each repo
95
+ * gets its own worktree on the SAME task branch name; reuse keeps live
96
+ * worktrees as-is per repo (续跑), falling back to a fresh preparation per
97
+ * repo — and a stale blocking directory gets one forced-fresh retry.
98
+ */
99
+ export async function prepareMirror(
100
+ deps: { git: GitFace; scanner: RepoScanner },
101
+ args: { workspacePath: string; taskId: string; branch: string; reuse: boolean },
102
+ ): Promise<MirrorPrepareOutcome> {
103
+ const { git, scanner } = deps
104
+
105
+ // 1. Discover. The root repo is probed through the git face; nested repos
106
+ // come from the bounded scanner.
107
+ const repos: RepoRef[] = []
108
+ let inside = false
109
+ try {
110
+ inside = await git.detect(args.workspacePath)
111
+ } catch { /* fail-soft → treated as "root is not a repo" */ }
112
+ if (inside) repos.push({ relPath: '', absPath: args.workspacePath })
113
+ let nested: RepoRef[] = []
114
+ try {
115
+ nested = await scanner.findNestedRepos(args.workspacePath)
116
+ } catch { nested = [] }
117
+ for (const repo of nested) {
118
+ assertRepoKey(repo.relPath)
119
+ repos.push(repo)
120
+ }
121
+
122
+ if (repos.length === 0) {
123
+ // Distinguish 未装 git from 非 git 仓库 (0.3.1 wording preserved).
124
+ let hasBinary = true
125
+ try {
126
+ hasBinary = await git.binaryAvailable()
127
+ } catch { /* fail-soft → repo-side wording */ }
128
+ return {
129
+ note: hasBinary
130
+ ? '当前项目不是 git 仓库,已在原目录执行'
131
+ : 'git 不可用(未安装或不在 PATH),已在原目录执行',
132
+ }
133
+ }
134
+ if (repos.length > MAX_MIRROR_REPOS) {
135
+ return { note: '工作区内 git 仓库数超过镜像上限(' + repos.length + ' > ' + MAX_MIRROR_REPOS + '),已在原目录执行' }
136
+ }
137
+
138
+ // 2. Prepare per repo. FIRST repo failing → whole-run degrade (legacy
139
+ // semantics); a later failure drops just that repo from the mirror.
140
+ const mirrorRoot = worktreePathOf(args.workspacePath, args.taskId)
141
+ const prepared: PreparedMirrorRepo[] = []
142
+ const skipped: PreparedMirror['skipped'] = []
143
+ for (let i = 0; i < repos.length; i++) {
144
+ const repo = repos[i]!
145
+ const target = under(mirrorRoot, repo.relPath)
146
+ let info
147
+ try {
148
+ info = await git.prepareWorktree(repo.absPath, target, args.branch, args.reuse ? 'reuse' : 'fresh')
149
+ } catch { /* fail-soft */ }
150
+ if (info === undefined && args.reuse) {
151
+ // A stale non-matching directory can block reuse; force one fresh
152
+ // attempt (fresh mode already drops + prunes the stale worktree).
153
+ try {
154
+ info = await git.prepareWorktree(repo.absPath, target, args.branch, 'fresh')
155
+ } catch { /* fail-soft */ }
156
+ }
157
+ if (info === undefined) {
158
+ if (i === 0) return { note: 'worktree 准备失败(git 报错或目录被占用),已在原目录执行' }
159
+ skipped.push({ repo: repo.relPath, reason: 'worktree 准备失败' })
160
+ continue
161
+ }
162
+ prepared.push({
163
+ repo: repo.relPath,
164
+ branch: info.branch,
165
+ worktreePath: info.path,
166
+ baseCommit: info.baseCommit,
167
+ ...(info.reused === true ? { reused: true } : {}),
168
+ })
169
+ }
170
+ return {
171
+ mirror: {
172
+ root: mirrorRoot,
173
+ repos: prepared,
174
+ skipped,
175
+ allReused: prepared.length > 0 && prepared.every(p => p.reused === true),
176
+ },
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Remove a task whole mirror: aggregate the dirty pre-check across EVERY
182
+ * repo worktree first (one dirty repo refuses everything, nothing is
183
+ * deleted), then remove children before the root (see module doc).
184
+ * Unknown-to-git leftovers report as unregistered — the caller fs-removes
185
+ * the mirror root afterwards (scope-verified route flows own that rm).
186
+ * @throws with code dirty-mirror when any repo worktree holds uncommitted changes.
187
+ */
188
+ export async function removeMirror(
189
+ deps: { git: GitFace; scanner: RepoScanner },
190
+ args: { workspacePath: string; taskId: string },
191
+ ): Promise<void> {
192
+ const { git, scanner } = deps
193
+ const mirrorRoot = worktreePathOf(args.workspacePath, args.taskId)
194
+ // Structural teardown must target a FRESH discovery: a stale TTL cache
195
+ // could miss a repo added since the last scan and strand its mirror
196
+ // worktree inside the root (which then reads as unremovable noise). The
197
+ // cache exists for the prepare/merge hot path, not for teardown.
198
+ scanner.clearCache()
199
+ let nested: RepoRef[] = []
200
+ try {
201
+ nested = await scanner.findNestedRepos(args.workspacePath)
202
+ } catch { nested = [] }
203
+
204
+ const targets: Array<{ repo: string; repoRoot: string; path: string }> = []
205
+ for (const repo of nested) {
206
+ assertRepoKey(repo.relPath)
207
+ const path = under(mirrorRoot, repo.relPath)
208
+ if (await pathExists(path)) targets.push({ repo: repo.relPath, repoRoot: repo.absPath, path })
209
+ }
210
+ // The mirror ROOT is always a target (the legacy flow called removeWorktree
211
+ // unconditionally): when the workspace root is a repo this removes the root
212
+ // worktree; when it is not (plain mirror dir, already-gone path) git side
213
+ // reports it unregistered and the caller's fs rm cleans up.
214
+ targets.push({ repo: '', repoRoot: args.workspacePath, path: mirrorRoot });
215
+
216
+ const childRels = targets.filter(t => t.repo !== '').map(t => t.repo)
217
+ const dirty: Array<{ repo: string; lines: string[] }> = []
218
+ for (const target of targets) {
219
+ const raw = await git.dirtyLines(target.path).catch(() => undefined)
220
+ // The root worktree's status lists its nested child worktrees as untracked
221
+ // noise (`?? sub/`) — those trees belong to the children (checked in their
222
+ // own pass below), not to the root repo's uncommitted changes. Without
223
+ // this exemption a fully committed mirror still reads as dirty and every
224
+ // cleanup route refuses forever (0.6.3 review fix).
225
+ const lines = target.repo === '' && raw !== undefined
226
+ ? raw.filter(l => !statusLineUnder(l, childRels))
227
+ : raw
228
+ if (lines !== undefined && lines.length > 0) dirty.push({ repo: target.repo, lines })
229
+ }
230
+ if (dirty.length > 0) {
231
+ const detail = dirty
232
+ .map(d => (d.repo === '' ? '根仓库' : d.repo) + ':\n' + d.lines.slice(0, 10).join('\n'))
233
+ .join('\n')
234
+ throw Object.assign(
235
+ new Error('镜像中 ' + dirty.length + ' 个仓库有未提交修改,拒绝删除:\n' + detail),
236
+ { code: 'dirty-mirror' },
237
+ )
238
+ }
239
+
240
+ // Removal walks the array FORWARD: children were pushed before the root, so
241
+ // this IS the children-first order. (The original reverse loop removed the
242
+ // root FIRST — its own doc said children-first; the P1 dirty refusal above
243
+ // masked the bug because the loop never actually ran on a real mirror.)
244
+ const failures: string[] = []
245
+ for (const target of targets) {
246
+ try {
247
+ // The root's structural noise (nested child worktrees / gitlink drift)
248
+ // survived the aggregated pre-check above — exempt + force is safe by
249
+ // construction: real dirt never got past it.
250
+ await git.removeWorktree(target.repoRoot, target.path,
251
+ target.repo === '' ? { exempt: childRels, force: true } : undefined)
252
+ } catch (error) {
253
+ failures.push((target.repo === '' ? '根仓库' : target.repo) + ':' + (error instanceof Error ? error.message : String(error)))
254
+ }
255
+ }
256
+ if (failures.length > 0) {
257
+ throw new Error('删除镜像失败:\n' + failures.slice(0, 5).join('\n'))
258
+ }
259
+ }
260
+
261
+ /**
262
+ * Absolute path of a repo main checkout inside the workspace (the fallback
263
+ * cwd for diff views after a mirror is gone).
264
+ */
265
+ export function repoMainPath(workspacePath: string, repo: string): string {
266
+ assertRepoKey(repo)
267
+ return under(workspacePath, repo)
268
+ }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Workspace repository discovery (0.6.3): locate the PARALLEL nested git
3
+ * repositories inside a workspace so worktree isolation can mirror the
4
+ * whole multi-repo layout under the task's worktree directory (plan §4.1).
5
+ *
6
+ * Scope rules:
7
+ * - Only TRUE independent repositories qualify: a child directory counts
8
+ * when it contains a `.git` DIRECTORY. A `.git` FILE (a linked worktree
9
+ * or a submodule) is skipped — its refs live in some other repository's
10
+ * object store, so treating it as a repo would double-register that one.
11
+ * - Bounded scan (depth ≤ {@link MAX_SCAN_DEPTH}), never descending into a
12
+ * discovered repo, a skip-listed directory (node_modules, build output,
13
+ * the plugin's own .dsh-worktrees), or a dot directory.
14
+ * - FAIL-SOFT: every IO error shrinks the result and never throws — the
15
+ * caller degrades to single-repo (or plain no-git) behavior.
16
+ * - Per-workspace TTL cache: the layout rarely changes mid-run.
17
+ *
18
+ * The workspace ROOT repo is not discovered here — the caller (isolation)
19
+ * composes it in front of this list when `GitFace.detect` says the root is
20
+ * a work tree, whatever `.git` shape it has.
21
+ *
22
+ * @module dsh-taskboard/host/repos
23
+ */
24
+
25
+ /** How deep below the workspace root nested repos are looked for. */
26
+ export const MAX_SCAN_DEPTH = 3
27
+
28
+ /** Default TTL of the per-workspace discovery cache (aligns routes' git-detect TTL). */
29
+ const CACHE_TTL_MS = 60_000
30
+
31
+ /** Skip-listed directory names at every level of the scan. */
32
+ const SKIP_DIRS: ReadonlySet<string> = new Set([
33
+ 'node_modules',
34
+ '.dsh-worktrees',
35
+ 'lib',
36
+ 'dist',
37
+ 'build',
38
+ 'out',
39
+ 'coverage',
40
+ '.venv',
41
+ 'venv',
42
+ '__pycache__',
43
+ 'target',
44
+ '.next',
45
+ '.nuxt',
46
+ '.cache',
47
+ '.gradle',
48
+ 'Pods',
49
+ ])
50
+
51
+ /** One discovered repository: path relative to the workspace + absolute. */
52
+ export interface RepoRef {
53
+ /**
54
+ * Forward-slash path relative to the workspace root. `` (empty) is
55
+ * reserved for the workspace root repo and is NEVER returned here.
56
+ */
57
+ relPath: string
58
+ /** Absolute working-tree path of the repository. */
59
+ absPath: string
60
+ }
61
+
62
+ /** Injectable IO face (unit tests script the layout without a filesystem). */
63
+ export interface RepoIo {
64
+ /** Direct child directory names of `dir` ([] on any error). */
65
+ readDir(dir: string): Promise<string[]>
66
+ /** Whether `dir` contains a `.git` DIRECTORY (true independent repo). */
67
+ hasGitDir(dir: string): Promise<boolean>
68
+ }
69
+
70
+ /** Real IO over node:fs/promises (dynamic import like the git face). */
71
+ const realRepoIo: RepoIo = {
72
+ async readDir(dir) {
73
+ try {
74
+ const { readdir } = await import('node:fs/promises')
75
+ const entries = await readdir(dir, { withFileTypes: true })
76
+ return entries.filter(e => e.isDirectory()).map(e => e.name)
77
+ } catch {
78
+ return []
79
+ }
80
+ },
81
+ async hasGitDir(dir) {
82
+ try {
83
+ const { stat } = await import('node:fs/promises')
84
+ return (await stat(`${dir}/.git`)).isDirectory()
85
+ } catch {
86
+ return false
87
+ }
88
+ },
89
+ }
90
+
91
+ /** The scanner: discovery plus its TTL cache. */
92
+ export interface RepoScanner {
93
+ /** Discover the nested parallel repos of a workspace (cache-backed). */
94
+ findNestedRepos(workspacePath: string): Promise<RepoRef[]>
95
+ /** Drop every cached discovery (mirror teardown re-discovers fresh — the TTL cache only serves the prepare/merge hot path). */
96
+ clearCache(): void
97
+ }
98
+
99
+ /**
100
+ * Build a scanner over an injectable IO face.
101
+ * @param io - the IO face (real filesystem when omitted).
102
+ * @param ttlMs - cache lifetime; `0` disables caching (tests).
103
+ */
104
+ export function createRepoScanner(io: RepoIo = realRepoIo, ttlMs: number = CACHE_TTL_MS): RepoScanner {
105
+ const cache = new Map<string, { at: number; repos: RepoRef[] }>()
106
+
107
+ const scanDir = async (dir: string, rel: string, depth: number, out: RepoRef[]): Promise<void> => {
108
+ if (depth > MAX_SCAN_DEPTH) return
109
+ const names = (await io.readDir(dir)).slice().sort()
110
+ for (const name of names) {
111
+ if (name.startsWith('.') || SKIP_DIRS.has(name)) continue
112
+ const childRel = rel.length === 0 ? name : `${rel}/${name}`
113
+ const childAbs = `${dir}/${name}`
114
+ if (await io.hasGitDir(childAbs)) {
115
+ // A discovered repo is a LEAF: repos nested inside it (vendored
116
+ // copies) are not part of the workspace's parallel layout.
117
+ out.push({ relPath: childRel, absPath: childAbs })
118
+ continue
119
+ }
120
+ // One unreadable subtree must not sink the whole walk (fail-soft).
121
+ try {
122
+ await scanDir(childAbs, childRel, depth + 1, out)
123
+ } catch { /* skip the unreadable subtree */ }
124
+ }
125
+ }
126
+
127
+ return {
128
+ async findNestedRepos(workspacePath) {
129
+ const root = workspacePath.replace(/[\\/]+$/, '')
130
+ const now = Date.now()
131
+ const cached = cache.get(root)
132
+ if (ttlMs > 0 && cached !== undefined && now - cached.at < ttlMs) return cached.repos
133
+ const out: RepoRef[] = []
134
+ try {
135
+ await scanDir(root, '', 1, out)
136
+ } catch {
137
+ /* fail-soft: keep whatever was found before the error */
138
+ }
139
+ if (ttlMs > 0) cache.set(root, { at: now, repos: out })
140
+ return out
141
+ },
142
+ clearCache() {
143
+ cache.clear()
144
+ },
145
+ }
146
+ }