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
53 changes: 48 additions & 5 deletions src/git/index.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
const fs = require("node:fs/promises");
const path = require("node:path");
const { findRepoRoot } = require("./cliGit");
const { HybridGitService } = require("./vscodeGit");

Expand All @@ -14,18 +16,59 @@ 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.
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;
Expand Down
124 changes: 118 additions & 6 deletions test/gitRoots.test.js
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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();
}
}
});
});
Loading