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. 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. diff --git a/src/engine/git-files.ts b/src/engine/git-files.ts index ada143da..568ebf44 100644 --- a/src/engine/git-files.ts +++ b/src/engine/git-files.ts @@ -3,6 +3,7 @@ /** Git file-listing utilities for ADR scope resolution and change detection. */ import { logDebug, logWarn } from "../helpers/log"; +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; @@ -151,11 +152,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), @@ -250,7 +250,8 @@ export async function resolveBaseRef( return options.configBase; } - return (await detectBaseRef(projectRoot)) ?? undefined; + // Lazy-save: detect + persist to config.json so future runs skip detection. + return (await ensureBaseBranch(projectRoot, detectBaseRef)) ?? undefined; } /** 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..19a77c77 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 { ensureBaseBranch } from "./project-config"; import { writeRulesShim } from "./rules-shim"; import { configureVscodeSettings } from "./vscode-settings"; @@ -122,6 +124,10 @@ 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. + await ensureBaseBranch(projectRoot, detectBaseRef); + // Plugin installation (optional — requires stored credentials) let plugin: PluginResult | undefined; if (options?.installPlugin) { 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); } 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( diff --git a/tests/engine/git-files.test.ts b/tests/engine/git-files.test.ts index 11fa30a6..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"; @@ -10,6 +10,7 @@ import { getStagedFiles, getChangedFiles, detectBaseRef, + resolveBaseRef, getFilesChangedSinceRef, resolveScopedFiles, SCOPE_FILE_WARN_THRESHOLD, @@ -111,6 +112,90 @@ 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); + + 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", () => { 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 () => {