Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@ frontend/coverage/
# Editors/OS
.DS_Store
.idea/
.vscode/
!.vscode/
.vscode/*
!.vscode/settings.json
*.swp
*.swo
*~
Expand Down Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"git.repositoryScanIgnoredFolders": [
".omx/agent-worktrees",
"**/.omx/agent-worktrees",
".omc/agent-worktrees",
"**/.omc/agent-worktrees"
]
}
191 changes: 191 additions & 0 deletions bin/multiagent-safety.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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' };
Expand Down Expand Up @@ -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)));

Expand Down Expand Up @@ -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)));

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-04-22
Original file line number Diff line number Diff line change
@@ -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`).
16 changes: 16 additions & 0 deletions test/helpers/install-test-helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -520,6 +535,7 @@ module.exports = {
runCmd,
runHumanCmd,
assertZeroCopyManagedGitignore,
assertManagedRepoVscodeSettings,
createFakeBin,
createFakeNpmScript,
createFakeOpenSpecScript,
Expand Down
35 changes: 35 additions & 0 deletions test/setup.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const {
runCmd,
runHumanCmd,
assertZeroCopyManagedGitignore,
assertManagedRepoVscodeSettings,
createFakeBin,
createFakeNpmScript,
createFakeOpenSpecScript,
Expand Down Expand Up @@ -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',
];

Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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();
Expand Down