Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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": [
Expand Down
157 changes: 141 additions & 16 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
/* `<state><sha> <path> (<describe>)`: 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 = []
Expand Down Expand Up @@ -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() {
Expand Down
206 changes: 206 additions & 0 deletions tests/repos.test.mjs
Original file line number Diff line number Diff line change
@@ -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 })
}
})