From 421b5443b4c530252814e1069971bcbe5b6d1b3e Mon Sep 17 00:00:00 2001 From: jchrist Date: Thu, 22 Oct 2026 12:22:22 +0300 Subject: [PATCH] fix: discover repositories beneath workspace folders Opening a parent folder previously left Graphima without a selected repository even when it contained multiple projects. Discover descendant repositories so the existing switcher can show their histories, while avoiding duplicate entries and unnecessary scans inside repositories. --- src/git/index.js | 53 ++++++++++++++++-- test/gitRoots.test.js | 124 ++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 166 insertions(+), 11 deletions(-) diff --git a/src/git/index.js b/src/git/index.js index c5d005b..dd1403b 100644 --- a/src/git/index.js +++ b/src/git/index.js @@ -1,3 +1,5 @@ +const fs = require("node:fs/promises"); +const path = require("node:path"); const { findRepoRoot } = require("./cliGit"); const { HybridGitService } = require("./vscodeGit"); @@ -14,6 +16,41 @@ async function createGitService(vscode, output) { return store.getService(); } +async function findWorkspaceRepositories(startDir) { + const root = await findRepoRoot(startDir); + if (root) { + return [{ folder: startDir, root }]; + } + const repos = []; + const pending = [startDir]; + for (let i = 0; i < pending.length; i++) { + const folder = pending[i]; + let entries; + try { + entries = await fs.readdir(folder, { withFileTypes: true }); + } catch (err) { + if (["EACCES", "EPERM", "ENOENT", "ENOTDIR"].includes(err.code)) { + continue; + } + throw err; + } + if (entries.some((entry) => entry.name === ".git")) { + const repoRoot = await findRepoRoot(folder); + if (repoRoot) { + repos.push({ folder, root: repoRoot }); + // Stop at repository boundaries to avoid crawling source and build trees. + continue; + } + } + for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { + if (entry.isDirectory() && entry.name !== ".git" && entry.name !== "node_modules") { + pending.push(path.join(folder, entry.name)); + } + } + } + return repos; +} + // Multi-root store: one service per repository, one active at a time. // The webview lists `roots`, switches with `useRoot`, and talks to the // active service for everything else. @@ -21,11 +58,17 @@ async function createRootStore(vscode, output) { const roots = []; const services = new Map(); for (const startDir of getWorkspaceRoots(vscode)) { - const name = startDir.split("/").pop() || startDir; - const repoRoot = await findRepoRoot(startDir); - roots.push({ name, folder: startDir, root: repoRoot || null }); - if (repoRoot && !services.has(repoRoot)) { - services.set(repoRoot, new HybridGitService(vscode, repoRoot)); + const repos = await findWorkspaceRepositories(startDir); + if (repos.length === 0) { + roots.push({ name: path.basename(startDir) || startDir, folder: startDir, root: null }); + } + for (const { folder, root } of repos) { + if (services.has(root)) { + continue; + } + const name = path.relative(startDir, folder) || path.basename(startDir) || startDir; + roots.push({ name, folder, root }); + services.set(root, new HybridGitService(vscode, root)); } } let activeRoot = (roots.find((r) => r.root) || {}).root; diff --git a/test/gitRoots.test.js b/test/gitRoots.test.js index c6ca9a3..0be0898 100644 --- a/test/gitRoots.test.js +++ b/test/gitRoots.test.js @@ -1,17 +1,24 @@ -import { describe, it, beforeAll, afterAll } from "vite-plus/test"; +import { describe, it, beforeAll, afterAll, vi } from "vite-plus/test"; import assert from "node:assert/strict"; import fs from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { createRootStore } from "../src/git/index.js"; +import { activate } from "../src/extension.js"; +import { GitWindowPanel } from "../src/GitWindowPanel.js"; +import { createFakeVscode } from "./helpers/fakeVscode.js"; const ex = promisify(execFile); describe("createRootStore", () => { + let fixtureDir; let repoDir; let plainDir; + let projectsDir; + let alphaDir; + let betaDir; + let worktreeDir; function fakeVscode(...dirs) { return { @@ -22,19 +29,39 @@ describe("createRootStore", () => { } beforeAll(async () => { - repoDir = await fs.mkdtemp(path.join(os.tmpdir(), "graphima-root-a-")); - plainDir = await fs.mkdtemp(path.join(os.tmpdir(), "graphima-root-b-")); + const resultsDir = path.resolve("test-results"); + await fs.mkdir(resultsDir, { recursive: true }); + fixtureDir = await fs.mkdtemp(path.join(resultsDir, "graphima-roots-")); + vi.stubEnv("GIT_CEILING_DIRECTORIES", fixtureDir); + repoDir = path.join(fixtureDir, "repo"); + plainDir = path.join(fixtureDir, "plain"); + await fs.mkdir(repoDir); + await fs.mkdir(plainDir); await ex("git", ["init", "-b", "main", "--quiet"], { cwd: repoDir }); await ex("git", ["config", "user.email", "t@t"], { cwd: repoDir }); await ex("git", ["config", "user.name", "t"], { cwd: repoDir }); + await ex("git", ["config", "commit.gpgsign", "false"], { cwd: repoDir }); await fs.writeFile(path.join(repoDir, "f.txt"), "x\n"); await ex("git", ["add", "f.txt"], { cwd: repoDir }); await ex("git", ["commit", "-m", "init", "--quiet"], { cwd: repoDir }); + + projectsDir = path.join(fixtureDir, "projects"); + alphaDir = path.join(projectsDir, "alpha"); + betaDir = path.join(projectsDir, "team", "beta"); + worktreeDir = path.join(projectsDir, "linked"); + await fs.cp(repoDir, alphaDir, { recursive: true }); + await fs.cp(repoDir, betaDir, { recursive: true }); + await ex("git", ["commit", "--allow-empty", "-m", "beta change", "--quiet"], { + cwd: betaDir, + }); + await ex("git", ["worktree", "add", "--detach", "--quiet", worktreeDir, "HEAD"], { + cwd: alphaDir, + }); }); afterAll(async () => { - await fs.rm(repoDir, { recursive: true, force: true }); - await fs.rm(plainDir, { recursive: true, force: true }); + vi.unstubAllEnvs(); + await fs.rm(fixtureDir, { recursive: true, force: true }); }); it("lists one entry per workspace folder with detected roots", async () => { @@ -67,4 +94,89 @@ describe("createRootStore", () => { assert.equal(store.getActiveRoot(), undefined); assert.throws(() => store.getService(), /No git repository selected/); }); + + it("discovers sibling and grouped repositories and worktrees beneath a parent folder", async () => { + const store = await createRootStore(fakeVscode(projectsDir)); + assert.deepEqual(store.roots, [ + { name: "alpha", folder: alphaDir, root: alphaDir }, + { name: "linked", folder: worktreeDir, root: worktreeDir }, + { name: path.join("team", "beta"), folder: betaDir, root: betaDir }, + ]); + assert.equal(store.getActiveRoot(), alphaDir); + }); + + it("switches histories between discovered repositories and worktrees", async () => { + const store = await createRootStore(fakeVscode(projectsDir)); + for (const [root, subject, count] of [ + [betaDir, "beta change", 2], + [alphaDir, "init", 1], + [worktreeDir, "init", 1], + ]) { + store.useRoot(root); + const commits = await store.getService().listCommits(5); + assert.equal(store.getActiveRoot(), root); + assert.equal(commits.length, count); + assert.equal(commits[0].subject, subject); + } + }); + + it("deduplicates repositories from overlapping workspace folders", async () => { + await fs.mkdir(path.join(alphaDir, "src"), { recursive: true }); + const store = await createRootStore( + fakeVscode(projectsDir, alphaDir, path.join(alphaDir, "src")), + ); + assert.deepEqual( + store.roots.map((r) => r.root), + [alphaDir, worktreeDir, betaDir], + ); + }); + + it("skips dependencies and symlink loops while scanning past invalid git markers", async () => { + const scanDir = path.join(fixtureDir, "scan"); + const foundDir = path.join(scanDir, "broken", "real-repo"); + await fs.cp(repoDir, foundDir, { recursive: true }); + await fs.writeFile(path.join(scanDir, "broken", ".git"), "invalid git marker\n"); + await fs.cp(repoDir, path.join(scanDir, "node_modules", "dependency"), { recursive: true }); + await fs.symlink(scanDir, path.join(scanDir, "loop"), "junction"); + const store = await createRootStore(fakeVscode(scanDir, path.join(scanDir, "missing"))); + assert.deepEqual( + store.roots.filter((r) => r.root).map((r) => r.root), + [foundDir], + ); + }); + + it("answers parent-folder roots and switching requests through the extension", async () => { + const previousPanel = GitWindowPanel.currentPanel; + GitWindowPanel.currentPanel = undefined; + const fake = createFakeVscode({ workspaceRoot: projectsDir }); + const context = { extensionUri: fake.vscode.Uri.file(process.cwd()), subscriptions: [] }; + try { + activate(context, fake.vscode); + await fake.commandsRegistered.get("graphima.openGitWindow")(); + const panel = fake.panels[0]; + await panel.handler({ type: "git/request", id: "roots", method: "roots" }); + const response = panel.posted.at(-1); + assert.equal(response.ok, true); + assert.deepEqual( + response.data.roots.map((r) => r.root), + [alphaDir, worktreeDir, betaDir], + ); + await panel.handler({ + type: "git/request", + id: "switch", + method: "useRoot", + args: { root: betaDir }, + }); + assert.equal(panel.posted.at(-1).ok, true); + await panel.handler({ type: "git/request", id: "log", method: "log", args: { limit: 5 } }); + assert.equal(panel.posted.at(-1).ok, true); + assert.equal(panel.posted.at(-1).data[0].subject, "beta change"); + } finally { + GitWindowPanel.currentPanel?.dispose(); + GitWindowPanel.currentPanel = previousPanel; + for (const disposable of context.subscriptions) { + disposable.dispose(); + } + } + }); });