From 33857061d08627cceaea47c09ec5209218a39620 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Wed, 22 Apr 2026 12:16:28 +0200 Subject: [PATCH] Hide nested agent worktrees from default VS Code Source Control scans Setup and fix now write or merge a shared .vscode/settings.json with repository-scan ignores for Guardex .omx and .omc worktree roots. The managed gitignore block also re-includes that one tracked settings file while keeping the rest of .vscode local-only, so plain repo checkouts stop filling Source Control with raw sandbox repositories. Constraint: Default repo views should hide nested Guardex worktrees while the optional parent-workspace view still exposes them intentionally Rejected: Add the .vscode/settings.json fix only to this repo | would not improve gx-managed repos created by setup Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep .vscode/settings.json as the only shared VS Code file unless setup and tests are updated together Tested: node --test test/setup.test.js --test-name-pattern "setup provisions workflow files and repo config|setup appends managed gitignore block without clobbering existing entries|setup merges Guardex repo-scan ignores into tracked VS Code workspace settings"; node --check bin/multiagent-safety.js; git check-ignore -v .vscode/tmp-local.json .vscode/settings.json Not-tested: Manual VS Code UI verification against a live repo window --- .gitignore | 7 +- .vscode/settings.json | 8 + bin/multiagent-safety.js | 191 ++++++++++++++++++ .../.openspec.yaml | 2 + .../notes.md | 25 +++ test/helpers/install-test-helpers.js | 16 ++ test/setup.test.js | 35 ++++ 7 files changed, 283 insertions(+), 1 deletion(-) create mode 100644 .vscode/settings.json create mode 100644 openspec/changes/agent-codex-vscode-setup-ignore-worktrees-2026-04-22-12-18/.openspec.yaml create mode 100644 openspec/changes/agent-codex-vscode-setup-ignore-worktrees-2026-04-22-12-18/notes.md diff --git a/.gitignore b/.gitignore index dc5ec96..d950c20 100644 --- a/.gitignore +++ b/.gitignore @@ -38,7 +38,9 @@ frontend/coverage/ # Editors/OS .DS_Store .idea/ -.vscode/ +!.vscode/ +.vscode/* +!.vscode/settings.json *.swp *.swo *~ @@ -78,6 +80,9 @@ openspec/plan/* # multiagent-safety:START .omx/ .omc/ +!.vscode/ +.vscode/* +!.vscode/settings.json scripts/agent-session-state.js scripts/guardex-docker-loader.sh scripts/guardex-env.sh diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..f21e8dc --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,8 @@ +{ + "git.repositoryScanIgnoredFolders": [ + ".omx/agent-worktrees", + "**/.omx/agent-worktrees", + ".omc/agent-worktrees", + "**/.omc/agent-worktrees" + ] +} diff --git a/bin/multiagent-safety.js b/bin/multiagent-safety.js index a02c1ed..a7e1f8c 100755 --- a/bin/multiagent-safety.js +++ b/bin/multiagent-safety.js @@ -221,13 +221,24 @@ const GITIGNORE_MARKER_START = '# multiagent-safety:START'; const GITIGNORE_MARKER_END = '# multiagent-safety:END'; const CODEX_WORKTREE_RELATIVE_DIR = path.join('.omx', 'agent-worktrees'); const CLAUDE_WORKTREE_RELATIVE_DIR = path.join('.omc', 'agent-worktrees'); +const SHARED_VSCODE_SETTINGS_RELATIVE = path.posix.join('.vscode', 'settings.json'); +const REPO_SCAN_IGNORED_FOLDERS_SETTING = 'git.repositoryScanIgnoredFolders'; const AGENT_WORKTREE_RELATIVE_DIRS = [ CODEX_WORKTREE_RELATIVE_DIR, CLAUDE_WORKTREE_RELATIVE_DIR, ]; +const MANAGED_REPO_SCAN_IGNORED_FOLDERS = [ + '.omx/agent-worktrees', + '**/.omx/agent-worktrees', + '.omc/agent-worktrees', + '**/.omc/agent-worktrees', +]; const MANAGED_GITIGNORE_PATHS = [ '.omx/', '.omc/', + '!.vscode/', + '.vscode/*', + '!.vscode/settings.json', 'scripts/agent-session-state.js', 'scripts/guardex-docker-loader.sh', 'scripts/guardex-env.sh', @@ -1566,6 +1577,184 @@ function ensureManagedGitignore(repoRoot, dryRun) { return { status: 'updated', file: '.gitignore', note: 'appended gitguardex-managed entries' }; } +function stripJsonComments(source) { + let result = ''; + let inString = false; + let escapeNext = false; + let inLineComment = false; + let inBlockComment = false; + + for (let index = 0; index < source.length; index += 1) { + const current = source[index]; + const next = source[index + 1]; + + if (inLineComment) { + if (current === '\n' || current === '\r') { + inLineComment = false; + result += current; + } + continue; + } + + if (inBlockComment) { + if (current === '*' && next === '/') { + inBlockComment = false; + index += 1; + continue; + } + if (current === '\n' || current === '\r') { + result += current; + } + continue; + } + + if (inString) { + result += current; + if (escapeNext) { + escapeNext = false; + } else if (current === '\\') { + escapeNext = true; + } else if (current === '"') { + inString = false; + } + continue; + } + + if (current === '"') { + inString = true; + result += current; + continue; + } + + if (current === '/' && next === '/') { + inLineComment = true; + index += 1; + continue; + } + + if (current === '/' && next === '*') { + inBlockComment = true; + index += 1; + continue; + } + + result += current; + } + + return result; +} + +function stripJsonTrailingCommas(source) { + let result = ''; + let inString = false; + let escapeNext = false; + + for (let index = 0; index < source.length; index += 1) { + const current = source[index]; + + if (inString) { + result += current; + if (escapeNext) { + escapeNext = false; + } else if (current === '\\') { + escapeNext = true; + } else if (current === '"') { + inString = false; + } + continue; + } + + if (current === '"') { + inString = true; + result += current; + continue; + } + + if (current === ',') { + let lookahead = index + 1; + while (lookahead < source.length && /\s/.test(source[lookahead])) { + lookahead += 1; + } + if (source[lookahead] === '}' || source[lookahead] === ']') { + continue; + } + } + + result += current; + } + + return result; +} + +function parseJsonObjectLikeFile(source, relativePath) { + let parsed; + try { + parsed = JSON.parse(stripJsonTrailingCommas(stripJsonComments(source))); + } catch (error) { + throw new Error(`Unable to parse ${relativePath} as JSON or JSONC: ${error.message}`); + } + + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error(`${relativePath} must contain a top-level object.`); + } + + return parsed; +} + +function uniqueStringList(values) { + const seen = new Set(); + const result = []; + + for (const value of values) { + if (typeof value !== 'string' || seen.has(value)) { + continue; + } + seen.add(value); + result.push(value); + } + + return result; +} + +function buildRepoVscodeSettings(existingSettings = {}) { + const nextSettings = { ...existingSettings }; + const existingIgnoredFolders = Array.isArray(existingSettings[REPO_SCAN_IGNORED_FOLDERS_SETTING]) + ? existingSettings[REPO_SCAN_IGNORED_FOLDERS_SETTING] + : []; + + nextSettings[REPO_SCAN_IGNORED_FOLDERS_SETTING] = uniqueStringList([ + ...existingIgnoredFolders, + ...MANAGED_REPO_SCAN_IGNORED_FOLDERS, + ]); + + return nextSettings; +} + +function ensureRepoVscodeSettings(repoRoot, dryRun) { + const settingsPath = path.join(repoRoot, SHARED_VSCODE_SETTINGS_RELATIVE); + const destinationExists = fs.existsSync(settingsPath); + const existingContent = destinationExists ? fs.readFileSync(settingsPath, 'utf8') : ''; + const existingSettings = destinationExists + ? parseJsonObjectLikeFile(existingContent, SHARED_VSCODE_SETTINGS_RELATIVE) + : {}; + const nextContent = `${JSON.stringify(buildRepoVscodeSettings(existingSettings), null, 2)}\n`; + + if (destinationExists && existingContent === nextContent) { + return { status: 'unchanged', file: SHARED_VSCODE_SETTINGS_RELATIVE }; + } + + ensureParentDir(repoRoot, settingsPath, dryRun); + if (!dryRun) { + fs.writeFileSync(settingsPath, nextContent, 'utf8'); + } + + return { + status: destinationExists ? 'updated' : 'created', + file: SHARED_VSCODE_SETTINGS_RELATIVE, + note: 'shared VS Code repo scan ignores for Guardex worktrees', + }; +} + function configureHooks(repoRoot, dryRun) { if (dryRun) { return { status: 'would-set', key: 'core.hooksPath', value: '.githooks' }; @@ -5426,6 +5615,7 @@ function runInstallInternal(options) { if (!options.skipGitignore) { operations.push(ensureManagedGitignore(repoRoot, Boolean(options.dryRun))); } + operations.push(ensureRepoVscodeSettings(repoRoot, Boolean(options.dryRun))); operations.push(...ensureOmxScaffold(repoRoot, Boolean(options.dryRun))); @@ -5484,6 +5674,7 @@ function runFixInternal(options) { if (!options.skipGitignore) { operations.push(ensureManagedGitignore(repoRoot, Boolean(options.dryRun))); } + operations.push(ensureRepoVscodeSettings(repoRoot, Boolean(options.dryRun))); operations.push(...ensureOmxScaffold(repoRoot, Boolean(options.dryRun))); diff --git a/openspec/changes/agent-codex-vscode-setup-ignore-worktrees-2026-04-22-12-18/.openspec.yaml b/openspec/changes/agent-codex-vscode-setup-ignore-worktrees-2026-04-22-12-18/.openspec.yaml new file mode 100644 index 0000000..25345f4 --- /dev/null +++ b/openspec/changes/agent-codex-vscode-setup-ignore-worktrees-2026-04-22-12-18/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-22 diff --git a/openspec/changes/agent-codex-vscode-setup-ignore-worktrees-2026-04-22-12-18/notes.md b/openspec/changes/agent-codex-vscode-setup-ignore-worktrees-2026-04-22-12-18/notes.md new file mode 100644 index 0000000..2210625 --- /dev/null +++ b/openspec/changes/agent-codex-vscode-setup-ignore-worktrees-2026-04-22-12-18/notes.md @@ -0,0 +1,25 @@ +# agent-codex-vscode-setup-ignore-worktrees-2026-04-22-12-18 (minimal / T1) + +Branch: `agent/codex/vscode-setup-ignore-worktrees-2026-04-22-12-18` + +Plain `gx setup` repos should not flood VS Code Source Control with nested Guardex sandbox repos. Add a shared tracked `.vscode/settings.json` contract that tells Git to ignore `.omx/.omc` worktree scans in the default repo view, while leaving the optional parent-workspace view untouched for operators who intentionally want raw worktree repositories. + +Scope: +- Teach setup/fix to create or merge `.vscode/settings.json` with shared `git.repositoryScanIgnoredFolders` entries for `.omx/agent-worktrees` and `.omc/agent-worktrees`. +- Allow repos to track `.vscode/settings.json` without unignoring the rest of `.vscode/*`. +- Add focused setup coverage for the generated settings file and JSONC merge behavior. +- Add the same tracked `.vscode/settings.json` contract to this repo so the local checkout matches the shipped behavior. + +Verification: +- `node --test test/setup.test.js --test-name-pattern "setup provisions workflow files and repo config|setup appends managed gitignore block without clobbering existing entries|setup merges Guardex repo-scan ignores into tracked VS Code workspace settings"` + +## Handoff + +- Handoff: change=`agent-codex-vscode-setup-ignore-worktrees-2026-04-22-12-18`; branch=`agent/codex/vscode-setup-ignore-worktrees-2026-04-22-12-18`; scope=`bin/multiagent-safety.js, test/setup.test.js, test/helpers/install-test-helpers.js, .gitignore, .vscode/settings.json, openspec/changes/agent-codex-vscode-setup-ignore-worktrees-2026-04-22-12-18/*`; action=`finish this sandbox via PR merge + cleanup after targeted verification`. +- Copy prompt: Continue `agent-codex-vscode-setup-ignore-worktrees-2026-04-22-12-18` on branch `agent/codex/vscode-setup-ignore-worktrees-2026-04-22-12-18`. Work inside the existing sandbox, review `openspec/changes/agent-codex-vscode-setup-ignore-worktrees-2026-04-22-12-18/notes.md`, continue from the current state instead of creating a new sandbox, and when the work is done run `gx branch finish --branch agent/codex/vscode-setup-ignore-worktrees-2026-04-22-12-18 --base main --via-pr --wait-for-merge --cleanup`. + +## Cleanup + +- [ ] Run: `gx branch finish --branch agent/codex/vscode-setup-ignore-worktrees-2026-04-22-12-18 --base main --via-pr --wait-for-merge --cleanup` +- [ ] Record PR URL + `MERGED` state in the completion handoff. +- [ ] Confirm sandbox worktree is gone (`git worktree list`, `git branch -a`). diff --git a/test/helpers/install-test-helpers.js b/test/helpers/install-test-helpers.js index 0dfb1ac..e10db74 100644 --- a/test/helpers/install-test-helpers.js +++ b/test/helpers/install-test-helpers.js @@ -136,6 +136,9 @@ function runHumanCmd(cmd, args, cwd, options = {}) { function assertZeroCopyManagedGitignore(content) { assert.match(content, /# multiagent-safety:START/); + assert.match(content, /^!\.vscode\/$/m); + assert.match(content, /^\.vscode\/\*$/m); + assert.match(content, /^!\.vscode\/settings\.json$/m); assert.match(content, /^scripts\/agent-session-state\.js$/m); assert.match(content, /^scripts\/guardex-docker-loader\.sh$/m); assert.match(content, /^scripts\/guardex-env\.sh$/m); @@ -147,6 +150,18 @@ function assertZeroCopyManagedGitignore(content) { assert.match(content, /# multiagent-safety:END/); } +function assertManagedRepoVscodeSettings(settings) { + assert.equal(typeof settings, 'object'); + assert.notEqual(settings, null); + assert.equal(Array.isArray(settings['git.repositoryScanIgnoredFolders']), true); + assert.deepEqual(settings['git.repositoryScanIgnoredFolders'], [ + '.omx/agent-worktrees', + '**/.omx/agent-worktrees', + '.omc/agent-worktrees', + '**/.omc/agent-worktrees', + ]); +} + function createFakeBin(name, scriptBody, prefix = `guardex-fake-${name}-`) { const fakeBin = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); const fakePath = path.join(fakeBin, name); @@ -520,6 +535,7 @@ module.exports = { runCmd, runHumanCmd, assertZeroCopyManagedGitignore, + assertManagedRepoVscodeSettings, createFakeBin, createFakeNpmScript, createFakeOpenSpecScript, diff --git a/test/setup.test.js b/test/setup.test.js index 4a0c980..fcbb467 100644 --- a/test/setup.test.js +++ b/test/setup.test.js @@ -26,6 +26,7 @@ const { runCmd, runHumanCmd, assertZeroCopyManagedGitignore, + assertManagedRepoVscodeSettings, createFakeBin, createFakeNpmScript, createFakeOpenSpecScript, @@ -93,6 +94,7 @@ test('setup provisions workflow files and repo config', () => { '.github/workflows/cr.yml', '.omx/state/agent-file-locks.json', '.gitignore', + '.vscode/settings.json', 'AGENTS.md', ]; @@ -156,6 +158,9 @@ test('setup provisions workflow files and repo config', () => { assert.match(gitignoreContent, /\.omx\/state\/agent-file-locks\.json/); assert.match(gitignoreContent, /# multiagent-safety:END/); + const vscodeSettings = JSON.parse(fs.readFileSync(path.join(repoDir, '.vscode', 'settings.json'), 'utf8')); + assertManagedRepoVscodeSettings(vscodeSettings); + result = runCmd('git', ['config', '--get', 'core.hooksPath'], repoDir); assert.equal(result.status, 0, result.stderr); assert.equal(result.stdout.trim(), '.githooks'); @@ -1110,6 +1115,36 @@ test('setup appends managed gitignore block without clobbering existing entries' assert.equal(blockStarts.length, 1, 'managed gitignore block should be unique'); }); +test('setup merges Guardex repo-scan ignores into tracked VS Code workspace settings', () => { + const repoDir = initRepo(); + const vscodeDir = path.join(repoDir, '.vscode'); + fs.mkdirSync(vscodeDir, { recursive: true }); + fs.writeFileSync( + path.join(vscodeDir, 'settings.json'), + '{\n' + + ' // keep custom workspace settings\n' + + ' "editor.formatOnSave": true,\n' + + ' "git.repositoryScanIgnoredFolders": [\n' + + ' "custom-folder",\n' + + ' ],\n' + + '}\n', + 'utf8', + ); + + const result = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + + const settings = JSON.parse(fs.readFileSync(path.join(vscodeDir, 'settings.json'), 'utf8')); + assert.equal(settings['editor.formatOnSave'], true); + assert.deepEqual(settings['git.repositoryScanIgnoredFolders'], [ + 'custom-folder', + '.omx/agent-worktrees', + '**/.omx/agent-worktrees', + '.omc/agent-worktrees', + '**/.omc/agent-worktrees', + ]); +}); + test('setup --no-gitignore skips creating managed gitignore block', () => { const repoDir = initRepo();