From 82beb5001726eee682fe4abf28552431aaba4022 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Mon, 25 May 2026 13:37:33 +0200 Subject: [PATCH 1/6] perf(check): batch git subprocesses and auto-populate baseBranch on init (#343, #346) Parallelize independent git subprocess calls to reduce `archgate check` latency, and auto-detect the base branch during `archgate init` so subsequent checks skip the 1-4 probe detection entirely. - Parallelize staged + unstaged diff calls in `getChangedFiles()` via `Promise.all` (~25ms saved on Windows per call) - Parallelize `changedFiles` + `trackedFiles` fetch in `runChecks()`, interleaving synchronous work while git I/O runs - Auto-detect and save `baseBranch` to `.archgate/config.json` during `initProject()`, completing the existing config plumbing - Add `resolveBaseRef()` test suite covering priority resolution - Add baseBranch auto-detection tests (save, idempotent re-init, non-git) Closes #343, closes #346 Signed-off-by: Rhuan Barreto --- src/engine/git-files.ts | 9 ++-- src/engine/runner.ts | 20 +++++--- src/helpers/init-project.ts | 21 ++++++++ tests/engine/git-files.test.ts | 44 ++++++++++++++++ tests/helpers/init-base-branch.test.ts | 71 ++++++++++++++++++++++++++ tests/helpers/init-project.test.ts | 3 +- 6 files changed, 156 insertions(+), 12 deletions(-) create mode 100644 tests/helpers/init-base-branch.test.ts diff --git a/src/engine/git-files.ts b/src/engine/git-files.ts index ada143da..32d0ff3c 100644 --- a/src/engine/git-files.ts +++ b/src/engine/git-files.ts @@ -151,11 +151,10 @@ export async function getStagedFiles(projectRoot: string): Promise { /** Get all changed files (staged + unstaged). */ export async function getChangedFiles(projectRoot: string): Promise { try { - const staged = await runGit( - ["diff", "--cached", "--name-only"], - projectRoot - ); - const unstaged = await runGit(["diff", "--name-only"], projectRoot); + const [staged, unstaged] = await Promise.all([ + runGit(["diff", "--cached", "--name-only"], projectRoot), + runGit(["diff", "--name-only"], projectRoot), + ]); const all = new Set([ ...staged.trim().split("\n").filter(Boolean), ...unstaged.trim().split("\n").filter(Boolean), diff --git a/src/engine/runner.ts b/src/engine/runner.ts index 4647509a..9e54b237 100644 --- a/src/engine/runner.ts +++ b/src/engine/runner.ts @@ -198,11 +198,16 @@ export async function runChecks( options: { staged?: boolean; files?: string[]; base?: string } = {} ): Promise { const startTime = performance.now(); - const changedFiles = options.staged - ? await getStagedFiles(projectRoot) + + // Start git I/O concurrently — changedFiles and trackedFiles are independent + const changedFilesPromise = options.staged + ? getStagedFiles(projectRoot) : options.base - ? await getFilesChangedSinceRef(projectRoot, options.base) - : []; + ? getFilesChangedSinceRef(projectRoot, options.base) + : Promise.resolve([]); + const allTrackedFilesPromise = getGitTrackedFiles(projectRoot); + + // Do synchronous work while git subprocesses run const results: RuleResult[] = loadResults .filter((lr) => lr.type === "blocked") .map((lr) => blockedToRuleResult(projectRoot, lr.value)); @@ -224,8 +229,11 @@ export async function runChecks( ); } - // Resolve tracked files once (cached per-process) for gitignore filtering - const allTrackedFiles = await getGitTrackedFiles(projectRoot); + // Await both git operations (started above, run concurrently) + const [changedFiles, allTrackedFiles] = await Promise.all([ + changedFilesPromise, + allTrackedFilesPromise, + ]); // Run ADRs in parallel const adrResults = await Promise.allSettled( diff --git a/src/helpers/init-project.ts b/src/helpers/init-project.ts index af900d52..0e6b370a 100644 --- a/src/helpers/init-project.ts +++ b/src/helpers/init-project.ts @@ -3,6 +3,7 @@ import { existsSync, readdirSync } from "node:fs"; import { basename, join } from "node:path"; +import { detectBaseRef } from "../engine/git-files"; import { generateExampleAdr } from "./adr-templates"; import { configureClaudeSettings } from "./claude-settings"; import { configureCopilotSettings } from "./copilot-settings"; @@ -13,6 +14,7 @@ import { opencodeAgentsDir, projectPaths, } from "./paths"; +import { loadProjectConfig, saveProjectConfig } from "./project-config"; import { writeRulesShim } from "./rules-shim"; import { configureVscodeSettings } from "./vscode-settings"; @@ -122,6 +124,25 @@ Archgate standardizes \`.archgate/lint/\` as the location for linter rules that const editor = options?.editor ?? "claude"; const editorSettingsPath = await configureEditorSettings(projectRoot, editor); + // Auto-detect base branch and save to config.json when not already configured. + // Runs after directory creation so .archgate/ exists for saveProjectConfig. + // Non-fatal — detection may fail if not in a git repo. + const existingConfig = loadProjectConfig(projectRoot); + if (!existingConfig.baseBranch) { + try { + const detectedBase = await detectBaseRef(projectRoot); + if (detectedBase) { + await saveProjectConfig(projectRoot, { + ...existingConfig, + baseBranch: detectedBase, + }); + logDebug("Auto-detected base branch:", detectedBase); + } + } catch { + logDebug("Base branch detection failed during init (not a git repo?)"); + } + } + // Plugin installation (optional — requires stored credentials) let plugin: PluginResult | undefined; if (options?.installPlugin) { diff --git a/tests/engine/git-files.test.ts b/tests/engine/git-files.test.ts index 11fa30a6..a518f05a 100644 --- a/tests/engine/git-files.test.ts +++ b/tests/engine/git-files.test.ts @@ -10,6 +10,7 @@ import { getStagedFiles, getChangedFiles, detectBaseRef, + resolveBaseRef, getFilesChangedSinceRef, resolveScopedFiles, SCOPE_FILE_WARN_THRESHOLD, @@ -111,6 +112,49 @@ describe("git-files", () => { }); }); + describe("resolveBaseRef", () => { + test("returns undefined when staged is true", async () => { + const ref = await resolveBaseRef(tempDir, { + staged: true, + base: "main", + configBase: "origin/main", + }); + expect(ref).toBeUndefined(); + }); + + test("explicit base string wins over configBase", async () => { + const ref = await resolveBaseRef(tempDir, { + base: "develop", + configBase: "origin/main", + }); + expect(ref).toBe("develop"); + }); + + test("configBase wins over auto-detect", async () => { + // tempDir is not a git repo, so detectBaseRef would return null. + // configBase should be returned without calling detectBaseRef. + const ref = await resolveBaseRef(tempDir, { configBase: "origin/main" }); + expect(ref).toBe("origin/main"); + }); + + test("falls back to undefined when no config and not a git repo", async () => { + const ref = await resolveBaseRef(tempDir, {}); + expect(ref).toBeUndefined(); + }); + + test("falls back to detectBaseRef and returns detected branch", async () => { + await git(["init", "--initial-branch=main"], tempDir); + await git(["config", "user.email", "test@test.com"], tempDir); + await git(["config", "user.name", "Test"], tempDir); + writeFileSync(join(tempDir, "file.ts"), "export const x = 1;"); + await git(["add", "file.ts"], tempDir); + await git(["commit", "-m", "init"], tempDir); + + const ref = await resolveBaseRef(tempDir, {}); + expect(ref).toBe("main"); + }, 15_000); + }); + describe("getFilesChangedSinceRef", () => { test("returns empty array for non-git directory", async () => { const files = await getFilesChangedSinceRef(tempDir, "main"); diff --git a/tests/helpers/init-base-branch.test.ts b/tests/helpers/init-base-branch.test.ts new file mode 100644 index 00000000..7c4ca78c --- /dev/null +++ b/tests/helpers/init-base-branch.test.ts @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Archgate +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, existsSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { initProject } from "../../src/helpers/init-project"; +import { git, safeRmSync } from "../test-utils"; + +describe("initProject — baseBranch auto-detection", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-initbase-test-")); + }); + + afterEach(() => { + safeRmSync(tempDir); + }); + + test("saves detected baseBranch in config.json during init in a git repo", async () => { + await git(["init", "--initial-branch=main"], tempDir); + await git(["config", "user.email", "test@test.com"], tempDir); + await git(["config", "user.name", "Test"], tempDir); + writeFileSync(join(tempDir, "file.ts"), "export const x = 1;"); + await git(["add", "file.ts"], tempDir); + await git(["commit", "-m", "init"], tempDir); + + await initProject(tempDir); + + const configPath = join(tempDir, ".archgate", "config.json"); + expect(existsSync(configPath)).toBe(true); + const config = JSON.parse(await Bun.file(configPath).text()); + expect(config.baseBranch).toBe("main"); + }, 15_000); + + test("does not overwrite existing baseBranch on re-init", async () => { + await git(["init", "--initial-branch=main"], tempDir); + await git(["config", "user.email", "test@test.com"], tempDir); + await git(["config", "user.name", "Test"], tempDir); + writeFileSync(join(tempDir, "file.ts"), "export const x = 1;"); + await git(["add", "file.ts"], tempDir); + await git(["commit", "-m", "init"], tempDir); + + // First init saves baseBranch + await initProject(tempDir); + + // Manually change baseBranch to a custom value + const configPath = join(tempDir, ".archgate", "config.json"); + const config = JSON.parse(await Bun.file(configPath).text()); + config.baseBranch = "develop"; + await Bun.write(configPath, JSON.stringify(config, null, 2) + "\n"); + + // Re-init should not overwrite the custom baseBranch + await initProject(tempDir); + + const updatedConfig = JSON.parse(await Bun.file(configPath).text()); + expect(updatedConfig.baseBranch).toBe("develop"); + }, 15_000); + + test("does not save baseBranch when not in a git repo", async () => { + await initProject(tempDir); + + const configPath = join(tempDir, ".archgate", "config.json"); + if (existsSync(configPath)) { + const config = JSON.parse(await Bun.file(configPath).text()); + expect(config.baseBranch).toBeUndefined(); + } + }); +}); diff --git a/tests/helpers/init-project.test.ts b/tests/helpers/init-project.test.ts index 6f6bf601..46ad83bf 100644 --- a/tests/helpers/init-project.test.ts +++ b/tests/helpers/init-project.test.ts @@ -16,6 +16,7 @@ import { join } from "node:path"; import * as credentialStore from "../../src/helpers/credential-store"; import { initProject } from "../../src/helpers/init-project"; import * as pluginInstall from "../../src/helpers/plugin-install"; +import { safeRmSync } from "../test-utils"; describe("initProject", () => { let tempDir: string; @@ -25,7 +26,7 @@ describe("initProject", () => { }); afterEach(() => { - rmSync(tempDir, { recursive: true, force: true }); + safeRmSync(tempDir); }); test("creates .archgate/adrs/ directory structure", async () => { From 3c4275cd7c1338391566f0039d40cea7514c7387 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Mon, 25 May 2026 14:32:44 +0200 Subject: [PATCH 2/6] docs: document baseBranch config field and init auto-detection Add baseBranch to the configuration reference (EN + PT-BR) with type, default, description, and manual override example. Document the init command's base branch auto-detection behavior in both locales. Update the config.json intro to reflect that init now creates the file for baseBranch (not just custom domains). Signed-off-by: Rhuan Barreto --- .../content/docs/pt-br/reference/cli/init.mdx | 8 +++++++ .../docs/pt-br/reference/configuration.mdx | 23 +++++++++++++++++-- docs/src/content/docs/reference/cli/init.mdx | 8 +++++++ .../content/docs/reference/configuration.mdx | 23 +++++++++++++++++-- 4 files changed, 58 insertions(+), 4 deletions(-) diff --git a/docs/src/content/docs/pt-br/reference/cli/init.mdx b/docs/src/content/docs/pt-br/reference/cli/init.mdx index d20b562d..c6b6eba3 100644 --- a/docs/src/content/docs/pt-br/reference/cli/init.mdx +++ b/docs/src/content/docs/pt-br/reference/cli/init.mdx @@ -41,6 +41,14 @@ Archgate plugin installed for Claude Code. Quando `--editor cursor` é usado, a saída mostra `.cursor/` em vez de `.claude/`. +## Detecção de branch base + +Quando executado dentro de um repositório git, `archgate init` detecta automaticamente o branch base e o salva em `.archgate/config.json` como o campo `baseBranch`. Isso permite que `archgate check` pule a detecção de branch a cada execução, economizando 1-4 chamadas de subprocesso git. + +A detecção tenta `origin/HEAD`, `origin/main`, `origin/master`, `main` local e `master` local (primeiro encontrado vence). Se nenhum for encontrado (ex: não é um repositório git), nenhum `baseBranch` é gravado. + +Re-executar `archgate init` **não** sobrescreve um `baseBranch` configurado manualmente. Veja [Configuração -- `baseBranch`](/reference/configuration/#basebranch) para detalhes. + ## Estrutura gerada ``` diff --git a/docs/src/content/docs/pt-br/reference/configuration.mdx b/docs/src/content/docs/pt-br/reference/configuration.mdx index 492aa6c1..1f422e70 100644 --- a/docs/src/content/docs/pt-br/reference/configuration.mdx +++ b/docs/src/content/docs/pt-br/reference/configuration.mdx @@ -5,14 +5,15 @@ description: Referência do arquivo de configuração .archgate/config.json. Con O arquivo `.archgate/config.json` armazena configurações do projeto que são versionadas no controle de versão e compartilhadas com toda a equipe. -Este arquivo é criado automaticamente pelo `archgate init` (quando domínios customizados são registrados) ou quando você adiciona configurações manualmente. Ele fica dentro do diretório `.archgate/` na raiz do seu projeto. +Este arquivo é criado automaticamente pelo `archgate init` (para armazenar o branch base auto-detectado e quaisquer domínios customizados) ou quando você adiciona configurações manualmente. Ele fica dentro do diretório `.archgate/` na raiz do seu projeto. ## Schema ```json { "domains": { "security": "SEC", "compliance": "COMP" }, - "paths": { "adrs": "docs/adrs", "rules": "docs/adrs" } + "paths": { "adrs": "docs/adrs", "rules": "docs/adrs" }, + "baseBranch": "main" } ``` @@ -26,6 +27,24 @@ Mapeamentos personalizados de domínio para prefixo. Veja [Domínios Personaliza Esses são mesclados com os domínios built-in (`backend`, `frontend`, `data`, `architecture`, `general`) em tempo de leitura. Entradas personalizadas não podem sobrescrever nomes ou prefixos built-in. +### `baseBranch` + +Branch base para detecção de mudanças no `archgate check`. Quando definido, `archgate check` pula as probes de auto-detecção e usa este valor diretamente para popular `ctx.changedFiles` via `git diff ...HEAD`. + +| Tipo | Padrão | Descrição | +| -------- | --------------- | -------------------------------------------------------- | +| `string` | _(auto-detect)_ | Nome do branch ou ref remoto (ex: `main`, `origin/main`) | + +Este campo é **preenchido automaticamente** pelo `archgate init` quando um repositório git é detectado. A auto-detecção tenta `origin/HEAD`, `origin/main`, `origin/master`, `main` local e `master` local (primeiro encontrado vence). Re-executar `archgate init` não sobrescreve um valor configurado manualmente. + +Você também pode defini-lo manualmente: + +```json +{ "baseBranch": "main" } +``` + +Veja [`archgate check` -- Detecção de arquivos alterados](/reference/cli/check/#changed-files-detection) para a prioridade completa de resolução. + ### `paths` Sobrescreve os diretórios padrão para ADRs e regras. diff --git a/docs/src/content/docs/reference/cli/init.mdx b/docs/src/content/docs/reference/cli/init.mdx index 46a4de4c..54f1cc96 100644 --- a/docs/src/content/docs/reference/cli/init.mdx +++ b/docs/src/content/docs/reference/cli/init.mdx @@ -41,6 +41,14 @@ Archgate plugin installed for Claude Code. When `--editor cursor` is used, the output shows `.cursor/` instead of `.claude/`. +## Base branch detection + +When run inside a git repository, `archgate init` auto-detects the base branch and saves it to `.archgate/config.json` as the `baseBranch` field. This allows `archgate check` to skip branch detection on every run, saving 1-4 git subprocess calls. + +The detection tries `origin/HEAD`, `origin/main`, `origin/master`, local `main`, and local `master` (first match wins). If none are found (e.g., not a git repo), no `baseBranch` is written. + +Re-running `archgate init` does **not** overwrite a manually configured `baseBranch`. See [Configuration -- `baseBranch`](/reference/configuration/#basebranch) for details. + ## Generated structure ``` diff --git a/docs/src/content/docs/reference/configuration.mdx b/docs/src/content/docs/reference/configuration.mdx index 0d5803bf..00de3ce1 100644 --- a/docs/src/content/docs/reference/configuration.mdx +++ b/docs/src/content/docs/reference/configuration.mdx @@ -5,14 +5,15 @@ description: Reference for the .archgate/config.json project configuration file. The `.archgate/config.json` file stores project-level configuration that is committed to version control and shared across the team. -This file is created automatically by `archgate init` (when custom domains are registered) or when you manually add configuration. It lives inside the `.archgate/` directory at your project root. +This file is created automatically by `archgate init` (to store the auto-detected base branch and any custom domains) or when you manually add configuration. It lives inside the `.archgate/` directory at your project root. ## Schema ```json { "domains": { "security": "SEC", "compliance": "COMP" }, - "paths": { "adrs": "docs/adrs", "rules": "docs/adrs" } + "paths": { "adrs": "docs/adrs", "rules": "docs/adrs" }, + "baseBranch": "main" } ``` @@ -26,6 +27,24 @@ Custom domain-to-prefix mappings. See [Custom Domains](/concepts/domains/#custom These are merged with the built-in domains (`backend`, `frontend`, `data`, `architecture`, `general`) at read time. Custom entries cannot override built-in names or prefixes. +### `baseBranch` + +Base branch for change detection in `archgate check`. When set, `archgate check` skips the auto-detection probes and uses this value directly for `ctx.changedFiles` population via `git diff ...HEAD`. + +| Type | Default | Description | +| -------- | --------------- | ------------------------------------------------------- | +| `string` | _(auto-detect)_ | Branch name or remote ref (e.g., `main`, `origin/main`) | + +This field is **auto-populated** by `archgate init` when a git repository is detected. The auto-detection tries `origin/HEAD`, `origin/main`, `origin/master`, local `main`, and local `master` (first match wins). Re-running `archgate init` does not overwrite a manually configured value. + +You can also set it manually: + +```json +{ "baseBranch": "main" } +``` + +See [`archgate check` -- Changed files detection](/reference/cli/check/#changed-files-detection) for the full resolution priority. + ### `paths` Override default directories for ADRs and rules. From 4121e6006ae2cbfa0b98746140d929fd1f7a10fc Mon Sep 17 00:00:00 2001 From: rhuanbarreto <283004+rhuanbarreto@users.noreply.github.com> Date: Mon, 25 May 2026 12:33:16 +0000 Subject: [PATCH 3/6] docs: regenerate llms-full.txt Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- docs/public/llms-full.txt | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/docs/public/llms-full.txt b/docs/public/llms-full.txt index 36a48bdd..ebc575fa 100644 --- a/docs/public/llms-full.txt +++ b/docs/public/llms-full.txt @@ -3917,6 +3917,14 @@ Archgate plugin installed for Claude Code. When `--editor cursor` is used, the output shows `.cursor/` instead of `.claude/`. +## Base branch detection + +When run inside a git repository, `archgate init` auto-detects the base branch and saves it to `.archgate/config.json` as the `baseBranch` field. This allows `archgate check` to skip branch detection on every run, saving 1-4 git subprocess calls. + +The detection tries `origin/HEAD`, `origin/main`, `origin/master`, local `main`, and local `master` (first match wins). If none are found (e.g., not a git repo), no `baseBranch` is written. + +Re-running `archgate init` does **not** overwrite a manually configured `baseBranch`. See [Configuration -- `baseBranch`](/reference/configuration/#basebranch) for details. + ## Generated structure ``` @@ -4424,14 +4432,15 @@ Source: https://cli.archgate.dev/reference/configuration/ The `.archgate/config.json` file stores project-level configuration that is committed to version control and shared across the team. -This file is created automatically by `archgate init` (when custom domains are registered) or when you manually add configuration. It lives inside the `.archgate/` directory at your project root. +This file is created automatically by `archgate init` (to store the auto-detected base branch and any custom domains) or when you manually add configuration. It lives inside the `.archgate/` directory at your project root. ## Schema ```json { "domains": { "security": "SEC", "compliance": "COMP" }, - "paths": { "adrs": "docs/adrs", "rules": "docs/adrs" } + "paths": { "adrs": "docs/adrs", "rules": "docs/adrs" }, + "baseBranch": "main" } ``` @@ -4445,6 +4454,24 @@ Custom domain-to-prefix mappings. See [Custom Domains](/concepts/domains/#custom These are merged with the built-in domains (`backend`, `frontend`, `data`, `architecture`, `general`) at read time. Custom entries cannot override built-in names or prefixes. +### `baseBranch` + +Base branch for change detection in `archgate check`. When set, `archgate check` skips the auto-detection probes and uses this value directly for `ctx.changedFiles` population via `git diff ...HEAD`. + +| Type | Default | Description | +| -------- | --------------- | ------------------------------------------------------- | +| `string` | _(auto-detect)_ | Branch name or remote ref (e.g., `main`, `origin/main`) | + +This field is **auto-populated** by `archgate init` when a git repository is detected. The auto-detection tries `origin/HEAD`, `origin/main`, `origin/master`, local `main`, and local `master` (first match wins). Re-running `archgate init` does not overwrite a manually configured value. + +You can also set it manually: + +```json +{ "baseBranch": "main" } +``` + +See [`archgate check` -- Changed files detection](/reference/cli/check/#changed-files-detection) for the full resolution priority. + ### `paths` Override default directories for ADRs and rules. From fae1b3715fb71216abfc941c3a3316e2410dacc4 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Mon, 25 May 2026 14:38:18 +0200 Subject: [PATCH 4/6] feat(check): lazy-save detected baseBranch to config on first check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When resolveBaseRef() falls back to auto-detection and succeeds, save the detected branch to .archgate/config.json so all subsequent runs skip the 1-4 git probe detection entirely. This benefits users who never run archgate init — the optimization kicks in after the first check invocation. The lazy-save is non-fatal (try/catch) so read-only filesystems and CI environments are unaffected. It also preserves any existing baseBranch value to avoid overwriting manual configuration. Signed-off-by: Rhuan Barreto --- src/engine/git-files.ts | 23 +++++++++++++++++- tests/engine/git-files.test.ts | 43 +++++++++++++++++++++++++++++++++- 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/src/engine/git-files.ts b/src/engine/git-files.ts index 32d0ff3c..2b496506 100644 --- a/src/engine/git-files.ts +++ b/src/engine/git-files.ts @@ -3,6 +3,10 @@ /** Git file-listing utilities for ADR scope resolution and change detection. */ import { logDebug, logWarn } from "../helpers/log"; +import { + loadProjectConfig, + saveProjectConfig, +} from "../helpers/project-config"; /** Warn when an ADR's resolved file scope exceeds this many files. */ export const SCOPE_FILE_WARN_THRESHOLD = 1000; @@ -249,7 +253,24 @@ export async function resolveBaseRef( return options.configBase; } - return (await detectBaseRef(projectRoot)) ?? undefined; + const detected = await detectBaseRef(projectRoot); + if (detected) { + // Lazy-save: persist detected base branch to config.json so future runs + // skip detection entirely. Non-fatal — read-only filesystems are fine. + try { + const config = loadProjectConfig(projectRoot); + if (!config.baseBranch) { + await saveProjectConfig(projectRoot, { + ...config, + baseBranch: detected, + }); + logDebug("Saved detected base branch to config:", detected); + } + } catch { + logDebug("Could not save detected base branch to config"); + } + } + return detected ?? undefined; } /** diff --git a/tests/engine/git-files.test.ts b/tests/engine/git-files.test.ts index a518f05a..054bdec5 100644 --- a/tests/engine/git-files.test.ts +++ b/tests/engine/git-files.test.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Archgate import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test"; -import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; +import { mkdtempSync, mkdirSync, writeFileSync, existsSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -153,6 +153,47 @@ describe("git-files", () => { const ref = await resolveBaseRef(tempDir, {}); expect(ref).toBe("main"); }, 15_000); + + test("lazy-saves detected base branch to config.json", async () => { + await git(["init", "--initial-branch=main"], tempDir); + await git(["config", "user.email", "test@test.com"], tempDir); + await git(["config", "user.name", "Test"], tempDir); + writeFileSync(join(tempDir, "file.ts"), "export const x = 1;"); + await git(["add", "file.ts"], tempDir); + await git(["commit", "-m", "init"], tempDir); + + // No configBase — triggers detectBaseRef + lazy-save + await resolveBaseRef(tempDir, {}); + + const configPath = join(tempDir, ".archgate", "config.json"); + expect(existsSync(configPath)).toBe(true); + const config = JSON.parse(await Bun.file(configPath).text()); + expect(config.baseBranch).toBe("main"); + }, 15_000); + + test("does not overwrite existing baseBranch on lazy-save", async () => { + await git(["init", "--initial-branch=main"], tempDir); + await git(["config", "user.email", "test@test.com"], tempDir); + await git(["config", "user.name", "Test"], tempDir); + writeFileSync(join(tempDir, "file.ts"), "export const x = 1;"); + await git(["add", "file.ts"], tempDir); + await git(["commit", "-m", "init"], tempDir); + + // Pre-create config with a custom baseBranch + mkdirSync(join(tempDir, ".archgate"), { recursive: true }); + await Bun.write( + join(tempDir, ".archgate", "config.json"), + JSON.stringify({ baseBranch: "develop" }, null, 2) + "\n" + ); + + // configBase is null (simulating caller didn't find it) but config has baseBranch + await resolveBaseRef(tempDir, {}); + + const config = JSON.parse( + await Bun.file(join(tempDir, ".archgate", "config.json")).text() + ); + expect(config.baseBranch).toBe("develop"); + }, 15_000); }); describe("getFilesChangedSinceRef", () => { From 1c29082cafdf5b6363f1367c540fcce354754f87 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Mon, 25 May 2026 15:02:28 +0200 Subject: [PATCH 5/6] refactor: extract ensureBaseBranch() to deduplicate detect-and-save logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The same detect → check config → save pattern was duplicated in init-project.ts and git-files.ts. Extract into ensureBaseBranch() in project-config.ts (where the other baseBranch helpers live). Both init (eager) and resolveBaseRef (lazy) now call the same function, which accepts detectBaseRef as a parameter to avoid a circular dependency between engine and helpers. Signed-off-by: Rhuan Barreto --- src/engine/git-files.ts | 25 +++---------------------- src/helpers/init-project.ts | 19 ++----------------- src/helpers/project-config.ts | 27 +++++++++++++++++++++++++++ 3 files changed, 32 insertions(+), 39 deletions(-) diff --git a/src/engine/git-files.ts b/src/engine/git-files.ts index 2b496506..568ebf44 100644 --- a/src/engine/git-files.ts +++ b/src/engine/git-files.ts @@ -3,10 +3,7 @@ /** Git file-listing utilities for ADR scope resolution and change detection. */ import { logDebug, logWarn } from "../helpers/log"; -import { - loadProjectConfig, - saveProjectConfig, -} from "../helpers/project-config"; +import { ensureBaseBranch } from "../helpers/project-config"; /** Warn when an ADR's resolved file scope exceeds this many files. */ export const SCOPE_FILE_WARN_THRESHOLD = 1000; @@ -253,24 +250,8 @@ export async function resolveBaseRef( return options.configBase; } - const detected = await detectBaseRef(projectRoot); - if (detected) { - // Lazy-save: persist detected base branch to config.json so future runs - // skip detection entirely. Non-fatal — read-only filesystems are fine. - try { - const config = loadProjectConfig(projectRoot); - if (!config.baseBranch) { - await saveProjectConfig(projectRoot, { - ...config, - baseBranch: detected, - }); - logDebug("Saved detected base branch to config:", detected); - } - } catch { - logDebug("Could not save detected base branch to config"); - } - } - return detected ?? undefined; + // Lazy-save: detect + persist to config.json so future runs skip detection. + return (await ensureBaseBranch(projectRoot, detectBaseRef)) ?? undefined; } /** diff --git a/src/helpers/init-project.ts b/src/helpers/init-project.ts index 0e6b370a..19a77c77 100644 --- a/src/helpers/init-project.ts +++ b/src/helpers/init-project.ts @@ -14,7 +14,7 @@ import { opencodeAgentsDir, projectPaths, } from "./paths"; -import { loadProjectConfig, saveProjectConfig } from "./project-config"; +import { ensureBaseBranch } from "./project-config"; import { writeRulesShim } from "./rules-shim"; import { configureVscodeSettings } from "./vscode-settings"; @@ -126,22 +126,7 @@ Archgate standardizes \`.archgate/lint/\` as the location for linter rules that // Auto-detect base branch and save to config.json when not already configured. // Runs after directory creation so .archgate/ exists for saveProjectConfig. - // Non-fatal — detection may fail if not in a git repo. - const existingConfig = loadProjectConfig(projectRoot); - if (!existingConfig.baseBranch) { - try { - const detectedBase = await detectBaseRef(projectRoot); - if (detectedBase) { - await saveProjectConfig(projectRoot, { - ...existingConfig, - baseBranch: detectedBase, - }); - logDebug("Auto-detected base branch:", detectedBase); - } - } catch { - logDebug("Base branch detection failed during init (not a git repo?)"); - } - } + await ensureBaseBranch(projectRoot, detectBaseRef); // Plugin installation (optional — requires stored credentials) let plugin: PluginResult | undefined; diff --git a/src/helpers/project-config.ts b/src/helpers/project-config.ts index 20cdab9c..5abe3f54 100644 --- a/src/helpers/project-config.ts +++ b/src/helpers/project-config.ts @@ -116,6 +116,33 @@ export function getConfiguredBaseBranch(projectRoot: string): string | null { return config.baseBranch ?? null; } +/** + * Detect the base branch and save it to `.archgate/config.json` when not + * already configured. Idempotent — skips if `baseBranch` is already set. + * Non-fatal — silently logs on failure (not a git repo, read-only fs, etc.). + * + * Used by both `archgate init` (eager) and `resolveBaseRef` (lazy on first check). + */ +export async function ensureBaseBranch( + projectRoot: string, + detectBaseRef: (root: string) => Promise +): Promise { + const config = loadProjectConfig(projectRoot); + if (config.baseBranch) return config.baseBranch; + + try { + const detected = await detectBaseRef(projectRoot); + if (detected) { + await saveProjectConfig(projectRoot, { ...config, baseBranch: detected }); + logDebug("Saved detected base branch to config:", detected); + } + return detected; + } catch { + logDebug("Base branch detection failed (not a git repo?)"); + return null; + } +} + export function isDefaultDomain(domain: string): boolean { return (DEFAULT_DOMAINS as readonly string[]).includes(domain); } From bd5b509ff4d5d5298e48977ee679f580f043c871 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Mon, 25 May 2026 15:11:39 +0200 Subject: [PATCH 6/6] fix(test): use safeRmSync in context.test.ts to fix Windows EBUSY flake The buildReviewContext tests used bare rmSync for temp dir cleanup, which fails intermittently on Windows when git processes haven't fully released file locks. Switch to safeRmSync (retry with backoff), matching the pattern already used in git-files.test.ts and init-project.test.ts. Signed-off-by: Rhuan Barreto --- tests/engine/context.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/engine/context.test.ts b/tests/engine/context.test.ts index 568aea8b..5a006136 100644 --- a/tests/engine/context.test.ts +++ b/tests/engine/context.test.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Archgate import { describe, expect, test, beforeEach, afterEach } from "bun:test"; -import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from "node:fs"; +import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -12,6 +12,7 @@ import { buildReviewContext, } from "../../src/engine/context"; import type { AdrDocument, AdrDomain } from "../../src/formats/adr"; +import { safeRmSync } from "../test-utils"; function makeAdr( overrides: Partial = {}, @@ -248,7 +249,7 @@ describe("buildReviewContext", () => { }); afterEach(() => { - rmSync(tempDir, { recursive: true, force: true }); + safeRmSync(tempDir); }); function writeAdr(