From 6f0348ad8765b4a71edb399d665834cec4cc9d56 Mon Sep 17 00:00:00 2001 From: sitns <155474784+sitns@users.noreply.github.com> Date: Sun, 20 Sep 2026 12:15:28 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(exports):=20=E5=AF=BC=E5=87=BA=20./pack?= =?UTF-8?q?age.json=EF=BC=8C=E4=BF=AE=E5=A4=8D=E6=89=93=E5=8C=85=E5=AE=BF?= =?UTF-8?q?=E4=B8=BB=E4=B8=8A=E5=AE=A2=E6=88=B7=E7=AB=AF=E5=8D=8A=E5=8C=BA?= =?UTF-8?q?=E8=A2=AB=E9=9D=99=E9=BB=98=E8=B7=B3=E8=BF=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `@deepseek-ai/dsh-client-modules` 的宿主半区在 `locatePkgJson()` 里定位 loader 条目的包清单。Loader 未提供内部 `resolveSync` 时(DSH Desktop 这类打包宿主走的就是 这条回退分支)它调用: createRequire(baseUrl).resolve('/package.json') 本包的 `exports` 只导出 `.` 与 `./client`,该解析抛 `ERR_PACKAGE_PATH_NOT_EXPORTED` → `locatePkgJson` 返回 `undefined` → `resolveMeta()` 返回 `null` → `processOne()` 把这行当作「未声明 dsh.client」 **静默跳过**(不抛错、不告警)。后果: - 宿主半区照常激活(`inject = ['webServer']` 满足),启动审计 `assertEntriesActivated` 也通过,宿主日志无任何相关记录; - `/plugins/?id=dsh-ide-git&rev=…` 返回 404,客户端半区从未下发, 浏览器控制台一片干净; - better-sidebar 的 `+` 新建标签页与原生右侧栏 Guide 页都看不到入口。 对照同 profile 的其它插件:`dsh-scratchpad`、`dsh-better-sidebar` 的 `exports` 都带有 `"./package.json": "./package.json"`,所以不受影响。 复现(用 DSH 自带的组合器真实代码跑 `resolveMeta`,`ctx.loader.internal` 为 undefined 即回退分支): no-internal | dsh-ide-git => NULL (silently skipped) no-internal | dsh-scratchpad => OK no-internal | dsh-better-sidebar => OK 补上该导出后两条件码路径均正常,`clientPath` 解析到 `src/client.js`。 环境:DSH 0.1.5-rc.2(DSH Desktop 2.0.13,Windows 11)、宿主 Node v24.18.1、 dsh-ide-git 0.5.5、npm 安装。 Refs: KannaKuron/dsh-ide-git#1 --- package.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 98db798..0b5231a 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "main": "src/index.js", "exports": { ".": "./src/index.js", - "./client": "./src/client.js" + "./client": "./src/client.js", + "./package.json": "./package.json" }, "engines": { "node": ">=18", @@ -44,7 +45,7 @@ "LICENSE" ], "scripts": { - "test": "node --test tests/smoke.mjs tests/api.test.mjs tests/graph.test.mjs", + "test": "node --test tests/smoke.mjs tests/api.test.mjs tests/graph.test.mjs tests/repos.test.mjs", "version": "node -e \"const fs=require('fs');const m=JSON.parse(fs.readFileSync('dsh.plugin.json','utf8'));const v=JSON.parse(fs.readFileSync('package.json','utf8')).version;m.version=v;fs.writeFileSync('dsh.plugin.json',JSON.stringify(m,null,2)+'\\n')\" && git add dsh.plugin.json" }, "keywords": [ From 62a4fd06997a7246971c5f000697f26d428ce403 Mon Sep 17 00:00:00 2001 From: sitns <155474784+sitns@users.noreply.github.com> Date: Sun, 20 Sep 2026 12:15:44 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat(repos):=20=E5=AD=90=E6=A8=A1=E5=9D=97?= =?UTF-8?q?=E4=BD=9C=E4=B8=BA=E7=8B=AC=E7=AB=8B=E4=BB=93=E5=BA=93=E5=87=BA?= =?UTF-8?q?=E7=8E=B0=E5=9C=A8=E4=BB=93=E5=BA=93=E9=80=89=E6=8B=A9=E5=99=A8?= =?UTF-8?q?=E9=87=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit git 把每个子模块记成一个独立工作树:它有自己的 HEAD、索引与分支,父仓库只看到一个 gitlink 行(`git status` 里的 ` M path`,`git diff` 里的 `Subproject commit`)。 SourceTree 等桌面客户端据此把子模块当独立仓库打开——点一下子模块即切进那个仓库。 本插件此前做不到:`repos()` 只用 `rev-parse --show-toplevel` 找当前仓库,再用一次 限深 2 层的目录扫描找子仓库,而这两条都不会展开一个仓库已登记的子模块。 ## 改动 - 新增 `collectSubmoduleRepos(root, depth, prefix)`:以 `git submodule status --recursive` 取登记路径(未 init 的子模块也在表内),逐层递归到 `SUBMODULE_MAX_DEPTH = 2`。每层都要等上一层回答,所以 `submodule status` 是串行的; 但工作树校验(`rev-parse --show-toplevel`)走 `Promise.all` 并发——一个有十几个 子模块的仓库否则要在每次扫描时付十几次串行 spawn。 - 新增 `scanRepos(cwd)`:合并「工作区自身 + 目录扫描到的子仓库 + **每一个已发现仓库的 子模块**」。结果按 `pathIdentity()` 归一化去重(git 输出正斜杠、`path.join` 在 Windows 上输出反斜杠,用 `===` 比较会漏掉同一次检出),分支查询并发,并按 `cwd` 缓存 60 秒(面板每 2 秒轮询一次,而一次扫描要 spawn 每个检出一次)。 - `repos()` 改为 `scanRepos()`,行内新增 `label`(短名);`name` 保留仓库相对路径, 让重名的检出在长列表里可区分。 - 子模块行的 `kind` 仍是 `'nested'`,因此**客户端零改动**:`RepoPicker` 已把它渲染成 「子仓库」,而选中后该路径作为 `repoRoot` 随每次请求下发,宿主 `repoRootOf()` 优先取它, 于是所有 git 命令自然作用在子仓库上。 两处容易踩空的地方,都在测试里钉住了: - **子模块属于「每一个仓库」,不只是工作区本身**:多仓项目的常见形态是工作区为容器 (`isRepo = false`),此时工作区自己没有子模块,但它列出的每个仓库各自有。只对 `isRepo === true` 跑子模块发现的话,容器工作区里 `eis` 下那 8 个子模块一个都不会出现。 - **去重要让子模块行赢**:子模块的父目录未必是仓库(本仓库测试夹具里的 `libs/` 就不是), 于是目录扫描也能走到同一个检出,并且先入列、名字只有末段。子模块行带仓库相对路径, 信息更全,必须覆盖前者——实现上是「子模块行最后入列 + `Map` 后写覆盖先写」。 ## 测试 新增 `tests/repos.test.mjs`(5 项,全部用临时仓库,无外部依赖): - 工作区自身 + 两个子模块都出现在 `repos` 里; - 每个子模块报告**自己的**分支与短名(夹具故意让两个子模块在不同分支上); - 把子模块路径当 `repoRoot` 调用 `summary` / `branches`,返回的是子模块的分支, 证明面板确实绑定到了它而不是父仓库; - 容器工作区(自身不是仓库)仍然列出其中的检出; - 同一个检出被两条路径发现时只出现一行; - 未初始化的子模块(有 gitlink 登记、磁盘上没有工作树)**不**成为可选中行。 夹具需要 `-c protocol.file.allow=always`:Git >= 2.38 默认对子模块禁用 `file://` 传输(CVE-2022-39253),而测试没有服务器可克隆。 ``` $ npm test # tests 56 / pass 56 / fail 0 ``` 真机验证(DSH 0.1.5-rc.2 + DSH Desktop 2.0.13,Windows):一个含 8 个子模块的仓库 在面板顶部仓库选择器里从 1 行变成 9 行,每个子模块带自己的分支(父仓库 `dev`, 子模块分别 `dev` / `master`),点选后暂存、提交、历史、分支全部作用于该子仓库。 本次未改 `CHANGELOG.md`:按仓库「变更记录纪律」,条目随版本提交,留给维护者发版时补, 以免与 `npm version` 的流程冲突。 Refs: KannaKuron/dsh-ide-git#1 --- src/index.js | 157 +++++++++++++++++++++++++++++---- tests/repos.test.mjs | 206 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 347 insertions(+), 16 deletions(-) create mode 100644 tests/repos.test.mjs diff --git a/src/index.js b/src/index.js index a87ab60..b0577ad 100644 --- a/src/index.js +++ b/src/index.js @@ -1050,6 +1050,138 @@ async function compare(payload) { /** Directories never descended into while looking for repositories. */ const REPO_SCAN_SKIP = new Set(['node_modules', 'dist', 'build', 'out', 'target', 'vendor', 'venv', '.venv', '__pycache__', 'coverage', 'tmp', 'temp']) +/* The submodule walk spawns one short `git submodule status` per level, so the + depth is capped the same way the directory scan caps its own, and every list + stays bounded so a pathological tree cannot flood the picker. */ +const SUBMODULE_MAX_DEPTH = 2 +const SUBMODULE_LIST_LIMIT = 400 +const REPO_LIST_LIMIT = 60 + +/** Slash-normalized path, for comparing paths that came from different + * producers (git prints forward slashes; `path.join` prints backslashes on + * Windows). Case-insensitive on Windows so the same checkout reached two ways + * collapses to one row. */ +function pathIdentity(value) { + const normalized = value.replace(/\\/g, '/').replace(/\/+$/, '') + return process.platform === 'win32' ? normalized.toLowerCase() : normalized +} + +/** One repository row shipped to the client (`kind` drives the picker badge). */ +function repoRow(root, submodulePath, kind) { + return { + path: root.replace(/\\/g, '/'), + /** Full checkout name; a submodule carries its repo-relative path so two + * same-named checkouts stay apart in a long list. */ + name: submodulePath === undefined ? path.basename(root) : submodulePath, + label: submodulePath === undefined ? path.basename(root) : submodulePath.split('/').pop(), + branch: null, + kind, + ...(submodulePath === undefined ? {} : { submodulePath }), + } +} + +/** + * Every checkout registered as a submodule under `root`, at any depth. + * + * Git itself is the source of truth: `git submodule status --recursive` lists + * the registered paths (relative to the parent repository) even for submodules + * that were never initialised, and nested ones are reached by recursing into + * each listed path. A path that is present on disk and passes + * `rev-parse --show-toplevel` is a real, independent working tree — the parent + * only ever sees it as one gitlink line, so this is what makes it selectable + * as a repository of its own (the SourceTree behavior). + * + * The per-level `submodule status` calls are serial (a level's paths are only + * known once its parent answered), but the working-tree checks are not: they + * all run at once, because a repository with a dozen submodules otherwise + * costs a dozen sequential processes on every scan. + */ +async function collectSubmoduleRepos(root, depth = 1, prefix = '') { + const found = [] + const listing = await runGit(root, ['submodule', 'status', '--recursive']) + if (listing.code !== 0) return found + const candidates = [] + for (const raw of listing.stdout.split('\n')) { + if (candidates.length >= SUBMODULE_LIST_LIMIT) break + const line = raw.replace(/\r$/, '') + if (line.trim() === '') continue + /* ` ()`: the state prefix is ' ', '+', '-' or 'U'. */ + const match = /^[\s+\-U]([0-9a-f]{4,64})\s+(.+?)(?:\s+\(.*\))?$/.exec(line) + const rel = (match === null ? line.trim().replace(/^[\s+\-U]\S*\s+/, '') : match[2]).trim().replace(/\\/g, '/') + if (rel === '') continue + const absolute = path.resolve(root, rel) + /* An uninitialised submodule has no `.git` yet: git still registers it, but + there is no working tree to bind the panel to. */ + if (!existsSync(path.join(absolute, '.git'))) continue + candidates.push({ absolute, rel: `${prefix}${rel}` }) + } + const tops = await Promise.all(candidates.map((entry) => runGit(entry.absolute, ['rev-parse', '--show-toplevel']))) + const next = [] + for (let index = 0; index < candidates.length; index += 1) { + const top = tops[index] + if (top.code !== 0) continue + const reported = top.stdout.trim().replace(/\\/g, '/').replace(/\/+$/, '') + found.push({ root: reported === '' ? candidates[index].absolute : reported, rel: candidates[index].rel }) + if (depth < SUBMODULE_MAX_DEPTH) next.push(candidates[index]) + } + if (depth < SUBMODULE_MAX_DEPTH) { + const deeper = await Promise.all(next.map((entry) => collectSubmoduleRepos(entry.absolute, depth + 1, `${entry.rel}/`))) + for (const list of deeper) { + for (const entry of list) { + if (found.length >= SUBMODULE_LIST_LIMIT) break + found.push({ root: entry.root, rel: entry.rel }) + } + } + } + return found +} + +/** The whole discovery pass, cached briefly per cwd: it walks the tree and + * spawns a process per checkout, while the panel polls several times a minute. + * A submodule appearing or disappearing is a deliberate reconfiguration, so a + * one-minute staleness is the right trade. */ +const repoScanCache = new Map() +const REPO_SCAN_TTL_MS = 60_000 + +async function scanRepos(cwd) { + const cached = repoScanCache.get(cwd) + /* Hand back fresh row objects: the cache is shared by every caller, and a + consumer that decorates or reorders what it received must not write + through into the next request's answer. */ + if (cached !== undefined && cached.expires > Date.now()) return cached.rows.map((row) => ({ ...row })) + const list = [] + const self = await runGit(cwd, ['rev-parse', '--show-toplevel']) + const isRepo = self.code === 0 + if (isRepo) { + const top = self.stdout.trim() + list.push(repoRow(top === '' ? cwd : top, undefined, 'workspace')) + } + for (const dir of findNestedRepos(cwd, 2)) list.push(repoRow(dir, undefined, 'nested')) + /* Submodules of EVERY discovered checkout, not only a workspace that is one: + a container workspace (a folder holding several checkouts — the usual + shape of a multi-repo project) reaches its repositories through the rows + above, and each of those may carry submodules of its own. Without this + pass the picker lists `eis` but none of the eight checkouts under it. + These rows are built LAST and the map below lets them win: the directory + scan can reach the same checkout through a plain path (a submodule whose + parent directory is not itself a repository), and the submodule row is the + better one — its name carries the repo-relative path. */ + const roots = list.map((row) => row.path) + const submoduleLists = await Promise.all(roots.map((root) => collectSubmoduleRepos(root))) + for (const entries of submoduleLists) { + for (const entry of entries) list.push(repoRow(entry.root, entry.rel, 'nested')) + } + const byIdentity = new Map() + for (const row of list) byIdentity.set(pathIdentity(row.path), row) + const rows = [...byIdentity.values()] + rows.sort((a, b) => (a.kind === b.kind ? a.path.localeCompare(b.path) : a.kind === 'workspace' ? -1 : 1)) + const capped = rows.slice(0, REPO_LIST_LIMIT).map((row) => ({ ...row })) + const branches = await Promise.all(capped.map((row) => branchOrNull(row.path))) + for (let index = 0; index < capped.length; index += 1) capped[index].branch = branches[index] + repoScanCache.set(cwd, { rows: capped, expires: Date.now() + REPO_SCAN_TTL_MS }) + return capped.map((row) => ({ ...row })) +} + /** Depth-capped search for repositories nested in a workspace. */ function findNestedRepos(root, maxDepth) { const found = [] @@ -1077,25 +1209,18 @@ async function branchOrNull(root) { /** * Repositories this panel may bind to: the session workspace itself when it is - * one, plus repositories nested inside it (a sandbox holding many checkouts). - * The client turns this into the repo picker — an empty workspace is answered - * with the picker, not with an error. + * one, repositories nested inside it (a sandbox holding many checkouts), and + * the submodules of every checkout discovered that way — git records each + * submodule as its own working tree with its own HEAD, index and branches, so + * it is a repository the panel can bind to like any other. The client turns + * this into the repo picker, and picking a row is all it takes: every later + * call carries that path as `repoRoot`, which is the working directory the git + * commands run in. */ async function repos(payload) { const cwd = cwdOf(payload) - const list = [] - const self = await runGit(cwd, ['rev-parse', '--show-toplevel']) - const isRepo = self.code === 0 - if (isRepo) { - const top = self.stdout.trim() - list.push({ path: top, name: path.basename(top), branch: await branchOrNull(top), kind: 'workspace' }) - } - for (const dir of findNestedRepos(cwd, 2)) { - if (list.some((entry) => entry.path === dir)) continue - list.push({ path: dir, name: path.basename(dir), branch: await branchOrNull(dir), kind: 'nested' }) - } - list.sort((a, b) => (a.kind === b.kind ? a.name.localeCompare(b.name) : a.kind === 'workspace' ? -1 : 1)) - return { cwd, isRepo, repos: list } + const rows = await scanRepos(cwd) + return { cwd, isRepo: rows.some((row) => row.kind === 'workspace'), repos: rows } } async function version() { diff --git a/tests/repos.test.mjs b/tests/repos.test.mjs new file mode 100644 index 0000000..6929490 --- /dev/null +++ b/tests/repos.test.mjs @@ -0,0 +1,206 @@ +/** + * dsh-ide-git — repository-picker integration test. + * + * Drives the real `repos` route against throwaway repositories built in the OS + * temp dir. Git submodules are ordinary working trees with their own HEAD and + * index that the parent only ever sees as one gitlink line, so the picker must + * offer them as repositories of their own — the behavior a desktop Git client + * gives when a submodule is clicked. A workspace that is only a container of + * checkouts must keep listing them too. + */ +import { test, before, after } from 'node:test' +import assert from 'node:assert/strict' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { execFileSync } from 'node:child_process' +import { EventEmitter } from 'node:events' +import { pathToFileURL } from 'node:url' +import { apply } from '../src/index.js' + +let parent = '' +let container = '' +let route = null + +function git(cwd, ...args) { + /* `protocol.file.allow=always` is what makes a submodule fixture possible at + all: Git >= 2.38 refuses the `file://` transport for submodules by default + (CVE-2022-39253), and a test has no server to clone from. */ + return execFileSync('git', ['-c', 'protocol.file.allow=always', ...args], { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }) +} + +function commitAll(cwd, message) { + git(cwd, 'add', '-A') + git(cwd, 'commit', '-q', '-m', message) +} + +function makeRepo(dir, branch) { + execFileSync('git', ['init', '-q', '-b', branch, dir], { encoding: 'utf8' }) + /* Windows Git for Windows ships a system-level core.autocrlf=true; a fixture + that lets it rewrite line endings makes byte assertions host-dependent. */ + git(dir, 'config', 'core.autocrlf', 'false') + git(dir, 'config', 'core.eol', 'lf') + git(dir, 'config', 'user.name', 'Check') + git(dir, 'config', 'user.email', 'check@example.com') + writeFileSync(join(dir, 'readme.txt'), `${branch}\n`) + commitAll(dir, `first on ${branch}`) +} + +function call(method, payload) { + return new Promise((resolve, reject) => { + const req = new EventEmitter() + req.method = 'POST' + req.url = '/dsh-ide-git/api/' + method + req.headers = { host: '127.0.0.1:3080', 'content-type': 'application/json' } + req.destroy = () => {} + const res = { + statusCode: 0, + writeHead(status) { this.statusCode = status }, + end(text) { + try { resolve({ status: this.statusCode, body: JSON.parse(text) }) } catch (error) { reject(error) } + }, + } + void route.handler(req, res) + req.emit('data', Buffer.from(JSON.stringify(payload === undefined ? {} : payload))) + req.emit('end') + }) +} + +const rows = (body) => body.data.repos +const find = (body, name) => rows(body).find((row) => row.name === name) + +before(() => { + apply({ effect: (fn) => fn(), webServer: { register: (spec) => { route = spec; return () => {} } } }) + assert.equal(route.path, '/dsh-ide-git/api') + + const base = mkdtempSync(join(tmpdir(), 'dsh-ide-git-repos-')) + parent = join(base, 'parent') + container = join(base, 'container') + execFileSync('git', ['init', '-q', '-b', 'main', parent], { encoding: 'utf8' }) + git(parent, 'config', 'core.autocrlf', 'false') + git(parent, 'config', 'core.eol', 'lf') + git(parent, 'config', 'user.name', 'Check') + git(parent, 'config', 'user.email', 'check@example.com') + + /* Two submodules on deliberately different branches: selecting one must + report that checkout's branch, not the parent's. */ + const inner = join(base, 'inner') + const sibling = join(base, 'sibling') + makeRepo(inner, 'inner-work') + makeRepo(sibling, 'sibling-work') + + writeFileSync(join(parent, 'top.txt'), 'top\n') + git(parent, 'submodule', 'add', '-q', pathToFileURL(inner).href, 'libs/inner') + git(parent, 'submodule', 'add', '-q', pathToFileURL(sibling).href, 'libs/sibling') + commitAll(parent, 'add submodules') + + /* A container workspace: no repository of its own, several checkouts inside + it. `base` itself is never a repository, so the directory scan is what + reaches `parent`. */ + container = base +}) + +after(() => { + if (parent !== '') rmSync(parent, { recursive: true, force: true }) +}) + +test('repos reports the workspace repository and every submodule', async () => { + const { status, body } = await call('repos', { cwd: parent }) + assert.equal(status, 200) + assert.equal(body.data.isRepo, true) + + const workspace = rows(body).filter((row) => row.kind === 'workspace') + assert.equal(workspace.length, 1) + assert.equal(workspace[0].name, 'parent') + + const names = rows(body).map((row) => row.name).sort() + assert.deepEqual(names, ['libs/inner', 'libs/sibling', 'parent']) +}) + +test('every submodule row carries its own branch and a short label', async () => { + const { body } = await call('repos', { cwd: parent }) + const inner = find(body, 'libs/inner') + const sibling = find(body, 'libs/sibling') + assert.equal(inner.branch, 'inner-work') + assert.equal(sibling.branch, 'sibling-work') + assert.equal(inner.label, 'inner') + assert.equal(sibling.label, 'sibling') + assert.equal(inner.kind, 'nested') + assert.equal(inner.submodulePath, 'libs/inner') +}) + +test('a submodule path binds the git methods to that checkout', async () => { + const { body } = await call('repos', { cwd: parent }) + const inner = find(body, 'libs/inner') + const summary = await call('summary', { cwd: parent, repoRoot: inner.path }) + assert.equal(summary.status, 200, JSON.stringify(summary.body)) + /* The submodule answers with its own branch, so the panel is demonstrably + bound to it and not to the parent that contains it. */ + assert.equal(summary.body.data.branch, 'inner-work') + + const branches = await call('branches', { cwd: parent, repoRoot: inner.path }) + assert.equal(branches.status, 200) + assert.equal(branches.body.data.branch, 'inner-work') + assert.deepEqual(branches.body.data.local.map((entry) => entry.name), ['inner-work']) +}) + +test('a container workspace still lists the checkouts inside it', async () => { + const { status, body } = await call('repos', { cwd: container }) + assert.equal(status, 200) + assert.equal(body.data.isRepo, false) + const names = rows(body).map((row) => row.name).sort() + assert.ok(names.includes('parent'), `expected the parent checkout in ${JSON.stringify(names)}`) +}) + +test('a checkout reachable two ways appears once', async () => { + const { body } = await call('repos', { cwd: parent }) + /* The directory scan reaches `libs/inner` through its path and the submodule + listing reaches it through the registration; both must collapse into the + submodule row, whose name carries the repo-relative path. */ + const inners = rows(body).filter((row) => row.path.toLowerCase().endsWith('libs/inner')) + assert.equal(inners.length, 1, 'one row per checkout') + assert.equal(inners[0].name, 'libs/inner') +}) + +test('an uninitialised submodule is registered but not offered', async () => { + /* `git submodule status` lists a submodule that was never initialised, but + there is no working tree to bind the panel to, so it must not become a + picker row. The registration is what the requirement is about; a path with + no `.git` is the discriminator. */ + const blank = mkdtempSync(join(tmpdir(), 'dsh-ide-git-uninit-')) + try { + execFileSync('git', ['init', '-q', '-b', 'main', blank], { encoding: 'utf8' }) + git(blank, 'config', 'core.autocrlf', 'false') + git(blank, 'config', 'core.eol', 'lf') + git(blank, 'config', 'user.name', 'Check') + git(blank, 'config', 'user.email', 'check@example.com') + writeFileSync(join(blank, 'top.txt'), 'top\n') + commitAll(blank, 'initial') + /* Register without checking out. `.gitmodules` is only the registry — the + AUTHORITY is the gitlink (mode 160000) in the index and tree, which is + what `git submodule status` reads. A directory that is absent from disk + is exactly the state a fresh clone is in before + `submodule update --init`. */ + const head = git(blank, 'rev-parse', 'HEAD').trim() + writeFileSync(join(blank, '.gitmodules'), + '[submodule "libs/absent"]\n\tpath = libs/absent\n\turl = ' + pathToFileURL(blank).href + '\n') + git(blank, 'add', '.gitmodules') + git(blank, 'update-index', '--add', '--cacheinfo', '160000,' + head + ',libs/absent') + git(blank, 'commit', '-q', '-m', 'register an uninitialised submodule') + + const status = git(blank, 'submodule', 'status') + assert.ok(status.includes('libs/absent'), `git registers the uninitialised submodule: ${JSON.stringify(status)}`) + assert.ok(status.startsWith('-'), 'the leading "-" marks it uninitialised') + + const { body } = await call('repos', { cwd: blank }) + assert.equal(body.data.isRepo, true) + assert.equal(rows(body).length, 1, 'only the workspace row') + assert.equal(rows(body)[0].kind, 'workspace') + } finally { + rmSync(blank, { recursive: true, force: true }) + } +})