From e50af6138e1ad3fc615bb3ee8419ef2d61047cab Mon Sep 17 00:00:00 2001 From: asispts <79239132+asispts@users.noreply.github.com> Date: Fri, 10 Apr 2026 09:55:07 +0100 Subject: [PATCH 01/36] Add a new entrypoint --- esbuild.js | 2 +- package.json | 2 +- src/extension/bootstrap.ts | 16 ++++ src/extension/main.ts | 17 +++++ tests/extension/bootstrap.test.ts | 118 ++++++++++++++++++++++++++++++ vitest.config.ts | 7 ++ 6 files changed, 160 insertions(+), 2 deletions(-) create mode 100644 src/extension/bootstrap.ts create mode 100644 src/extension/main.ts create mode 100644 tests/extension/bootstrap.test.ts diff --git a/esbuild.js b/esbuild.js index 91a74e5b..a029593b 100644 --- a/esbuild.js +++ b/esbuild.js @@ -33,7 +33,7 @@ const aliasPlugin = { async function main() { const extension = await esbuild.context({ - entryPoints: ["src/extension.ts"], + entryPoints: ["src/extension/main.ts"], bundle: true, format: "cjs", minify: production, diff --git a/package.json b/package.json index 59b75eb0..6f69e58e 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "package": "pnpm run typecheck && pnpm run lint && node esbuild.js --production", "clean": "node -e \"require('fs').rmSync('out',{recursive:true,force:true})\"", "compile": "pnpm run clean && node esbuild.js", - "test": "vitest run --project backend && vitest run --project webview", + "test": "vitest run --project backend && vitest run --project extension && vitest run --project webview", "test:ext": "pnpm run compile && pnpm run compile-tests && vscode-test", "compile-tests": "tsc -p tests/extension/tsconfig.json && tsc-alias -p tests/extension/tsconfig.json", "watch:esbuild": "node esbuild.js --watch", diff --git a/src/extension/bootstrap.ts b/src/extension/bootstrap.ts new file mode 100644 index 00000000..357391b2 --- /dev/null +++ b/src/extension/bootstrap.ts @@ -0,0 +1,16 @@ +import * as vscode from "vscode"; + +import { searchDirectoryForRepos } from "@/backend/utils/repoSearch"; + +export async function findGitRepos( + paths: string[], + gitPath: string, + maxDepth: number +): Promise { + const results = await Promise.all( + paths.map((p) => searchDirectoryForRepos(p, maxDepth, gitPath, [])) + ); + return results.flat(); +} + +export function bootstrap(_ctx: vscode.ExtensionContext, _repos: string[]): void {} diff --git a/src/extension/main.ts b/src/extension/main.ts new file mode 100644 index 00000000..c3edfcbd --- /dev/null +++ b/src/extension/main.ts @@ -0,0 +1,17 @@ +import * as vscode from "vscode"; + +import { config } from "@/config"; +import { bootstrap, findGitRepos } from "@/extension/bootstrap"; + +export async function activate(ctx: vscode.ExtensionContext) { + const paths = (vscode.workspace.workspaceFolders ?? []).map((f) => f.uri.fsPath); + + const repoDirs = await findGitRepos(paths, config.gitPath(), config.maxDepthOfRepoSearch()); + if (repoDirs.length === 0) { + return; + } + + bootstrap(ctx, repoDirs); +} + +export function deactivate() {} diff --git a/tests/extension/bootstrap.test.ts b/tests/extension/bootstrap.test.ts new file mode 100644 index 00000000..c751a08a --- /dev/null +++ b/tests/extension/bootstrap.test.ts @@ -0,0 +1,118 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { findGitRepos } from "@/extension/bootstrap"; + +import { git } from "@tests/backend/helpers"; + +// Directory layout created in beforeAll: +// tmpDir/ +// repo-a/ ← git repo (depth 1 from tmpDir) +// plain/ ← plain directory, no git +// nested/ +// repo-b/ ← git repo (depth 2 from tmpDir) + +let tmpDir: string; +let repoA: string; +let repoB: string; +let plain: string; + +function initRepo(dir: string) { + fs.mkdirSync(dir, { recursive: true }); + try { + git(["init", "-b", "main"], dir); + } catch { + git(["init"], dir); + git(["checkout", "-b", "main"], dir); + } + git(["config", "user.email", "t@t.com"], dir); + git(["config", "user.name", "T"], dir); + git(["config", "commit.gpgsign", "false"], dir); + fs.writeFileSync(path.join(dir, "f"), "x"); + git(["add", "."], dir); + git(["commit", "-m", "init"], dir); +} + +beforeAll(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ngg-bootstrap-")); + repoA = path.join(tmpDir, "repo-a"); + repoB = path.join(tmpDir, "nested", "repo-b"); + plain = path.join(tmpDir, "plain"); + + initRepo(repoA); + initRepo(repoB); + fs.mkdirSync(plain); +}); + +afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe("findGitRepos", () => { + it("returns [] when paths is empty", async () => { + const result = await findGitRepos([], "git", 2); + expect(result).toEqual([]); + }); + + it("finds a repo when the path itself is a git repo (depth 0)", async () => { + const result = await findGitRepos([repoA], "git", 0); + expect(result).toEqual([repoA]); + }); + + it("returns [] when path is not a repo and maxDepth is 0", async () => { + const result = await findGitRepos([plain], "git", 0); + expect(result).toEqual([]); + }); + + it("returns [] for a non-existent path", async () => { + const result = await findGitRepos(["/tmp/ngg-does-not-exist-xyz"], "git", 2); + expect(result).toEqual([]); + }); + + it("finds a repo nested at depth 1", async () => { + const result = await findGitRepos([tmpDir], "git", 1); + expect(result).toContain(repoA); + }); + + it("does not find a repo beyond maxDepth", async () => { + // repoB is at depth 2 from tmpDir + const result = await findGitRepos([tmpDir], "git", 1); + expect(result).not.toContain(repoB); + }); + + it("finds a repo exactly at maxDepth", async () => { + // repoB is at depth 2 from tmpDir + const result = await findGitRepos([tmpDir], "git", 2); + expect(result).toContain(repoB); + }); + + it("finds multiple repos at the same depth", async () => { + const result = await findGitRepos([tmpDir], "git", 1); + const repoC = path.join(tmpDir, "repo-c"); + initRepo(repoC); + try { + const result2 = await findGitRepos([tmpDir], "git", 1); + expect(result2).toContain(repoA); + expect(result2).toContain(repoC); + } finally { + fs.rmSync(repoC, { recursive: true, force: true }); + } + + // original result should only have repoA at depth 1 + expect(result).toContain(repoA); + }); + + it("aggregates repos across multiple workspace paths", async () => { + const nestedDir = path.join(tmpDir, "nested"); + const result = await findGitRepos([repoA, nestedDir], "git", 1); + expect(result.sort()).toEqual([repoA, repoB].sort()); + }); + + it("does not report .git directories as repos", async () => { + const result = await findGitRepos([tmpDir], "git", 2); + expect(result.every((r) => !r.endsWith("/.git"))).toBe(true); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 6e5bfe0b..aa70c1fd 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -17,6 +17,13 @@ export default defineConfig({ include: ["tests/backend/**/*.test.ts"] } }, + { + resolve: { alias }, + test: { + name: "extension", + include: ["tests/extension/bootstrap.test.ts"] + } + }, { resolve: { alias: [ From 6c48213e6b84959e08d8b7ac285c097e5179a155 Mon Sep 17 00:00:00 2001 From: asispts <79239132+asispts@users.noreply.github.com> Date: Fri, 10 Apr 2026 10:01:23 +0000 Subject: [PATCH 02/36] split tests into vitest (tests/) and vscode-test (tests-ext/) --- .oxlintrc.json | 6 + .vscode-test.mjs | 2 +- package.json | 6 +- src/backend/utils/repoSearch.ts | 11 ++ src/extension/bootstrap.ts | 13 -- src/extension/main.ts | 3 +- .../extension => tests-ext}/extension.test.ts | 0 .../repoManager.test.ts | 0 tests-ext/tsconfig.json | 18 +++ .../workspaceSearch.test.ts | 0 .../workspaceWatcher.test.ts | 0 tests/backend/utils/repoSearch.test.ts | 44 ++++++- tests/extension/bootstrap.test.ts | 118 ------------------ tests/extension/tsconfig.json | 8 -- tests/tsconfig.json | 1 - vitest.config.ts | 7 -- 16 files changed, 84 insertions(+), 153 deletions(-) rename {tests/extension => tests-ext}/extension.test.ts (100%) rename {tests/extension => tests-ext}/repoManager.test.ts (100%) create mode 100644 tests-ext/tsconfig.json rename {tests/extension => tests-ext}/workspaceSearch.test.ts (100%) rename {tests/extension => tests-ext}/workspaceWatcher.test.ts (100%) delete mode 100644 tests/extension/bootstrap.test.ts delete mode 100644 tests/extension/tsconfig.json diff --git a/.oxlintrc.json b/.oxlintrc.json index 3e59483c..9db5a5d6 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -65,6 +65,12 @@ "import/named": "off" } }, + { + "files": ["tests-ext/**/*.ts"], + "env": { + "mocha": true + } + }, { "files": ["src/webview/**/*.ts"], "env": { diff --git a/.vscode-test.mjs b/.vscode-test.mjs index ac1cc7a7..d68a265c 100644 --- a/.vscode-test.mjs +++ b/.vscode-test.mjs @@ -1,7 +1,7 @@ import { defineConfig } from "@vscode/test-cli"; export default defineConfig({ - files: "tests/out/extension/**/*.test.js", + files: "tests-ext/out/**/*.test.js", workspaceFolder: ".", version: "stable" }); diff --git a/package.json b/package.json index 6f69e58e..406021e5 100644 --- a/package.json +++ b/package.json @@ -36,14 +36,14 @@ "publisher": "asispts", "main": "./out/extension.js", "scripts": { - "typecheck": "tsc -p ./src --noEmit && tsc -p ./src/webview --noEmit && tsc -p ./tests --noEmit", + "typecheck": "tsc -p ./src --noEmit && tsc -p ./src/webview --noEmit && tsc -p ./tests --noEmit && tsc -p ./tests-ext --noEmit", "vscode:prepublish": "pnpm run clean && pnpm run package", "package": "pnpm run typecheck && pnpm run lint && node esbuild.js --production", "clean": "node -e \"require('fs').rmSync('out',{recursive:true,force:true})\"", "compile": "pnpm run clean && node esbuild.js", - "test": "vitest run --project backend && vitest run --project extension && vitest run --project webview", + "test": "vitest run --project backend && vitest run --project webview", "test:ext": "pnpm run compile && pnpm run compile-tests && vscode-test", - "compile-tests": "tsc -p tests/extension/tsconfig.json && tsc-alias -p tests/extension/tsconfig.json", + "compile-tests": "tsc -p tests-ext/tsconfig.json && tsc-alias -p tests-ext/tsconfig.json", "watch:esbuild": "node esbuild.js --watch", "watch:tsc": "tsc -p ./src --noEmit --watch", "format": "oxfmt --check", diff --git a/src/backend/utils/repoSearch.ts b/src/backend/utils/repoSearch.ts index 1ad7ec06..9bdb31bd 100644 --- a/src/backend/utils/repoSearch.ts +++ b/src/backend/utils/repoSearch.ts @@ -46,3 +46,14 @@ export async function searchDirectoryForRepos( ); return results.flat(); } + +export async function findGitRepos( + paths: string[], + gitPath: string, + maxDepth: number +): Promise { + const results = await Promise.all( + paths.map((p) => searchDirectoryForRepos(p, maxDepth, gitPath, [])) + ); + return results.flat(); +} diff --git a/src/extension/bootstrap.ts b/src/extension/bootstrap.ts index 357391b2..fa9f0971 100644 --- a/src/extension/bootstrap.ts +++ b/src/extension/bootstrap.ts @@ -1,16 +1,3 @@ import * as vscode from "vscode"; -import { searchDirectoryForRepos } from "@/backend/utils/repoSearch"; - -export async function findGitRepos( - paths: string[], - gitPath: string, - maxDepth: number -): Promise { - const results = await Promise.all( - paths.map((p) => searchDirectoryForRepos(p, maxDepth, gitPath, [])) - ); - return results.flat(); -} - export function bootstrap(_ctx: vscode.ExtensionContext, _repos: string[]): void {} diff --git a/src/extension/main.ts b/src/extension/main.ts index c3edfcbd..c286780a 100644 --- a/src/extension/main.ts +++ b/src/extension/main.ts @@ -1,7 +1,8 @@ import * as vscode from "vscode"; +import { findGitRepos } from "@/backend/utils/repoSearch"; import { config } from "@/config"; -import { bootstrap, findGitRepos } from "@/extension/bootstrap"; +import { bootstrap } from "@/extension/bootstrap"; export async function activate(ctx: vscode.ExtensionContext) { const paths = (vscode.workspace.workspaceFolders ?? []).map((f) => f.uri.fsPath); diff --git a/tests/extension/extension.test.ts b/tests-ext/extension.test.ts similarity index 100% rename from tests/extension/extension.test.ts rename to tests-ext/extension.test.ts diff --git a/tests/extension/repoManager.test.ts b/tests-ext/repoManager.test.ts similarity index 100% rename from tests/extension/repoManager.test.ts rename to tests-ext/repoManager.test.ts diff --git a/tests-ext/tsconfig.json b/tests-ext/tsconfig.json new file mode 100644 index 00000000..0b316afc --- /dev/null +++ b/tests-ext/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "baseUrl": "..", + "paths": { + "@/*": ["src/*"], + "@tests/*": ["tests/*"] + }, + "module": "commonjs", + "target": "es6", + "lib": ["es2020"], + "strict": true, + "noImplicitAny": true, + "skipLibCheck": true, + "outDir": "./out", + "sourceMap": true + }, + "include": ["./**/*.ts"] +} diff --git a/tests/extension/workspaceSearch.test.ts b/tests-ext/workspaceSearch.test.ts similarity index 100% rename from tests/extension/workspaceSearch.test.ts rename to tests-ext/workspaceSearch.test.ts diff --git a/tests/extension/workspaceWatcher.test.ts b/tests-ext/workspaceWatcher.test.ts similarity index 100% rename from tests/extension/workspaceWatcher.test.ts rename to tests-ext/workspaceWatcher.test.ts diff --git a/tests/backend/utils/repoSearch.test.ts b/tests/backend/utils/repoSearch.test.ts index d1ee8a55..4dc7bc2e 100644 --- a/tests/backend/utils/repoSearch.test.ts +++ b/tests/backend/utils/repoSearch.test.ts @@ -4,7 +4,7 @@ import * as path from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { searchDirectoryForRepos } from "@/backend/utils/repoSearch"; +import { findGitRepos, searchDirectoryForRepos } from "@/backend/utils/repoSearch"; import { git } from "@tests/backend/helpers"; @@ -46,6 +46,7 @@ beforeAll(() => { initRepo(repoB); fs.mkdirSync(nonRepoDir); fs.writeFileSync(path.join(nonRepoDir, "readme.txt"), "hello"); + fs.mkdirSync(path.join(tmpDir, "plain")); }); afterAll(() => { @@ -104,3 +105,44 @@ describe("searchDirectoryForRepos", () => { expect(result.every((r) => !r.includes("/.git"))).toBe(true); }); }); + +describe("findGitRepos", () => { + it("returns [] when paths is empty", async () => { + expect(await findGitRepos([], "git", 2)).toEqual([]); + }); + + it("finds a repo when the path itself is a git repo", async () => { + expect(await findGitRepos([repoA], "git", 0)).toEqual([repoA]); + }); + + it("returns [] when path is not a repo and maxDepth is 0", async () => { + expect(await findGitRepos([path.join(tmpDir, "plain")], "git", 0)).toEqual([]); + }); + + it("returns [] for a non-existent path", async () => { + expect(await findGitRepos(["/tmp/ngg-does-not-exist-xyz"], "git", 2)).toEqual([]); + }); + + it("finds a repo nested at depth 1", async () => { + expect(await findGitRepos([tmpDir], "git", 1)).toContain(repoA); + }); + + it("does not find a repo beyond maxDepth", async () => { + expect(await findGitRepos([tmpDir], "git", 1)).not.toContain(repoB); + }); + + it("finds a repo exactly at maxDepth", async () => { + expect(await findGitRepos([tmpDir], "git", 2)).toContain(repoB); + }); + + it("aggregates repos across multiple workspace paths", async () => { + const nestedDir = path.join(tmpDir, "nested"); + const result = await findGitRepos([repoA, nestedDir], "git", 1); + expect(result.sort()).toEqual([repoA, repoB].sort()); + }); + + it("does not report .git directories as repos", async () => { + const result = await findGitRepos([tmpDir], "git", 2); + expect(result.every((r) => !r.endsWith("/.git"))).toBe(true); + }); +}); diff --git a/tests/extension/bootstrap.test.ts b/tests/extension/bootstrap.test.ts deleted file mode 100644 index c751a08a..00000000 --- a/tests/extension/bootstrap.test.ts +++ /dev/null @@ -1,118 +0,0 @@ -import * as fs from "node:fs"; -import * as os from "node:os"; -import * as path from "node:path"; - -import { afterAll, beforeAll, describe, expect, it } from "vitest"; - -import { findGitRepos } from "@/extension/bootstrap"; - -import { git } from "@tests/backend/helpers"; - -// Directory layout created in beforeAll: -// tmpDir/ -// repo-a/ ← git repo (depth 1 from tmpDir) -// plain/ ← plain directory, no git -// nested/ -// repo-b/ ← git repo (depth 2 from tmpDir) - -let tmpDir: string; -let repoA: string; -let repoB: string; -let plain: string; - -function initRepo(dir: string) { - fs.mkdirSync(dir, { recursive: true }); - try { - git(["init", "-b", "main"], dir); - } catch { - git(["init"], dir); - git(["checkout", "-b", "main"], dir); - } - git(["config", "user.email", "t@t.com"], dir); - git(["config", "user.name", "T"], dir); - git(["config", "commit.gpgsign", "false"], dir); - fs.writeFileSync(path.join(dir, "f"), "x"); - git(["add", "."], dir); - git(["commit", "-m", "init"], dir); -} - -beforeAll(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ngg-bootstrap-")); - repoA = path.join(tmpDir, "repo-a"); - repoB = path.join(tmpDir, "nested", "repo-b"); - plain = path.join(tmpDir, "plain"); - - initRepo(repoA); - initRepo(repoB); - fs.mkdirSync(plain); -}); - -afterAll(() => { - fs.rmSync(tmpDir, { recursive: true, force: true }); -}); - -describe("findGitRepos", () => { - it("returns [] when paths is empty", async () => { - const result = await findGitRepos([], "git", 2); - expect(result).toEqual([]); - }); - - it("finds a repo when the path itself is a git repo (depth 0)", async () => { - const result = await findGitRepos([repoA], "git", 0); - expect(result).toEqual([repoA]); - }); - - it("returns [] when path is not a repo and maxDepth is 0", async () => { - const result = await findGitRepos([plain], "git", 0); - expect(result).toEqual([]); - }); - - it("returns [] for a non-existent path", async () => { - const result = await findGitRepos(["/tmp/ngg-does-not-exist-xyz"], "git", 2); - expect(result).toEqual([]); - }); - - it("finds a repo nested at depth 1", async () => { - const result = await findGitRepos([tmpDir], "git", 1); - expect(result).toContain(repoA); - }); - - it("does not find a repo beyond maxDepth", async () => { - // repoB is at depth 2 from tmpDir - const result = await findGitRepos([tmpDir], "git", 1); - expect(result).not.toContain(repoB); - }); - - it("finds a repo exactly at maxDepth", async () => { - // repoB is at depth 2 from tmpDir - const result = await findGitRepos([tmpDir], "git", 2); - expect(result).toContain(repoB); - }); - - it("finds multiple repos at the same depth", async () => { - const result = await findGitRepos([tmpDir], "git", 1); - const repoC = path.join(tmpDir, "repo-c"); - initRepo(repoC); - try { - const result2 = await findGitRepos([tmpDir], "git", 1); - expect(result2).toContain(repoA); - expect(result2).toContain(repoC); - } finally { - fs.rmSync(repoC, { recursive: true, force: true }); - } - - // original result should only have repoA at depth 1 - expect(result).toContain(repoA); - }); - - it("aggregates repos across multiple workspace paths", async () => { - const nestedDir = path.join(tmpDir, "nested"); - const result = await findGitRepos([repoA, nestedDir], "git", 1); - expect(result.sort()).toEqual([repoA, repoB].sort()); - }); - - it("does not report .git directories as repos", async () => { - const result = await findGitRepos([tmpDir], "git", 2); - expect(result.every((r) => !r.endsWith("/.git"))).toBe(true); - }); -}); diff --git a/tests/extension/tsconfig.json b/tests/extension/tsconfig.json deleted file mode 100644 index 369398e7..00000000 --- a/tests/extension/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "../tsconfig.json", - "compilerOptions": { - "outDir": "../out/extension", - "sourceMap": true - }, - "include": ["./**/*.ts"] -} diff --git a/tests/tsconfig.json b/tests/tsconfig.json index c257d812..c4a742c3 100644 --- a/tests/tsconfig.json +++ b/tests/tsconfig.json @@ -14,7 +14,6 @@ }, "include": [ "./backend/**/*.ts", - "./extension/**/*.ts", "./webview/**/*.ts", "../src/backend/**/*.ts", "../src/webview/**/*.ts" diff --git a/vitest.config.ts b/vitest.config.ts index aa70c1fd..6e5bfe0b 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -17,13 +17,6 @@ export default defineConfig({ include: ["tests/backend/**/*.test.ts"] } }, - { - resolve: { alias }, - test: { - name: "extension", - include: ["tests/extension/bootstrap.test.ts"] - } - }, { resolve: { alias: [ From f553df4e50dc190b82c4b5f38524f1fafde4a68d Mon Sep 17 00:00:00 2001 From: asispts <79239132+asispts@users.noreply.github.com> Date: Fri, 10 Apr 2026 10:09:51 +0000 Subject: [PATCH 03/36] Move findGitRepos into queries dir --- src/backend/queries/repoSearch.ts | 12 ++++ src/backend/utils/repoSearch.ts | 11 --- src/extension/main.ts | 2 +- tests/backend/queries/repoSearch.test.ts | 91 ++++++++++++++++++++++++ tests/backend/utils/repoSearch.test.ts | 43 +---------- 5 files changed, 105 insertions(+), 54 deletions(-) create mode 100644 src/backend/queries/repoSearch.ts create mode 100644 tests/backend/queries/repoSearch.test.ts diff --git a/src/backend/queries/repoSearch.ts b/src/backend/queries/repoSearch.ts new file mode 100644 index 00000000..d31c2452 --- /dev/null +++ b/src/backend/queries/repoSearch.ts @@ -0,0 +1,12 @@ +import { searchDirectoryForRepos } from "@/backend/utils/repoSearch"; + +export async function findGitRepos( + paths: string[], + gitPath: string, + maxDepth: number +): Promise { + const results = await Promise.all( + paths.map((p) => searchDirectoryForRepos(p, maxDepth, gitPath, [])) + ); + return results.flat(); +} diff --git a/src/backend/utils/repoSearch.ts b/src/backend/utils/repoSearch.ts index 9bdb31bd..1ad7ec06 100644 --- a/src/backend/utils/repoSearch.ts +++ b/src/backend/utils/repoSearch.ts @@ -46,14 +46,3 @@ export async function searchDirectoryForRepos( ); return results.flat(); } - -export async function findGitRepos( - paths: string[], - gitPath: string, - maxDepth: number -): Promise { - const results = await Promise.all( - paths.map((p) => searchDirectoryForRepos(p, maxDepth, gitPath, [])) - ); - return results.flat(); -} diff --git a/src/extension/main.ts b/src/extension/main.ts index c286780a..fd221ea1 100644 --- a/src/extension/main.ts +++ b/src/extension/main.ts @@ -1,6 +1,6 @@ import * as vscode from "vscode"; -import { findGitRepos } from "@/backend/utils/repoSearch"; +import { findGitRepos } from "@/backend/queries/repoSearch"; import { config } from "@/config"; import { bootstrap } from "@/extension/bootstrap"; diff --git a/tests/backend/queries/repoSearch.test.ts b/tests/backend/queries/repoSearch.test.ts new file mode 100644 index 00000000..df3a6118 --- /dev/null +++ b/tests/backend/queries/repoSearch.test.ts @@ -0,0 +1,91 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { findGitRepos } from "@/backend/queries/repoSearch"; + +import { git } from "@tests/backend/helpers"; + +// Directory layout created in beforeAll: +// tmpDir/ +// repo-a/ ← git repo +// not-a-repo/ ← plain directory +// nested/ +// repo-b/ ← git repo (depth 2 from tmpDir) + +let tmpDir: string; +let repoA: string; +let repoB: string; + +function initRepo(dir: string) { + fs.mkdirSync(dir, { recursive: true }); + try { + git(["init", "-b", "main"], dir); + } catch { + git(["init"], dir); + git(["checkout", "-b", "main"], dir); + } + git(["config", "user.email", "t@t.com"], dir); + git(["config", "user.name", "T"], dir); + git(["config", "commit.gpgsign", "false"], dir); + fs.writeFileSync(path.join(dir, "f"), "x"); + git(["add", "."], dir); + git(["commit", "-m", "init"], dir); +} + +beforeAll(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ngg-search-")); + repoA = path.join(tmpDir, "repo-a"); + repoB = path.join(tmpDir, "nested", "repo-b"); + + initRepo(repoA); + initRepo(repoB); + fs.mkdirSync(path.join(tmpDir, "plain")); +}); + +afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe("findGitRepos", () => { + it("returns [] when paths is empty", async () => { + expect(await findGitRepos([], "git", 2)).toEqual([]); + }); + + it("finds a repo when the path itself is a git repo", async () => { + expect(await findGitRepos([repoA], "git", 0)).toEqual([repoA]); + }); + + it("returns [] when path is not a repo and maxDepth is 0", async () => { + expect(await findGitRepos([path.join(tmpDir, "plain")], "git", 0)).toEqual([]); + }); + + it("returns [] for a non-existent path", async () => { + expect(await findGitRepos(["/tmp/ngg-does-not-exist-xyz"], "git", 2)).toEqual([]); + }); + + it("finds a repo nested at depth 1", async () => { + expect(await findGitRepos([tmpDir], "git", 1)).toContain(repoA); + }); + + it("does not find a repo beyond maxDepth", async () => { + expect(await findGitRepos([tmpDir], "git", 1)).not.toContain(repoB); + }); + + it("finds a repo exactly at maxDepth", async () => { + expect(await findGitRepos([tmpDir], "git", 2)).toContain(repoB); + }); + + it("aggregates repos across multiple workspace paths", async () => { + const nestedDir = path.join(tmpDir, "nested"); + const result = await findGitRepos([repoA, nestedDir], "git", 1); + expect(result.sort()).toEqual([repoA, repoB].sort()); + }); + + it("does not report .git directories as repos", async () => { + const result = await findGitRepos([tmpDir], "git", 2); + expect(result.every((r) => !r.endsWith("/.git"))).toBe(true); + }); +}); diff --git a/tests/backend/utils/repoSearch.test.ts b/tests/backend/utils/repoSearch.test.ts index 4dc7bc2e..50314943 100644 --- a/tests/backend/utils/repoSearch.test.ts +++ b/tests/backend/utils/repoSearch.test.ts @@ -4,7 +4,7 @@ import * as path from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { findGitRepos, searchDirectoryForRepos } from "@/backend/utils/repoSearch"; +import { searchDirectoryForRepos } from "@/backend/utils/repoSearch"; import { git } from "@tests/backend/helpers"; @@ -105,44 +105,3 @@ describe("searchDirectoryForRepos", () => { expect(result.every((r) => !r.includes("/.git"))).toBe(true); }); }); - -describe("findGitRepos", () => { - it("returns [] when paths is empty", async () => { - expect(await findGitRepos([], "git", 2)).toEqual([]); - }); - - it("finds a repo when the path itself is a git repo", async () => { - expect(await findGitRepos([repoA], "git", 0)).toEqual([repoA]); - }); - - it("returns [] when path is not a repo and maxDepth is 0", async () => { - expect(await findGitRepos([path.join(tmpDir, "plain")], "git", 0)).toEqual([]); - }); - - it("returns [] for a non-existent path", async () => { - expect(await findGitRepos(["/tmp/ngg-does-not-exist-xyz"], "git", 2)).toEqual([]); - }); - - it("finds a repo nested at depth 1", async () => { - expect(await findGitRepos([tmpDir], "git", 1)).toContain(repoA); - }); - - it("does not find a repo beyond maxDepth", async () => { - expect(await findGitRepos([tmpDir], "git", 1)).not.toContain(repoB); - }); - - it("finds a repo exactly at maxDepth", async () => { - expect(await findGitRepos([tmpDir], "git", 2)).toContain(repoB); - }); - - it("aggregates repos across multiple workspace paths", async () => { - const nestedDir = path.join(tmpDir, "nested"); - const result = await findGitRepos([repoA, nestedDir], "git", 1); - expect(result.sort()).toEqual([repoA, repoB].sort()); - }); - - it("does not report .git directories as repos", async () => { - const result = await findGitRepos([tmpDir], "git", 2); - expect(result.every((r) => !r.endsWith("/.git"))).toBe(true); - }); -}); From 8d6db175c90f02a7280b4b1a967c4b8679dc6b06 Mon Sep 17 00:00:00 2001 From: asispts <79239132+asispts@users.noreply.github.com> Date: Fri, 10 Apr 2026 13:49:02 +0000 Subject: [PATCH 04/36] Add lightweight repo watcher --- package.json | 2 +- src/extension/main.ts | 4 +- src/extension/waitForRepo.ts | 42 +++++++++ tests/extension/__mocks__/vscode.ts | 9 ++ tests/extension/waitForRepo.test.ts | 139 ++++++++++++++++++++++++++++ vitest.config.ts | 15 +++ 6 files changed, 209 insertions(+), 2 deletions(-) create mode 100644 src/extension/waitForRepo.ts create mode 100644 tests/extension/__mocks__/vscode.ts create mode 100644 tests/extension/waitForRepo.test.ts diff --git a/package.json b/package.json index 406021e5..c808a527 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "package": "pnpm run typecheck && pnpm run lint && node esbuild.js --production", "clean": "node -e \"require('fs').rmSync('out',{recursive:true,force:true})\"", "compile": "pnpm run clean && node esbuild.js", - "test": "vitest run --project backend && vitest run --project webview", + "test": "vitest run --project backend && vitest run --project extension && vitest run --project webview", "test:ext": "pnpm run compile && pnpm run compile-tests && vscode-test", "compile-tests": "tsc -p tests-ext/tsconfig.json && tsc-alias -p tests-ext/tsconfig.json", "watch:esbuild": "node esbuild.js --watch", diff --git a/src/extension/main.ts b/src/extension/main.ts index fd221ea1..d7426637 100644 --- a/src/extension/main.ts +++ b/src/extension/main.ts @@ -3,12 +3,14 @@ import * as vscode from "vscode"; import { findGitRepos } from "@/backend/queries/repoSearch"; import { config } from "@/config"; import { bootstrap } from "@/extension/bootstrap"; +import { waitForRepo } from "@/extension/waitForRepo"; export async function activate(ctx: vscode.ExtensionContext) { const paths = (vscode.workspace.workspaceFolders ?? []).map((f) => f.uri.fsPath); const repoDirs = await findGitRepos(paths, config.gitPath(), config.maxDepthOfRepoSearch()); - if (repoDirs.length === 0) { + if (repoDirs.length <= 0) { + ctx.subscriptions.push(waitForRepo(ctx)); return; } diff --git a/src/extension/waitForRepo.ts b/src/extension/waitForRepo.ts new file mode 100644 index 00000000..5f4278d6 --- /dev/null +++ b/src/extension/waitForRepo.ts @@ -0,0 +1,42 @@ +import * as vscode from "vscode"; + +import { findGitRepos } from "@/backend/queries/repoSearch"; +import { config } from "@/config"; +import { bootstrap } from "@/extension/bootstrap"; + +export type WorkspaceApi = Pick< + typeof vscode.workspace, + "createFileSystemWatcher" | "onDidChangeWorkspaceFolders" | "workspaceFolders" +>; + +type WatcherState = { + disposed: boolean; + disposables: vscode.Disposable[]; +}; + +function dispose(state: WatcherState) { + state.disposed = true; + for (const d of state.disposables) d.dispose(); + state.disposables.length = 0; +} + +async function check(ctx: vscode.ExtensionContext, workspace: WorkspaceApi, state: WatcherState) { + const paths = (workspace.workspaceFolders ?? []).map((f) => f.uri.fsPath); + const repoDirs = await findGitRepos(paths, config.gitPath(), config.maxDepthOfRepoSearch()); + if (repoDirs.length === 0 || state.disposed) return; + dispose(state); + bootstrap(ctx, repoDirs); +} + +export function waitForRepo( + ctx: vscode.ExtensionContext, + workspace: WorkspaceApi = vscode.workspace +): { dispose(): void } { + const gitWatcher = workspace.createFileSystemWatcher("**/.git"); + const state: WatcherState = { disposed: false, disposables: [gitWatcher] }; + + state.disposables.push(gitWatcher.onDidCreate(() => check(ctx, workspace, state))); + state.disposables.push(workspace.onDidChangeWorkspaceFolders(() => check(ctx, workspace, state))); + + return { dispose: () => dispose(state) }; +} diff --git a/tests/extension/__mocks__/vscode.ts b/tests/extension/__mocks__/vscode.ts new file mode 100644 index 00000000..a89b09ce --- /dev/null +++ b/tests/extension/__mocks__/vscode.ts @@ -0,0 +1,9 @@ +export const workspace = { + getConfiguration: () => ({ get: (_key: string, def: unknown) => def }), + workspaceFolders: undefined, + createFileSystemWatcher: () => ({ + onDidCreate: () => ({ dispose: () => {} }), + dispose: () => {} + }), + onDidChangeWorkspaceFolders: () => ({ dispose: () => {} }) +}; diff --git a/tests/extension/waitForRepo.test.ts b/tests/extension/waitForRepo.test.ts new file mode 100644 index 00000000..234f874c --- /dev/null +++ b/tests/extension/waitForRepo.test.ts @@ -0,0 +1,139 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type * as vscode from "vscode"; + +vi.mock("@/backend/queries/repoSearch"); +vi.mock("@/extension/bootstrap"); + +import { findGitRepos } from "@/backend/queries/repoSearch"; +import { bootstrap } from "@/extension/bootstrap"; +import { waitForRepo } from "@/extension/waitForRepo"; +import type { WorkspaceApi } from "@/extension/waitForRepo"; + +function makeFakeWorkspace(folderPaths: string[] = []): { + workspace: WorkspaceApi; + gitWatcher: { dispose: ReturnType }; + triggerGitCreate: () => void; + triggerFolderChange: () => void; +} { + let gitCreateHandler: (() => void) | null = null; + let folderChangeHandler: (() => void) | null = null; + + const gitWatcher = { + onDidCreate(handler: () => void) { + gitCreateHandler = handler; + return { dispose: vi.fn() }; + }, + dispose: vi.fn() + }; + + const workspace: WorkspaceApi = { + workspaceFolders: folderPaths.map((p) => ({ uri: { fsPath: p } }) as vscode.WorkspaceFolder), + createFileSystemWatcher: vi.fn(() => gitWatcher as unknown as vscode.FileSystemWatcher), + onDidChangeWorkspaceFolders(handler: () => void) { + folderChangeHandler = handler; + return { dispose: vi.fn() }; + } + }; + + return { + workspace, + gitWatcher, + triggerGitCreate: () => gitCreateHandler?.(), + triggerFolderChange: () => folderChangeHandler?.() + }; +} + +const fakeCtx = {} as unknown as vscode.ExtensionContext; + +beforeEach(() => { + vi.mocked(findGitRepos).mockResolvedValue([]); + vi.mocked(bootstrap).mockReturnValue(undefined); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe("waitForRepo", () => { + it("watches **/.git for creation events", () => { + const { workspace } = makeFakeWorkspace(); + waitForRepo(fakeCtx, workspace); + expect(workspace.createFileSystemWatcher).toHaveBeenCalledWith("**/.git"); + }); + + it("calls bootstrap when a .git is created and repos are found", async () => { + const { workspace, triggerGitCreate } = makeFakeWorkspace(["/workspace/project"]); + vi.mocked(findGitRepos).mockResolvedValue(["/workspace/project"]); + + waitForRepo(fakeCtx, workspace); + triggerGitCreate(); + await vi.waitFor(() => expect(bootstrap).toHaveBeenCalledWith(fakeCtx, ["/workspace/project"])); + }); + + it("calls bootstrap when workspace folders change and repos are found", async () => { + const { workspace, triggerFolderChange } = makeFakeWorkspace(["/workspace/project"]); + vi.mocked(findGitRepos).mockResolvedValue(["/workspace/project"]); + + waitForRepo(fakeCtx, workspace); + triggerFolderChange(); + await vi.waitFor(() => expect(bootstrap).toHaveBeenCalledWith(fakeCtx, ["/workspace/project"])); + }); + + it("does not call bootstrap when no repos are found after detection", async () => { + const { workspace, triggerGitCreate } = makeFakeWorkspace(["/workspace/project"]); + vi.mocked(findGitRepos).mockResolvedValue([]); + + waitForRepo(fakeCtx, workspace); + triggerGitCreate(); + await new Promise((r) => setTimeout(r, 50)); + expect(bootstrap).not.toHaveBeenCalled(); + }); + + it("disposes watchers after repos are found", async () => { + const { workspace, gitWatcher, triggerGitCreate } = makeFakeWorkspace(["/workspace/project"]); + vi.mocked(findGitRepos).mockResolvedValue(["/workspace/project"]); + + waitForRepo(fakeCtx, workspace); + triggerGitCreate(); + await vi.waitFor(() => expect(bootstrap).toHaveBeenCalled()); + expect(gitWatcher.dispose).toHaveBeenCalled(); + }); + + it("dispose() is idempotent", () => { + const { workspace, gitWatcher } = makeFakeWorkspace(); + const detector = waitForRepo(fakeCtx, workspace); + detector.dispose(); + detector.dispose(); + expect(gitWatcher.dispose).toHaveBeenCalledTimes(1); + }); + + it("does not double-bootstrap when both events fire before check resolves", async () => { + const { workspace, triggerGitCreate, triggerFolderChange } = makeFakeWorkspace([ + "/workspace/project" + ]); + vi.mocked(findGitRepos).mockResolvedValue(["/workspace/project"]); + + waitForRepo(fakeCtx, workspace); + triggerGitCreate(); + triggerFolderChange(); + + await vi.waitFor(() => expect(bootstrap).toHaveBeenCalled()); + await new Promise((r) => setTimeout(r, 20)); + expect(bootstrap).toHaveBeenCalledTimes(1); + }); + + it("does not bootstrap after external dispose while check is in-flight", async () => { + const { workspace, triggerGitCreate } = makeFakeWorkspace(["/workspace/project"]); + + let resolveFindGitRepos!: (v: string[]) => void; + vi.mocked(findGitRepos).mockImplementation(() => new Promise((r) => (resolveFindGitRepos = r))); + + const detector = waitForRepo(fakeCtx, workspace); + triggerGitCreate(); + detector.dispose(); + resolveFindGitRepos(["/workspace/project"]); + + await new Promise((r) => setTimeout(r, 20)); + expect(bootstrap).not.toHaveBeenCalled(); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 6e5bfe0b..b3162da1 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -17,6 +17,21 @@ export default defineConfig({ include: ["tests/backend/**/*.test.ts"] } }, + { + resolve: { + alias: [ + ...alias, + { + find: "vscode", + replacement: path.resolve(__dirname, "tests/extension/__mocks__/vscode.ts") + } + ] + }, + test: { + name: "extension", + include: ["tests/extension/**/*.test.ts"] + } + }, { resolve: { alias: [ From c878a3dbcaa8face9bfb23e351aa6d212f6428d3 Mon Sep 17 00:00:00 2001 From: asispts <79239132+asispts@users.noreply.github.com> Date: Fri, 10 Apr 2026 14:55:12 +0000 Subject: [PATCH 05/36] Fix typecheck issues --- tests/extension/waitForRepo.test.ts | 6 +++--- tests/tsconfig.json | 2 ++ 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/extension/waitForRepo.test.ts b/tests/extension/waitForRepo.test.ts index 234f874c..df118544 100644 --- a/tests/extension/waitForRepo.test.ts +++ b/tests/extension/waitForRepo.test.ts @@ -16,7 +16,7 @@ function makeFakeWorkspace(folderPaths: string[] = []): { triggerFolderChange: () => void; } { let gitCreateHandler: (() => void) | null = null; - let folderChangeHandler: (() => void) | null = null; + let folderChangeHandler: ((e: vscode.WorkspaceFoldersChangeEvent) => void) | null = null; const gitWatcher = { onDidCreate(handler: () => void) { @@ -29,7 +29,7 @@ function makeFakeWorkspace(folderPaths: string[] = []): { const workspace: WorkspaceApi = { workspaceFolders: folderPaths.map((p) => ({ uri: { fsPath: p } }) as vscode.WorkspaceFolder), createFileSystemWatcher: vi.fn(() => gitWatcher as unknown as vscode.FileSystemWatcher), - onDidChangeWorkspaceFolders(handler: () => void) { + onDidChangeWorkspaceFolders(handler: (e: vscode.WorkspaceFoldersChangeEvent) => void) { folderChangeHandler = handler; return { dispose: vi.fn() }; } @@ -39,7 +39,7 @@ function makeFakeWorkspace(folderPaths: string[] = []): { workspace, gitWatcher, triggerGitCreate: () => gitCreateHandler?.(), - triggerFolderChange: () => folderChangeHandler?.() + triggerFolderChange: () => folderChangeHandler?.({} as vscode.WorkspaceFoldersChangeEvent) }; } diff --git a/tests/tsconfig.json b/tests/tsconfig.json index c4a742c3..61aba8fc 100644 --- a/tests/tsconfig.json +++ b/tests/tsconfig.json @@ -14,8 +14,10 @@ }, "include": [ "./backend/**/*.ts", + "./extension/**/*.ts", "./webview/**/*.ts", "../src/backend/**/*.ts", + "../src/extension/**/*.ts", "../src/webview/**/*.ts" ] } From 0ebe67decb1c3958b98c65e358705a5840432adf Mon Sep 17 00:00:00 2001 From: asispts <79239132+asispts@users.noreply.github.com> Date: Fri, 10 Apr 2026 14:58:02 +0000 Subject: [PATCH 06/36] Use temp dir --- tests/extension/waitForRepo.test.ts | 36 +++++++++++++++++------------ 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/tests/extension/waitForRepo.test.ts b/tests/extension/waitForRepo.test.ts index df118544..7e6b0aba 100644 --- a/tests/extension/waitForRepo.test.ts +++ b/tests/extension/waitForRepo.test.ts @@ -1,3 +1,7 @@ +import { mkdtempSync, rmdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type * as vscode from "vscode"; @@ -45,13 +49,17 @@ function makeFakeWorkspace(folderPaths: string[] = []): { const fakeCtx = {} as unknown as vscode.ExtensionContext; +let tmpDir: string; + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "ngg-waitForRepo-test-")); vi.mocked(findGitRepos).mockResolvedValue([]); vi.mocked(bootstrap).mockReturnValue(undefined); }); afterEach(() => { vi.clearAllMocks(); + rmdirSync(tmpDir); }); describe("waitForRepo", () => { @@ -62,25 +70,25 @@ describe("waitForRepo", () => { }); it("calls bootstrap when a .git is created and repos are found", async () => { - const { workspace, triggerGitCreate } = makeFakeWorkspace(["/workspace/project"]); - vi.mocked(findGitRepos).mockResolvedValue(["/workspace/project"]); + const { workspace, triggerGitCreate } = makeFakeWorkspace([tmpDir]); + vi.mocked(findGitRepos).mockResolvedValue([tmpDir]); waitForRepo(fakeCtx, workspace); triggerGitCreate(); - await vi.waitFor(() => expect(bootstrap).toHaveBeenCalledWith(fakeCtx, ["/workspace/project"])); + await vi.waitFor(() => expect(bootstrap).toHaveBeenCalledWith(fakeCtx, [tmpDir])); }); it("calls bootstrap when workspace folders change and repos are found", async () => { - const { workspace, triggerFolderChange } = makeFakeWorkspace(["/workspace/project"]); - vi.mocked(findGitRepos).mockResolvedValue(["/workspace/project"]); + const { workspace, triggerFolderChange } = makeFakeWorkspace([tmpDir]); + vi.mocked(findGitRepos).mockResolvedValue([tmpDir]); waitForRepo(fakeCtx, workspace); triggerFolderChange(); - await vi.waitFor(() => expect(bootstrap).toHaveBeenCalledWith(fakeCtx, ["/workspace/project"])); + await vi.waitFor(() => expect(bootstrap).toHaveBeenCalledWith(fakeCtx, [tmpDir])); }); it("does not call bootstrap when no repos are found after detection", async () => { - const { workspace, triggerGitCreate } = makeFakeWorkspace(["/workspace/project"]); + const { workspace, triggerGitCreate } = makeFakeWorkspace([tmpDir]); vi.mocked(findGitRepos).mockResolvedValue([]); waitForRepo(fakeCtx, workspace); @@ -90,8 +98,8 @@ describe("waitForRepo", () => { }); it("disposes watchers after repos are found", async () => { - const { workspace, gitWatcher, triggerGitCreate } = makeFakeWorkspace(["/workspace/project"]); - vi.mocked(findGitRepos).mockResolvedValue(["/workspace/project"]); + const { workspace, gitWatcher, triggerGitCreate } = makeFakeWorkspace([tmpDir]); + vi.mocked(findGitRepos).mockResolvedValue([tmpDir]); waitForRepo(fakeCtx, workspace); triggerGitCreate(); @@ -108,10 +116,8 @@ describe("waitForRepo", () => { }); it("does not double-bootstrap when both events fire before check resolves", async () => { - const { workspace, triggerGitCreate, triggerFolderChange } = makeFakeWorkspace([ - "/workspace/project" - ]); - vi.mocked(findGitRepos).mockResolvedValue(["/workspace/project"]); + const { workspace, triggerGitCreate, triggerFolderChange } = makeFakeWorkspace([tmpDir]); + vi.mocked(findGitRepos).mockResolvedValue([tmpDir]); waitForRepo(fakeCtx, workspace); triggerGitCreate(); @@ -123,7 +129,7 @@ describe("waitForRepo", () => { }); it("does not bootstrap after external dispose while check is in-flight", async () => { - const { workspace, triggerGitCreate } = makeFakeWorkspace(["/workspace/project"]); + const { workspace, triggerGitCreate } = makeFakeWorkspace([tmpDir]); let resolveFindGitRepos!: (v: string[]) => void; vi.mocked(findGitRepos).mockImplementation(() => new Promise((r) => (resolveFindGitRepos = r))); @@ -131,7 +137,7 @@ describe("waitForRepo", () => { const detector = waitForRepo(fakeCtx, workspace); triggerGitCreate(); detector.dispose(); - resolveFindGitRepos(["/workspace/project"]); + resolveFindGitRepos([tmpDir]); await new Promise((r) => setTimeout(r, 20)); expect(bootstrap).not.toHaveBeenCalled(); From 470bda65aa1c6dd1141babc9d917bf69b3356e86 Mon Sep 17 00:00:00 2001 From: asispts <79239132+asispts@users.noreply.github.com> Date: Fri, 10 Apr 2026 15:06:37 +0000 Subject: [PATCH 07/36] Don't mock findGitRepos --- tests/extension/waitForRepo.test.ts | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/tests/extension/waitForRepo.test.ts b/tests/extension/waitForRepo.test.ts index 7e6b0aba..197fcb1a 100644 --- a/tests/extension/waitForRepo.test.ts +++ b/tests/extension/waitForRepo.test.ts @@ -1,14 +1,13 @@ -import { mkdtempSync, rmdirSync } from "node:fs"; +import { execSync } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type * as vscode from "vscode"; -vi.mock("@/backend/queries/repoSearch"); vi.mock("@/extension/bootstrap"); -import { findGitRepos } from "@/backend/queries/repoSearch"; import { bootstrap } from "@/extension/bootstrap"; import { waitForRepo } from "@/extension/waitForRepo"; import type { WorkspaceApi } from "@/extension/waitForRepo"; @@ -53,13 +52,13 @@ let tmpDir: string; beforeEach(() => { tmpDir = mkdtempSync(join(tmpdir(), "ngg-waitForRepo-test-")); - vi.mocked(findGitRepos).mockResolvedValue([]); vi.mocked(bootstrap).mockReturnValue(undefined); }); afterEach(() => { + vi.restoreAllMocks(); vi.clearAllMocks(); - rmdirSync(tmpDir); + rmSync(tmpDir, { recursive: true, force: true }); }); describe("waitForRepo", () => { @@ -70,8 +69,8 @@ describe("waitForRepo", () => { }); it("calls bootstrap when a .git is created and repos are found", async () => { + execSync("git init", { cwd: tmpDir }); const { workspace, triggerGitCreate } = makeFakeWorkspace([tmpDir]); - vi.mocked(findGitRepos).mockResolvedValue([tmpDir]); waitForRepo(fakeCtx, workspace); triggerGitCreate(); @@ -79,8 +78,8 @@ describe("waitForRepo", () => { }); it("calls bootstrap when workspace folders change and repos are found", async () => { + execSync("git init", { cwd: tmpDir }); const { workspace, triggerFolderChange } = makeFakeWorkspace([tmpDir]); - vi.mocked(findGitRepos).mockResolvedValue([tmpDir]); waitForRepo(fakeCtx, workspace); triggerFolderChange(); @@ -89,7 +88,6 @@ describe("waitForRepo", () => { it("does not call bootstrap when no repos are found after detection", async () => { const { workspace, triggerGitCreate } = makeFakeWorkspace([tmpDir]); - vi.mocked(findGitRepos).mockResolvedValue([]); waitForRepo(fakeCtx, workspace); triggerGitCreate(); @@ -98,8 +96,8 @@ describe("waitForRepo", () => { }); it("disposes watchers after repos are found", async () => { + execSync("git init", { cwd: tmpDir }); const { workspace, gitWatcher, triggerGitCreate } = makeFakeWorkspace([tmpDir]); - vi.mocked(findGitRepos).mockResolvedValue([tmpDir]); waitForRepo(fakeCtx, workspace); triggerGitCreate(); @@ -116,8 +114,8 @@ describe("waitForRepo", () => { }); it("does not double-bootstrap when both events fire before check resolves", async () => { + execSync("git init", { cwd: tmpDir }); const { workspace, triggerGitCreate, triggerFolderChange } = makeFakeWorkspace([tmpDir]); - vi.mocked(findGitRepos).mockResolvedValue([tmpDir]); waitForRepo(fakeCtx, workspace); triggerGitCreate(); @@ -127,12 +125,16 @@ describe("waitForRepo", () => { await new Promise((r) => setTimeout(r, 20)); expect(bootstrap).toHaveBeenCalledTimes(1); }); +}); +describe("waitForRepo — mocked findGitRepos for timing control", () => { it("does not bootstrap after external dispose while check is in-flight", async () => { const { workspace, triggerGitCreate } = makeFakeWorkspace([tmpDir]); let resolveFindGitRepos!: (v: string[]) => void; - vi.mocked(findGitRepos).mockImplementation(() => new Promise((r) => (resolveFindGitRepos = r))); + vi.spyOn(await import("@/backend/queries/repoSearch"), "findGitRepos").mockImplementation( + () => new Promise((r) => (resolveFindGitRepos = r)) + ); const detector = waitForRepo(fakeCtx, workspace); triggerGitCreate(); From dc47ad923fefafe90aedc9ecc37c2d29fc18173a Mon Sep 17 00:00:00 2001 From: asispts <79239132+asispts@users.noreply.github.com> Date: Fri, 10 Apr 2026 23:57:58 +0100 Subject: [PATCH 08/36] Can display the panel --- src/extension/bootstrap.ts | 97 +++++++++++++++++++++++++++++- tests/extension/bootstrap.test.ts | 98 +++++++++++++++++++++++++++++++ 2 files changed, 194 insertions(+), 1 deletion(-) create mode 100644 tests/extension/bootstrap.test.ts diff --git a/src/extension/bootstrap.ts b/src/extension/bootstrap.ts index fa9f0971..65800008 100644 --- a/src/extension/bootstrap.ts +++ b/src/extension/bootstrap.ts @@ -1,3 +1,98 @@ import * as vscode from "vscode"; -export function bootstrap(_ctx: vscode.ExtensionContext, _repos: string[]): void {} +import { AvatarManager } from "@/avatarManager"; +import { gitClientFactory } from "@/backend/gitClient"; +import { buildExtensionUri } from "@/backend/utils/path"; +import { config } from "@/config"; +import { ExtensionState } from "@/extensionState"; +import { initL10n } from "@/l10n"; +import * as l10n from "@/l10n"; +import { RepoFileWatcher } from "@/repoFileWatcher"; +import { StatusBarItem } from "@/statusBarItem"; + +import { registerMessageHandlers } from "./messageHandler"; +import { createRepoManager } from "./repoManager"; +import { webviewBridgeFactory, WebviewBridge } from "./webviewBridge"; +import { createWebviewPanel } from "./webviewPanel"; + +export type PanelHandle = { + reveal(): void; +}; + +export function viewGitGraphCommand( + panelFactory: (onDispose: () => void) => PanelHandle +): () => void { + let currentPanel: PanelHandle | undefined; + + return () => { + if (currentPanel) { + currentPanel.reveal(); + return; + } + currentPanel = panelFactory(() => { + currentPanel = undefined; + }); + }; +} + +export function bootstrap(ctx: vscode.ExtensionContext, repos: string[]): void { + initL10n(ctx.extensionPath); + const extensionState = new ExtensionState(ctx); + const statusBarItem = new StatusBarItem(ctx, config); + const gitClient = gitClientFactory(extensionState.getLastActiveRepo() ?? "", config.gitPath()); + const avatarManager = new AvatarManager(config.gitPath, extensionState); + const repoManager = createRepoManager(extensionState, statusBarItem, config); + + for (const repo of repos) { + repoManager.addRepo(repo); + } + + ctx.subscriptions.push( + vscode.commands.registerCommand( + "neo-git-graph.view", + viewGitGraphCommand((onDispose) => { + const column = vscode.window.activeTextEditor?.viewColumn; + const vsPanel = vscode.window.createWebviewPanel( + "neo-git-graph", + l10n.t("outputChannel.text"), + column ?? vscode.ViewColumn.One, + { + enableScripts: true, + localResourceRoots: [ + buildExtensionUri(ctx.extensionPath, "media"), + buildExtensionUri(ctx.extensionPath, "out") + ] + } + ); + + let bridge!: WebviewBridge; + const repoFileWatcher = new RepoFileWatcher(() => { + if (vsPanel.visible) bridge.post({ command: "refresh" }); + }); + bridge = webviewBridgeFactory(vsPanel.webview, repoFileWatcher); + avatarManager.registerBridge(bridge.post.bind(bridge)); + const { onPanelShown } = registerMessageHandlers(bridge, { + config, + gitClient, + repoManager, + extensionState, + avatarManager, + repoFileWatcher + }); + + return createWebviewPanel({ + panel: vsPanel, + bridge, + config, + repoFileWatcher, + extensionPath: ctx.extensionPath, + extensionState, + avatarManager, + repoManager, + onDispose, + onPanelShown + }); + }) + ) + ); +} diff --git a/tests/extension/bootstrap.test.ts b/tests/extension/bootstrap.test.ts new file mode 100644 index 00000000..a0cc3676 --- /dev/null +++ b/tests/extension/bootstrap.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { PanelHandle } from "@/extension/bootstrap"; +import { viewGitGraphCommand } from "@/extension/bootstrap"; + +function makeFakePanel() { + const reveal = vi.fn(); + const panel: PanelHandle = { reveal: reveal as unknown as PanelHandle["reveal"] }; + + let onDisposeCallback: (() => void) | undefined; + const factory = vi.fn((onDispose: () => void) => { + onDisposeCallback = onDispose; + return panel; + }); + + return { + panel, + reveal, + factory, + simulateDispose: () => onDisposeCallback?.() + }; +} + +describe("viewGitGraphCommand", () => { + it("creates a panel on first invocation", () => { + const { factory } = makeFakePanel(); + const command = viewGitGraphCommand(factory); + + command(); + + expect(factory).toHaveBeenCalledTimes(1); + }); + + it("reveals the existing panel on second invocation", () => { + const { factory, reveal } = makeFakePanel(); + const command = viewGitGraphCommand(factory); + + command(); + command(); + + expect(factory).toHaveBeenCalledTimes(1); + expect(reveal).toHaveBeenCalledTimes(1); + }); + + it("does not create a new panel while one is already open", () => { + const { factory } = makeFakePanel(); + const command = viewGitGraphCommand(factory); + + command(); + command(); + command(); + + expect(factory).toHaveBeenCalledTimes(1); + }); + + it("creates a new panel after the previous one is disposed", () => { + const { factory, simulateDispose } = makeFakePanel(); + const command = viewGitGraphCommand(factory); + + command(); + simulateDispose(); + command(); + + expect(factory).toHaveBeenCalledTimes(2); + }); + + it("does not reveal after dispose — creates a fresh panel instead", () => { + const { factory, reveal, simulateDispose } = makeFakePanel(); + const command = viewGitGraphCommand(factory); + + command(); + simulateDispose(); + command(); + + expect(reveal).not.toHaveBeenCalled(); + }); + + it("passes an onDispose callback to the factory", () => { + const { factory } = makeFakePanel(); + const command = viewGitGraphCommand(factory); + + command(); + + expect(factory).toHaveBeenCalledWith(expect.any(Function)); + }); + + it("clears the panel reference when onDispose is called", () => { + const { factory, simulateDispose } = makeFakePanel(); + const command = viewGitGraphCommand(factory); + + command(); + simulateDispose(); + command(); + + // After dispose, factory must be called again (panel reference was cleared) + expect(factory).toHaveBeenCalledTimes(2); + }); +}); From 180fb2a23db1e8301eee6032d1aba41c007cd6cf Mon Sep 17 00:00:00 2001 From: asispts <79239132+asispts@users.noreply.github.com> Date: Fri, 10 Apr 2026 22:59:49 +0000 Subject: [PATCH 09/36] Add diff provider --- src/extension/bootstrap.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/extension/bootstrap.ts b/src/extension/bootstrap.ts index 65800008..bd8dea22 100644 --- a/src/extension/bootstrap.ts +++ b/src/extension/bootstrap.ts @@ -4,6 +4,7 @@ import { AvatarManager } from "@/avatarManager"; import { gitClientFactory } from "@/backend/gitClient"; import { buildExtensionUri } from "@/backend/utils/path"; import { config } from "@/config"; +import { DiffDocProvider } from "@/diffDocProvider"; import { ExtensionState } from "@/extensionState"; import { initL10n } from "@/l10n"; import * as l10n from "@/l10n"; @@ -95,4 +96,11 @@ export function bootstrap(ctx: vscode.ExtensionContext, repos: string[]): void { }) ) ); + + ctx.subscriptions.push( + vscode.workspace.registerTextDocumentContentProvider( + DiffDocProvider.scheme, + new DiffDocProvider(gitClient.getInstance) + ) + ); } From bf4d73ee43481ad7810c737a99d1a90535ae8fca Mon Sep 17 00:00:00 2001 From: asispts <79239132+asispts@users.noreply.github.com> Date: Mon, 13 Apr 2026 07:10:40 +0100 Subject: [PATCH 10/36] Bring back clearAvatarCache command --- src/extension/bootstrap.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/extension/bootstrap.ts b/src/extension/bootstrap.ts index bd8dea22..437db87e 100644 --- a/src/extension/bootstrap.ts +++ b/src/extension/bootstrap.ts @@ -97,6 +97,12 @@ export function bootstrap(ctx: vscode.ExtensionContext, repos: string[]): void { ) ); + ctx.subscriptions.push( + vscode.commands.registerCommand("neo-git-graph.clearAvatarCache", () => { + avatarManager.clearCache(); + }) + ); + ctx.subscriptions.push( vscode.workspace.registerTextDocumentContentProvider( DiffDocProvider.scheme, From 530d1edd7b926907c3a0510e7522a8393a00618e Mon Sep 17 00:00:00 2001 From: asispts <79239132+asispts@users.noreply.github.com> Date: Mon, 13 Apr 2026 08:20:26 +0000 Subject: [PATCH 11/36] Modify activate function to register the ext commands --- src/extension/main.ts | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/src/extension/main.ts b/src/extension/main.ts index d7426637..7cf68d84 100644 --- a/src/extension/main.ts +++ b/src/extension/main.ts @@ -2,19 +2,38 @@ import * as vscode from "vscode"; import { findGitRepos } from "@/backend/queries/repoSearch"; import { config } from "@/config"; -import { bootstrap } from "@/extension/bootstrap"; import { waitForRepo } from "@/extension/waitForRepo"; +import * as l10n from "@/l10n"; export async function activate(ctx: vscode.ExtensionContext) { - const paths = (vscode.workspace.workspaceFolders ?? []).map((f) => f.uri.fsPath); + l10n.initL10n(ctx.extensionUri.fsPath); + const paths = (vscode.workspace.workspaceFolders ?? []).map((f) => f.uri.fsPath); const repoDirs = await findGitRepos(paths, config.gitPath(), config.maxDepthOfRepoSearch()); - if (repoDirs.length <= 0) { - ctx.subscriptions.push(waitForRepo(ctx)); + + if (repoDirs.length > 0) { + // bootstrap(ctx, repoDirs); return; } - bootstrap(ctx, repoDirs); + ctx.subscriptions.push(waitForRepo(ctx)); + + // Commands fallback + ctx.subscriptions.push( + vscode.commands.registerCommand("neo-git-graph.view", async () => { + await vscode.window.showErrorMessage(l10n.t("statusBar.text"), { + detail: l10n.t("error.noGitRepository"), + modal: true + }); + }), + + vscode.commands.registerCommand("neo-git-graph.clearAvatarCache", async () => { + await vscode.window.showErrorMessage(l10n.t("statusBar.text"), { + detail: l10n.t("error.noGitRepository"), + modal: true + }); + }) + ); } export function deactivate() {} From 80da7e4181390b20d315f7bda00a2991004bdd04 Mon Sep 17 00:00:00 2001 From: asispts <79239132+asispts@users.noreply.github.com> Date: Mon, 13 Apr 2026 10:27:16 +0000 Subject: [PATCH 12/36] Create a better bootstrap --- src/extension/bootstrap.ts | 150 ++++++++++++++---------------- src/extension/main.ts | 3 +- src/extension/repoManager.ts | 10 +- tests/extension/bootstrap.test.ts | 98 ------------------- 4 files changed, 81 insertions(+), 180 deletions(-) delete mode 100644 tests/extension/bootstrap.test.ts diff --git a/src/extension/bootstrap.ts b/src/extension/bootstrap.ts index 437db87e..1b9fdfed 100644 --- a/src/extension/bootstrap.ts +++ b/src/extension/bootstrap.ts @@ -1,101 +1,88 @@ import * as vscode from "vscode"; import { AvatarManager } from "@/avatarManager"; -import { gitClientFactory } from "@/backend/gitClient"; +import { GitClient, gitClientFactory } from "@/backend/gitClient"; import { buildExtensionUri } from "@/backend/utils/path"; import { config } from "@/config"; import { DiffDocProvider } from "@/diffDocProvider"; +import { registerMessageHandlers } from "@/extension/messageHandler"; +import { createRepoManager } from "@/extension/repoManager"; +import { WebviewBridge, webviewBridgeFactory } from "@/extension/webviewBridge"; +import { createWebviewPanel, WebviewPanel } from "@/extension/webviewPanel"; import { ExtensionState } from "@/extensionState"; -import { initL10n } from "@/l10n"; import * as l10n from "@/l10n"; import { RepoFileWatcher } from "@/repoFileWatcher"; import { StatusBarItem } from "@/statusBarItem"; -import { registerMessageHandlers } from "./messageHandler"; -import { createRepoManager } from "./repoManager"; -import { webviewBridgeFactory, WebviewBridge } from "./webviewBridge"; -import { createWebviewPanel } from "./webviewPanel"; - -export type PanelHandle = { - reveal(): void; -}; - -export function viewGitGraphCommand( - panelFactory: (onDispose: () => void) => PanelHandle -): () => void { - let currentPanel: PanelHandle | undefined; - - return () => { - if (currentPanel) { - currentPanel.reveal(); - return; - } - currentPanel = panelFactory(() => { - currentPanel = undefined; - }); - }; -} - -export function bootstrap(ctx: vscode.ExtensionContext, repos: string[]): void { - initL10n(ctx.extensionPath); - const extensionState = new ExtensionState(ctx); +function registerViewCommand( + ctx: vscode.ExtensionContext, + repos: string[], + extensionState: ExtensionState, + avatarManager: AvatarManager, + gitClient: GitClient +) { const statusBarItem = new StatusBarItem(ctx, config); - const gitClient = gitClientFactory(extensionState.getLastActiveRepo() ?? "", config.gitPath()); - const avatarManager = new AvatarManager(config.gitPath, extensionState); const repoManager = createRepoManager(extensionState, statusBarItem, config); + repoManager.setRepos(repos); - for (const repo of repos) { - repoManager.addRepo(repo); - } - + let currentPanel: WebviewPanel | undefined; ctx.subscriptions.push( - vscode.commands.registerCommand( - "neo-git-graph.view", - viewGitGraphCommand((onDispose) => { - const column = vscode.window.activeTextEditor?.viewColumn; - const vsPanel = vscode.window.createWebviewPanel( - "neo-git-graph", - l10n.t("outputChannel.text"), - column ?? vscode.ViewColumn.One, - { - enableScripts: true, - localResourceRoots: [ - buildExtensionUri(ctx.extensionPath, "media"), - buildExtensionUri(ctx.extensionPath, "out") - ] - } - ); + vscode.commands.registerCommand("neo-git-graph.view", () => { + if (currentPanel) { + currentPanel.reveal(vscode.window.activeTextEditor?.viewColumn); + return; + } - let bridge!: WebviewBridge; - const repoFileWatcher = new RepoFileWatcher(() => { - if (vsPanel.visible) bridge.post({ command: "refresh" }); - }); - bridge = webviewBridgeFactory(vsPanel.webview, repoFileWatcher); - avatarManager.registerBridge(bridge.post.bind(bridge)); - const { onPanelShown } = registerMessageHandlers(bridge, { - config, - gitClient, - repoManager, - extensionState, - avatarManager, - repoFileWatcher - }); + const vsPanel = vscode.window.createWebviewPanel( + "neo-git-graph", + l10n.t("outputChannel.text"), + vscode.window.activeTextEditor?.viewColumn ?? vscode.ViewColumn.One, + { + enableScripts: true, + localResourceRoots: [ + buildExtensionUri(ctx.extensionPath, "media"), + buildExtensionUri(ctx.extensionPath, "out") + ] + } + ); - return createWebviewPanel({ - panel: vsPanel, - bridge, - config, - repoFileWatcher, - extensionPath: ctx.extensionPath, - extensionState, - avatarManager, - repoManager, - onDispose, - onPanelShown - }); - }) - ) + let bridge!: WebviewBridge; + const repoFileWatcher = new RepoFileWatcher(() => { + if (vsPanel.visible) bridge.post({ command: "refresh" }); + }); + bridge = webviewBridgeFactory(vsPanel.webview, repoFileWatcher); + avatarManager.registerBridge(bridge.post.bind(bridge)); + + const { onPanelShown } = registerMessageHandlers(bridge, { + config, + gitClient, + repoManager, + extensionState, + avatarManager, + repoFileWatcher + }); + + currentPanel = createWebviewPanel({ + panel: vsPanel, + bridge, + config, + repoFileWatcher, + extensionPath: ctx.extensionPath, + extensionState, + avatarManager, + repoManager, + onDispose: () => { + currentPanel = undefined; + }, + onPanelShown + }); + }) ); +} + +export function bootstrap(ctx: vscode.ExtensionContext, repos: string[]) { + const extensionState = new ExtensionState(ctx); + const avatarManager = new AvatarManager(config.gitPath, extensionState); ctx.subscriptions.push( vscode.commands.registerCommand("neo-git-graph.clearAvatarCache", () => { @@ -103,10 +90,13 @@ export function bootstrap(ctx: vscode.ExtensionContext, repos: string[]): void { }) ); + const gitClient = gitClientFactory(extensionState.getLastActiveRepo() ?? "", config.gitPath()); ctx.subscriptions.push( vscode.workspace.registerTextDocumentContentProvider( DiffDocProvider.scheme, new DiffDocProvider(gitClient.getInstance) ) ); + + registerViewCommand(ctx, repos, extensionState, avatarManager, gitClient); } diff --git a/src/extension/main.ts b/src/extension/main.ts index 7cf68d84..d92eb641 100644 --- a/src/extension/main.ts +++ b/src/extension/main.ts @@ -2,6 +2,7 @@ import * as vscode from "vscode"; import { findGitRepos } from "@/backend/queries/repoSearch"; import { config } from "@/config"; +import { bootstrap } from "@/extension/bootstrap"; import { waitForRepo } from "@/extension/waitForRepo"; import * as l10n from "@/l10n"; @@ -12,7 +13,7 @@ export async function activate(ctx: vscode.ExtensionContext) { const repoDirs = await findGitRepos(paths, config.gitPath(), config.maxDepthOfRepoSearch()); if (repoDirs.length > 0) { - // bootstrap(ctx, repoDirs); + bootstrap(ctx, repoDirs); return; } diff --git a/src/extension/repoManager.ts b/src/extension/repoManager.ts index 4b842aff..3cb22c18 100644 --- a/src/extension/repoManager.ts +++ b/src/extension/repoManager.ts @@ -25,6 +25,13 @@ export function createRepoManager( let repos = extensionState.getRepos(); let viewCallback: ((repos: GitRepoSet, numRepos: number) => void) | null = null; + function setRepos(repos: string[]) { + repos.length = 0; + repos.forEach((repo) => { + addRepo(repo); + }); + } + function getRepos() { return sortRepos(repos); } @@ -123,9 +130,10 @@ export function createRepoManager( return { registerViewCallback, deregisterViewCallback, - getRepos, isDirectoryWithinRepos, + getRepos, sendRepos, + setRepos, addRepo, removeRepo, removeReposWithinFolder, diff --git a/tests/extension/bootstrap.test.ts b/tests/extension/bootstrap.test.ts deleted file mode 100644 index a0cc3676..00000000 --- a/tests/extension/bootstrap.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -import type { PanelHandle } from "@/extension/bootstrap"; -import { viewGitGraphCommand } from "@/extension/bootstrap"; - -function makeFakePanel() { - const reveal = vi.fn(); - const panel: PanelHandle = { reveal: reveal as unknown as PanelHandle["reveal"] }; - - let onDisposeCallback: (() => void) | undefined; - const factory = vi.fn((onDispose: () => void) => { - onDisposeCallback = onDispose; - return panel; - }); - - return { - panel, - reveal, - factory, - simulateDispose: () => onDisposeCallback?.() - }; -} - -describe("viewGitGraphCommand", () => { - it("creates a panel on first invocation", () => { - const { factory } = makeFakePanel(); - const command = viewGitGraphCommand(factory); - - command(); - - expect(factory).toHaveBeenCalledTimes(1); - }); - - it("reveals the existing panel on second invocation", () => { - const { factory, reveal } = makeFakePanel(); - const command = viewGitGraphCommand(factory); - - command(); - command(); - - expect(factory).toHaveBeenCalledTimes(1); - expect(reveal).toHaveBeenCalledTimes(1); - }); - - it("does not create a new panel while one is already open", () => { - const { factory } = makeFakePanel(); - const command = viewGitGraphCommand(factory); - - command(); - command(); - command(); - - expect(factory).toHaveBeenCalledTimes(1); - }); - - it("creates a new panel after the previous one is disposed", () => { - const { factory, simulateDispose } = makeFakePanel(); - const command = viewGitGraphCommand(factory); - - command(); - simulateDispose(); - command(); - - expect(factory).toHaveBeenCalledTimes(2); - }); - - it("does not reveal after dispose — creates a fresh panel instead", () => { - const { factory, reveal, simulateDispose } = makeFakePanel(); - const command = viewGitGraphCommand(factory); - - command(); - simulateDispose(); - command(); - - expect(reveal).not.toHaveBeenCalled(); - }); - - it("passes an onDispose callback to the factory", () => { - const { factory } = makeFakePanel(); - const command = viewGitGraphCommand(factory); - - command(); - - expect(factory).toHaveBeenCalledWith(expect.any(Function)); - }); - - it("clears the panel reference when onDispose is called", () => { - const { factory, simulateDispose } = makeFakePanel(); - const command = viewGitGraphCommand(factory); - - command(); - simulateDispose(); - command(); - - // After dispose, factory must be called again (panel reference was cleared) - expect(factory).toHaveBeenCalledTimes(2); - }); -}); From e0367fdcfbf8b912f261b19feb313b958459f99e Mon Sep 17 00:00:00 2001 From: asispts <79239132+asispts@users.noreply.github.com> Date: Mon, 13 Apr 2026 10:35:15 +0000 Subject: [PATCH 13/36] Invoke sendRepos when reset the repos --- src/extension/repoManager.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/extension/repoManager.ts b/src/extension/repoManager.ts index 3cb22c18..775b50fd 100644 --- a/src/extension/repoManager.ts +++ b/src/extension/repoManager.ts @@ -30,6 +30,8 @@ export function createRepoManager( repos.forEach((repo) => { addRepo(repo); }); + + sendRepos(); } function getRepos() { From 492bcea7e610869d69602d09430b5d8bf2f1703f Mon Sep 17 00:00:00 2001 From: asispts <79239132+asispts@users.noreply.github.com> Date: Mon, 13 Apr 2026 11:40:34 +0000 Subject: [PATCH 14/36] Add configuration listener --- src/extension/bootstrap.ts | 15 +++++++++++++-- src/extension/main.ts | 6 ++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/extension/bootstrap.ts b/src/extension/bootstrap.ts index 1b9fdfed..c9845330 100644 --- a/src/extension/bootstrap.ts +++ b/src/extension/bootstrap.ts @@ -18,10 +18,10 @@ function registerViewCommand( ctx: vscode.ExtensionContext, repos: string[], extensionState: ExtensionState, + statusBarItem: StatusBarItem, avatarManager: AvatarManager, gitClient: GitClient ) { - const statusBarItem = new StatusBarItem(ctx, config); const repoManager = createRepoManager(extensionState, statusBarItem, config); repoManager.setRepos(repos); @@ -98,5 +98,16 @@ export function bootstrap(ctx: vscode.ExtensionContext, repos: string[]) { ) ); - registerViewCommand(ctx, repos, extensionState, avatarManager, gitClient); + const statusBarItem = new StatusBarItem(ctx, config); + registerViewCommand(ctx, repos, extensionState, statusBarItem, avatarManager, gitClient); + + ctx.subscriptions.push( + vscode.workspace.onDidChangeConfiguration((e) => { + if (e.affectsConfiguration("neo-git-graph.showStatusBarItem")) { + statusBarItem.refresh(); + } else if (e.affectsConfiguration("git.path")) { + gitClient.setGitPath(config.gitPath()); + } + }) + ); } diff --git a/src/extension/main.ts b/src/extension/main.ts index d92eb641..4cc02a29 100644 --- a/src/extension/main.ts +++ b/src/extension/main.ts @@ -12,6 +12,12 @@ export async function activate(ctx: vscode.ExtensionContext) { const paths = (vscode.workspace.workspaceFolders ?? []).map((f) => f.uri.fsPath); const repoDirs = await findGitRepos(paths, config.gitPath(), config.maxDepthOfRepoSearch()); + vscode.workspace.onDidChangeConfiguration(async (e) => { + if (e.affectsConfiguration("neo-git-graph.maxDepthOfRepoSearch")) { + // const repoDirs = await findGitRepos(paths, config.gitPath(), config.maxDepthOfRepoSearch()); + } + }); + if (repoDirs.length > 0) { bootstrap(ctx, repoDirs); return; From 221e5d2acb3538b7a67b18d46093da7e82eee89c Mon Sep 17 00:00:00 2001 From: asispts <79239132+asispts@users.noreply.github.com> Date: Mon, 13 Apr 2026 15:22:53 +0000 Subject: [PATCH 15/36] Fix tests-ext tsconfig --- tests-ext/tsconfig.json | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/tests-ext/tsconfig.json b/tests-ext/tsconfig.json index 0b316afc..12da0652 100644 --- a/tests-ext/tsconfig.json +++ b/tests-ext/tsconfig.json @@ -1,18 +1,8 @@ { + "extends": "../tsconfig.json", "compilerOptions": { - "baseUrl": "..", - "paths": { - "@/*": ["src/*"], - "@tests/*": ["tests/*"] - }, - "module": "commonjs", - "target": "es6", - "lib": ["es2020"], - "strict": true, - "noImplicitAny": true, - "skipLibCheck": true, - "outDir": "./out", - "sourceMap": true + "noEmit": false, + "outDir": "./out" }, "include": ["./**/*.ts"] } From e2ad753c25d15e88fb6b6ec6cb8162093e7229c6 Mon Sep 17 00:00:00 2001 From: asispts <79239132+asispts@users.noreply.github.com> Date: Mon, 13 Apr 2026 15:26:03 +0000 Subject: [PATCH 16/36] Fix linter issues --- src/extension/repoManager.ts | 8 +++++--- tests/backend/queries/repoSearch.test.ts | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/extension/repoManager.ts b/src/extension/repoManager.ts index 2e46cc63..1b590ace 100644 --- a/src/extension/repoManager.ts +++ b/src/extension/repoManager.ts @@ -25,9 +25,11 @@ export function createRepoManager( let repos = extensionState.getRepos(); let viewCallback: ((repos: GitRepoSet, numRepos: number) => void) | null = null; - function setRepos(repos: string[]) { - repos.length = 0; - repos.forEach((repo) => { + function setRepos(repoDirs: string[]) { + for (const key of Object.keys(repos)) { + delete repos[key]; + } + repoDirs.forEach((repo) => { addRepo(repo); }); diff --git a/tests/backend/queries/repoSearch.test.ts b/tests/backend/queries/repoSearch.test.ts index df3a6118..221294e9 100644 --- a/tests/backend/queries/repoSearch.test.ts +++ b/tests/backend/queries/repoSearch.test.ts @@ -81,7 +81,7 @@ describe("findGitRepos", () => { it("aggregates repos across multiple workspace paths", async () => { const nestedDir = path.join(tmpDir, "nested"); const result = await findGitRepos([repoA, nestedDir], "git", 1); - expect(result.sort()).toEqual([repoA, repoB].sort()); + expect(result.toSorted()).toEqual([repoA, repoB].toSorted()); }); it("does not report .git directories as repos", async () => { From 3841ce8d86d95fc5868ee3dfc7a4e103b24c8539 Mon Sep 17 00:00:00 2001 From: asispts <79239132+asispts@users.noreply.github.com> Date: Mon, 13 Apr 2026 17:49:22 +0000 Subject: [PATCH 17/36] Fix --- tests-ext/tsconfig.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests-ext/tsconfig.json b/tests-ext/tsconfig.json index 12da0652..d062d39a 100644 --- a/tests-ext/tsconfig.json +++ b/tests-ext/tsconfig.json @@ -1,6 +1,8 @@ { "extends": "../tsconfig.json", "compilerOptions": { + "module": "CommonJS", + "moduleResolution": "node", "noEmit": false, "outDir": "./out" }, From ce62348c7bc752735020baac98fac447bcbed841 Mon Sep 17 00:00:00 2001 From: asispts <79239132+asispts@users.noreply.github.com> Date: Mon, 13 Apr 2026 20:52:45 +0000 Subject: [PATCH 18/36] Move makeDepth listener to waitForRepo --- src/extension/main.ts | 11 +---------- src/extension/waitForRepo.ts | 12 +++++++++++- tests/extension/waitForRepo.test.ts | 12 +++++++++++- 3 files changed, 23 insertions(+), 12 deletions(-) diff --git a/src/extension/main.ts b/src/extension/main.ts index 4cc02a29..82d9badb 100644 --- a/src/extension/main.ts +++ b/src/extension/main.ts @@ -12,28 +12,19 @@ export async function activate(ctx: vscode.ExtensionContext) { const paths = (vscode.workspace.workspaceFolders ?? []).map((f) => f.uri.fsPath); const repoDirs = await findGitRepos(paths, config.gitPath(), config.maxDepthOfRepoSearch()); - vscode.workspace.onDidChangeConfiguration(async (e) => { - if (e.affectsConfiguration("neo-git-graph.maxDepthOfRepoSearch")) { - // const repoDirs = await findGitRepos(paths, config.gitPath(), config.maxDepthOfRepoSearch()); - } - }); - if (repoDirs.length > 0) { bootstrap(ctx, repoDirs); return; } - ctx.subscriptions.push(waitForRepo(ctx)); - - // Commands fallback ctx.subscriptions.push( + waitForRepo(ctx), vscode.commands.registerCommand("neo-git-graph.view", async () => { await vscode.window.showErrorMessage(l10n.t("statusBar.text"), { detail: l10n.t("error.noGitRepository"), modal: true }); }), - vscode.commands.registerCommand("neo-git-graph.clearAvatarCache", async () => { await vscode.window.showErrorMessage(l10n.t("statusBar.text"), { detail: l10n.t("error.noGitRepository"), diff --git a/src/extension/waitForRepo.ts b/src/extension/waitForRepo.ts index 5f4278d6..3b29d9f5 100644 --- a/src/extension/waitForRepo.ts +++ b/src/extension/waitForRepo.ts @@ -6,7 +6,10 @@ import { bootstrap } from "@/extension/bootstrap"; export type WorkspaceApi = Pick< typeof vscode.workspace, - "createFileSystemWatcher" | "onDidChangeWorkspaceFolders" | "workspaceFolders" + | "createFileSystemWatcher" + | "onDidChangeWorkspaceFolders" + | "onDidChangeConfiguration" + | "workspaceFolders" >; type WatcherState = { @@ -37,6 +40,13 @@ export function waitForRepo( state.disposables.push(gitWatcher.onDidCreate(() => check(ctx, workspace, state))); state.disposables.push(workspace.onDidChangeWorkspaceFolders(() => check(ctx, workspace, state))); + state.disposables.push( + workspace.onDidChangeConfiguration((e) => { + if (e.affectsConfiguration("neo-git-graph.maxDepthOfRepoSearch")) { + void check(ctx, workspace, state); + } + }) + ); return { dispose: () => dispose(state) }; } diff --git a/tests/extension/waitForRepo.test.ts b/tests/extension/waitForRepo.test.ts index 197fcb1a..56c39796 100644 --- a/tests/extension/waitForRepo.test.ts +++ b/tests/extension/waitForRepo.test.ts @@ -17,9 +17,11 @@ function makeFakeWorkspace(folderPaths: string[] = []): { gitWatcher: { dispose: ReturnType }; triggerGitCreate: () => void; triggerFolderChange: () => void; + triggerConfigChange: (section: string) => void; } { let gitCreateHandler: (() => void) | null = null; let folderChangeHandler: ((e: vscode.WorkspaceFoldersChangeEvent) => void) | null = null; + let configChangeHandler: ((e: vscode.ConfigurationChangeEvent) => void) | null = null; const gitWatcher = { onDidCreate(handler: () => void) { @@ -35,6 +37,10 @@ function makeFakeWorkspace(folderPaths: string[] = []): { onDidChangeWorkspaceFolders(handler: (e: vscode.WorkspaceFoldersChangeEvent) => void) { folderChangeHandler = handler; return { dispose: vi.fn() }; + }, + onDidChangeConfiguration(handler: (e: vscode.ConfigurationChangeEvent) => void) { + configChangeHandler = handler; + return { dispose: vi.fn() }; } }; @@ -42,7 +48,11 @@ function makeFakeWorkspace(folderPaths: string[] = []): { workspace, gitWatcher, triggerGitCreate: () => gitCreateHandler?.(), - triggerFolderChange: () => folderChangeHandler?.({} as vscode.WorkspaceFoldersChangeEvent) + triggerFolderChange: () => folderChangeHandler?.({} as vscode.WorkspaceFoldersChangeEvent), + triggerConfigChange: (section: string) => + configChangeHandler?.({ + affectsConfiguration: (s) => s === section + } as vscode.ConfigurationChangeEvent) }; } From f9589a7cd48da61239482f2a510a6c6602585f71 Mon Sep 17 00:00:00 2001 From: asispts <79239132+asispts@users.noreply.github.com> Date: Mon, 13 Apr 2026 20:59:34 +0000 Subject: [PATCH 19/36] Move fallback commands to waitForRepo --- src/extension/main.ts | 16 +--------------- src/extension/waitForRepo.ts | 17 +++++++++++++++-- tests/extension/__mocks__/vscode.ts | 11 ++++++++++- 3 files changed, 26 insertions(+), 18 deletions(-) diff --git a/src/extension/main.ts b/src/extension/main.ts index 82d9badb..5e3e4a20 100644 --- a/src/extension/main.ts +++ b/src/extension/main.ts @@ -17,21 +17,7 @@ export async function activate(ctx: vscode.ExtensionContext) { return; } - ctx.subscriptions.push( - waitForRepo(ctx), - vscode.commands.registerCommand("neo-git-graph.view", async () => { - await vscode.window.showErrorMessage(l10n.t("statusBar.text"), { - detail: l10n.t("error.noGitRepository"), - modal: true - }); - }), - vscode.commands.registerCommand("neo-git-graph.clearAvatarCache", async () => { - await vscode.window.showErrorMessage(l10n.t("statusBar.text"), { - detail: l10n.t("error.noGitRepository"), - modal: true - }); - }) - ); + ctx.subscriptions.push(waitForRepo(ctx)); } export function deactivate() {} diff --git a/src/extension/waitForRepo.ts b/src/extension/waitForRepo.ts index 3b29d9f5..e4689e8e 100644 --- a/src/extension/waitForRepo.ts +++ b/src/extension/waitForRepo.ts @@ -3,6 +3,7 @@ import * as vscode from "vscode"; import { findGitRepos } from "@/backend/queries/repoSearch"; import { config } from "@/config"; import { bootstrap } from "@/extension/bootstrap"; +import * as l10n from "@/l10n"; export type WorkspaceApi = Pick< typeof vscode.workspace, @@ -38,13 +39,25 @@ export function waitForRepo( const gitWatcher = workspace.createFileSystemWatcher("**/.git"); const state: WatcherState = { disposed: false, disposables: [gitWatcher] }; - state.disposables.push(gitWatcher.onDidCreate(() => check(ctx, workspace, state))); - state.disposables.push(workspace.onDidChangeWorkspaceFolders(() => check(ctx, workspace, state))); state.disposables.push( + gitWatcher.onDidCreate(() => check(ctx, workspace, state)), + workspace.onDidChangeWorkspaceFolders(() => check(ctx, workspace, state)), workspace.onDidChangeConfiguration((e) => { if (e.affectsConfiguration("neo-git-graph.maxDepthOfRepoSearch")) { void check(ctx, workspace, state); } + }), + vscode.commands.registerCommand("neo-git-graph.view", async () => { + await vscode.window.showErrorMessage(l10n.t("statusBar.text"), { + detail: l10n.t("error.noGitRepository"), + modal: true + }); + }), + vscode.commands.registerCommand("neo-git-graph.clearAvatarCache", async () => { + await vscode.window.showErrorMessage(l10n.t("statusBar.text"), { + detail: l10n.t("error.noGitRepository"), + modal: true + }); }) ); diff --git a/tests/extension/__mocks__/vscode.ts b/tests/extension/__mocks__/vscode.ts index a89b09ce..c6a64d22 100644 --- a/tests/extension/__mocks__/vscode.ts +++ b/tests/extension/__mocks__/vscode.ts @@ -5,5 +5,14 @@ export const workspace = { onDidCreate: () => ({ dispose: () => {} }), dispose: () => {} }), - onDidChangeWorkspaceFolders: () => ({ dispose: () => {} }) + onDidChangeWorkspaceFolders: () => ({ dispose: () => {} }), + onDidChangeConfiguration: () => ({ dispose: () => {} }) +}; + +export const commands = { + registerCommand: () => ({ dispose: () => {} }) +}; + +export const window = { + showErrorMessage: () => Promise.resolve(undefined) }; From 499adc8c2d2fe97af81960501ddbe667c7f8fa1c Mon Sep 17 00:00:00 2001 From: asispts <79239132+asispts@users.noreply.github.com> Date: Mon, 13 Apr 2026 21:08:09 +0000 Subject: [PATCH 20/36] Rename to initExtension and watchForRepos --- .../{bootstrap.ts => initExtension.ts} | 2 +- src/extension/main.ts | 8 +-- .../{waitForRepo.ts => watchForRepos.ts} | 6 +- ...tForRepo.test.ts => watchForRepos.test.ts} | 56 +++++++++---------- 4 files changed, 36 insertions(+), 36 deletions(-) rename src/extension/{bootstrap.ts => initExtension.ts} (97%) rename src/extension/{waitForRepo.ts => watchForRepos.ts} (94%) rename tests/extension/{waitForRepo.test.ts => watchForRepos.test.ts} (69%) diff --git a/src/extension/bootstrap.ts b/src/extension/initExtension.ts similarity index 97% rename from src/extension/bootstrap.ts rename to src/extension/initExtension.ts index c9845330..47f63193 100644 --- a/src/extension/bootstrap.ts +++ b/src/extension/initExtension.ts @@ -80,7 +80,7 @@ function registerViewCommand( ); } -export function bootstrap(ctx: vscode.ExtensionContext, repos: string[]) { +export function initExtension(ctx: vscode.ExtensionContext, repos: string[]) { const extensionState = new ExtensionState(ctx); const avatarManager = new AvatarManager(config.gitPath, extensionState); diff --git a/src/extension/main.ts b/src/extension/main.ts index 5e3e4a20..909e9ae2 100644 --- a/src/extension/main.ts +++ b/src/extension/main.ts @@ -2,8 +2,8 @@ import * as vscode from "vscode"; import { findGitRepos } from "@/backend/queries/repoSearch"; import { config } from "@/config"; -import { bootstrap } from "@/extension/bootstrap"; -import { waitForRepo } from "@/extension/waitForRepo"; +import { initExtension } from "@/extension/initExtension"; +import { watchForRepos } from "@/extension/watchForRepos"; import * as l10n from "@/l10n"; export async function activate(ctx: vscode.ExtensionContext) { @@ -13,11 +13,11 @@ export async function activate(ctx: vscode.ExtensionContext) { const repoDirs = await findGitRepos(paths, config.gitPath(), config.maxDepthOfRepoSearch()); if (repoDirs.length > 0) { - bootstrap(ctx, repoDirs); + initExtension(ctx, repoDirs); return; } - ctx.subscriptions.push(waitForRepo(ctx)); + ctx.subscriptions.push(watchForRepos(ctx)); } export function deactivate() {} diff --git a/src/extension/waitForRepo.ts b/src/extension/watchForRepos.ts similarity index 94% rename from src/extension/waitForRepo.ts rename to src/extension/watchForRepos.ts index e4689e8e..47f48a19 100644 --- a/src/extension/waitForRepo.ts +++ b/src/extension/watchForRepos.ts @@ -2,7 +2,7 @@ import * as vscode from "vscode"; import { findGitRepos } from "@/backend/queries/repoSearch"; import { config } from "@/config"; -import { bootstrap } from "@/extension/bootstrap"; +import { initExtension } from "@/extension/initExtension"; import * as l10n from "@/l10n"; export type WorkspaceApi = Pick< @@ -29,10 +29,10 @@ async function check(ctx: vscode.ExtensionContext, workspace: WorkspaceApi, stat const repoDirs = await findGitRepos(paths, config.gitPath(), config.maxDepthOfRepoSearch()); if (repoDirs.length === 0 || state.disposed) return; dispose(state); - bootstrap(ctx, repoDirs); + initExtension(ctx, repoDirs); } -export function waitForRepo( +export function watchForRepos( ctx: vscode.ExtensionContext, workspace: WorkspaceApi = vscode.workspace ): { dispose(): void } { diff --git a/tests/extension/waitForRepo.test.ts b/tests/extension/watchForRepos.test.ts similarity index 69% rename from tests/extension/waitForRepo.test.ts rename to tests/extension/watchForRepos.test.ts index 56c39796..e786baca 100644 --- a/tests/extension/waitForRepo.test.ts +++ b/tests/extension/watchForRepos.test.ts @@ -6,11 +6,11 @@ import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type * as vscode from "vscode"; -vi.mock("@/extension/bootstrap"); +vi.mock("@/extension/initExtension"); -import { bootstrap } from "@/extension/bootstrap"; -import { waitForRepo } from "@/extension/waitForRepo"; -import type { WorkspaceApi } from "@/extension/waitForRepo"; +import { initExtension } from "@/extension/initExtension"; +import { watchForRepos } from "@/extension/watchForRepos"; +import type { WorkspaceApi } from "@/extension/watchForRepos"; function makeFakeWorkspace(folderPaths: string[] = []): { workspace: WorkspaceApi; @@ -61,8 +61,8 @@ const fakeCtx = {} as unknown as vscode.ExtensionContext; let tmpDir: string; beforeEach(() => { - tmpDir = mkdtempSync(join(tmpdir(), "ngg-waitForRepo-test-")); - vi.mocked(bootstrap).mockReturnValue(undefined); + tmpDir = mkdtempSync(join(tmpdir(), "ngg-watchForRepos-test-")); + vi.mocked(initExtension).mockReturnValue(undefined); }); afterEach(() => { @@ -71,74 +71,74 @@ afterEach(() => { rmSync(tmpDir, { recursive: true, force: true }); }); -describe("waitForRepo", () => { +describe("watchForRepos", () => { it("watches **/.git for creation events", () => { const { workspace } = makeFakeWorkspace(); - waitForRepo(fakeCtx, workspace); + watchForRepos(fakeCtx, workspace); expect(workspace.createFileSystemWatcher).toHaveBeenCalledWith("**/.git"); }); - it("calls bootstrap when a .git is created and repos are found", async () => { + it("calls initExtension when a .git is created and repos are found", async () => { execSync("git init", { cwd: tmpDir }); const { workspace, triggerGitCreate } = makeFakeWorkspace([tmpDir]); - waitForRepo(fakeCtx, workspace); + watchForRepos(fakeCtx, workspace); triggerGitCreate(); - await vi.waitFor(() => expect(bootstrap).toHaveBeenCalledWith(fakeCtx, [tmpDir])); + await vi.waitFor(() => expect(initExtension).toHaveBeenCalledWith(fakeCtx, [tmpDir])); }); - it("calls bootstrap when workspace folders change and repos are found", async () => { + it("calls initExtension when workspace folders change and repos are found", async () => { execSync("git init", { cwd: tmpDir }); const { workspace, triggerFolderChange } = makeFakeWorkspace([tmpDir]); - waitForRepo(fakeCtx, workspace); + watchForRepos(fakeCtx, workspace); triggerFolderChange(); - await vi.waitFor(() => expect(bootstrap).toHaveBeenCalledWith(fakeCtx, [tmpDir])); + await vi.waitFor(() => expect(initExtension).toHaveBeenCalledWith(fakeCtx, [tmpDir])); }); - it("does not call bootstrap when no repos are found after detection", async () => { + it("does not call initExtension when no repos are found after detection", async () => { const { workspace, triggerGitCreate } = makeFakeWorkspace([tmpDir]); - waitForRepo(fakeCtx, workspace); + watchForRepos(fakeCtx, workspace); triggerGitCreate(); await new Promise((r) => setTimeout(r, 50)); - expect(bootstrap).not.toHaveBeenCalled(); + expect(initExtension).not.toHaveBeenCalled(); }); it("disposes watchers after repos are found", async () => { execSync("git init", { cwd: tmpDir }); const { workspace, gitWatcher, triggerGitCreate } = makeFakeWorkspace([tmpDir]); - waitForRepo(fakeCtx, workspace); + watchForRepos(fakeCtx, workspace); triggerGitCreate(); - await vi.waitFor(() => expect(bootstrap).toHaveBeenCalled()); + await vi.waitFor(() => expect(initExtension).toHaveBeenCalled()); expect(gitWatcher.dispose).toHaveBeenCalled(); }); it("dispose() is idempotent", () => { const { workspace, gitWatcher } = makeFakeWorkspace(); - const detector = waitForRepo(fakeCtx, workspace); + const detector = watchForRepos(fakeCtx, workspace); detector.dispose(); detector.dispose(); expect(gitWatcher.dispose).toHaveBeenCalledTimes(1); }); - it("does not double-bootstrap when both events fire before check resolves", async () => { + it("does not double-initExtension when both events fire before check resolves", async () => { execSync("git init", { cwd: tmpDir }); const { workspace, triggerGitCreate, triggerFolderChange } = makeFakeWorkspace([tmpDir]); - waitForRepo(fakeCtx, workspace); + watchForRepos(fakeCtx, workspace); triggerGitCreate(); triggerFolderChange(); - await vi.waitFor(() => expect(bootstrap).toHaveBeenCalled()); + await vi.waitFor(() => expect(initExtension).toHaveBeenCalled()); await new Promise((r) => setTimeout(r, 20)); - expect(bootstrap).toHaveBeenCalledTimes(1); + expect(initExtension).toHaveBeenCalledTimes(1); }); }); -describe("waitForRepo — mocked findGitRepos for timing control", () => { - it("does not bootstrap after external dispose while check is in-flight", async () => { +describe("watchForRepos — mocked findGitRepos for timing control", () => { + it("does not initExtension after external dispose while check is in-flight", async () => { const { workspace, triggerGitCreate } = makeFakeWorkspace([tmpDir]); let resolveFindGitRepos!: (v: string[]) => void; @@ -146,12 +146,12 @@ describe("waitForRepo — mocked findGitRepos for timing control", () => { () => new Promise((r) => (resolveFindGitRepos = r)) ); - const detector = waitForRepo(fakeCtx, workspace); + const detector = watchForRepos(fakeCtx, workspace); triggerGitCreate(); detector.dispose(); resolveFindGitRepos([tmpDir]); await new Promise((r) => setTimeout(r, 20)); - expect(bootstrap).not.toHaveBeenCalled(); + expect(initExtension).not.toHaveBeenCalled(); }); }); From 15fa965b4bd92e5ed40bc93b7af8062cac975ea3 Mon Sep 17 00:00:00 2001 From: asispts <79239132+asispts@users.noreply.github.com> Date: Mon, 13 Apr 2026 21:17:24 +0000 Subject: [PATCH 21/36] Listen for maxDepth change in initExtension --- src/extension/initExtension.ts | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/extension/initExtension.ts b/src/extension/initExtension.ts index 47f63193..9328ef7a 100644 --- a/src/extension/initExtension.ts +++ b/src/extension/initExtension.ts @@ -2,11 +2,12 @@ import * as vscode from "vscode"; import { AvatarManager } from "@/avatarManager"; import { GitClient, gitClientFactory } from "@/backend/gitClient"; +import { findGitRepos } from "@/backend/queries/repoSearch"; import { buildExtensionUri } from "@/backend/utils/path"; import { config } from "@/config"; import { DiffDocProvider } from "@/diffDocProvider"; import { registerMessageHandlers } from "@/extension/messageHandler"; -import { createRepoManager } from "@/extension/repoManager"; +import { createRepoManager, RepoManager } from "@/extension/repoManager"; import { WebviewBridge, webviewBridgeFactory } from "@/extension/webviewBridge"; import { createWebviewPanel, WebviewPanel } from "@/extension/webviewPanel"; import { ExtensionState } from "@/extensionState"; @@ -16,15 +17,11 @@ import { StatusBarItem } from "@/statusBarItem"; function registerViewCommand( ctx: vscode.ExtensionContext, - repos: string[], + repoManager: RepoManager, extensionState: ExtensionState, - statusBarItem: StatusBarItem, avatarManager: AvatarManager, gitClient: GitClient ) { - const repoManager = createRepoManager(extensionState, statusBarItem, config); - repoManager.setRepos(repos); - let currentPanel: WebviewPanel | undefined; ctx.subscriptions.push( vscode.commands.registerCommand("neo-git-graph.view", () => { @@ -99,7 +96,9 @@ export function initExtension(ctx: vscode.ExtensionContext, repos: string[]) { ); const statusBarItem = new StatusBarItem(ctx, config); - registerViewCommand(ctx, repos, extensionState, statusBarItem, avatarManager, gitClient); + const repoManager = createRepoManager(extensionState, statusBarItem, config); + repoManager.setRepos(repos); + registerViewCommand(ctx, repoManager, extensionState, avatarManager, gitClient); ctx.subscriptions.push( vscode.workspace.onDidChangeConfiguration((e) => { @@ -107,6 +106,13 @@ export function initExtension(ctx: vscode.ExtensionContext, repos: string[]) { statusBarItem.refresh(); } else if (e.affectsConfiguration("git.path")) { gitClient.setGitPath(config.gitPath()); + } else if (e.affectsConfiguration("neo-git-graph.maxDepthOfRepoSearch")) { + const paths = (vscode.workspace.workspaceFolders ?? []).map((f) => f.uri.fsPath); + void findGitRepos(paths, config.gitPath(), config.maxDepthOfRepoSearch()).then( + (repoDirs) => { + if (repoDirs.length > 0) repoManager.setRepos(repoDirs); + } + ); } }) ); From 6b84dfb884ab7548fa8957991a12d1112e3b809b Mon Sep 17 00:00:00 2001 From: asispts <79239132+asispts@users.noreply.github.com> Date: Tue, 14 Apr 2026 10:24:15 +0000 Subject: [PATCH 22/36] Add maxDepthTracker --- src/extension/initExtension.ts | 15 +++++++++------ src/extension/maxDepthTracker.ts | 9 +++++++++ src/extension/watchForRepos.ts | 5 ++++- 3 files changed, 22 insertions(+), 7 deletions(-) create mode 100644 src/extension/maxDepthTracker.ts diff --git a/src/extension/initExtension.ts b/src/extension/initExtension.ts index 9328ef7a..54c000e7 100644 --- a/src/extension/initExtension.ts +++ b/src/extension/initExtension.ts @@ -6,6 +6,7 @@ import { findGitRepos } from "@/backend/queries/repoSearch"; import { buildExtensionUri } from "@/backend/utils/path"; import { config } from "@/config"; import { DiffDocProvider } from "@/diffDocProvider"; +import { maxDepthIncreased } from "@/extension/maxDepthTracker"; import { registerMessageHandlers } from "@/extension/messageHandler"; import { createRepoManager, RepoManager } from "@/extension/repoManager"; import { WebviewBridge, webviewBridgeFactory } from "@/extension/webviewBridge"; @@ -107,12 +108,14 @@ export function initExtension(ctx: vscode.ExtensionContext, repos: string[]) { } else if (e.affectsConfiguration("git.path")) { gitClient.setGitPath(config.gitPath()); } else if (e.affectsConfiguration("neo-git-graph.maxDepthOfRepoSearch")) { - const paths = (vscode.workspace.workspaceFolders ?? []).map((f) => f.uri.fsPath); - void findGitRepos(paths, config.gitPath(), config.maxDepthOfRepoSearch()).then( - (repoDirs) => { - if (repoDirs.length > 0) repoManager.setRepos(repoDirs); - } - ); + if (maxDepthIncreased()) { + const paths = (vscode.workspace.workspaceFolders ?? []).map((f) => f.uri.fsPath); + void findGitRepos(paths, config.gitPath(), config.maxDepthOfRepoSearch()).then( + (repoDirs) => { + if (repoDirs.length > 0) repoManager.setRepos(repoDirs); + } + ); + } } }) ); diff --git a/src/extension/maxDepthTracker.ts b/src/extension/maxDepthTracker.ts new file mode 100644 index 00000000..5742a602 --- /dev/null +++ b/src/extension/maxDepthTracker.ts @@ -0,0 +1,9 @@ +import { config } from "@/config"; + +let maxDepth = config.maxDepthOfRepoSearch(); + +export function maxDepthIncreased(): boolean { + const prev = maxDepth; + maxDepth = config.maxDepthOfRepoSearch(); + return maxDepth > prev; +} diff --git a/src/extension/watchForRepos.ts b/src/extension/watchForRepos.ts index 47f48a19..2b2526b5 100644 --- a/src/extension/watchForRepos.ts +++ b/src/extension/watchForRepos.ts @@ -3,6 +3,7 @@ import * as vscode from "vscode"; import { findGitRepos } from "@/backend/queries/repoSearch"; import { config } from "@/config"; import { initExtension } from "@/extension/initExtension"; +import { maxDepthIncreased } from "@/extension/maxDepthTracker"; import * as l10n from "@/l10n"; export type WorkspaceApi = Pick< @@ -44,7 +45,9 @@ export function watchForRepos( workspace.onDidChangeWorkspaceFolders(() => check(ctx, workspace, state)), workspace.onDidChangeConfiguration((e) => { if (e.affectsConfiguration("neo-git-graph.maxDepthOfRepoSearch")) { - void check(ctx, workspace, state); + if (maxDepthIncreased()) { + void check(ctx, workspace, state); + } } }), vscode.commands.registerCommand("neo-git-graph.view", async () => { From f44391262966056594032e1ffed677ff71413417 Mon Sep 17 00:00:00 2001 From: asispts <79239132+asispts@users.noreply.github.com> Date: Tue, 14 Apr 2026 15:01:49 +0000 Subject: [PATCH 23/36] Add VscodeWorkspace type --- src/extension/types/index.ts | 1 + src/extension/types/vscode.ts | 9 +++++++++ src/extension/watchForRepos.ts | 17 +++++++---------- tests/extension/watchForRepos.test.ts | 6 +++--- 4 files changed, 20 insertions(+), 13 deletions(-) create mode 100644 src/extension/types/index.ts create mode 100644 src/extension/types/vscode.ts diff --git a/src/extension/types/index.ts b/src/extension/types/index.ts new file mode 100644 index 00000000..b073934b --- /dev/null +++ b/src/extension/types/index.ts @@ -0,0 +1 @@ +export type { VscodeWorkspace } from "./vscode"; diff --git a/src/extension/types/vscode.ts b/src/extension/types/vscode.ts new file mode 100644 index 00000000..f7de70a9 --- /dev/null +++ b/src/extension/types/vscode.ts @@ -0,0 +1,9 @@ +import * as vscode from "vscode"; + +export type VscodeWorkspace = Pick< + typeof vscode.workspace, + | "createFileSystemWatcher" + | "onDidChangeWorkspaceFolders" + | "onDidChangeConfiguration" + | "workspaceFolders" +>; diff --git a/src/extension/watchForRepos.ts b/src/extension/watchForRepos.ts index 2b2526b5..2ad65791 100644 --- a/src/extension/watchForRepos.ts +++ b/src/extension/watchForRepos.ts @@ -4,16 +4,9 @@ import { findGitRepos } from "@/backend/queries/repoSearch"; import { config } from "@/config"; import { initExtension } from "@/extension/initExtension"; import { maxDepthIncreased } from "@/extension/maxDepthTracker"; +import { VscodeWorkspace } from "@/extension/types"; import * as l10n from "@/l10n"; -export type WorkspaceApi = Pick< - typeof vscode.workspace, - | "createFileSystemWatcher" - | "onDidChangeWorkspaceFolders" - | "onDidChangeConfiguration" - | "workspaceFolders" ->; - type WatcherState = { disposed: boolean; disposables: vscode.Disposable[]; @@ -25,7 +18,11 @@ function dispose(state: WatcherState) { state.disposables.length = 0; } -async function check(ctx: vscode.ExtensionContext, workspace: WorkspaceApi, state: WatcherState) { +async function check( + ctx: vscode.ExtensionContext, + workspace: VscodeWorkspace, + state: WatcherState +) { const paths = (workspace.workspaceFolders ?? []).map((f) => f.uri.fsPath); const repoDirs = await findGitRepos(paths, config.gitPath(), config.maxDepthOfRepoSearch()); if (repoDirs.length === 0 || state.disposed) return; @@ -35,7 +32,7 @@ async function check(ctx: vscode.ExtensionContext, workspace: WorkspaceApi, stat export function watchForRepos( ctx: vscode.ExtensionContext, - workspace: WorkspaceApi = vscode.workspace + workspace: VscodeWorkspace = vscode.workspace ): { dispose(): void } { const gitWatcher = workspace.createFileSystemWatcher("**/.git"); const state: WatcherState = { disposed: false, disposables: [gitWatcher] }; diff --git a/tests/extension/watchForRepos.test.ts b/tests/extension/watchForRepos.test.ts index e786baca..7d834287 100644 --- a/tests/extension/watchForRepos.test.ts +++ b/tests/extension/watchForRepos.test.ts @@ -9,11 +9,11 @@ import type * as vscode from "vscode"; vi.mock("@/extension/initExtension"); import { initExtension } from "@/extension/initExtension"; +import type { VscodeWorkspace } from "@/extension/types"; import { watchForRepos } from "@/extension/watchForRepos"; -import type { WorkspaceApi } from "@/extension/watchForRepos"; function makeFakeWorkspace(folderPaths: string[] = []): { - workspace: WorkspaceApi; + workspace: VscodeWorkspace; gitWatcher: { dispose: ReturnType }; triggerGitCreate: () => void; triggerFolderChange: () => void; @@ -31,7 +31,7 @@ function makeFakeWorkspace(folderPaths: string[] = []): { dispose: vi.fn() }; - const workspace: WorkspaceApi = { + const workspace: VscodeWorkspace = { workspaceFolders: folderPaths.map((p) => ({ uri: { fsPath: p } }) as vscode.WorkspaceFolder), createFileSystemWatcher: vi.fn(() => gitWatcher as unknown as vscode.FileSystemWatcher), onDidChangeWorkspaceFolders(handler: (e: vscode.WorkspaceFoldersChangeEvent) => void) { From 18c9ef23c02eda40a2bf13926aeca3a807d77644 Mon Sep 17 00:00:00 2001 From: asispts <79239132+asispts@users.noreply.github.com> Date: Tue, 14 Apr 2026 15:02:22 +0000 Subject: [PATCH 24/36] Listen for on workspace folders changed --- src/extension/initExtension.ts | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/extension/initExtension.ts b/src/extension/initExtension.ts index 54c000e7..cd125d03 100644 --- a/src/extension/initExtension.ts +++ b/src/extension/initExtension.ts @@ -9,6 +9,7 @@ import { DiffDocProvider } from "@/diffDocProvider"; import { maxDepthIncreased } from "@/extension/maxDepthTracker"; import { registerMessageHandlers } from "@/extension/messageHandler"; import { createRepoManager, RepoManager } from "@/extension/repoManager"; +import { VscodeWorkspace } from "@/extension/types"; import { WebviewBridge, webviewBridgeFactory } from "@/extension/webviewBridge"; import { createWebviewPanel, WebviewPanel } from "@/extension/webviewPanel"; import { ExtensionState } from "@/extensionState"; @@ -78,7 +79,11 @@ function registerViewCommand( ); } -export function initExtension(ctx: vscode.ExtensionContext, repos: string[]) { +export function initExtension( + ctx: vscode.ExtensionContext, + repos: string[], + workspace: VscodeWorkspace = vscode.workspace +) { const extensionState = new ExtensionState(ctx); const avatarManager = new AvatarManager(config.gitPath, extensionState); @@ -102,6 +107,21 @@ export function initExtension(ctx: vscode.ExtensionContext, repos: string[]) { registerViewCommand(ctx, repoManager, extensionState, avatarManager, gitClient); ctx.subscriptions.push( + workspace.onDidChangeWorkspaceFolders(async (e) => { + if (e.added.length > 0) { + const paths = e.added.map((f) => f.uri.fsPath); + const repoDirs = await findGitRepos(paths, config.gitPath(), config.maxDepthOfRepoSearch()); + for (const repo of repoDirs) repoManager.addRepo(repo); + if (repoDirs.length > 0) repoManager.sendRepos(); + } + if (e.removed.length > 0) { + let changes = false; + for (const folder of e.removed) { + if (repoManager.removeReposWithinFolder(folder.uri.fsPath)) changes = true; + } + if (changes) repoManager.sendRepos(); + } + }), vscode.workspace.onDidChangeConfiguration((e) => { if (e.affectsConfiguration("neo-git-graph.showStatusBarItem")) { statusBarItem.refresh(); From ecde95c27dca0bb706a2029511eface9f610739d Mon Sep 17 00:00:00 2001 From: asispts <79239132+asispts@users.noreply.github.com> Date: Tue, 14 Apr 2026 15:29:45 +0000 Subject: [PATCH 25/36] Watch for file system change in initExtension --- src/extension/initExtension.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/extension/initExtension.ts b/src/extension/initExtension.ts index cd125d03..56229c49 100644 --- a/src/extension/initExtension.ts +++ b/src/extension/initExtension.ts @@ -106,7 +106,18 @@ export function initExtension( repoManager.setRepos(repos); registerViewCommand(ctx, repoManager, extensionState, avatarManager, gitClient); + const gitWatcher = workspace.createFileSystemWatcher("**/.git"); ctx.subscriptions.push( + gitWatcher, + gitWatcher.onDidCreate((uri) => { + const repoPath = uri.fsPath.slice(0, -5); + repoManager.addRepo(repoPath); + repoManager.sendRepos(); + }), + gitWatcher.onDidDelete((uri) => { + const repoPath = uri.fsPath.slice(0, -5); + if (repoManager.removeReposWithinFolder(repoPath)) repoManager.sendRepos(); + }), workspace.onDidChangeWorkspaceFolders(async (e) => { if (e.added.length > 0) { const paths = e.added.map((f) => f.uri.fsPath); From 11bd2d8d0211b3058433a4a9a49843e07e2c7606 Mon Sep 17 00:00:00 2001 From: asispts <79239132+asispts@users.noreply.github.com> Date: Tue, 14 Apr 2026 15:32:18 +0000 Subject: [PATCH 26/36] Deprecate the old activate function --- src/extension.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/extension.ts b/src/extension.ts index cd4afe46..e571fba9 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -17,6 +17,9 @@ import { initL10n } from "./l10n"; import { RepoFileWatcher } from "./repoFileWatcher"; import { StatusBarItem } from "./statusBarItem"; +/** + * @deprecated See src/extension/main.ts + */ export function activate(context: vscode.ExtensionContext) { initL10n(context.extensionPath); const outputChannel = vscode.window.createOutputChannel(l10n.t("outputChannel.text")); From 55cfda0e3650cffee754d21b38b0a4582e1681a9 Mon Sep 17 00:00:00 2001 From: asispts <79239132+asispts@users.noreply.github.com> Date: Tue, 14 Apr 2026 22:45:22 +0000 Subject: [PATCH 27/36] Update barrel --- src/extension/types/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/extension/types/index.ts b/src/extension/types/index.ts index b073934b..75a050e8 100644 --- a/src/extension/types/index.ts +++ b/src/extension/types/index.ts @@ -1 +1 @@ -export type { VscodeWorkspace } from "./vscode"; +export * from "./vscode"; From d66adf2b5d78f5efd57d45527f1d5f6ef050ad48 Mon Sep 17 00:00:00 2001 From: asispts <79239132+asispts@users.noreply.github.com> Date: Wed, 15 Apr 2026 07:17:06 +0000 Subject: [PATCH 28/36] Update --- src/extension/initExtension.ts | 13 +- src/extension/main.ts | 2 +- src/extension/types/index.ts | 1 - src/extension/types/vscode.ts | 9 - src/extension/watchForRepos.ts | 23 +- tests/extension/watchForRepos.test.ts | 311 ++++++++++++++++---------- 6 files changed, 212 insertions(+), 147 deletions(-) delete mode 100644 src/extension/types/index.ts delete mode 100644 src/extension/types/vscode.ts diff --git a/src/extension/initExtension.ts b/src/extension/initExtension.ts index 56229c49..6910d643 100644 --- a/src/extension/initExtension.ts +++ b/src/extension/initExtension.ts @@ -9,7 +9,6 @@ import { DiffDocProvider } from "@/diffDocProvider"; import { maxDepthIncreased } from "@/extension/maxDepthTracker"; import { registerMessageHandlers } from "@/extension/messageHandler"; import { createRepoManager, RepoManager } from "@/extension/repoManager"; -import { VscodeWorkspace } from "@/extension/types"; import { WebviewBridge, webviewBridgeFactory } from "@/extension/webviewBridge"; import { createWebviewPanel, WebviewPanel } from "@/extension/webviewPanel"; import { ExtensionState } from "@/extensionState"; @@ -17,6 +16,8 @@ import * as l10n from "@/l10n"; import { RepoFileWatcher } from "@/repoFileWatcher"; import { StatusBarItem } from "@/statusBarItem"; +export type InitExtension = typeof initExtension; + function registerViewCommand( ctx: vscode.ExtensionContext, repoManager: RepoManager, @@ -79,11 +80,7 @@ function registerViewCommand( ); } -export function initExtension( - ctx: vscode.ExtensionContext, - repos: string[], - workspace: VscodeWorkspace = vscode.workspace -) { +export function initExtension(ctx: vscode.ExtensionContext, repos: string[]) { const extensionState = new ExtensionState(ctx); const avatarManager = new AvatarManager(config.gitPath, extensionState); @@ -106,7 +103,7 @@ export function initExtension( repoManager.setRepos(repos); registerViewCommand(ctx, repoManager, extensionState, avatarManager, gitClient); - const gitWatcher = workspace.createFileSystemWatcher("**/.git"); + const gitWatcher = vscode.workspace.createFileSystemWatcher("**/.git"); ctx.subscriptions.push( gitWatcher, gitWatcher.onDidCreate((uri) => { @@ -118,7 +115,7 @@ export function initExtension( const repoPath = uri.fsPath.slice(0, -5); if (repoManager.removeReposWithinFolder(repoPath)) repoManager.sendRepos(); }), - workspace.onDidChangeWorkspaceFolders(async (e) => { + vscode.workspace.onDidChangeWorkspaceFolders(async (e) => { if (e.added.length > 0) { const paths = e.added.map((f) => f.uri.fsPath); const repoDirs = await findGitRepos(paths, config.gitPath(), config.maxDepthOfRepoSearch()); diff --git a/src/extension/main.ts b/src/extension/main.ts index 909e9ae2..6dfc15da 100644 --- a/src/extension/main.ts +++ b/src/extension/main.ts @@ -17,7 +17,7 @@ export async function activate(ctx: vscode.ExtensionContext) { return; } - ctx.subscriptions.push(watchForRepos(ctx)); + ctx.subscriptions.push(watchForRepos(ctx, initExtension)); } export function deactivate() {} diff --git a/src/extension/types/index.ts b/src/extension/types/index.ts deleted file mode 100644 index 75a050e8..00000000 --- a/src/extension/types/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./vscode"; diff --git a/src/extension/types/vscode.ts b/src/extension/types/vscode.ts deleted file mode 100644 index f7de70a9..00000000 --- a/src/extension/types/vscode.ts +++ /dev/null @@ -1,9 +0,0 @@ -import * as vscode from "vscode"; - -export type VscodeWorkspace = Pick< - typeof vscode.workspace, - | "createFileSystemWatcher" - | "onDidChangeWorkspaceFolders" - | "onDidChangeConfiguration" - | "workspaceFolders" ->; diff --git a/src/extension/watchForRepos.ts b/src/extension/watchForRepos.ts index 2ad65791..6bf7cc28 100644 --- a/src/extension/watchForRepos.ts +++ b/src/extension/watchForRepos.ts @@ -2,9 +2,8 @@ import * as vscode from "vscode"; import { findGitRepos } from "@/backend/queries/repoSearch"; import { config } from "@/config"; -import { initExtension } from "@/extension/initExtension"; +import type { InitExtension } from "@/extension/initExtension"; import { maxDepthIncreased } from "@/extension/maxDepthTracker"; -import { VscodeWorkspace } from "@/extension/types"; import * as l10n from "@/l10n"; type WatcherState = { @@ -20,30 +19,30 @@ function dispose(state: WatcherState) { async function check( ctx: vscode.ExtensionContext, - workspace: VscodeWorkspace, - state: WatcherState + state: WatcherState, + onReposFound: InitExtension ) { - const paths = (workspace.workspaceFolders ?? []).map((f) => f.uri.fsPath); + const paths = (vscode.workspace.workspaceFolders ?? []).map((f) => f.uri.fsPath); const repoDirs = await findGitRepos(paths, config.gitPath(), config.maxDepthOfRepoSearch()); if (repoDirs.length === 0 || state.disposed) return; dispose(state); - initExtension(ctx, repoDirs); + onReposFound(ctx, repoDirs); } export function watchForRepos( ctx: vscode.ExtensionContext, - workspace: VscodeWorkspace = vscode.workspace + onReposFound: InitExtension ): { dispose(): void } { - const gitWatcher = workspace.createFileSystemWatcher("**/.git"); + const gitWatcher = vscode.workspace.createFileSystemWatcher("**/.git"); const state: WatcherState = { disposed: false, disposables: [gitWatcher] }; state.disposables.push( - gitWatcher.onDidCreate(() => check(ctx, workspace, state)), - workspace.onDidChangeWorkspaceFolders(() => check(ctx, workspace, state)), - workspace.onDidChangeConfiguration((e) => { + gitWatcher.onDidCreate(() => check(ctx, state, onReposFound)), + vscode.workspace.onDidChangeWorkspaceFolders(() => check(ctx, state, onReposFound)), + vscode.workspace.onDidChangeConfiguration((e) => { if (e.affectsConfiguration("neo-git-graph.maxDepthOfRepoSearch")) { if (maxDepthIncreased()) { - void check(ctx, workspace, state); + void check(ctx, state, onReposFound); } } }), diff --git a/tests/extension/watchForRepos.test.ts b/tests/extension/watchForRepos.test.ts index 7d834287..e6303044 100644 --- a/tests/extension/watchForRepos.test.ts +++ b/tests/extension/watchForRepos.test.ts @@ -1,157 +1,236 @@ -import { execSync } from "node:child_process"; -import { mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type * as vscode from "vscode"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -vi.mock("@/extension/initExtension"); - -import { initExtension } from "@/extension/initExtension"; -import type { VscodeWorkspace } from "@/extension/types"; +import type { InitExtension } from "@/extension/initExtension"; import { watchForRepos } from "@/extension/watchForRepos"; -function makeFakeWorkspace(folderPaths: string[] = []): { - workspace: VscodeWorkspace; - gitWatcher: { dispose: ReturnType }; - triggerGitCreate: () => void; - triggerFolderChange: () => void; - triggerConfigChange: (section: string) => void; -} { - let gitCreateHandler: (() => void) | null = null; - let folderChangeHandler: ((e: vscode.WorkspaceFoldersChangeEvent) => void) | null = null; - let configChangeHandler: ((e: vscode.ConfigurationChangeEvent) => void) | null = null; - - const gitWatcher = { - onDidCreate(handler: () => void) { - gitCreateHandler = handler; - return { dispose: vi.fn() }; +import { makeRepo } from "@tests/backend/helpers"; + +// ─── controllable vscode mock ───────────────────────────────────────────────── +// +// vi.hoisted runs before any imports so the factory below can safely close over +// these variables when vi.mock("vscode") is evaluated. + +const mock = vi.hoisted(() => { + let folders: Array<{ uri: { fsPath: string } }> = []; + let maxDepthVal = 0; + + let onCreateCb: (() => void) | undefined; + let onFolderChangeCb: (() => void) | undefined; + let onConfigChangeCb: ((e: { affectsConfiguration(k: string): boolean }) => void) | undefined; + const commands: Record Promise> = {}; + const showErrorMessage = vi.fn(); + + return { + workspace: { + get workspaceFolders() { + return folders; + }, + getConfiguration: (section: string) => ({ + get: (key: string, def: unknown) => { + if (section === "neo-git-graph" && key === "maxDepthOfRepoSearch") return maxDepthVal; + if (section === "git" && key === "path") return null; // falls back to "git" + return def; + } + }), + createFileSystemWatcher: () => ({ + onDidCreate: (cb: () => void) => { + onCreateCb = cb; + return { dispose: vi.fn() }; + }, + dispose: vi.fn() + }), + onDidChangeWorkspaceFolders: (cb: () => void) => { + onFolderChangeCb = cb; + return { dispose: vi.fn() }; + }, + onDidChangeConfiguration: (cb: (e: { affectsConfiguration(k: string): boolean }) => void) => { + onConfigChangeCb = cb; + return { dispose: vi.fn() }; + } }, - dispose: vi.fn() - }; + commands: { + registerCommand: (name: string, handler: () => Promise) => { + commands[name] = handler; + return { dispose: vi.fn() }; + } + }, + window: { showErrorMessage }, + l10n: { t: (key: string) => key, uri: undefined }, - const workspace: VscodeWorkspace = { - workspaceFolders: folderPaths.map((p) => ({ uri: { fsPath: p } }) as vscode.WorkspaceFolder), - createFileSystemWatcher: vi.fn(() => gitWatcher as unknown as vscode.FileSystemWatcher), - onDidChangeWorkspaceFolders(handler: (e: vscode.WorkspaceFoldersChangeEvent) => void) { - folderChangeHandler = handler; - return { dispose: vi.fn() }; + // ── test controls ──────────────────────────────────────────────────────── + setFolders(paths: string[]) { + folders = paths.map((p) => ({ uri: { fsPath: p } })); + }, + setMaxDepth(d: number) { + maxDepthVal = d; + }, + fireCreate() { + onCreateCb?.(); + }, + fireFolderChange() { + onFolderChangeCb?.(); }, - onDidChangeConfiguration(handler: (e: vscode.ConfigurationChangeEvent) => void) { - configChangeHandler = handler; - return { dispose: vi.fn() }; - } + fireConfigChange(key: string) { + onConfigChangeCb?.({ affectsConfiguration: (k) => k === key }); + }, + async invokeCommand(name: string) { + await commands[name]?.(); + }, + showErrorMessage }; +}); - return { - workspace, - gitWatcher, - triggerGitCreate: () => gitCreateHandler?.(), - triggerFolderChange: () => folderChangeHandler?.({} as vscode.WorkspaceFoldersChangeEvent), - triggerConfigChange: (section: string) => - configChangeHandler?.({ - affectsConfiguration: (s) => s === section - } as vscode.ConfigurationChangeEvent) - }; -} +vi.mock("vscode", () => mock); -const fakeCtx = {} as unknown as vscode.ExtensionContext; +// ─── shared fixtures ────────────────────────────────────────────────────────── -let tmpDir: string; +// ctx is never inspected by watchForRepos itself — it's just forwarded to onReposFound. +const ctx = {} as unknown as import("vscode").ExtensionContext; + +// Enough time for findGitRepos (real fs) to complete. +const tick = (ms = 150) => new Promise((r) => setTimeout(r, ms)); + +let repoDir: string; +let plainDir: string; + +beforeAll(() => { + repoDir = makeRepo(); + plainDir = fs.mkdtempSync(path.join(os.tmpdir(), "ngg-watch-plain-")); +}); + +afterAll(() => { + fs.rmSync(repoDir, { recursive: true, force: true }); + fs.rmSync(plainDir, { recursive: true, force: true }); +}); + +let watcher: ReturnType | undefined; +let onReposFound: ReturnType>; beforeEach(() => { - tmpDir = mkdtempSync(join(tmpdir(), "ngg-watchForRepos-test-")); - vi.mocked(initExtension).mockReturnValue(undefined); + vi.clearAllMocks(); + mock.setFolders([]); + mock.setMaxDepth(0); + onReposFound = vi.fn(); + watcher = undefined; }); afterEach(() => { - vi.restoreAllMocks(); - vi.clearAllMocks(); - rmSync(tmpDir, { recursive: true, force: true }); + watcher?.dispose(); }); +// ─── tests ──────────────────────────────────────────────────────────────────── + describe("watchForRepos", () => { - it("watches **/.git for creation events", () => { - const { workspace } = makeFakeWorkspace(); - watchForRepos(fakeCtx, workspace); - expect(workspace.createFileSystemWatcher).toHaveBeenCalledWith("**/.git"); - }); + describe(".git creation trigger", () => { + it("calls onReposFound with found repos", async () => { + mock.setFolders([repoDir]); + watcher = watchForRepos(ctx, onReposFound); - it("calls initExtension when a .git is created and repos are found", async () => { - execSync("git init", { cwd: tmpDir }); - const { workspace, triggerGitCreate } = makeFakeWorkspace([tmpDir]); + mock.fireCreate(); - watchForRepos(fakeCtx, workspace); - triggerGitCreate(); - await vi.waitFor(() => expect(initExtension).toHaveBeenCalledWith(fakeCtx, [tmpDir])); - }); + await vi.waitFor(() => expect(onReposFound).toHaveBeenCalledOnce()); + expect(onReposFound).toHaveBeenCalledWith(ctx, expect.arrayContaining([repoDir])); + }); + + it("does not call onReposFound when no repos are found", async () => { + mock.setFolders([plainDir]); + watcher = watchForRepos(ctx, onReposFound); - it("calls initExtension when workspace folders change and repos are found", async () => { - execSync("git init", { cwd: tmpDir }); - const { workspace, triggerFolderChange } = makeFakeWorkspace([tmpDir]); + mock.fireCreate(); + await tick(); - watchForRepos(fakeCtx, workspace); - triggerFolderChange(); - await vi.waitFor(() => expect(initExtension).toHaveBeenCalledWith(fakeCtx, [tmpDir])); + expect(onReposFound).not.toHaveBeenCalled(); + }); }); - it("does not call initExtension when no repos are found after detection", async () => { - const { workspace, triggerGitCreate } = makeFakeWorkspace([tmpDir]); + describe("workspace folders change trigger", () => { + it("calls onReposFound with found repos", async () => { + mock.setFolders([repoDir]); + watcher = watchForRepos(ctx, onReposFound); - watchForRepos(fakeCtx, workspace); - triggerGitCreate(); - await new Promise((r) => setTimeout(r, 50)); - expect(initExtension).not.toHaveBeenCalled(); + mock.fireFolderChange(); + + await vi.waitFor(() => expect(onReposFound).toHaveBeenCalledOnce()); + expect(onReposFound).toHaveBeenCalledWith(ctx, expect.arrayContaining([repoDir])); + }); }); - it("disposes watchers after repos are found", async () => { - execSync("git init", { cwd: tmpDir }); - const { workspace, gitWatcher, triggerGitCreate } = makeFakeWorkspace([tmpDir]); + describe("config change trigger", () => { + it("does not call onReposFound when maxDepth did not increase", async () => { + mock.setFolders([repoDir]); + mock.setMaxDepth(0); // same as the tracker's current value → maxDepthIncreased() = false + watcher = watchForRepos(ctx, onReposFound); - watchForRepos(fakeCtx, workspace); - triggerGitCreate(); - await vi.waitFor(() => expect(initExtension).toHaveBeenCalled()); - expect(gitWatcher.dispose).toHaveBeenCalled(); - }); + mock.fireConfigChange("neo-git-graph.maxDepthOfRepoSearch"); + await tick(); - it("dispose() is idempotent", () => { - const { workspace, gitWatcher } = makeFakeWorkspace(); - const detector = watchForRepos(fakeCtx, workspace); - detector.dispose(); - detector.dispose(); - expect(gitWatcher.dispose).toHaveBeenCalledTimes(1); + expect(onReposFound).not.toHaveBeenCalled(); + }); + + it("calls onReposFound when maxDepth increases", async () => { + mock.setFolders([repoDir]); + mock.setMaxDepth(5); // higher than tracker's current value → maxDepthIncreased() = true + watcher = watchForRepos(ctx, onReposFound); + + mock.fireConfigChange("neo-git-graph.maxDepthOfRepoSearch"); + + await vi.waitFor(() => expect(onReposFound).toHaveBeenCalledOnce()); + }); + + it("does not call onReposFound for an unrelated config key", async () => { + mock.setFolders([repoDir]); + watcher = watchForRepos(ctx, onReposFound); + + mock.fireConfigChange("neo-git-graph.graphStyle"); + await tick(); + + expect(onReposFound).not.toHaveBeenCalled(); + }); }); - it("does not double-initExtension when both events fire before check resolves", async () => { - execSync("git init", { cwd: tmpDir }); - const { workspace, triggerGitCreate, triggerFolderChange } = makeFakeWorkspace([tmpDir]); + describe("one-shot disposal", () => { + it("ignores further triggers after onReposFound has been called once", async () => { + mock.setFolders([repoDir]); + watcher = watchForRepos(ctx, onReposFound); + + mock.fireCreate(); + await vi.waitFor(() => expect(onReposFound).toHaveBeenCalledOnce()); - watchForRepos(fakeCtx, workspace); - triggerGitCreate(); - triggerFolderChange(); + // State is now disposed — a second event must not re-trigger. + mock.fireCreate(); + await tick(); - await vi.waitFor(() => expect(initExtension).toHaveBeenCalled()); - await new Promise((r) => setTimeout(r, 20)); - expect(initExtension).toHaveBeenCalledTimes(1); + expect(onReposFound).toHaveBeenCalledOnce(); + }); }); -}); -describe("watchForRepos — mocked findGitRepos for timing control", () => { - it("does not initExtension after external dispose while check is in-flight", async () => { - const { workspace, triggerGitCreate } = makeFakeWorkspace([tmpDir]); + describe("error commands (shown before any repo is found)", () => { + it("neo-git-graph.view shows a modal error", async () => { + watcher = watchForRepos(ctx, onReposFound); + + await mock.invokeCommand("neo-git-graph.view"); + + expect(mock.showErrorMessage).toHaveBeenCalledOnce(); + expect(mock.showErrorMessage).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ modal: true }) + ); + }); - let resolveFindGitRepos!: (v: string[]) => void; - vi.spyOn(await import("@/backend/queries/repoSearch"), "findGitRepos").mockImplementation( - () => new Promise((r) => (resolveFindGitRepos = r)) - ); + it("neo-git-graph.clearAvatarCache shows a modal error", async () => { + watcher = watchForRepos(ctx, onReposFound); - const detector = watchForRepos(fakeCtx, workspace); - triggerGitCreate(); - detector.dispose(); - resolveFindGitRepos([tmpDir]); + await mock.invokeCommand("neo-git-graph.clearAvatarCache"); - await new Promise((r) => setTimeout(r, 20)); - expect(initExtension).not.toHaveBeenCalled(); + expect(mock.showErrorMessage).toHaveBeenCalledOnce(); + expect(mock.showErrorMessage).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ modal: true }) + ); + }); }); }); From 655d9cefc56e08c7f77223f0d21706f624238dd4 Mon Sep 17 00:00:00 2001 From: asispts <79239132+asispts@users.noreply.github.com> Date: Wed, 15 Apr 2026 08:51:26 +0000 Subject: [PATCH 29/36] Remove global maxDepth initial value --- src/extension/initExtension.ts | 5 +++-- src/extension/maxDepthTracker.ts | 17 +++++++++-------- src/extension/watchForRepos.ts | 5 +++-- tests/extension/watchForRepos.test.ts | 4 ++-- 4 files changed, 17 insertions(+), 14 deletions(-) diff --git a/src/extension/initExtension.ts b/src/extension/initExtension.ts index 6910d643..8be2481c 100644 --- a/src/extension/initExtension.ts +++ b/src/extension/initExtension.ts @@ -6,7 +6,7 @@ import { findGitRepos } from "@/backend/queries/repoSearch"; import { buildExtensionUri } from "@/backend/utils/path"; import { config } from "@/config"; import { DiffDocProvider } from "@/diffDocProvider"; -import { maxDepthIncreased } from "@/extension/maxDepthTracker"; +import { createMaxDepthTracker } from "@/extension/maxDepthTracker"; import { registerMessageHandlers } from "@/extension/messageHandler"; import { createRepoManager, RepoManager } from "@/extension/repoManager"; import { WebviewBridge, webviewBridgeFactory } from "@/extension/webviewBridge"; @@ -98,6 +98,7 @@ export function initExtension(ctx: vscode.ExtensionContext, repos: string[]) { ) ); + const maxDepth = createMaxDepthTracker(config.maxDepthOfRepoSearch()); const statusBarItem = new StatusBarItem(ctx, config); const repoManager = createRepoManager(extensionState, statusBarItem, config); repoManager.setRepos(repos); @@ -136,7 +137,7 @@ export function initExtension(ctx: vscode.ExtensionContext, repos: string[]) { } else if (e.affectsConfiguration("git.path")) { gitClient.setGitPath(config.gitPath()); } else if (e.affectsConfiguration("neo-git-graph.maxDepthOfRepoSearch")) { - if (maxDepthIncreased()) { + if (maxDepth.increased(config.maxDepthOfRepoSearch())) { const paths = (vscode.workspace.workspaceFolders ?? []).map((f) => f.uri.fsPath); void findGitRepos(paths, config.gitPath(), config.maxDepthOfRepoSearch()).then( (repoDirs) => { diff --git a/src/extension/maxDepthTracker.ts b/src/extension/maxDepthTracker.ts index 5742a602..cc3fa313 100644 --- a/src/extension/maxDepthTracker.ts +++ b/src/extension/maxDepthTracker.ts @@ -1,9 +1,10 @@ -import { config } from "@/config"; - -let maxDepth = config.maxDepthOfRepoSearch(); - -export function maxDepthIncreased(): boolean { - const prev = maxDepth; - maxDepth = config.maxDepthOfRepoSearch(); - return maxDepth > prev; +export function createMaxDepthTracker(initialDepth: number) { + let current = initialDepth; + return { + increased: (newDepth: number) => { + const prev = current; + current = newDepth; + return current > prev; + } + }; } diff --git a/src/extension/watchForRepos.ts b/src/extension/watchForRepos.ts index 6bf7cc28..feb6ad71 100644 --- a/src/extension/watchForRepos.ts +++ b/src/extension/watchForRepos.ts @@ -3,7 +3,7 @@ import * as vscode from "vscode"; import { findGitRepos } from "@/backend/queries/repoSearch"; import { config } from "@/config"; import type { InitExtension } from "@/extension/initExtension"; -import { maxDepthIncreased } from "@/extension/maxDepthTracker"; +import { createMaxDepthTracker } from "@/extension/maxDepthTracker"; import * as l10n from "@/l10n"; type WatcherState = { @@ -33,6 +33,7 @@ export function watchForRepos( ctx: vscode.ExtensionContext, onReposFound: InitExtension ): { dispose(): void } { + const maxDepth = createMaxDepthTracker(config.maxDepthOfRepoSearch()); const gitWatcher = vscode.workspace.createFileSystemWatcher("**/.git"); const state: WatcherState = { disposed: false, disposables: [gitWatcher] }; @@ -41,7 +42,7 @@ export function watchForRepos( vscode.workspace.onDidChangeWorkspaceFolders(() => check(ctx, state, onReposFound)), vscode.workspace.onDidChangeConfiguration((e) => { if (e.affectsConfiguration("neo-git-graph.maxDepthOfRepoSearch")) { - if (maxDepthIncreased()) { + if (maxDepth.increased(config.maxDepthOfRepoSearch())) { void check(ctx, state, onReposFound); } } diff --git a/tests/extension/watchForRepos.test.ts b/tests/extension/watchForRepos.test.ts index e6303044..c223d82b 100644 --- a/tests/extension/watchForRepos.test.ts +++ b/tests/extension/watchForRepos.test.ts @@ -173,8 +173,8 @@ describe("watchForRepos", () => { it("calls onReposFound when maxDepth increases", async () => { mock.setFolders([repoDir]); - mock.setMaxDepth(5); // higher than tracker's current value → maxDepthIncreased() = true - watcher = watchForRepos(ctx, onReposFound); + watcher = watchForRepos(ctx, onReposFound); // tracker initializes at 0 + mock.setMaxDepth(5); // increase after tracker is created mock.fireConfigChange("neo-git-graph.maxDepthOfRepoSearch"); From e1c3ce155f3b679c52053f2e1c4b816f1da7c017 Mon Sep 17 00:00:00 2001 From: asispts <79239132+asispts@users.noreply.github.com> Date: Wed, 15 Apr 2026 10:16:42 +0100 Subject: [PATCH 30/36] Use path.dirname --- src/extension/initExtension.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/extension/initExtension.ts b/src/extension/initExtension.ts index 8be2481c..bf22c79d 100644 --- a/src/extension/initExtension.ts +++ b/src/extension/initExtension.ts @@ -1,3 +1,5 @@ +import * as path from "path"; + import * as vscode from "vscode"; import { AvatarManager } from "@/avatarManager"; @@ -108,12 +110,12 @@ export function initExtension(ctx: vscode.ExtensionContext, repos: string[]) { ctx.subscriptions.push( gitWatcher, gitWatcher.onDidCreate((uri) => { - const repoPath = uri.fsPath.slice(0, -5); + const repoPath = path.dirname(uri.fsPath); repoManager.addRepo(repoPath); repoManager.sendRepos(); }), gitWatcher.onDidDelete((uri) => { - const repoPath = uri.fsPath.slice(0, -5); + const repoPath = path.dirname(uri.fsPath); if (repoManager.removeReposWithinFolder(repoPath)) repoManager.sendRepos(); }), vscode.workspace.onDidChangeWorkspaceFolders(async (e) => { From 20fd472f3c09e29bb33779aeeaa1055ff76de949 Mon Sep 17 00:00:00 2001 From: asispts <79239132+asispts@users.noreply.github.com> Date: Thu, 16 Apr 2026 07:36:55 +0100 Subject: [PATCH 31/36] Update extensionState when reset the repos --- src/extension/repoManager.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/extension/repoManager.ts b/src/extension/repoManager.ts index 1b590ace..a98366f6 100644 --- a/src/extension/repoManager.ts +++ b/src/extension/repoManager.ts @@ -29,6 +29,7 @@ export function createRepoManager( for (const key of Object.keys(repos)) { delete repos[key]; } + extensionState.saveRepos(repos); repoDirs.forEach((repo) => { addRepo(repo); }); From 8fffdd70a109f5f489aa0b108d4f1937bb14976c Mon Sep 17 00:00:00 2001 From: asispts <79239132+asispts@users.noreply.github.com> Date: Thu, 16 Apr 2026 07:38:22 +0100 Subject: [PATCH 32/36] Fix N disk writes when calling setRepos --- src/extension/repoManager.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/extension/repoManager.ts b/src/extension/repoManager.ts index a98366f6..aa3d71fc 100644 --- a/src/extension/repoManager.ts +++ b/src/extension/repoManager.ts @@ -29,10 +29,10 @@ export function createRepoManager( for (const key of Object.keys(repos)) { delete repos[key]; } + for (const repo of repoDirs) { + repos[repo] = { columnWidths: null }; + } extensionState.saveRepos(repos); - repoDirs.forEach((repo) => { - addRepo(repo); - }); sendRepos(); } From 71572ce6cf600aa8b663e4485988ddfd6c4127eb Mon Sep 17 00:00:00 2001 From: asispts <79239132+asispts@users.noreply.github.com> Date: Thu, 16 Apr 2026 07:41:22 +0100 Subject: [PATCH 33/36] Don't sendRepos unconditionally in onDidCreate event --- src/extension/initExtension.ts | 3 +-- src/extension/repoManager.ts | 2 ++ 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/extension/initExtension.ts b/src/extension/initExtension.ts index bf22c79d..40b1c511 100644 --- a/src/extension/initExtension.ts +++ b/src/extension/initExtension.ts @@ -111,8 +111,7 @@ export function initExtension(ctx: vscode.ExtensionContext, repos: string[]) { gitWatcher, gitWatcher.onDidCreate((uri) => { const repoPath = path.dirname(uri.fsPath); - repoManager.addRepo(repoPath); - repoManager.sendRepos(); + if (repoManager.addRepo(repoPath)) repoManager.sendRepos(); }), gitWatcher.onDidDelete((uri) => { const repoPath = path.dirname(uri.fsPath); diff --git a/src/extension/repoManager.ts b/src/extension/repoManager.ts index aa3d71fc..d37895b2 100644 --- a/src/extension/repoManager.ts +++ b/src/extension/repoManager.ts @@ -70,8 +70,10 @@ export function createRepoManager( } function addRepo(repo: string) { + if (repos[repo]) return false; repos[repo] = { columnWidths: null }; extensionState.saveRepos(repos); + return true; } function removeReposWithinFolder(path: string) { From 33b406b812bc3a92f5bd1efea89e065f82a340e8 Mon Sep 17 00:00:00 2001 From: asispts <79239132+asispts@users.noreply.github.com> Date: Thu, 16 Apr 2026 08:19:53 +0100 Subject: [PATCH 34/36] Return early if already disposed --- src/extension/watchForRepos.ts | 1 + tests/extension/watchForRepos.test.ts | 12 +++++------- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/extension/watchForRepos.ts b/src/extension/watchForRepos.ts index feb6ad71..b9615d1d 100644 --- a/src/extension/watchForRepos.ts +++ b/src/extension/watchForRepos.ts @@ -22,6 +22,7 @@ async function check( state: WatcherState, onReposFound: InitExtension ) { + if (state.disposed) return; const paths = (vscode.workspace.workspaceFolders ?? []).map((f) => f.uri.fsPath); const repoDirs = await findGitRepos(paths, config.gitPath(), config.maxDepthOfRepoSearch()); if (repoDirs.length === 0 || state.disposed) return; diff --git a/tests/extension/watchForRepos.test.ts b/tests/extension/watchForRepos.test.ts index c223d82b..33b94a50 100644 --- a/tests/extension/watchForRepos.test.ts +++ b/tests/extension/watchForRepos.test.ts @@ -91,7 +91,7 @@ vi.mock("vscode", () => mock); // ctx is never inspected by watchForRepos itself — it's just forwarded to onReposFound. const ctx = {} as unknown as import("vscode").ExtensionContext; -// Enough time for findGitRepos (real fs) to complete. +// Allow real-fs async paths (findGitRepos) to complete. const tick = (ms = 150) => new Promise((r) => setTimeout(r, ms)); let repoDir: string; @@ -160,13 +160,12 @@ describe("watchForRepos", () => { }); describe("config change trigger", () => { - it("does not call onReposFound when maxDepth did not increase", async () => { + it("does not call onReposFound when maxDepth did not increase", () => { mock.setFolders([repoDir]); mock.setMaxDepth(0); // same as the tracker's current value → maxDepthIncreased() = false watcher = watchForRepos(ctx, onReposFound); mock.fireConfigChange("neo-git-graph.maxDepthOfRepoSearch"); - await tick(); expect(onReposFound).not.toHaveBeenCalled(); }); @@ -181,12 +180,11 @@ describe("watchForRepos", () => { await vi.waitFor(() => expect(onReposFound).toHaveBeenCalledOnce()); }); - it("does not call onReposFound for an unrelated config key", async () => { + it("does not call onReposFound for an unrelated config key", () => { mock.setFolders([repoDir]); watcher = watchForRepos(ctx, onReposFound); mock.fireConfigChange("neo-git-graph.graphStyle"); - await tick(); expect(onReposFound).not.toHaveBeenCalled(); }); @@ -200,9 +198,9 @@ describe("watchForRepos", () => { mock.fireCreate(); await vi.waitFor(() => expect(onReposFound).toHaveBeenCalledOnce()); - // State is now disposed — a second event must not re-trigger. + // State is now disposed — check() exits synchronously before any await. mock.fireCreate(); - await tick(); + await Promise.resolve(); expect(onReposFound).toHaveBeenCalledOnce(); }); From d0e8d0018312605331d0506578ca361a3439f888 Mon Sep 17 00:00:00 2001 From: asispts <79239132+asispts@users.noreply.github.com> Date: Thu, 16 Apr 2026 08:24:53 +0100 Subject: [PATCH 35/36] Should call sendRepos manually --- src/extension/initExtension.ts | 5 ++++- src/extension/repoManager.ts | 2 -- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/extension/initExtension.ts b/src/extension/initExtension.ts index 40b1c511..8dfab442 100644 --- a/src/extension/initExtension.ts +++ b/src/extension/initExtension.ts @@ -142,7 +142,10 @@ export function initExtension(ctx: vscode.ExtensionContext, repos: string[]) { const paths = (vscode.workspace.workspaceFolders ?? []).map((f) => f.uri.fsPath); void findGitRepos(paths, config.gitPath(), config.maxDepthOfRepoSearch()).then( (repoDirs) => { - if (repoDirs.length > 0) repoManager.setRepos(repoDirs); + if (repoDirs.length > 0) { + repoManager.setRepos(repoDirs); + repoManager.sendRepos(); + } } ); } diff --git a/src/extension/repoManager.ts b/src/extension/repoManager.ts index d37895b2..3edaf838 100644 --- a/src/extension/repoManager.ts +++ b/src/extension/repoManager.ts @@ -33,8 +33,6 @@ export function createRepoManager( repos[repo] = { columnWidths: null }; } extensionState.saveRepos(repos); - - sendRepos(); } function getRepos() { From 123ce3d6389166276af85476157ab0cb205da21a Mon Sep 17 00:00:00 2001 From: asispts <79239132+asispts@users.noreply.github.com> Date: Thu, 16 Apr 2026 10:14:56 +0100 Subject: [PATCH 36/36] Reformat code --- src/extension/initExtension.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/extension/initExtension.ts b/src/extension/initExtension.ts index 8dfab442..e8c4b2fc 100644 --- a/src/extension/initExtension.ts +++ b/src/extension/initExtension.ts @@ -143,9 +143,9 @@ export function initExtension(ctx: vscode.ExtensionContext, repos: string[]) { void findGitRepos(paths, config.gitPath(), config.maxDepthOfRepoSearch()).then( (repoDirs) => { if (repoDirs.length > 0) { - repoManager.setRepos(repoDirs); - repoManager.sendRepos(); - } + repoManager.setRepos(repoDirs); + repoManager.sendRepos(); + } } ); }