dsh-ide-git 0.23.0 → 0.25.0

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,348 @@
1
+ /** dsh-ide-git host: read-only methods (status, log, detail, diff, compare). */
2
+
3
+ import path from 'node:path'
4
+
5
+ import { PanelError, badRequest, cwdOf, requireRef, requireRelativePath, clampInt } from './core.js'
6
+
7
+ import {
8
+
9
+ git, runGit, repoRootOf, parseLog, LOG_FORMAT, decorate, parsePorcelain, classify, numstatOf,
10
+
11
+ ignoredEntriesOf, stashCountOf, worktreesOf, currentBranchOf, upstreamOf,
12
+
13
+ aheadBehindOf, originNameOf, sameDirectory, FIELD_SEP, RECORD_SEP,
14
+
15
+ MAX_DIFF_CHARS, gitLog, GIT_LOG_MAX_ENTRIES,
16
+
17
+ } from './git.js'
18
+
19
+ import { operationOf } from './undo.js'
20
+
21
+
22
+
23
+ async function summary(payload) {
24
+ const cwd = cwdOf(payload)
25
+ const root = await repoRootOf(cwd, payload)
26
+ /* Two-phase changes (issue #11): with `stats: false` the two `diff
27
+ --numstat` passes — the most expensive step once a tree holds thousands
28
+ of changes — are skipped, `statsDeferred: true` announces that the +/-
29
+ numbers arrive through the `stats` method instead. The rows themselves
30
+ are complete without them; nothing is truncated. */
31
+ const deferStats = payload.stats === false
32
+ /* The branch chain (branch → upstream → ahead/behind) is sequential by
33
+ necessity; everything else is independent and runs concurrently, so a
34
+ large `status` scan no longer serialises behind five cheap calls
35
+ (issue #11: the sum of ~ten sequential commands became the max of two
36
+ chains). */
37
+ const [branchInfo, statusOut, unstagedStat, stagedStat, ignored, stashCount, worktrees, operation, head, originName] = await Promise.all([
38
+ (async () => {
39
+ const branch = await currentBranchOf(root)
40
+ const detached = branch === 'HEAD'
41
+ const upstream = detached ? null : await upstreamOf(root, branch)
42
+ const tracking = upstream === null ? { ahead: 0, behind: 0 } : await aheadBehindOf(root, upstream, branch)
43
+ return { branch, detached, upstream, tracking }
44
+ })(),
45
+ git(root, ['status', '--porcelain=v1', '-z', '--untracked-files=all']),
46
+ deferStats ? Promise.resolve(new Map()) : numstatOf(root, []),
47
+ deferStats ? Promise.resolve(new Map()) : numstatOf(root, ['--cached']),
48
+ payload.ignored === true ? ignoredEntriesOf(root) : { entries: [], truncated: false },
49
+ stashCountOf(root),
50
+ worktreesOf(root),
51
+ operationOf(root),
52
+ /* HEAD hash (v0.18.7): lets the client's poll notice OUTSIDE commits and
53
+ checkouts — the panel's own actions already refresh everything, but
54
+ nothing else watches the repo. Empty string on an unborn branch. */
55
+ git(root, ['rev-parse', 'HEAD']).then((out) => out.trim()).catch(() => ''),
56
+ originNameOf(root),
57
+ ])
58
+ const groups = classify(parsePorcelain(statusOut))
59
+ return {
60
+ repoRoot: root,
61
+ cwd,
62
+ branch: branchInfo.branch,
63
+ detached: branchInfo.detached,
64
+ upstream: branchInfo.upstream,
65
+ ahead: branchInfo.tracking.ahead,
66
+ behind: branchInfo.tracking.behind,
67
+ head: head,
68
+ /* The repository's own name (v0.19.1): the origin URL's leaf, so a
69
+ renamed clone still shows the real repo name, not the folder's. */
70
+ origin: originName,
71
+ changes: {
72
+ staged: decorate(groups.staged, stagedStat),
73
+ unstaged: decorate(groups.unstaged, unstagedStat),
74
+ untracked: decorate(groups.untracked, new Map()),
75
+ conflicted: decorate(groups.conflicted, new Map()),
76
+ ignored: ignored.entries,
77
+ ignoredTruncated: ignored.truncated,
78
+ },
79
+ statsDeferred: deferStats === true,
80
+ stashCount: stashCount,
81
+ worktrees: worktrees,
82
+ operation: operation,
83
+ }
84
+ }
85
+
86
+ /* The second phase of the two-phase changes (issue #11): the two numstat
87
+ passes, concurrent, packed as [path, additions, deletions, binary] rows so
88
+ they survive JSON. Paths are post-rename paths — exactly what `summary`
89
+ decorates its entries with. Read-only: deliberately not in WRITE_METHODS. */
90
+ async function stats(payload) {
91
+ const root = await repoRootOf(cwdOf(payload), payload)
92
+ const [unstaged, staged] = await Promise.all([
93
+ numstatOf(root, []),
94
+ numstatOf(root, ['--cached']),
95
+ ])
96
+ const pack = (map) => {
97
+ const rows = []
98
+ for (const [path, stat] of map) rows.push([path, stat.additions, stat.deletions, stat.binary === true])
99
+ return rows
100
+ }
101
+ return { unstaged: pack(unstaged), staged: pack(staged) }
102
+ }
103
+
104
+ async function branches(payload) {
105
+ const cwd = cwdOf(payload)
106
+ const root = await repoRootOf(cwd, payload)
107
+ const branch = await currentBranchOf(root)
108
+ const format = ['%(refname)', '%(refname:short)', '%(objectname)', '%(objectname:short)', '%(HEAD)', '%(upstream:short)', '%(committerdate:iso-strict)', '%(contents:subject)', '%(symref)'].join(FIELD_SEP)
109
+ const out = await git(root, ['for-each-ref', '--format=' + format + RECORD_SEP, 'refs/heads', 'refs/remotes', 'refs/tags'])
110
+ const local = []
111
+ const remote = []
112
+ const tags = []
113
+ const worktrees = await worktreesOf(root)
114
+ const busy = new Map()
115
+ for (const tree of worktrees) {
116
+ if (tree.branch === null || tree.branch === '') continue
117
+ // `git worktree list` includes the checkout this repository lives in, so
118
+ // without this the CURRENT branch was flagged as occupied by its own working
119
+ // tree (reported on v0.3.11: main showed the marker while checked out here).
120
+ // Only a SECOND working tree holding a branch blocks checking it out here.
121
+ if (sameDirectory(tree.path, root)) continue
122
+ busy.set(tree.branch, tree.path)
123
+ }
124
+ for (const record of out.split(RECORD_SEP)) {
125
+ const trimmed = record.replace(/^\n+/, '')
126
+ if (trimmed.trim() === '') continue
127
+ const parts = trimmed.split(FIELD_SEP)
128
+ if (parts.length < 9) continue
129
+ const entry = {
130
+ name: parts[1],
131
+ hash: parts[2],
132
+ shortHash: parts[3],
133
+ head: parts[4] === '*',
134
+ upstream: parts[5] === '' ? null : parts[5],
135
+ date: parts[6],
136
+ subject: parts[7],
137
+ worktree: busy.get(parts[1]) === undefined ? null : busy.get(parts[1]),
138
+ ahead: 0,
139
+ behind: 0,
140
+ }
141
+ if (parts[0].startsWith('refs/heads/')) local.push(entry)
142
+ else if (parts[0].startsWith('refs/remotes/')) {
143
+ /* "<remote>/HEAD" is the symbolic pointer at a remote's default branch —
144
+ its %(refname:short) collapses to the BARE remote name, so it is not a
145
+ branch anyone can check out. It never becomes a row (the stray
146
+ "<remote>" entry used to pollute the remote tree and, grouped, it also
147
+ defeated the tree's single-remote prefix strip). */
148
+ if (/^refs\/remotes\/[^/]+\/HEAD$/.test(parts[0]) === false) remote.push(entry)
149
+ }
150
+ else if (parts[0].startsWith('refs/tags/')) tags.push({ name: entry.name, hash: entry.hash, shortHash: entry.shortHash, date: entry.date, subject: entry.subject })
151
+ }
152
+ for (const entry of local) {
153
+ if (entry.upstream === null) continue
154
+ const tracking = await aheadBehindOf(root, entry.upstream, entry.name)
155
+ entry.ahead = tracking.ahead
156
+ entry.behind = tracking.behind
157
+ }
158
+ local.sort((a, b) => (a.head === b.head ? a.name.localeCompare(b.name) : a.head ? -1 : 1))
159
+ remote.sort((a, b) => a.name.localeCompare(b.name))
160
+ tags.sort((a, b) => a.name.localeCompare(b.name))
161
+ const remotesOut = await runGit(root, ['remote'])
162
+ const remotes = remotesOut.code === 0 ? remotesOut.stdout.split('\n').map((line) => line.trim()).filter((line) => line !== '') : []
163
+ return { repoRoot: root, branch, local, remote, tags, remotes }
164
+ }
165
+
166
+ async function log(payload) {
167
+ const cwd = cwdOf(payload)
168
+ const root = await repoRootOf(cwd, payload)
169
+ const limit = clampInt(payload.limit, 1, 500, 100)
170
+ const skip = clampInt(payload.skip, 0, 1_000_000, 0)
171
+ const args = ['log', '--max-count=' + (limit + 1), '--skip=' + skip, '--date-order', '--pretty=format:' + LOG_FORMAT]
172
+ if (typeof payload.rev === 'string' && payload.rev.trim() !== '') args.push(requireRef(payload.rev, 'rev'))
173
+ if (typeof payload.path === 'string' && payload.path.trim() !== '') args.push('--', payload.path)
174
+ const out = await git(root, args)
175
+ const all = parseLog(out)
176
+ const hasMore = all.length > limit
177
+ return { repoRoot: root, commits: hasMore ? all.slice(0, limit) : all, hasMore, skip, limit }
178
+ }
179
+ /* The merge that carried `from` INTO `to`, newest first — the anchor the
180
+ branch tree's double-click-to-browse lands on (IDEA-style: double-click
181
+ branch 2, then branch 1, and 1's list opens scrolled to the merge commit
182
+ where 2 was merged in, so "2 has been merged here" is visible at a
183
+ glance even though 1 kept moving. No merge relationship -> null, and the
184
+ caller just opens the list at the top. Pure rev arithmetic, no network. */
185
+ /* Who am I here: git config resolves user.name/email through its usual
186
+ cascade (repo-local overrides global), so the answer is per repository.
187
+ The client caches it per repo root for the whole panel lifetime — a git
188
+ identity rarely changes mid-session — and pins it to the top of the
189
+ author filter (v0.19.3), even when this branch has no commits by me:
190
+ picking it and seeing an empty list IS the answer ("I never committed
191
+ here"). Empty strings mean no identity is configured. */
192
+ async function me(payload) {
193
+ const cwd = cwdOf(payload)
194
+ const root = await repoRootOf(cwd, payload)
195
+ const name = await runGit(root, ['config', 'user.name'])
196
+ const email = await runGit(root, ['config', 'user.email'])
197
+ return {
198
+ repoRoot: root,
199
+ name: name.code === 0 ? name.stdout.trim() : '',
200
+ email: email.code === 0 ? email.stdout.trim() : '',
201
+ }
202
+ }
203
+
204
+ async function mergePoint(payload) {
205
+ const cwd = cwdOf(payload)
206
+ const root = await repoRootOf(cwd, payload)
207
+ const from = requireRef(payload.from, 'from')
208
+ const to = requireRef(payload.to, 'to')
209
+ const out = await git(root, ['rev-list', '--merges', '--ancestry-path', '--max-count=1', from + '..' + to])
210
+ const hash = out.trim().split('\n')[0] || ''
211
+ return { repoRoot: root, hash: hash === '' ? null : hash }
212
+ }
213
+
214
+ async function commitDetail(payload) {
215
+ const cwd = cwdOf(payload)
216
+ const root = await repoRootOf(cwd, payload)
217
+ const hash = requireRef(payload.hash, 'hash')
218
+ const metaFormat = ['%H', '%h', '%P', '%an', '%ae', '%aI', '%s', '%b', '%D'].join(FIELD_SEP)
219
+ const metaOut = await git(root, ['show', '-s', '--format=' + metaFormat, hash])
220
+ const parts = metaOut.trim().split(FIELD_SEP)
221
+ const parents = parts[2] === undefined || parts[2].trim() === '' ? [] : parts[2].trim().split(' ')
222
+ const numstatOut = await git(root, ['show', '--numstat', '-z', '--format=', '-m', '--first-parent', hash])
223
+ const files = []
224
+ const records = numstatOut.split('\u0000')
225
+ for (let index = 0; index < records.length; index += 1) {
226
+ const record = records[index]
227
+ if (record === '') continue
228
+ const firstTab = record.indexOf('\t')
229
+ const secondTab = record.indexOf('\t', firstTab + 1)
230
+ if (firstTab < 0 || secondTab < 0) continue
231
+ let filePath = record.slice(secondTab + 1)
232
+ let origPath
233
+ if (filePath === '') {
234
+ origPath = records[index + 1]
235
+ filePath = records[index + 2]
236
+ index += 2
237
+ }
238
+ const addRaw = record.slice(0, firstTab)
239
+ const delRaw = record.slice(firstTab + 1, secondTab)
240
+ const binary = addRaw === '-' || delRaw === '-'
241
+ files.push({
242
+ path: filePath === undefined ? '' : filePath,
243
+ origPath: origPath === undefined || origPath === '' ? undefined : origPath,
244
+ additions: binary ? 0 : Number.parseInt(addRaw, 10) || 0,
245
+ deletions: binary ? 0 : Number.parseInt(delRaw, 10) || 0,
246
+ binary,
247
+ })
248
+ }
249
+ return {
250
+ hash: parts[0],
251
+ shortHash: parts[1],
252
+ parents,
253
+ author: parts[3],
254
+ email: parts[4],
255
+ date: parts[5],
256
+ subject: parts[6],
257
+ body: parts[7] === undefined ? '' : parts[7],
258
+ refs: parts[8] === undefined || parts[8].trim() === '' ? [] : parts[8].split(',').map((ref) => ref.trim()).filter((ref) => ref !== ''),
259
+ files,
260
+ }
261
+ }
262
+
263
+ async function diff(payload) {
264
+ const cwd = cwdOf(payload)
265
+ const root = await repoRootOf(cwd, payload)
266
+ /* Untracked files have no index entry, so plain `git diff [--cached] -- path`
267
+ is legitimately empty for them — the panel's change list still shows the
268
+ file and the preview must not go blank. `--no-index /dev/null <file>`
269
+ produces the honest "new file" patch; its exit code is 1 when differences
270
+ exist, which is data, not failure (only >1 rejects). */
271
+ if (payload.untracked === true) {
272
+ const rel = requireRelativePath(payload.path)
273
+ const absolute = path.isAbsolute(rel) ? rel : path.join(root, rel)
274
+ /* --no-index reads the file for real, so unlike the plain `git diff --`
275
+ form an absolute path must NOT be allowed to escape the repository. */
276
+ if (absolute !== root && !absolute.startsWith(root + path.sep)) throw badRequest('path must stay inside the repository')
277
+ /* git quotes and C-escapes backslash paths inside the diff header (and
278
+ git ≥2.55 spells the a/ side with the real path instead of /dev/null);
279
+ passing forward slashes keeps the header unquoted, so the rewrite below
280
+ can match it verbatim. POSIX is a no-op. */
281
+ const headerPath = absolute.split(path.sep).join('/')
282
+ const result = await runGit(root, ['diff', '--no-color', '--no-ext-diff', '-U3', '--no-index', '--', '/dev/null', headerPath])
283
+ if (result.code !== 0 && result.code !== 1) {
284
+ const detail = result.stderr.trim() === '' ? result.stdout.trim() : result.stderr.trim()
285
+ throw new PanelError('git-failed', 'git diff --no-index failed: ' + (detail === '' ? 'exit ' + result.code : detail), 409)
286
+ }
287
+ /* --no-index spells the b/ side with the absolute path we passed; rewrite
288
+ the header lines back to the repository-relative spelling so the preview
289
+ reads like every other patch (body lines are left untouched). */
290
+ let out = result.stdout
291
+ if (out.indexOf(headerPath) >= 0 && out.indexOf('\n') !== -1) {
292
+ out = out.split('\n').map((line) => {
293
+ if (line.startsWith('diff --git ') && line.indexOf(headerPath) >= 0) return 'diff --git a/' + rel + ' b/' + rel
294
+ /* git ≥2.55 quotes the header and spells the a/ side with the real
295
+ path instead of /dev/null, so match on the path, not the prefix. */
296
+ if (line.startsWith('+++ ') && line.indexOf(headerPath) >= 0) return '+++ b/' + rel
297
+ return line
298
+ }).join('\n')
299
+ }
300
+ const truncated = out.length > MAX_DIFF_CHARS
301
+ return { patch: truncated ? out.slice(0, MAX_DIFF_CHARS) : out, truncated }
302
+ }
303
+ const args = ['diff', '--no-color', '--no-ext-diff', '-U3']
304
+ if (payload.staged === true) args.push('--cached')
305
+ if (typeof payload.hash === 'string' && payload.hash.trim() !== '') {
306
+ args.length = 0
307
+ args.push('show', '--no-color', '--no-ext-diff', '-U3', '--format=', requireRef(payload.hash, 'hash'))
308
+ }
309
+ if (typeof payload.path === 'string' && payload.path.trim() !== '') args.push('--', requireRelativePath(payload.path))
310
+ const out = await git(root, args)
311
+ const truncated = out.length > MAX_DIFF_CHARS
312
+ return { patch: truncated ? out.slice(0, MAX_DIFF_CHARS) : out, truncated }
313
+ }
314
+
315
+ /* The tail of the git run ring buffer (see runGit). Read-only, global. */
316
+ async function gitLogs() {
317
+ return { entries: gitLog.slice(-GIT_LOG_MAX_ENTRIES) }
318
+ }
319
+
320
+ async function compare(payload) {
321
+ const cwd = cwdOf(payload)
322
+ const root = await repoRootOf(cwd, payload)
323
+ const base = requireRef(payload.base, 'base')
324
+ const head = requireRef(payload.head, 'head')
325
+ const stat = await git(root, ['diff', '--numstat', '-z', base + '...' + head])
326
+ const files = []
327
+ const records = stat.split('\u0000')
328
+ for (let index = 0; index < records.length; index += 1) {
329
+ const record = records[index]
330
+ if (record === '') continue
331
+ const firstTab = record.indexOf('\t')
332
+ const secondTab = record.indexOf('\t', firstTab + 1)
333
+ if (firstTab < 0 || secondTab < 0) continue
334
+ const addRaw = record.slice(0, firstTab)
335
+ const delRaw = record.slice(firstTab + 1, secondTab)
336
+ const binary = addRaw === '-' || delRaw === '-'
337
+ files.push({
338
+ path: record.slice(secondTab + 1),
339
+ additions: binary ? 0 : Number.parseInt(addRaw, 10) || 0,
340
+ deletions: binary ? 0 : Number.parseInt(delRaw, 10) || 0,
341
+ binary,
342
+ })
343
+ }
344
+ const logOut = await git(root, ['log', '--max-count=100', '--date-order', '--pretty=format:' + LOG_FORMAT, base + '..' + head])
345
+ return { base, head, files, commits: parseLog(logOut) }
346
+ }
347
+
348
+ export { summary, stats, branches, log, me, mergePoint, commitDetail, diff, gitLogs, compare }
@@ -0,0 +1,222 @@
1
+ /** dsh-ide-git host: repository discovery for the picker, plus git --version. */
2
+
3
+ import { existsSync, realpathSync, readdirSync } from 'node:fs'
4
+
5
+ import path from 'node:path'
6
+
7
+ import { cwdOf } from './core.js'
8
+
9
+ import { runGit, originNameOf } from './git.js'
10
+
11
+
12
+
13
+ /** Directories never descended into while looking for repositories. */
14
+ const REPO_SCAN_SKIP = new Set(['node_modules', 'dist', 'build', 'out', 'target', 'vendor', 'venv', '.venv', '__pycache__', 'coverage', 'tmp', 'temp'])
15
+
16
+ /* `git submodule status --recursive` already reports every nesting level of a
17
+ repository in ONE call (paths arrive fully qualified), so the submodule walk
18
+ costs exactly one `status` per checkout; the lists stay bounded so a
19
+ pathological tree cannot flood the picker. */
20
+ const SUBMODULE_LIST_LIMIT = 400
21
+ const REPO_LIST_LIMIT = 60
22
+
23
+ /** Slash-normalized path, for comparing paths that came from different
24
+ * producers (git prints forward slashes; `path.join` prints backslashes on
25
+ * Windows). Case-insensitive on Windows so the same checkout reached two ways
26
+ * collapses to one row. */
27
+ function pathIdentity(value) {
28
+ const normalized = value.replace(/\\/g, '/').replace(/\/+$/, '')
29
+ const key = process.platform === 'win32' ? normalized.toLowerCase() : normalized
30
+ /* realpath collapses producer spellings of one checkout: git's
31
+ * --show-toplevel resolves symlinks (/private/var on macOS) while a
32
+ * directory walk keeps the spelling it was handed (/var), and without this
33
+ * the same submodule arrived as two rows. An unresolvable path falls back
34
+ * to the slash-normalized form. */
35
+ try {
36
+ return realpathSync(key).replace(/\\/g, '/').replace(/\/+$/, '')
37
+ } catch {
38
+ return key
39
+ }
40
+ }
41
+
42
+ /** One repository row shipped to the client (`kind` drives the picker badge). */
43
+ function repoRow(root, submodulePath, kind) {
44
+ return {
45
+ path: root.replace(/\\/g, '/'),
46
+ /** Full checkout name; a submodule carries its repo-relative path so two
47
+ * same-named checkouts stay apart in a long list. */
48
+ name: submodulePath === undefined ? path.basename(root) : submodulePath,
49
+ label: submodulePath === undefined ? path.basename(root) : submodulePath.split('/').pop(),
50
+ branch: null,
51
+ /** The checkout's own repository name, cut from its origin URL (v0.19.2):
52
+ * the picker shows this first and falls back to the directory name, with
53
+ * the directory name as the small sub line whenever the two differ — so
54
+ * two clones of one repository stay tellable apart. Filled in by the
55
+ * scan below (an origin-less checkout keeps the empty string). */
56
+ origin: '',
57
+ kind,
58
+ ...(submodulePath === undefined ? {} : { submodulePath }),
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Every checkout registered as a submodule under `root`, at any depth.
64
+ *
65
+ * Git itself is the source of truth: `git submodule status --recursive` lists
66
+ * the registered paths (relative to the parent repository) even for submodules
67
+ * that were never initialised, and `--recursive` walks EVERY nesting level in
68
+ * that one call — deeper entries arrive as fully qualified paths
69
+ * ("outer/inner"), so one `status` per checkout is the whole discovery. (An
70
+ * earlier revision also re-ran `status` inside every confirmed submodule; those
71
+ * calls could only re-list checkouts the recursive pass had already reported,
72
+ * which multiplied the process count on a workspace full of checkouts without
73
+ * producing a single new row.) A path that is present on disk and passes
74
+ * `rev-parse --show-toplevel` is a real, independent working tree — the parent
75
+ * only ever sees it as one gitlink line, so this is what makes it selectable
76
+ * as a repository of its own (the SourceTree behavior).
77
+ *
78
+ * The working-tree checks all run at once, because a repository with a dozen
79
+ * submodules otherwise costs a dozen sequential processes on every scan.
80
+ */
81
+ async function collectSubmoduleRepos(root) {
82
+ const found = []
83
+ const listing = await runGit(root, ['submodule', 'status', '--recursive'])
84
+ if (listing.code !== 0) return found
85
+ const candidates = []
86
+ for (const raw of listing.stdout.split('\n')) {
87
+ if (candidates.length >= SUBMODULE_LIST_LIMIT) break
88
+ const line = raw.replace(/\r$/, '')
89
+ if (line.trim() === '') continue
90
+ /* `<state><sha> <path> (<describe>)`: the state prefix is ' ', '+', '-' or 'U'. */
91
+ const match = /^[\s+\-U]([0-9a-f]{4,64})\s+(.+?)(?:\s+\(.*\))?$/.exec(line)
92
+ const rel = (match === null ? line.trim().replace(/^[\s+\-U]\S*\s+/, '') : match[2]).trim().replace(/\\/g, '/')
93
+ if (rel === '') continue
94
+ const absolute = path.resolve(root, rel)
95
+ /* An uninitialised submodule has no `.git` yet: git still registers it, but
96
+ there is no working tree to bind the panel to. */
97
+ if (!existsSync(path.join(absolute, '.git'))) continue
98
+ candidates.push({ absolute, rel })
99
+ }
100
+ const tops = await Promise.all(candidates.map((entry) => runGit(entry.absolute, ['rev-parse', '--show-toplevel'])))
101
+ for (let index = 0; index < candidates.length; index += 1) {
102
+ const top = tops[index]
103
+ if (top.code !== 0) continue
104
+ const reported = top.stdout.trim().replace(/\\/g, '/').replace(/\/+$/, '')
105
+ found.push({ root: reported === '' ? candidates[index].absolute : reported, rel: candidates[index].rel })
106
+ }
107
+ return found
108
+ }
109
+
110
+ /* The whole discovery pass is cached in-process per cwd: one scan walks the
111
+ tree and spawns a git process per checkout, while the client re-POSTs `repos`
112
+ on every panel mount — a warm answer is what keeps switching back to the Git
113
+ page instant on a workspace full of checkouts. Entries are { at, rows };
114
+ REPOS_CACHE_TTL_MS is a module-level `let` so tests can pin it to 0 and
115
+ observe the always-rescan path.
116
+
117
+ The cache is deliberately NOT invalidated when a write method succeeds:
118
+ `repos` is a read-only discovery of which checkouts EXIST, and no write
119
+ method changes that set — a commit or a new branch does not conjure a
120
+ repository. A clone landing inside the 15s window simply stays invisible
121
+ until the next scan; that is an accepted trade, the client's background
122
+ refresh is the safety net. */
123
+ export const repoScanCache = new Map()
124
+ export let REPOS_CACHE_TTL_MS = 15000
125
+
126
+ async function scanRepos(cwd) {
127
+ const cached = repoScanCache.get(cwd)
128
+ /* Hand back fresh row objects: the cache is shared by every caller, and a
129
+ consumer that decorates or reorders what it received must not write
130
+ through into the next request's answer. */
131
+ if (cached !== undefined && Date.now() - cached.at < REPOS_CACHE_TTL_MS) {
132
+ return { cached: true, rows: cached.rows.map((row) => ({ ...row })) }
133
+ }
134
+ const list = []
135
+ const self = await runGit(cwd, ['rev-parse', '--show-toplevel'])
136
+ const isRepo = self.code === 0
137
+ if (isRepo) {
138
+ const top = self.stdout.trim()
139
+ list.push(repoRow(top === '' ? cwd : top, undefined, 'workspace'))
140
+ }
141
+ for (const dir of findNestedRepos(cwd, 2)) list.push(repoRow(dir, undefined, 'nested'))
142
+ /* Submodules of EVERY discovered checkout, not only a workspace that is one:
143
+ a container workspace (a folder holding several checkouts — the usual
144
+ shape of a multi-repo project) reaches its repositories through the rows
145
+ above, and each of those may carry submodules of its own. Without this
146
+ pass the picker lists `eis` but none of the eight checkouts under it.
147
+ These rows are built LAST and the map below lets them win: the directory
148
+ scan can reach the same checkout through a plain path (a submodule whose
149
+ parent directory is not itself a repository), and the submodule row is the
150
+ better one — its name carries the repo-relative path. */
151
+ const roots = list.map((row) => row.path)
152
+ const submoduleLists = await Promise.all(roots.map((root) => collectSubmoduleRepos(root)))
153
+ for (const entries of submoduleLists) {
154
+ for (const entry of entries) list.push(repoRow(entry.root, entry.rel, 'nested'))
155
+ }
156
+ const byIdentity = new Map()
157
+ for (const row of list) byIdentity.set(pathIdentity(row.path), row)
158
+ const rows = [...byIdentity.values()]
159
+ rows.sort((a, b) => (a.kind === b.kind ? a.path.localeCompare(b.path) : a.kind === 'workspace' ? -1 : 1))
160
+ const capped = rows.slice(0, REPO_LIST_LIMIT).map((row) => ({ ...row }))
161
+ const branches = await Promise.all(capped.map((row) => branchOrNull(row.path)))
162
+ for (let index = 0; index < capped.length; index += 1) capped[index].branch = branches[index]
163
+ /* Origin names ride the same one-call-per-row pass as the branches: a local
164
+ `git remote get-url` read, cheap, and the 15s cache means repeated picker
165
+ opens pay for it once. */
166
+ const origins = await Promise.all(capped.map((row) => originNameOf(row.path)))
167
+ for (let index = 0; index < capped.length; index += 1) capped[index].origin = origins[index]
168
+ repoScanCache.set(cwd, { at: Date.now(), rows: capped })
169
+ return { cached: false, rows: capped.map((row) => ({ ...row })) }
170
+ }
171
+
172
+ /** Depth-capped search for repositories nested in a workspace. */
173
+ function findNestedRepos(root, maxDepth) {
174
+ const found = []
175
+ const walk = (dir, depth) => {
176
+ if (found.length >= 40 || depth > maxDepth) return
177
+ let entries
178
+ try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
179
+ for (const entry of entries) {
180
+ if (!entry.isDirectory() || entry.name.startsWith('.') || REPO_SCAN_SKIP.has(entry.name)) continue
181
+ const child = path.join(dir, entry.name)
182
+ if (existsSync(path.join(child, '.git'))) { found.push(child); continue }
183
+ if (depth < maxDepth) walk(child, depth + 1)
184
+ }
185
+ }
186
+ walk(root, 1)
187
+ return found
188
+ }
189
+
190
+ async function branchOrNull(root) {
191
+ const result = await runGit(root, ['rev-parse', '--abbrev-ref', 'HEAD'])
192
+ if (result.code !== 0) return null
193
+ const name = result.stdout.trim()
194
+ return name === '' ? null : name
195
+ }
196
+
197
+ /**
198
+ * Repositories this panel may bind to: the session workspace itself when it is
199
+ * one, repositories nested inside it (a sandbox holding many checkouts), and
200
+ * the submodules of every checkout discovered that way — git records each
201
+ * submodule as its own working tree with its own HEAD, index and branches, so
202
+ * it is a repository the panel can bind to like any other. The client turns
203
+ * this into the repo picker, and picking a row is all it takes: every later
204
+ * call carries that path as `repoRoot`, which is the working directory the git
205
+ * commands run in.
206
+ */
207
+ async function repos(payload) {
208
+ const cwd = cwdOf(payload)
209
+ const { cached, rows } = await scanRepos(cwd)
210
+ /* `cached: true` rides a cache hit only; the client ignores it today, it just
211
+ makes the answer say where it came from. */
212
+ return { cwd, isRepo: rows.some((row) => row.kind === 'workspace'), repos: rows, ...(cached ? { cached: true } : {}) }
213
+ }
214
+
215
+ async function version() {
216
+ const result = await runGit(process.cwd(), ['--version'])
217
+ const raw = result.code === 0 ? result.stdout.trim() : ''
218
+ const match = /git version (\S+)/.exec(raw)
219
+ return { git: match === null ? raw : match[1], available: result.code === 0 }
220
+ }
221
+
222
+ export { repos, version }