dsh-taskboard 0.6.2 → 0.6.4

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.
@@ -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
+ }