From 23384844df8cf1a2dfe8cf67af383f732117611f Mon Sep 17 00:00:00 2001 From: Sophio Japharidze Date: Thu, 2 Jul 2026 17:51:57 +0200 Subject: [PATCH 1/2] CLI-715 better support for git worktrees during integration --- CLAUDE.md | 37 +++++- src/cli/commands/analyze/sqaa-auth.ts | 14 +- src/cli/commands/context/index.ts | 19 ++- .../_common/agent-integrate-postlude.ts | 3 +- .../integrate/_common/context-augmentation.ts | 13 +- .../commands/integrate/antigravity/index.ts | 23 ++-- src/cli/commands/integrate/claude/index.ts | 3 +- src/lib/project-workspace/git-worktree.ts | 125 ++++++++++++++++++ .../specs/analyze/analyze-sqaa.test.ts | 38 +++++- .../specs/context/passthrough.test.ts | 71 ++++++++++ .../integrate/context-augmentation.test.ts | 78 +++++++++++ .../integrate/claude/integrate.test.ts | 2 + 12 files changed, 401 insertions(+), 25 deletions(-) create mode 100644 src/lib/project-workspace/git-worktree.ts diff --git a/CLAUDE.md b/CLAUDE.md index 9c0c5c314..dd4ee84b4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -72,11 +72,11 @@ SonarQube Agentic Analysis (SQAA) is delivered per agent: - **Codex** uses a `PostToolUse` hook on `apply_patch` running `sonar hook codex-post-tool-use --project ` for change-set DEEP analysis after each patch (`codex-post-tool-use.ts`). - **Copilot, Cursor, and Antigravity use instructions/rules only** — Cursor's `afterFileEdit` hook is fire-and-forget (it cannot return `additionalContext` to the conversation), so a post-edit hook would be useless for SQAA. All three write the shared `buildSqaaSectionBody(projectKey)` template (`integrate/_common/instructions-templates.ts`) telling the agent to run `sonar analyze agentic --project ` on modified files at end-of-turn. Cursor's `sqaa-instructions` feature (`integrate/cursor/declaration.ts`) wraps it in `.cursor/rules/sonar-agentic-analysis.mdc` with `alwaysApply: true` YAML front-matter; Copilot writes `.github/instructions/sonarqube.instructions.md`; **Antigravity** writes `.agents/rules/sonar-agentic-analysis.md` with `trigger: always_on` front-matter per [Antigravity Rules](https://antigravity.google/docs/rules-workflows). Antigravity prompt-secrets uses workspace rules (`.agents/rules/sonar-prompt-secrets.md`) and global rules (`~/.gemini/GEMINI.md` snippet) as separate integrate features. -SQAA is **project-scoped and opt-in**: integrate orchestrators call `resolveSqaaSetup()` and only install when the org is entitled and a project key is known; `resolveSqaaSetup` owns the user-facing promotion / `--global` skip messaging. Claude/Cursor/Copilot/Antigravity removers key on the `# SonarQube Agentic Analysis protocol` managed marker so teardown only deletes content the CLI wrote. +SQAA is **project-scoped and opt-in**: integrate orchestrators call `resolveSqaaSetup()` and only install when the org is entitled and a project key is known; `resolveSqaaSetup` owns the user-facing promotion / `--global` skip messaging. Claude/Cursor/Copilot/Antigravity removers key on the `# SonarQube Agentic Analysis protocol` managed marker so teardown only deletes content the CLI wrote. SQAA project-key resolution is git-worktree-aware end to end: integrate records the `sonar-sqaa` extension's `projectRoot` as the repository's **main working tree** (`resolveMainWorktreeRoot`), and at runtime `resolveSqaaProjectKey` (`analyze/sqaa-auth.ts`) looks up the `agentExtensions` registry trying both the current repo root and its main-working-tree equivalent (`resolveWorktreeEquivalentPaths`). So `sonar analyze agentic` resolves the key from any worktree regardless of which worktree integrate ran in. (`projectRoot` here is an identity key only — SQAA teardown does not use it.) ### Context Augmentation -`sonar context [action] [args...]` is a passthrough to the locally-installed `sonar-context-augmentation` binary (CAG). It forwards args verbatim, propagates the child exit code, and injects context through `SONAR_CONTEXT_ORGANIZATION`, `SONAR_CONTEXT_PROJECT`, `SONAR_CONTEXT_TOKEN`, and `SONAR_CONTEXT_URL` env vars. Every CAG spawn (including the `--help` path and the integrate/post-update flows) also carries `SONAR_CONTEXT_INVOCATION_ID` — the per-CLI-process correlation id from `src/lib/invocation-id.ts`, shared with telemetry's `invocation_id` and forwarded by CAG to its daemon as the `x-sonar-invocation-id` header. The passthrough resolves project context from the recorded declarative CAG feature state for the current project rather than running full project auto-discovery. Implementation in `src/cli/commands/context/`. The binary is downloaded by `sonar integrate claude` / `sonar integrate copilot` / `sonar integrate codex` / `sonar integrate antigravity` / `sonar integrate cursor` (skip with `--skip-context`); `sonar context` itself never auto-installs and emits a clear "not installed" error pointing the user back to integrate. `--global` integrations also skip CAG setup; install it by re-running `sonar integrate ` from a project directory. The `--global` skip notice (`Skipping Context Augmentation: not supported with --global. Re-run without --global from a project directory to install it there.`) is only emitted when the org is actually entitled to CAG — unentitled orgs skip silently. The CLI owns the agent skill file declaratively as a `wholeFile` resource inside a single CAG feature and renders it by calling `sonar-context-augmentation tool print-skill --invocation-prefix "sonar context" --sca-enabled=`; the rendered file is written to `.claude/skills/sonar-context-augmentation/SKILL.md`, `.github/skills/sonar-context-augmentation/SKILL.md`, or `.agents/skills/sonar-context-augmentation/SKILL.md` depending on the agent. **Cursor** also writes to `.agents/skills/sonar-context-augmentation/SKILL.md` — the shared cross-tool skills directory it reads alongside Codex and Antigravity — rather than a Cursor-private `.cursor/skills` copy, so the three tools share one skill instead of duplicating it. Cursor loads skills on demand (distinct from Cursor's SQAA delivery, which is an always-applied `.cursor/rules/*.mdc` rule because that protocol must run every turn). That same feature also declares a `tool integrate --invocation-prefix "sonar context"` operation, but the operation is install-only: the framework passes `executionMode=install|update` into feature contexts, and CAG uses `shouldApply` so `tool integrate` runs during `sonar integrate ` but not during post-update refreshes. Post-update still reinstalls the shared dependency, best-effort runs `sonar-context-augmentation tool stop --all` against the previously-installed binary before replacing it, and refreshes the skill file. Replay failures are debug-logged so they do not abort CLI startup. +`sonar context [action] [args...]` is a passthrough to the locally-installed `sonar-context-augmentation` binary (CAG). It forwards args verbatim, propagates the child exit code, and injects context through `SONAR_CONTEXT_ORGANIZATION`, `SONAR_CONTEXT_PROJECT`, `SONAR_CONTEXT_TOKEN`, and `SONAR_CONTEXT_URL` env vars. Every CAG spawn (including the `--help` path and the integrate/post-update flows) also carries `SONAR_CONTEXT_INVOCATION_ID` — the per-CLI-process correlation id from `src/lib/invocation-id.ts`, shared with telemetry's `invocation_id` and forwarded by CAG to its daemon as the `x-sonar-invocation-id` header. The passthrough resolves project context from the recorded declarative CAG feature state for the current project rather than running full project auto-discovery. This lookup is git-worktree-aware so you can `sonar integrate` once and use `sonar context` from any worktree (including ones created later, and after the integrate-time worktree is removed): integrate records a stable `attrs.repoRoot` = the repository's **main working tree** (resolved via `resolveMainWorktreeRoot` in `src/lib/project-workspace/git-worktree.ts`, which reads `git worktree list --porcelain`) while keeping `targetRoot` = the physical install dir for teardown; the passthrough matches the current directory (mapped to its main-worktree equivalent via `resolveWorktreeEquivalentPaths`) against `attrs.repoRoot ?? targetRoot`, falling back to `targetRoot` for state written by older CLI versions. Implementation in `src/cli/commands/context/`. The binary is downloaded by `sonar integrate claude` / `sonar integrate copilot` / `sonar integrate codex` / `sonar integrate antigravity` / `sonar integrate cursor` (skip with `--skip-context`); `sonar context` itself never auto-installs and emits a clear "not installed" error pointing the user back to integrate. `--global` integrations also skip CAG setup; install it by re-running `sonar integrate ` from a project directory. The `--global` skip notice (`Skipping Context Augmentation: not supported with --global. Re-run without --global from a project directory to install it there.`) is only emitted when the org is actually entitled to CAG — unentitled orgs skip silently. The CLI owns the agent skill file declaratively as a `wholeFile` resource inside a single CAG feature and renders it by calling `sonar-context-augmentation tool print-skill --invocation-prefix "sonar context" --sca-enabled=`; the rendered file is written to `.claude/skills/sonar-context-augmentation/SKILL.md`, `.github/skills/sonar-context-augmentation/SKILL.md`, or `.agents/skills/sonar-context-augmentation/SKILL.md` depending on the agent. **Cursor** also writes to `.agents/skills/sonar-context-augmentation/SKILL.md` — the shared cross-tool skills directory it reads alongside Codex and Antigravity — rather than a Cursor-private `.cursor/skills` copy, so the three tools share one skill instead of duplicating it. Cursor loads skills on demand (distinct from Cursor's SQAA delivery, which is an always-applied `.cursor/rules/*.mdc` rule because that protocol must run every turn). That same feature also declares a `tool integrate --invocation-prefix "sonar context"` operation, but the operation is install-only: the framework passes `executionMode=install|update` into feature contexts, and CAG uses `shouldApply` so `tool integrate` runs during `sonar integrate ` but not during post-update refreshes. Post-update still reinstalls the shared dependency, best-effort runs `sonar-context-augmentation tool stop --all` against the previously-installed binary before replacing it, and refreshes the skill file. Replay failures are debug-logged so they do not abort CLI startup. `--help`, `-h`, and bare `sonar context` (no action) are forwarded to CAG. @@ -176,3 +176,36 @@ When adding, removing, or changing commands, scripts, or project structure, upda The docs site is generated from the CLI source — do not edit `commands.json`, `llms.txt`, or `sitemap.xml` by hand. This is done by automation post-release. The docs generator also stamps the current package version into the nav badge in `index.html` and `commands.html`, plus the JSON-LD `softwareVersion` and `meta-version` in `index.html`. Keep a fixed `` in the docs HTML so production resolves relative assets and internal links under the public mount point. This is intentionally production-oriented: Netlify deploy previews served from `/` will not work with that fixed base. + + + +# SonarQube Agentic Analysis protocol + +SonarQube Agentic Analysis is the final confirmation layer at the end of every turn in which you wrote to one or more files in the workspace (create, edit, patch, format — any tool call that changed file contents on disk). + +**Trigger:** the moment before you send your final reply for the turn. + +When the trigger fires, you **MUST** run SonarQube Agentic Analysis on the full set of files you modified this turn — **one command, not a per-file loop**. + +**Preferred:** pass every path you modified this turn in a single invocation (repeat `--file` for each path). Use paths **relative to the project root** (e.g. `src/foo.ts`). For end-of-turn analysis, **always** pass `--depth DEEP` — even when you changed only one file — so cross-file findings are included. + +```bash +sonar analyze agentic --project SonarSource_sonarqube-cli --depth DEEP --file --file +``` + +**Fallback:** when you cannot reliably list every modified path (e.g. you did not track them, or git state is the only source of truth), use the git change set with DEEP analysis: + +```bash +sonar analyze agentic --project SonarSource_sonarqube-cli --depth DEEP +``` + +Per-edit hooks run faster STANDARD analysis. End-of-turn analysis must always use `--depth DEEP` (including a single `--file`). Use `--depth STANDARD` only for optional faster passes — never for the mandatory end-of-turn run. + +Non-negotiable rules: + +1. Mid-turn edits do **not** need SonarQube Agentic Analysis — only the final on-disk state matters. Skipping SonarQube Agentic Analysis at end-of-turn is a protocol violation. +2. Run SonarQube Agentic Analysis **before** sending your final reply, marking the task done, or handing control back to the user. +3. If SonarQube Agentic Analysis reports issues on lines you touched in this turn, fix them, then re-run SonarQube Agentic Analysis on the same scope (change set or explicit file list). Repeat until clean (or only pre-existing findings on lines you did not touch remain). Pre-existing findings on untouched lines are out of scope — do not "fix" them unless the user asked. +4. If SonarQube Agentic Analysis is skipped (no SonarQube Cloud connection, or no project configured), state the skip reason to the user once and continue — do not retry. +5. Do not suppress, summarize away, or omit SonarQube Agentic Analysis findings from your reply. Surface them verbatim. + diff --git a/src/cli/commands/analyze/sqaa-auth.ts b/src/cli/commands/analyze/sqaa-auth.ts index 0bc0fd54c..1b711802a 100644 --- a/src/cli/commands/analyze/sqaa-auth.ts +++ b/src/cli/commands/analyze/sqaa-auth.ts @@ -25,6 +25,7 @@ import { resolve } from 'node:path'; import type { ResolvedAuth } from '../../../lib/auth-resolver'; import logger from '../../../lib/logger'; import { spawnProcess } from '../../../lib/process'; +import { resolveWorktreeEquivalentPaths } from '../../../lib/project-workspace/git-worktree'; import { loadState } from '../../../lib/repository/state-repository'; import { canonicalProjectRoot } from '../../../lib/state-manager'; import { blank, confirmPrompt, text, warn } from '../../../ui'; @@ -114,24 +115,31 @@ export function resolveCloudAuth( * passed to `sonar integrate claude`), so when the user runs SQAA from a * subdirectory we resolve the git repository top-level first — otherwise * `process.cwd()` is a non-match against the recorded root and we incorrectly - * skip with "no project configured". + * skip with "no project configured". We also try the root's equivalent in the + * repository's main working tree, so a linked worktree still resolves state + * recorded in the main checkout. * * Falls back to `process.cwd()` when not inside a git repository so the * single-file path still works outside git. */ export async function resolveSqaaProjectKey(projectRoot?: string): Promise { try { - const root = canonicalProjectRoot(projectRoot ?? (await tryResolveRepoRoot(process.cwd()))); + const root = projectRoot ?? (await tryResolveRepoRoot(process.cwd())); const state = loadState(); const claude = state.integrations.installed.find( (integration) => integration.integrationId === CLAUDE_INTEGRATION_ID, ); + // Try the repo root, then its equivalent in the main working tree, so a + // linked worktree still resolves state recorded in the main checkout. + const candidates = new Set( + (await resolveWorktreeEquivalentPaths(root)).map(canonicalProjectRoot), + ); const sqaaFeature = claude?.features.find( (feature) => feature.featureId === SQAA_HOOK_FEATURE_ID && feature.scope === 'project' && - canonicalProjectRoot(feature.targetRoot) === root, + candidates.has(canonicalProjectRoot(feature.targetRoot)), ); const projectKey = sqaaFeature?.attrs?.projectKey; diff --git a/src/cli/commands/context/index.ts b/src/cli/commands/context/index.ts index ce5553ff9..855a29694 100644 --- a/src/cli/commands/context/index.ts +++ b/src/cli/commands/context/index.ts @@ -26,6 +26,7 @@ import { resolveAuth, type ResolvedAuth } from '../../../lib/auth-resolver'; import { SONAR_CONTEXT_INVOCATION } from '../../../lib/config-constants'; import { getToken } from '../../../lib/keychain'; import logger from '../../../lib/logger'; +import { resolveWorktreeEquivalentPaths } from '../../../lib/project-workspace/git-worktree'; import type { InstalledIntegrationFeature, IntegrationStateAttribute } from '../../../lib/state'; import { loadState } from '../../../lib/state-manager'; import { buildContextAugmentationEnv } from '../_common/context-augmentation-env'; @@ -113,17 +114,25 @@ function getOptionalStringAttr( return typeof value === 'string' && value.length > 0 ? value : undefined; } -function resolveRecordedContextAugmentationConfig(cwd: string): RecordedContextAugmentationConfig { +async function resolveRecordedContextAugmentationConfig( + cwd: string, +): Promise { try { - const current = canonicalPath(cwd); + // Consult cwd, then its equivalent in the main working tree, so a linked + // worktree still matches state recorded in the main checkout. + const candidates = (await resolveWorktreeEquivalentPaths(cwd)).map(canonicalPath); const matches = loadState() .integrations.installed.flatMap((integration) => integration.features.filter(isProjectContextAugmentationFeature).map((feature) => ({ feature, - projectRoot: canonicalPath(feature.targetRoot), + // Prefer the stable main-working-tree key recorded at integrate time; + // fall back to targetRoot for state written by older CLI versions. + projectRoot: canonicalPath( + getOptionalStringAttr(feature.attrs, 'repoRoot') ?? feature.targetRoot, + ), })), ) - .filter(({ projectRoot }) => isPathInside(projectRoot, current)) + .filter(({ projectRoot }) => candidates.some((path) => isPathInside(projectRoot, path))) .sort( (a, b) => b.projectRoot.length - a.projectRoot.length || @@ -192,7 +201,7 @@ export async function runContextPassthrough( remediationHint: 'Run: sonar auth login', }); } - const recordedConfig = resolveRecordedContextAugmentationConfig(process.cwd()); + const recordedConfig = await resolveRecordedContextAugmentationConfig(process.cwd()); const serverUrl = recordedConfig.serverUrl ?? auth.serverUrl; const organization = recordedConfig.organization ?? auth.orgKey; env = buildContextAugmentationEnv({ diff --git a/src/cli/commands/integrate/_common/agent-integrate-postlude.ts b/src/cli/commands/integrate/_common/agent-integrate-postlude.ts index e2c47bb2c..2f9df9cd5 100644 --- a/src/cli/commands/integrate/_common/agent-integrate-postlude.ts +++ b/src/cli/commands/integrate/_common/agent-integrate-postlude.ts @@ -74,10 +74,11 @@ export async function finalizeAgentInstall = { projectKey: context.projectKey ?? null, ...(contextAugmentation - ? buildContextAugmentationAttrs( + ? await buildContextAugmentationAttrs( context.serverUrl, context.organization, contextAugmentation.scaEnabled, + context.project.rootDir, ) : {}), }; diff --git a/src/cli/commands/integrate/_common/context-augmentation.ts b/src/cli/commands/integrate/_common/context-augmentation.ts index b6fe93e6b..221b67d05 100644 --- a/src/cli/commands/integrate/_common/context-augmentation.ts +++ b/src/cli/commands/integrate/_common/context-augmentation.ts @@ -24,6 +24,7 @@ import type { ResolvedAuth } from '../../../../lib/auth-resolver'; import { isSonarQubeCloud } from '../../../../lib/auth-resolver'; import { SONAR_CONTEXT_INVOCATION } from '../../../../lib/config-constants'; import logger from '../../../../lib/logger'; +import { resolveMainWorktreeRoot } from '../../../../lib/project-workspace/git-worktree'; import { SONAR_CONTEXT_AUGMENTATION_VERSION } from '../../../../lib/signatures'; import type { IntegrationStateAttribute } from '../../../../lib/state'; import { SonarQubeClient } from '../../../../sonarqube/client'; @@ -48,15 +49,23 @@ export interface ResolvedContextAugmentationSetup { scaEnabled: boolean; } -export function buildContextAugmentationAttrs( +/** + * Build the persisted Context Augmentation attrs. `repoRoot` records the + * repository's main working tree (resolved from `projectRoot`) so `sonar context` + * can match the recorded connection from any linked worktree — including ones + * created after integrate ran. Falls back to `projectRoot` outside a git repo. + */ +export async function buildContextAugmentationAttrs( serverUrl: string, orgKey: string | undefined, scaEnabled: boolean, -): Record { + projectRoot: string, +): Promise> { return { orgKey: orgKey ?? null, scaEnabled, serverUrl, + repoRoot: (await resolveMainWorktreeRoot(projectRoot)) ?? projectRoot, }; } diff --git a/src/cli/commands/integrate/antigravity/index.ts b/src/cli/commands/integrate/antigravity/index.ts index 2622d4df5..04572a8c6 100644 --- a/src/cli/commands/integrate/antigravity/index.ts +++ b/src/cli/commands/integrate/antigravity/index.ts @@ -75,6 +75,18 @@ export async function integrateAntigravity( installContextAugmentation: contextAugmentation !== null, }; + const attrs = { + ...buildIntegrationAttrs(ctx), + ...(contextAugmentation + ? await buildContextAugmentationAttrs( + ctx.serverUrl, + ctx.organization, + contextAugmentation.scaEnabled, + ctx.project.rootDir, + ) + : {}), + }; + await installIntegration({ registry: supportedIntegrations, integrationId: ANTIGRAVITY_INTEGRATION_ID, @@ -84,16 +96,7 @@ export async function integrateAntigravity( auth, nonInteractive: options.nonInteractive, isFromRouter: options.isFromRouter, - attrs: { - ...buildIntegrationAttrs(ctx), - ...(contextAugmentation - ? buildContextAugmentationAttrs( - ctx.serverUrl, - ctx.organization, - contextAugmentation.scaEnabled, - ) - : {}), - }, + attrs, }); } diff --git a/src/cli/commands/integrate/claude/index.ts b/src/cli/commands/integrate/claude/index.ts index 97f7282b7..049e139c9 100644 --- a/src/cli/commands/integrate/claude/index.ts +++ b/src/cli/commands/integrate/claude/index.ts @@ -81,10 +81,11 @@ export async function integrateClaude( const featureAttrs = { ...buildIntegrationAttrs(config), ...(contextAugmentation - ? buildContextAugmentationAttrs( + ? await buildContextAugmentationAttrs( config.serverURL, config.organization, contextAugmentation.scaEnabled, + ctx.project.rootDir, ) : {}), }; diff --git a/src/lib/project-workspace/git-worktree.ts b/src/lib/project-workspace/git-worktree.ts new file mode 100644 index 000000000..1bc23c615 --- /dev/null +++ b/src/lib/project-workspace/git-worktree.ts @@ -0,0 +1,125 @@ +/* + * SonarQube CLI + * Copyright (C) SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +// Git worktree awareness: map a path inside a linked worktree back to the +// equivalent path in the repository's main working tree, so per-project state +// recorded by `sonar integrate` in the main checkout can still be found after a +// worktree is created (e.g. for SQAA project-key and CAG context lookups). + +import { realpathSync } from 'node:fs'; +import { isAbsolute, join, relative, resolve } from 'node:path'; + +import { spawnProcess } from '../process'; + +/** + * Resolve a path to its real on-disk form. On Windows this normalizes 8.3 short + * names (e.g. `RUNNER~1`) and drive-letter casing to the canonical long form, so + * paths coming from different sources (git output vs `process.cwd()`) compare + * equal. Falls back to `resolve()` when the path does not exist on disk. + */ +function canonicalize(path: string): string { + try { + return realpathSync.native(path); + } catch { + return resolve(path); + } +} + +interface WorktreeMapping { + /** git top-level of the input path (the linked worktree root when inside one). */ + currentRoot: string; + /** Main working tree root; differs from currentRoot only inside a linked worktree. */ + mainRoot: string; +} + +async function runGitStdout(args: string[], cwd: string): Promise { + try { + const result = await spawnProcess('git', args, { cwd }); + if (result.exitCode === 0) { + return result.stdout; + } + } catch { + // git not installed, or `cwd` is not inside a repository — caller falls back. + } + return null; +} + +/** + * Resolve the absolute path of the repository's main working tree for the given + * directory. `git worktree list` always reports the main working tree as its + * first entry, which is the stable checkout where `sonar integrate` should key + * per-project state (it outlives any linked worktree). Returns null when git is + * unavailable or the directory is not inside a repository. + */ +export async function resolveMainWorktreeRoot(dir: string): Promise { + const listing = await runGitStdout(['worktree', 'list', '--porcelain'], dir); + const firstEntry = listing + ?.split('\n') + .find((line) => line.startsWith('worktree ')) + ?.slice('worktree '.length) + .trim(); + return firstEntry ? resolve(firstEntry) : null; +} + +/** + * Resolve the current git top-level and the repository's main working tree root + * for the given directory. Both roots are canonicalized to their real on-disk + * form so all downstream comparisons (the currentRoot/mainRoot equality guard, + * `relative`, and the mapped path) operate on consistent forms. Returns null + * when git is unavailable or the directory is not inside a repository; when not + * inside a linked worktree, currentRoot equals mainRoot. + */ +async function resolveWorktreeMapping(dir: string): Promise { + const topLevel = await runGitStdout(['rev-parse', '--show-toplevel'], dir); + if (topLevel === null) { + return null; + } + const currentRoot = canonicalize(topLevel.trim()); + const mainRoot = canonicalize((await resolveMainWorktreeRoot(dir)) ?? currentRoot); + return { currentRoot, mainRoot }; +} + +/** + * Given a filesystem `path` inside a git working tree, return the de-duplicated + * list of equivalent paths to consult for per-project state lookups: `path` + * itself, plus its equivalent inside the repository's main working tree when + * `path` is inside a linked worktree. Order is [current, main]. + * + * Falls back to `[path]` when git is unavailable, `path` is not inside a linked + * worktree, or `path` is outside the current worktree root. + */ +export async function resolveWorktreeEquivalentPaths(path: string): Promise { + const mapping = await resolveWorktreeMapping(path); + if (!mapping || mapping.currentRoot === mapping.mainRoot) { + return [path]; + } + + // `currentRoot` is already canonical; canonicalize `path` too (it usually comes + // from `process.cwd()`) so the in-worktree offset is computed between comparable + // forms — on Windows the two can otherwise differ (8.3 short vs long path), + // making a raw `relative` spuriously start with `..`. + const rel = relative(mapping.currentRoot, canonicalize(path)); + if (rel.startsWith('..') || isAbsolute(rel)) { + return [path]; + } + + const mapped = rel === '' ? mapping.mainRoot : join(mapping.mainRoot, rel); + return mapped === path ? [path] : [path, mapped]; +} diff --git a/tests/integration/specs/analyze/analyze-sqaa.test.ts b/tests/integration/specs/analyze/analyze-sqaa.test.ts index 99461a47d..63bc3d91c 100644 --- a/tests/integration/specs/analyze/analyze-sqaa.test.ts +++ b/tests/integration/specs/analyze/analyze-sqaa.test.ts @@ -21,7 +21,7 @@ // Integration tests for `analyze agentic` and `verify` commands. import { existsSync, readFileSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; @@ -692,6 +692,42 @@ describe('analyze agentic', () => { { timeout: 15000 }, ); + it( + 'resolves the project key from a linked git worktree using the main checkout registry entry', + async () => { + const server = await harness + .newFakeServer() + .withAuthToken(VALID_TOKEN) + .withSqaaResponse({ issues: [] }) + .start(); + + // Register the SQAA extension against the main checkout, as `sonar integrate` would. + harness + .state() + .withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG) + .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); + + // Make the main checkout a git repo and add a linked worktree beside it. + initGitRepo(harness.cwd.path); + commitFile(harness.cwd.path, 'README.md', '# test\n'); + const worktreePath = join(dirname(harness.cwd.path), 'linked-worktree'); + git(['worktree', 'add', worktreePath, '-b', 'feature/x'], harness.cwd.path); + writeFileSync(join(worktreePath, 'index.ts'), 'const x = 1;'); + + // Run from the worktree, whose repo root never matches the registered projectRoot. + const result = await harness.run('analyze agentic --file index.ts', { cwd: worktreePath }); + + expect(result.exitCode).toBe(0); + expect(result.stdout + result.stderr).toContain('No issues found'); + const sqaaCalls = server + .getRecordedRequests() + .filter((r) => r.path === '/a3s-analysis/analyses'); + expect(sqaaCalls).toHaveLength(1); + expect(parseSqaaRequestBody(sqaaCalls[0].body).projectKey).toBe(TEST_PROJECT); + }, + { timeout: 15000 }, + ); + it( 'sends one multi-file DEEP request when multiple --file paths are given', async () => { diff --git a/tests/integration/specs/context/passthrough.test.ts b/tests/integration/specs/context/passthrough.test.ts index 566aea070..170a99e22 100644 --- a/tests/integration/specs/context/passthrough.test.ts +++ b/tests/integration/specs/context/passthrough.test.ts @@ -21,6 +21,8 @@ // Integration tests for `sonar context ` — the passthrough wrapper to // the locally-installed sonar-context-augmentation binary. +import { dirname, join } from 'node:path'; + import { afterEach, beforeEach, describe, expect, it, setDefaultTimeout } from 'bun:test'; import { CONTEXT_AUGMENTATION_FEATURE_ID } from '../../../../src/cli/commands/integrate/_common/features/context-augmentation-feature'; @@ -32,6 +34,7 @@ import { type CagInvocation, readCagInvocations as readInvocations, } from '../../harness/cag-invocations'; +import { commitFile, git, initGitRepo } from '../hook/git-test-helpers'; // CAG stub spawn + temp-dir teardown on Windows can exceed Bun's default hook timeout. setDefaultTimeout(30_000); @@ -55,6 +58,7 @@ function appendRecordedCagFeature( projectKey: string; orgKey: string; serverUrl: string; + repoRoot?: string; }, ): void { let integration = state.integrations.installed.find( @@ -89,6 +93,7 @@ function appendRecordedCagFeature( projectKey: args.projectKey, scaEnabled: false, serverUrl: args.serverUrl, + ...(args.repoRoot ? { repoRoot: args.repoRoot } : {}), }, }; integration.features.push(feature); @@ -169,6 +174,72 @@ describe('sonar context passthrough', () => { { timeout: 30000 }, ); + it( + 'resolves the recorded project CAG connection from inside a linked git worktree', + async () => { + const server = await harness.newFakeServer().start(); + const serverUrl = server.baseUrl(); + harness.withAuth(serverUrl, 'project-token', ORG_KEY); + // Record the CAG feature against the main checkout, as `sonar integrate` would. + harness + .state() + .withContextAugmentationBinaryInstalled() + .withContextAugmentationSkill(harness.cwd.path, PROJECT_KEY, ORG_KEY, serverUrl); + + // Make the main checkout a git repo and add a linked worktree beside it. + initGitRepo(harness.cwd.path); + commitFile(harness.cwd.path, 'README.md', '# test\n'); + const worktreePath = join(dirname(harness.cwd.path), 'linked-worktree'); + git(['worktree', 'add', worktreePath, '-b', 'feature/x'], harness.cwd.path); + + // Run from the worktree, whose path never matches the recorded targetRoot. + const result = await harness.run('context status', { cwd: worktreePath }); + + expect(result.exitCode).toBe(0); + const invocation = findInvocation(readInvocations(harness), ['status']); + expect(invocation.env.SONAR_CONTEXT_PROJECT).toBe(PROJECT_KEY); + expect(invocation.env.SONAR_CONTEXT_ORGANIZATION).toBe(ORG_KEY); + expect(invocation.env.SONAR_CONTEXT_URL).toBe(serverUrl); + expect(invocation.env.SONAR_CONTEXT_TOKEN).toBe('project-token'); + }, + { timeout: 30000 }, + ); + + it( + 'matches on the recorded repoRoot when targetRoot points at a different worktree', + async () => { + // Simulates integrate having run inside worktree-A (targetRoot = that worktree), + // while repoRoot records the stable main working tree. Running from the main + // tree must still resolve via repoRoot even though cwd is not inside targetRoot. + const server = await harness.newFakeServer().start(); + const serverUrl = server.baseUrl(); + const stateBuilder = harness + .state() + .withAuth(serverUrl, 'main-token', ORG_KEY) + .withContextAugmentationBinaryInstalled(); + const state = stateBuilder.build(); + appendRecordedCagFeature(state, { + integrationId: CLAUDE_INTEGRATION_ID, + targetRoot: join(dirname(harness.cwd.path), 'some-other-worktree'), + repoRoot: harness.cwd.path, + updatedAt: '2026-03-01T00:00:00.000Z', + projectKey: PROJECT_KEY, + orgKey: ORG_KEY, + serverUrl, + }); + stateBuilder.withRawState(JSON.stringify(state, null, 2)); + + const result = await harness.run('context status'); + + expect(result.exitCode).toBe(0); + const invocation = findInvocation(readInvocations(harness), ['status']); + expect(invocation.env.SONAR_CONTEXT_PROJECT).toBe(PROJECT_KEY); + expect(invocation.env.SONAR_CONTEXT_ORGANIZATION).toBe(ORG_KEY); + expect(invocation.env.SONAR_CONTEXT_URL).toBe(serverUrl); + }, + { timeout: 30000 }, + ); + it( 'uses the latest recorded CAG feature when multiple entries share the same project root', async () => { diff --git a/tests/integration/specs/integrate/context-augmentation.test.ts b/tests/integration/specs/integrate/context-augmentation.test.ts index 61c0b6ebe..867b0fe89 100644 --- a/tests/integration/specs/integrate/context-augmentation.test.ts +++ b/tests/integration/specs/integrate/context-augmentation.test.ts @@ -21,6 +21,9 @@ // Integration tests for the Context Augmentation step inside `sonar integrate // claude`, `sonar integrate copilot`, and `sonar integrate codex`. +import { realpathSync, writeFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; + import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; import { buildLocalCagBinaryName } from '../../../../src/cli/commands/_common/install/context-augmentation.js'; @@ -36,6 +39,23 @@ import { type CagInvocation, readCagInvocations as readInvocations, } from '../../harness/cag-invocations'; +import { commitFile, git, initGitRepo } from '../hook/git-test-helpers'; + +// Canonicalize a path to its real on-disk form for stable full-path assertions +// across 8.3 short vs long path forms. Uses realpathSync.native like production +// (git-worktree canonicalize); the extra lowercase on Windows is a test-only +// normalization — production does not lowercase, it relies on realpathSync.native +// returning canonical drive-letter casing. Lowercasing here is strictly more +// tolerant, so assertions never fail on casing alone. +function canonical(path: string): string { + let resolved: string; + try { + resolved = realpathSync.native(path); + } catch { + resolved = resolve(path); + } + return process.platform === 'win32' ? resolved.toLowerCase() : resolved; +} function findToolInvocation(invocations: CagInvocation[], subcommand: string): CagInvocation { const match = invocations.find((i) => i.argv[0] === 'tool' && i.argv[1] === subcommand); @@ -214,6 +234,64 @@ describe('integrate claude — Context Augmentation', () => { { timeout: 30000 }, ); + it( + 'keys CAG state on the main working tree when integrate runs inside a linked worktree', + async () => { + const server = await harness + .newFakeServer() + .withAuthToken(TOKEN) + .withProject(PROJECT_KEY) + .withCagEntitlement(ORG_KEY) + .withScaEnabled(true) + .start(); + const serverUrl = server.baseUrl(); + harness.withAuth(serverUrl, TOKEN, ORG_KEY); + harness.state().withContextAugmentationBinaryInstalled(); + + // Main checkout = harness.cwd; add a linked worktree beside it and run + // integrate from there. + initGitRepo(harness.cwd.path); + commitFile(harness.cwd.path, 'README.md', '# test\n'); + const worktreePath = join(dirname(harness.cwd.path), 'linked-worktree'); + git(['worktree', 'add', worktreePath, '-b', 'feature/x'], harness.cwd.path); + writeFileSync( + join(worktreePath, 'sonar-project.properties'), + [ + `sonar.host.url=${serverUrl}`, + `sonar.projectKey=${PROJECT_KEY}`, + `sonar.organization=${ORG_KEY}`, + ].join('\n'), + ); + + const integrateResult = await harness.run('integrate claude --non-interactive', { + cwd: worktreePath, + extraEnv: { + SONARQUBE_CLI_SONARCLOUD_URL: serverUrl, + SONARQUBE_CLI_SONARCLOUD_API_URL: serverUrl, + }, + }); + expect(integrateResult.exitCode).toBe(0); + + // targetRoot stays the physical worktree (so teardown deletes the files it + // wrote there); repoRoot records the stable main working tree, which is the + // key `sonar context` matches against from any worktree. (The read side is + // covered deterministically in the context passthrough spec — the harness + // re-applies its state builder on every run, so an integrate-then-context + // flow in one test cannot share state here.) + const entry = findRecordedCagFeature(loadState(harness), CLAUDE_INTEGRATION_ID); + expect(entry).toBeDefined(); + const targetRoot = entry?.feature.targetRoot ?? ''; + const repoRoot = entry?.feature.attrs?.repoRoot; + expect(typeof repoRoot).toBe('string'); + // Compare full canonical paths (not just basenames): targetRoot resolves to + // the physical worktree, repoRoot to the main working tree. + expect(canonical(targetRoot)).toBe(canonical(worktreePath)); + expect(canonical(repoRoot as string)).toBe(canonical(harness.cwd.path)); + expect(repoRoot).not.toBe(targetRoot); + }, + { timeout: 30000 }, + ); + it( 'passes --sca-enabled=false to print-skill and warns when SCA enablement check fails', async () => { diff --git a/tests/unit/cli/commands/integrate/claude/integrate.test.ts b/tests/unit/cli/commands/integrate/claude/integrate.test.ts index 7606406ff..fadf0a7fd 100644 --- a/tests/unit/cli/commands/integrate/claude/integrate.test.ts +++ b/tests/unit/cli/commands/integrate/claude/integrate.test.ts @@ -301,6 +301,8 @@ describe('integrateCommand', () => { projectKey: 'a-project', scaEnabled: true, serverUrl: 'https://sonarcloud.io', + // Not a git worktree in this unit test, so repoRoot falls back to the project root. + repoRoot: '/project/root', }, }), ); From 9cada2db6558540b39d7a18307799c08e9f60dfd Mon Sep 17 00:00:00 2001 From: Sophio Japharidze Date: Tue, 7 Jul 2026 13:13:36 +0200 Subject: [PATCH 2/2] CLI-715 set SONAR_WORKSPACE_DIR for CAG binary when available --- CLAUDE.md | 3 +- .../_common/context-augmentation-env.ts | 14 ++- src/cli/commands/analyze/sqaa-auth.ts | 44 ++------- src/cli/commands/context/index.ts | 9 ++ src/lib/project-workspace/git-worktree.ts | 92 +++++++++++++++---- tests/integration/harness/cag-invocations.ts | 1 + tests/integration/resources/cag-stub.ts | 1 + .../specs/analyze/analyze-sqaa.test.ts | 4 +- .../specs/context/passthrough.test.ts | 35 +++++++ .../_common/context-augmentation-env.test.ts | 4 + 10 files changed, 148 insertions(+), 59 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index dd4ee84b4..9823e169a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -76,7 +76,7 @@ SQAA is **project-scoped and opt-in**: integrate orchestrators call `resolveSqaa ### Context Augmentation -`sonar context [action] [args...]` is a passthrough to the locally-installed `sonar-context-augmentation` binary (CAG). It forwards args verbatim, propagates the child exit code, and injects context through `SONAR_CONTEXT_ORGANIZATION`, `SONAR_CONTEXT_PROJECT`, `SONAR_CONTEXT_TOKEN`, and `SONAR_CONTEXT_URL` env vars. Every CAG spawn (including the `--help` path and the integrate/post-update flows) also carries `SONAR_CONTEXT_INVOCATION_ID` — the per-CLI-process correlation id from `src/lib/invocation-id.ts`, shared with telemetry's `invocation_id` and forwarded by CAG to its daemon as the `x-sonar-invocation-id` header. The passthrough resolves project context from the recorded declarative CAG feature state for the current project rather than running full project auto-discovery. This lookup is git-worktree-aware so you can `sonar integrate` once and use `sonar context` from any worktree (including ones created later, and after the integrate-time worktree is removed): integrate records a stable `attrs.repoRoot` = the repository's **main working tree** (resolved via `resolveMainWorktreeRoot` in `src/lib/project-workspace/git-worktree.ts`, which reads `git worktree list --porcelain`) while keeping `targetRoot` = the physical install dir for teardown; the passthrough matches the current directory (mapped to its main-worktree equivalent via `resolveWorktreeEquivalentPaths`) against `attrs.repoRoot ?? targetRoot`, falling back to `targetRoot` for state written by older CLI versions. Implementation in `src/cli/commands/context/`. The binary is downloaded by `sonar integrate claude` / `sonar integrate copilot` / `sonar integrate codex` / `sonar integrate antigravity` / `sonar integrate cursor` (skip with `--skip-context`); `sonar context` itself never auto-installs and emits a clear "not installed" error pointing the user back to integrate. `--global` integrations also skip CAG setup; install it by re-running `sonar integrate ` from a project directory. The `--global` skip notice (`Skipping Context Augmentation: not supported with --global. Re-run without --global from a project directory to install it there.`) is only emitted when the org is actually entitled to CAG — unentitled orgs skip silently. The CLI owns the agent skill file declaratively as a `wholeFile` resource inside a single CAG feature and renders it by calling `sonar-context-augmentation tool print-skill --invocation-prefix "sonar context" --sca-enabled=`; the rendered file is written to `.claude/skills/sonar-context-augmentation/SKILL.md`, `.github/skills/sonar-context-augmentation/SKILL.md`, or `.agents/skills/sonar-context-augmentation/SKILL.md` depending on the agent. **Cursor** also writes to `.agents/skills/sonar-context-augmentation/SKILL.md` — the shared cross-tool skills directory it reads alongside Codex and Antigravity — rather than a Cursor-private `.cursor/skills` copy, so the three tools share one skill instead of duplicating it. Cursor loads skills on demand (distinct from Cursor's SQAA delivery, which is an always-applied `.cursor/rules/*.mdc` rule because that protocol must run every turn). That same feature also declares a `tool integrate --invocation-prefix "sonar context"` operation, but the operation is install-only: the framework passes `executionMode=install|update` into feature contexts, and CAG uses `shouldApply` so `tool integrate` runs during `sonar integrate ` but not during post-update refreshes. Post-update still reinstalls the shared dependency, best-effort runs `sonar-context-augmentation tool stop --all` against the previously-installed binary before replacing it, and refreshes the skill file. Replay failures are debug-logged so they do not abort CLI startup. +`sonar context [action] [args...]` is a passthrough to the locally-installed `sonar-context-augmentation` binary (CAG). It forwards args verbatim, propagates the child exit code, and injects context through `SONAR_CONTEXT_ORGANIZATION`, `SONAR_CONTEXT_PROJECT`, `SONAR_CONTEXT_TOKEN`, and `SONAR_CONTEXT_URL` env vars. Every CAG spawn (including the `--help` path and the integrate/post-update flows) also carries `SONAR_CONTEXT_INVOCATION_ID` — the per-CLI-process correlation id from `src/lib/invocation-id.ts`, shared with telemetry's `invocation_id` and forwarded by CAG to its daemon as the `x-sonar-invocation-id` header. The passthrough resolves project context from the recorded declarative CAG feature state for the current project rather than running full project auto-discovery. This lookup is git-worktree-aware so you can `sonar integrate` once and use `sonar context` from any worktree (including ones created later, and after the integrate-time worktree is removed): integrate records a stable `attrs.repoRoot` = the repository's **main working tree** (resolved via `resolveMainWorktreeRoot` in `src/lib/project-workspace/git-worktree.ts`, which reads `git worktree list --porcelain`) while keeping `targetRoot` = the physical install dir for teardown; the passthrough matches the current directory (mapped to its main-worktree equivalent via `resolveWorktreeEquivalentPaths`) against `attrs.repoRoot ?? targetRoot`, falling back to `targetRoot` for state written by older CLI versions. When a recorded integration matches, the passthrough also sets `SONAR_CONTEXT_WORKSPACE_ROOT` to that integration's recorded integrated folder (`attrs.repoRoot ?? targetRoot` — the repository's main working tree) so CAG can locate or lazily create its own per-workspace daemon folder. Because this value comes from recorded state rather than the current directory, it is correct from any subdirectory or linked worktree and does not require git at call time (e.g. a non-git project invoked from a subfolder); it is left unset (and any inherited value dropped) when no integration matches. Implementation in `src/cli/commands/context/`. The binary is downloaded by `sonar integrate claude` / `sonar integrate copilot` / `sonar integrate codex` / `sonar integrate antigravity` / `sonar integrate cursor` (skip with `--skip-context`); `sonar context` itself never auto-installs and emits a clear "not installed" error pointing the user back to integrate. `--global` integrations also skip CAG setup; install it by re-running `sonar integrate ` from a project directory. The `--global` skip notice (`Skipping Context Augmentation: not supported with --global. Re-run without --global from a project directory to install it there.`) is only emitted when the org is actually entitled to CAG — unentitled orgs skip silently. The CLI owns the agent skill file declaratively as a `wholeFile` resource inside a single CAG feature and renders it by calling `sonar-context-augmentation tool print-skill --invocation-prefix "sonar context" --sca-enabled=`; the rendered file is written to `.claude/skills/sonar-context-augmentation/SKILL.md`, `.github/skills/sonar-context-augmentation/SKILL.md`, or `.agents/skills/sonar-context-augmentation/SKILL.md` depending on the agent. **Cursor** also writes to `.agents/skills/sonar-context-augmentation/SKILL.md` — the shared cross-tool skills directory it reads alongside Codex and Antigravity — rather than a Cursor-private `.cursor/skills` copy, so the three tools share one skill instead of duplicating it. Cursor loads skills on demand (distinct from Cursor's SQAA delivery, which is an always-applied `.cursor/rules/*.mdc` rule because that protocol must run every turn). That same feature also declares a `tool integrate --invocation-prefix "sonar context"` operation, but the operation is install-only: the framework passes `executionMode=install|update` into feature contexts, and CAG uses `shouldApply` so `tool integrate` runs during `sonar integrate ` but not during post-update refreshes. Post-update still reinstalls the shared dependency, best-effort runs `sonar-context-augmentation tool stop --all` against the previously-installed binary before replacing it, and refreshes the skill file. Replay failures are debug-logged so they do not abort CLI startup. `--help`, `-h`, and bare `sonar context` (no action) are forwarded to CAG. @@ -178,7 +178,6 @@ The docs site is generated from the CLI source — do not edit `commands.json`, Keep a fixed `` in the docs HTML so production resolves relative assets and internal links under the public mount point. This is intentionally production-oriented: Netlify deploy previews served from `/` will not work with that fixed base. - # SonarQube Agentic Analysis protocol SonarQube Agentic Analysis is the final confirmation layer at the end of every turn in which you wrote to one or more files in the workspace (create, edit, patch, format — any tool call that changed file contents on disk). diff --git a/src/cli/commands/_common/context-augmentation-env.ts b/src/cli/commands/_common/context-augmentation-env.ts index 656506671..cd401a748 100644 --- a/src/cli/commands/_common/context-augmentation-env.ts +++ b/src/cli/commands/_common/context-augmentation-env.ts @@ -26,13 +26,21 @@ export interface ContextAugmentationEnvContext { projectKey?: string; serverUrl?: string; token?: string; + /** + * The repository's main working tree (the recorded integrated folder), passed + * to CAG so it can locate (or lazily create) the per-workspace daemon folder + * consistently from any git worktree. Set only when the repository's main + * working tree has a recorded integration. + */ + workspaceDir?: string; } type ContextAugmentationEnvKey = | 'SONAR_CONTEXT_ORGANIZATION' | 'SONAR_CONTEXT_PROJECT' | 'SONAR_CONTEXT_TOKEN' - | 'SONAR_CONTEXT_URL'; + | 'SONAR_CONTEXT_URL' + | 'SONAR_CONTEXT_WORKSPACE_ROOT'; /** * Build the env passed to sonar-context-augmentation subprocesses. @@ -59,6 +67,7 @@ export function buildContextAugmentationEnv( setContextEnvValue(env, 'SONAR_CONTEXT_PROJECT', context.projectKey); setContextEnvValue(env, 'SONAR_CONTEXT_TOKEN', context.token); setContextEnvValue(env, 'SONAR_CONTEXT_URL', context.serverUrl); + setContextEnvValue(env, 'SONAR_CONTEXT_WORKSPACE_ROOT', context.workspaceDir); return env; } @@ -87,5 +96,8 @@ function setContextEnvValue( case 'SONAR_CONTEXT_URL': delete env.SONAR_CONTEXT_URL; return; + case 'SONAR_CONTEXT_WORKSPACE_ROOT': + delete env.SONAR_CONTEXT_WORKSPACE_ROOT; + return; } } diff --git a/src/cli/commands/analyze/sqaa-auth.ts b/src/cli/commands/analyze/sqaa-auth.ts index 1b711802a..6cac787e5 100644 --- a/src/cli/commands/analyze/sqaa-auth.ts +++ b/src/cli/commands/analyze/sqaa-auth.ts @@ -20,12 +20,9 @@ // Auth and project-key resolution for SQAA commands. -import { resolve } from 'node:path'; - import type { ResolvedAuth } from '../../../lib/auth-resolver'; import logger from '../../../lib/logger'; -import { spawnProcess } from '../../../lib/process'; -import { resolveWorktreeEquivalentPaths } from '../../../lib/project-workspace/git-worktree'; +import { resolveWorktreeRootCandidates } from '../../../lib/project-workspace/git-worktree'; import { loadState } from '../../../lib/repository/state-repository'; import { canonicalProjectRoot } from '../../../lib/state-manager'; import { blank, confirmPrompt, text, warn } from '../../../ui'; @@ -111,29 +108,24 @@ export function resolveCloudAuth( * Look up the project key for the current project from the declarative * integration state (`integrations.installed`). * - * The SQAA hook feature is recorded per install target root (the directory - * passed to `sonar integrate claude`), so when the user runs SQAA from a - * subdirectory we resolve the git repository top-level first — otherwise - * `process.cwd()` is a non-match against the recorded root and we incorrectly - * skip with "no project configured". We also try the root's equivalent in the - * repository's main working tree, so a linked worktree still resolves state - * recorded in the main checkout. - * - * Falls back to `process.cwd()` when not inside a git repository so the - * single-file path still works outside git. + * The SQAA hook feature is recorded per install target root, so + * `resolveWorktreeRootCandidates` maps the current directory to its working-tree + * root(s) — the current tree, then the main tree — via a single `git worktree + * list` call. This lets SQAA resolve the key from a subdirectory and from any + * linked worktree, matching state recorded in the main checkout. Falls back to + * `process.cwd()` when not inside a git repository so the single-file path still + * works outside git. */ export async function resolveSqaaProjectKey(projectRoot?: string): Promise { try { - const root = projectRoot ?? (await tryResolveRepoRoot(process.cwd())); const state = loadState(); - const claude = state.integrations.installed.find( (integration) => integration.integrationId === CLAUDE_INTEGRATION_ID, ); - // Try the repo root, then its equivalent in the main working tree, so a + // Try the current working tree's root, then the main working tree, so a // linked worktree still resolves state recorded in the main checkout. const candidates = new Set( - (await resolveWorktreeEquivalentPaths(root)).map(canonicalProjectRoot), + (await resolveWorktreeRootCandidates(projectRoot ?? process.cwd())).map(canonicalProjectRoot), ); const sqaaFeature = claude?.features.find( (feature) => @@ -155,22 +147,6 @@ export async function resolveSqaaProjectKey(projectRoot?: string): Promise { - try { - const result = await spawnProcess('git', ['rev-parse', '--show-toplevel'], { cwd }); - if (result.exitCode === 0) { - return resolve(result.stdout.trim()); - } - } catch { - // git not installed or otherwise unavailable — fall through to cwd. - } - return cwd; -} - /** * Warn about a large change set and ask the user to confirm. * In non-interactive contexts (no stdin TTY — e.g. CI/agent runs), prints a diff --git a/src/cli/commands/context/index.ts b/src/cli/commands/context/index.ts index 855a29694..62e1058d1 100644 --- a/src/cli/commands/context/index.ts +++ b/src/cli/commands/context/index.ts @@ -80,6 +80,8 @@ interface RecordedContextAugmentationConfig { organization?: string; projectKey?: string; serverUrl?: string; + /** Current working tree root; set only when a recorded integration matched. */ + workspaceDir?: string; } function canonicalPath(path: string): string { @@ -146,6 +148,12 @@ async function resolveRecordedContextAugmentationConfig( organization: getOptionalStringAttr(match.attrs, 'orgKey'), projectKey: getOptionalStringAttr(match.attrs, 'projectKey'), serverUrl: getOptionalStringAttr(match.attrs, 'serverUrl'), + // Hand CAG the integrated folder itself — the stable main-working-tree key + // (or targetRoot for older state). This is correct from any subfolder or + // linked worktree and, unlike deriving it from the current directory, does + // not depend on git being available at call time (e.g. a non-git project + // invoked from a subdirectory). + workspaceDir: getOptionalStringAttr(match.attrs, 'repoRoot') ?? match.targetRoot, }; } catch (err) { logger.debug( @@ -209,6 +217,7 @@ export async function runContextPassthrough( projectKey: recordedConfig.projectKey, serverUrl, token: await resolveContextToken(auth, serverUrl, organization), + workspaceDir: recordedConfig.workspaceDir, }); } diff --git a/src/lib/project-workspace/git-worktree.ts b/src/lib/project-workspace/git-worktree.ts index 1bc23c615..53d3113e6 100644 --- a/src/lib/project-workspace/git-worktree.ts +++ b/src/lib/project-workspace/git-worktree.ts @@ -61,38 +61,69 @@ async function runGitStdout(args: string[], cwd: string): Promise return null; } +function isPathInside(parent: string, child: string): boolean { + if (child === parent) { + return true; + } + const rel = relative(parent, child); + return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel); +} + +/** + * List every working-tree root of the repository containing `dir`, in git's + * order (the main working tree is always first). A single `git worktree list + * --porcelain` yields both the main tree and every linked worktree, so all + * worktree resolution below needs just this one git invocation. Returns null + * when git is unavailable or `dir` is not inside a repository. + */ +async function listWorktreeRoots(dir: string): Promise { + const listing = await runGitStdout(['worktree', 'list', '--porcelain'], dir); + if (listing === null) { + return null; + } + const roots = listing + .split('\n') + .filter((line) => line.startsWith('worktree ')) + .map((line) => resolve(line.slice('worktree '.length).trim())) + .filter((path) => path.length > 0); + return roots.length > 0 ? roots : null; +} + /** * Resolve the absolute path of the repository's main working tree for the given - * directory. `git worktree list` always reports the main working tree as its - * first entry, which is the stable checkout where `sonar integrate` should key - * per-project state (it outlives any linked worktree). Returns null when git is - * unavailable or the directory is not inside a repository. + * directory — the first `git worktree list` entry, which is the stable checkout + * where `sonar integrate` should key per-project state (it outlives any linked + * worktree). Returns null when git is unavailable or the directory is not inside + * a repository. */ export async function resolveMainWorktreeRoot(dir: string): Promise { - const listing = await runGitStdout(['worktree', 'list', '--porcelain'], dir); - const firstEntry = listing - ?.split('\n') - .find((line) => line.startsWith('worktree ')) - ?.slice('worktree '.length) - .trim(); - return firstEntry ? resolve(firstEntry) : null; + const roots = await listWorktreeRoots(dir); + return roots?.[0] ?? null; } /** * Resolve the current git top-level and the repository's main working tree root - * for the given directory. Both roots are canonicalized to their real on-disk - * form so all downstream comparisons (the currentRoot/mainRoot equality guard, - * `relative`, and the mapped path) operate on consistent forms. Returns null - * when git is unavailable or the directory is not inside a repository; when not - * inside a linked worktree, currentRoot equals mainRoot. + * for the given directory from a single `git worktree list` call. Both roots are + * canonicalized to their real on-disk form so all downstream comparisons (the + * currentRoot/mainRoot equality guard, `relative`, and the mapped path) operate + * on consistent forms. The current root is the listed worktree that contains + * `dir` (most specific wins); it falls back to the main root when none matches. + * Returns null when git is unavailable or `dir` is not inside a repository; when + * not inside a linked worktree, currentRoot equals mainRoot. */ async function resolveWorktreeMapping(dir: string): Promise { - const topLevel = await runGitStdout(['rev-parse', '--show-toplevel'], dir); - if (topLevel === null) { + const roots = await listWorktreeRoots(dir); + if (!roots) { return null; } - const currentRoot = canonicalize(topLevel.trim()); - const mainRoot = canonicalize((await resolveMainWorktreeRoot(dir)) ?? currentRoot); + const canonicalRoots = roots.map(canonicalize); + const mainRoot = canonicalRoots[0]; + const current = canonicalize(dir); + const currentRoot = + canonicalRoots + .filter((root) => isPathInside(root, current)) + .sort((a, b) => b.length - a.length) + .at(0) ?? mainRoot; return { currentRoot, mainRoot }; } @@ -102,6 +133,8 @@ async function resolveWorktreeMapping(dir: string): Promise { + const mapping = await resolveWorktreeMapping(dir); + if (!mapping) { + return [dir]; + } + return mapping.currentRoot === mapping.mainRoot + ? [mapping.currentRoot] + : [mapping.currentRoot, mapping.mainRoot]; +} diff --git a/tests/integration/harness/cag-invocations.ts b/tests/integration/harness/cag-invocations.ts index ef55df88b..b1d68868c 100644 --- a/tests/integration/harness/cag-invocations.ts +++ b/tests/integration/harness/cag-invocations.ts @@ -33,6 +33,7 @@ export interface CagInvocation { SONAR_CONTEXT_TOKEN?: string; SONAR_CONTEXT_URL?: string; SONAR_CONTEXT_INVOCATION_ID?: string; + SONAR_CONTEXT_WORKSPACE_ROOT?: string; }; } diff --git a/tests/integration/resources/cag-stub.ts b/tests/integration/resources/cag-stub.ts index 51f742ffb..9f5049dfa 100644 --- a/tests/integration/resources/cag-stub.ts +++ b/tests/integration/resources/cag-stub.ts @@ -54,6 +54,7 @@ if (sentinel) { copyEnvValue(contextEnv, 'SONAR_CONTEXT_TOKEN'); copyEnvValue(contextEnv, 'SONAR_CONTEXT_URL'); copyEnvValue(contextEnv, 'SONAR_CONTEXT_INVOCATION_ID'); + copyEnvValue(contextEnv, 'SONAR_CONTEXT_WORKSPACE_ROOT'); const entry = JSON.stringify({ argv: args, diff --git a/tests/integration/specs/analyze/analyze-sqaa.test.ts b/tests/integration/specs/analyze/analyze-sqaa.test.ts index 63bc3d91c..3ccb84435 100644 --- a/tests/integration/specs/analyze/analyze-sqaa.test.ts +++ b/tests/integration/specs/analyze/analyze-sqaa.test.ts @@ -701,11 +701,11 @@ describe('analyze agentic', () => { .withSqaaResponse({ issues: [] }) .start(); - // Register the SQAA extension against the main checkout, as `sonar integrate` would. + // Record the SQAA feature against the main checkout, as `sonar integrate` would. harness .state() .withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG) - .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); + .withSqaaFeature(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); // Make the main checkout a git repo and add a linked worktree beside it. initGitRepo(harness.cwd.path); diff --git a/tests/integration/specs/context/passthrough.test.ts b/tests/integration/specs/context/passthrough.test.ts index 170a99e22..4d8527230 100644 --- a/tests/integration/specs/context/passthrough.test.ts +++ b/tests/integration/specs/context/passthrough.test.ts @@ -21,6 +21,7 @@ // Integration tests for `sonar context ` — the passthrough wrapper to // the locally-installed sonar-context-augmentation binary. +import { mkdirSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, setDefaultTimeout } from 'bun:test'; @@ -201,6 +202,36 @@ describe('sonar context passthrough', () => { expect(invocation.env.SONAR_CONTEXT_ORGANIZATION).toBe(ORG_KEY); expect(invocation.env.SONAR_CONTEXT_URL).toBe(serverUrl); expect(invocation.env.SONAR_CONTEXT_TOKEN).toBe('project-token'); + // Workspace dir is the recorded integrated folder (the main checkout), so CAG + // resolves the same workspace from any worktree without needing git itself. + expect(invocation.env.SONAR_CONTEXT_WORKSPACE_ROOT).toBe(harness.cwd.path); + }, + { timeout: 30000 }, + ); + + it( + 'sets SONAR_CONTEXT_WORKSPACE_ROOT to the integrated folder from a subdirectory of a non-git project', + async () => { + const server = await harness.newFakeServer().start(); + const serverUrl = server.baseUrl(); + harness.withAuth(serverUrl, 'project-token', ORG_KEY); + // Integrated at the project root; no git repo is initialised here. + harness + .state() + .withContextAugmentationBinaryInstalled() + .withContextAugmentationSkill(harness.cwd.path, PROJECT_KEY, ORG_KEY, serverUrl); + + const subdir = join(harness.cwd.path, 'packages', 'app'); + mkdirSync(subdir, { recursive: true }); + + const result = await harness.run('context status', { cwd: subdir }); + + expect(result.exitCode).toBe(0); + const invocation = findInvocation(readInvocations(harness), ['status']); + expect(invocation.env.SONAR_CONTEXT_PROJECT).toBe(PROJECT_KEY); + // Without git we cannot derive a tree root from the cwd, but the workspace dir + // is still the recorded integrated folder — not the subdirectory we ran in. + expect(invocation.env.SONAR_CONTEXT_WORKSPACE_ROOT).toBe(harness.cwd.path); }, { timeout: 30000 }, ); @@ -338,6 +369,7 @@ describe('sonar context passthrough', () => { SONAR_CONTEXT_PROJECT: 'caller-project', SONAR_CONTEXT_TOKEN: 'caller-token', SONAR_CONTEXT_URL: 'https://caller.example', + SONAR_CONTEXT_WORKSPACE_ROOT: '/caller/workspace', }, }); @@ -347,6 +379,9 @@ describe('sonar context passthrough', () => { expect(invocation.env.SONAR_CONTEXT_URL).toBe(serverUrl); expect(invocation.env.SONAR_CONTEXT_ORGANIZATION).toBe('auth-org'); expect(invocation.env.SONAR_CONTEXT_PROJECT).toBeUndefined(); + // No recorded integration matched → workspace dir is not set, and a stray + // caller-provided value is not inherited. + expect(invocation.env.SONAR_CONTEXT_WORKSPACE_ROOT).toBeUndefined(); expect(invocation.env.SONAR_CONTEXT_INVOCATION_ID).toMatch(UUID_V4_RE); }, { timeout: 30000 }, diff --git a/tests/unit/cli/commands/_common/context-augmentation-env.test.ts b/tests/unit/cli/commands/_common/context-augmentation-env.test.ts index e90767df3..272bc2af8 100644 --- a/tests/unit/cli/commands/_common/context-augmentation-env.test.ts +++ b/tests/unit/cli/commands/_common/context-augmentation-env.test.ts @@ -29,6 +29,7 @@ const KEYS = [ 'SONAR_CONTEXT_PROJECT', 'SONAR_CONTEXT_TOKEN', 'SONAR_CONTEXT_URL', + 'SONAR_CONTEXT_WORKSPACE_ROOT', ] as const; describe('buildContextAugmentationEnv', () => { @@ -65,12 +66,14 @@ describe('buildContextAugmentationEnv', () => { projectKey: 'my-project', serverUrl: 'https://sonar.example', token: 'tok', + workspaceDir: '/repo/main', }); expect(env.SONAR_CONTEXT_ORGANIZATION).toBe('my-org'); expect(env.SONAR_CONTEXT_PROJECT).toBe('my-project'); expect(env.SONAR_CONTEXT_URL).toBe('https://sonar.example'); expect(env.SONAR_CONTEXT_TOKEN).toBe('tok'); + expect(env.SONAR_CONTEXT_WORKSPACE_ROOT).toBe('/repo/main'); }); it('deletes each SONAR_CONTEXT_* key when its context field is undefined', () => { @@ -87,6 +90,7 @@ describe('buildContextAugmentationEnv', () => { projectKey: '', serverUrl: '', token: '', + workspaceDir: '', }); for (const key of KEYS) {