Skip to content

Commit 7a5a9e0

Browse files
committed
Confine demo fixtures and isolate helper Git environments
1 parent 227ac8d commit 7a5a9e0

7 files changed

Lines changed: 81 additions & 5 deletions

File tree

‎docs/implementation/read-only-review.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,7 @@ Reproduced and fixed no-change approval with item-owned ambiguous segments; the
3838
## Review round 2
3939

4040
Extended the attribution guard to mixed items with both owned and ambiguous segments. The regression reproduced ordinary approval succeeding with unresolved attribution. Approval now rejects any item-specific ambiguity, previously approved affected items become stale, and the UI routes every such item to attribution resolution. Added browser coverage for this mixed case. No findings declined.
41+
42+
A toolbar-focus browser regression reproduced review shortcuts being disabled while a button had focus. Only text-entry/select controls now suppress the single-letter shortcuts; native Enter/Space button behavior is unchanged. The plan-row click case already worked because rendering replaces the focused row.
43+
44+
Review round 3 identified demo fixture config escapes and inherited Git environment redirection. Regression checks first reproduced the config, symlink, and environment failures. Existing demos now require exact fixture paths and ordinary config/database/repository objects (including `.git` and SQLite sidecars). Demo and plant Git commands share a case-insensitive environment scrub and disable inherited global/system Git configuration. Four helper tests cover these boundaries, including mixed-case variable names for Windows.

‎scripts/demo.ts‎

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,30 @@
1-
import { existsSync, mkdirSync, mkdtempSync, rmSync, readFileSync, writeFileSync, chmodSync } from 'node:fs';
1+
import { existsSync, mkdirSync, mkdtempSync, rmSync, readFileSync, writeFileSync, chmodSync, lstatSync } from 'node:fs';
22
import { resolve, join } from 'node:path';
33
import { execFileSync } from 'node:child_process';
44
import { randomUUID } from 'node:crypto';
55
import { Store } from '../runner/store.ts';
66
import type { ReviewConfig } from '../runner/review.ts';
77
import type { Plan } from '../core/plan.ts';
8+
import { isolatedGitEnvironment } from './git-environment.ts';
89
/** Disposable fixture only. Never runs against the user's working repository. */
910
export function createDemo(directory: string): ReviewConfig {
1011
const root = resolve(directory), configPath = join(root, 'review.json');
11-
if (existsSync(configPath)) return JSON.parse(readFileSync(configPath, 'utf8')) as ReviewConfig;
12+
if (existsSync(root) && lstatSync(root).isSymbolicLink()) throw new Error('Demo fixture root must not be a symlink.');
13+
if (existsSync(configPath)) {
14+
const check = (path: string, directory: boolean) => {
15+
const stat = lstatSync(path);
16+
if (stat.isSymbolicLink() || (directory ? !stat.isDirectory() : !stat.isFile())) throw new Error('Demo fixture paths must be ordinary local files and directories.');
17+
};
18+
check(configPath, false);
19+
const config = JSON.parse(readFileSync(configPath, 'utf8')) as ReviewConfig;
20+
if (config.demo !== true || config.repository !== join(root, 'retry-service') || config.database !== join(root, 'review.sqlite')) throw new Error('Demo configuration must refer to its own fixture.');
21+
check(config.repository, true); check(join(config.repository, '.git'), true); check(config.database, false);
22+
for (const suffix of ['-wal', '-shm']) if (existsSync(config.database + suffix)) check(config.database + suffix, false);
23+
return config;
24+
}
1225
if (existsSync(root)) throw new Error('Demo directory exists without a configuration. Choose a new empty path.');
1326
mkdirSync(root, { recursive: true }); const repository = join(root, 'retry-service'); mkdirSync(repository);
14-
const git = (...args: string[]) => execFileSync('git', ['-c','core.hooksPath=/dev/null',...args], { cwd: repository, encoding: 'utf8', stdio: ['ignore','pipe','pipe'] }).trim();
27+
const git = (...args: string[]) => execFileSync('git', ['-c','core.hooksPath=/dev/null',...args], { cwd: repository, env: isolatedGitEnvironment(), encoding: 'utf8', stdio: ['ignore','pipe','pipe'] }).trim();
1528
git('init','-b','main'); git('config','user.name','Codeboost Demo'); git('config','user.email','demo@example.invalid'); git('config','commit.gpgsign','false');
1629
const write = (path: string, text: string | Buffer) => writeFileSync(join(repository,path),text);
1730
const commit = (message: string) => { git('add','-A');git('commit','-m',message);return git('rev-parse','HEAD'); };

‎scripts/git-environment.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/** Git environment names are case-insensitive on Windows. */
2+
export function isolatedGitEnvironment(): NodeJS.ProcessEnv {
3+
return {
4+
...Object.fromEntries(Object.entries(process.env).filter(([key]) => !/^GIT_/i.test(key))),
5+
GIT_CONFIG_NOSYSTEM: '1',
6+
GIT_CONFIG_GLOBAL: '/dev/null',
7+
GIT_TERMINAL_PROMPT: '0',
8+
};
9+
}

‎scripts/plant.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { isolatedGitEnvironment } from './git-environment.ts';
12
import { execFileSync } from 'node:child_process';
23
import { existsSync, mkdirSync, writeFileSync, appendFileSync, lstatSync, readFileSync } from 'node:fs';
34
import { resolve, join, dirname } from 'node:path';
@@ -29,7 +30,7 @@ export function plant(config: ReviewConfig, destination: string, input: PlantInp
2930
const declared=candidates[randomInt(candidates.length)]!;
3031
const owned=history.commits.map((commit,index)=>({index,owner:owners.get(commit.sha)})).filter(entry=>entry.owner&&plan!.items.some(item=>item.id===entry.owner));
3132
const outside=owned[randomInt(owned.length)]!;
32-
const env={...Object.fromEntries(Object.entries(process.env).filter(([key])=>!key.startsWith('GIT_'))),GIT_CONFIG_NOSYSTEM:'1',GIT_CONFIG_GLOBAL:'/dev/null'};
33+
const env=isolatedGitEnvironment();
3334
const gitRaw=(cwd:string,...args:string[])=>execFileSync('git',['-c','core.hooksPath=/dev/null','-c','commit.gpgsign=false',...args],{cwd,env,encoding:'utf8',stdio:['ignore','pipe','pipe'],timeout:30000,maxBuffer:32*1024*1024});
3435
const git=(cwd:string,...args:string[])=>gitRaw(cwd,...args).trim();
3536
// Refuse existing paths, symlink targets, and unsupported declared-file transitions before creating output.

‎test/browser/review.spec.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,3 +74,6 @@ test('routes mixed owned and ambiguous items to attribution resolution',async({p
7474
const head=execFileSync('git',['rev-parse','HEAD'],{cwd:repository,encoding:'utf8'}).trim(),snapshot=app.service.store.getSnapshot(identity);app.service.store.recordHistory(identity,{revision:1,snapshotId:snapshot.id},snapshot.base,head,[{sha:head,owner:'P2',origin:'owned',sourceSha:null}]);
7575
await page.goto(app.url);await page.getByRole('button',{name:/P2 Document retry behavior/}).click();await page.getByRole('button',{name:'Resolve ambiguous changes',exact:true}).click();await expect(page.getByRole('heading',{name:'Ambiguous',exact:true})).toBeVisible();
7676
});
77+
test('keeps review shortcuts active while a toolbar button has focus',async({page})=>{
78+
await page.goto(app.url);await expect(page.getByRole('heading',{name:'Bound exponential retries'})).toBeVisible();await page.getByRole('button',{name:'Refresh',exact:true}).focus();await page.keyboard.press('n');await expect(page.getByRole('heading',{name:'Document retry behavior'})).toBeVisible();
79+
});

‎test/demo.test.ts‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { afterEach, expect, it, vi } from 'vitest';
2+
import { mkdtempSync, renameSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
3+
import { tmpdir } from 'node:os';
4+
import { join } from 'node:path';
5+
import { isolatedGitEnvironment } from '../scripts/git-environment.ts';
6+
import { createDemo } from '../scripts/demo.ts';
7+
const roots: string[] = [];
8+
afterEach(() => { vi.unstubAllEnvs(); roots.splice(0).forEach(root => rmSync(root, { recursive: true, force: true })); });
9+
function root() { const path = mkdtempSync(join(tmpdir(), 'codeboost-demo-')); roots.push(path); return path; }
10+
it('rejects existing demo configs redirected outside their fixture', () => {
11+
const base = root(), directory = join(base, 'demo'), config = createDemo(directory);
12+
expect(createDemo(directory)).toEqual(config);
13+
for (const key of ['repository', 'database'] as const) {
14+
writeFileSync(join(directory, 'review.json'), JSON.stringify({ ...config, [key]: join(base, 'outside') }));
15+
expect(() => createDemo(directory)).toThrow(/fixture/i);
16+
}
17+
});
18+
it('rejects symlinked demo roots, configs, repositories and databases', () => {
19+
const base = root(), directory = join(base, 'demo'); createDemo(directory);
20+
symlinkSync(directory, join(base, 'alias'));
21+
expect(() => createDemo(join(base, 'alias'))).toThrow(/fixture/i);
22+
for (const name of ['review.json', 'retry-service', 'review.sqlite']) {
23+
const path = join(directory, name), backup = join(base, name);
24+
// Move each object out of the fixture and replace it with a symlink.
25+
renameSync(path, backup); symlinkSync(backup, path);
26+
expect(() => createDemo(directory)).toThrow(/fixture/i);
27+
rmSync(path); renameSync(backup, path);
28+
}
29+
});
30+
it('scrubs inherited Git repository and config environment in demo commands', () => {
31+
const base = root();
32+
vi.stubEnv('GIT_DIR', join(base, 'outside.git'));
33+
vi.stubEnv('GIT_WORK_TREE', join(base, 'outside'));
34+
vi.stubEnv('GIT_CONFIG_COUNT', '1');
35+
vi.stubEnv('GIT_CONFIG_KEY_0', 'init.defaultBranch');
36+
vi.stubEnv('GIT_CONFIG_VALUE_0', 'inherited');
37+
const config = createDemo(join(base, 'demo'));
38+
expect(readFileSync(join(config.repository, 'retry.ts'), 'utf8')).toContain('Math.min');
39+
});
40+
41+
it('scrubs Git environment names case-insensitively for Windows', () => {
42+
vi.stubEnv('git_dir', '/outside'); vi.stubEnv('Git_Work_Tree', '/outside');
43+
const env = isolatedGitEnvironment();
44+
expect(env).not.toHaveProperty('git_dir'); expect(env).not.toHaveProperty('Git_Work_Tree');
45+
expect(env.GIT_CONFIG_NOSYSTEM).toBe('1');
46+
});

‎web/public/app.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -397,7 +397,7 @@ document.addEventListener("keydown", (event) => {
397397
event.metaKey ||
398398
event.altKey ||
399399
$("dialog").open ||
400-
["TEXTAREA", "INPUT", "SELECT", "BUTTON"].includes(
400+
["TEXTAREA", "INPUT", "SELECT"].includes(
401401
document.activeElement?.tagName,
402402
) ||
403403
!data

0 commit comments

Comments
 (0)