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
4 changes: 4 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,10 @@ export {
type CreateWorktreeOpts,
} from './worktree/index.js';

// Scrubbed environment for spawning `git` against an explicit cwd (strips
// inherited GIT_* so a leaked GIT_DIR can't redirect the call).
export { gitSpawnEnv } from './util/git-env.js';

// launchd LaunchAgent installer (M8 — macOS scheduled tasks daemon)
export {
buildPlist,
Expand Down
17 changes: 11 additions & 6 deletions packages/core/src/sessions/snapshots.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,22 @@ import {
listSnapshots,
restoreSnapshot,
} from './snapshots.js';
import { gitSpawnEnv } from '../util/git-env.js';

const exec = promisify(execFile);
// env: gitSpawnEnv() is essential here — without it, running this test inside a
// git hook (a contributor's pre-commit running `pnpm test`) makes `git init`
// target the leaked GIT_DIR and re-initialize the real repo as bare.
const GIT = { env: gitSpawnEnv() };
async function gitInit(dir: string): Promise<void> {
await exec('git', ['init', '-q'], { cwd: dir });
await exec('git', ['config', 'user.email', '[email protected]'], { cwd: dir });
await exec('git', ['config', 'user.name', 'Test'], { cwd: dir });
await exec('git', ['config', 'commit.gpgsign', 'false'], { cwd: dir });
await exec('git', ['init', '-q'], { cwd: dir, ...GIT });
await exec('git', ['config', 'user.email', '[email protected]'], { cwd: dir, ...GIT });
await exec('git', ['config', 'user.name', 'Test'], { cwd: dir, ...GIT });
await exec('git', ['config', 'commit.gpgsign', 'false'], { cwd: dir, ...GIT });
}
async function gitCommitAll(dir: string, msg: string): Promise<void> {
await exec('git', ['add', '-A'], { cwd: dir });
await exec('git', ['commit', '-q', '-m', msg], { cwd: dir });
await exec('git', ['add', '-A'], { cwd: dir, ...GIT });
await exec('git', ['commit', '-q', '-m', msg], { cwd: dir, ...GIT });
}

describe('snapshots', () => {
Expand Down
9 changes: 8 additions & 1 deletion packages/core/src/sessions/snapshots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { createHash } from 'node:crypto';
import { dirname, isAbsolute, join, resolve } from 'node:path';
import { promisify } from 'node:util';
import { sessionFiles } from './storage.js';
import { gitSpawnEnv } from '../util/git-env.js';

const execFileAsync = promisify(execFile);

Expand All @@ -32,7 +33,13 @@ export interface Snapshot {
}

async function git(cwd: string, args: string[]): Promise<string> {
const { stdout } = await execFileAsync('git', args, { cwd, maxBuffer: 16 * 1024 * 1024 });
// env: gitSpawnEnv() strips inherited GIT_* vars so a leaked GIT_DIR (e.g. from
// a surrounding git hook) can't redirect this away from `cwd`. See git-env.ts.
const { stdout } = await execFileAsync('git', args, {
cwd,
env: gitSpawnEnv(),
maxBuffer: 16 * 1024 * 1024,
});
return stdout.trim();
}

Expand Down
14 changes: 9 additions & 5 deletions packages/core/src/tools/worktree-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,24 @@ import { join } from 'node:path';
import { promisify } from 'node:util';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { EnterWorktreeTool, ExitWorktreeTool } from './worktree-tools.js';
import { gitSpawnEnv } from '../util/git-env.js';
import type { ToolContext } from '../types.js';

const exec = promisify(execFile);
// env: gitSpawnEnv() strips inherited GIT_* so this setup can't be hijacked when
// the suite runs inside a git hook (which would re-init the real repo as bare).
const GIT = { env: gitSpawnEnv() };

describe('EnterWorktree / ExitWorktree', () => {
let repo: string;
beforeEach(async () => {
repo = await mkdtemp(join(tmpdir(), 'dc-wt-'));
await exec('git', ['init', '-q'], { cwd: repo });
await exec('git', ['config', 'user.email', 't@t'], { cwd: repo });
await exec('git', ['config', 'user.name', 't'], { cwd: repo });
await exec('git', ['init', '-q'], { cwd: repo, ...GIT });
await exec('git', ['config', 'user.email', 't@t'], { cwd: repo, ...GIT });
await exec('git', ['config', 'user.name', 't'], { cwd: repo, ...GIT });
await writeFile(join(repo, 'a.txt'), 'hi');
await exec('git', ['add', '.'], { cwd: repo });
await exec('git', ['commit', '-qm', 'init'], { cwd: repo });
await exec('git', ['add', '.'], { cwd: repo, ...GIT });
await exec('git', ['commit', '-qm', 'init'], { cwd: repo, ...GIT });
});
afterEach(async () => {
await rm(repo, { recursive: true, force: true });
Expand Down
37 changes: 37 additions & 0 deletions packages/core/src/util/git-env.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest';
import { gitSpawnEnv } from './git-env.js';

describe('gitSpawnEnv', () => {
it('strips every GIT_* variable', () => {
const env = gitSpawnEnv({
GIT_DIR: '/somewhere/.git',
GIT_WORK_TREE: '/somewhere',
GIT_INDEX_FILE: '/somewhere/.git/index',
GIT_OBJECT_DIRECTORY: '/somewhere/.git/objects',
GIT_AUTHOR_NAME: 'x',
PATH: '/usr/bin',
});
expect(env.GIT_DIR).toBeUndefined();
expect(env.GIT_WORK_TREE).toBeUndefined();
expect(env.GIT_INDEX_FILE).toBeUndefined();
expect(env.GIT_OBJECT_DIRECTORY).toBeUndefined();
expect(env.GIT_AUTHOR_NAME).toBeUndefined();
});

it('preserves non-GIT variables', () => {
const env = gitSpawnEnv({ PATH: '/usr/bin', HOME: '/home/u', GIT_DIR: '/x' });
expect(env.PATH).toBe('/usr/bin');
expect(env.HOME).toBe('/home/u');
});

it('does not mutate the input env', () => {
const base = { GIT_DIR: '/x', PATH: '/usr/bin' };
gitSpawnEnv(base);
expect(base.GIT_DIR).toBe('/x');
});

it('output never contains a GIT_* key (defaults to process.env)', () => {
const env = gitSpawnEnv();
expect(Object.keys(env).some((k) => k.startsWith('GIT_'))).toBe(false);
});
});
27 changes: 27 additions & 0 deletions packages/core/src/util/git-env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// Environment for spawning `git` against an explicit directory.
//
// why: git resolves the repository from GIT_DIR / GIT_WORK_TREE / GIT_INDEX_FILE
// (and friends) in the environment BEFORE falling back to the directory it is
// invoked in. When DeepCode — or, more commonly, its test suite — runs inside a
// `git` hook (a contributor's pre-commit hook executing `pnpm test`), git
// exports these vars and Node child processes inherit them. Any `git -C <dir> …`
// we then run is silently redirected at the hook's repo instead of <dir>,
// producing "fatal: this operation must be run in a work tree" and — for
// `git init` in a test's temp repo — re-initializing the REAL repo as bare
// (core.bare=true), which breaks every subsequent worktree operation.
//
// Stripping every GIT_* var forces git to rediscover the repository from the
// cwd we actually pass. (worktree/index.ts pioneered this; this is the shared
// helper the rest of the codebase and its tests reuse.)

/**
* Return a copy of `base` (default `process.env`) with every `GIT_*` variable
* removed, suitable for spawning `git` against an explicit `cwd`.
*/
export function gitSpawnEnv(base: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = { ...base };
for (const key of Object.keys(env)) {
if (key.startsWith('GIT_')) delete env[key];
}
return env;
}
26 changes: 13 additions & 13 deletions packages/core/src/worktree/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { promises as fs } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, basename } from 'node:path';
import type { WorktreeConfig } from '../config/types.js';
import { gitSpawnEnv } from '../util/git-env.js';

export interface WorktreeHandle {
/** Absolute path to the worktree dir. */
Expand Down Expand Up @@ -87,22 +88,21 @@ export async function removeWorktree(handle: WorktreeHandle): Promise<void> {
}
runGit(handle.source, ['worktree', 'remove', '--force', handle.path]);
// Delete the branch (best-effort)
const env: NodeJS.ProcessEnv = { ...process.env };
for (const k of Object.keys(env)) {
if (k.startsWith('GIT_')) delete env[k];
}
spawnSync('git', ['-C', handle.source, 'branch', '-D', handle.branch], { stdio: 'pipe', env });
spawnSync('git', ['-C', handle.source, 'branch', '-D', handle.branch], {
stdio: 'pipe',
env: gitSpawnEnv(),
});
}

function runGit(cwd: string, args: string[]): void {
// Strip GIT_* env vars that the parent process may have set (e.g. when
// running inside a `git commit` hook). Otherwise they hijack the cwd
// resolution and we end up operating on the wrong repo's index.
const env: NodeJS.ProcessEnv = { ...process.env };
for (const k of Object.keys(env)) {
if (k.startsWith('GIT_')) delete env[k];
}
const r = spawnSync('git', ['-C', cwd, ...args], { stdio: 'pipe', encoding: 'utf8', env });
// gitSpawnEnv() strips GIT_* vars the parent may have set (e.g. when running
// inside a `git commit` hook); otherwise they hijack cwd resolution and we
// operate on the wrong repo's index. See git-env.ts.
const r = spawnSync('git', ['-C', cwd, ...args], {
stdio: 'pipe',
encoding: 'utf8',
env: gitSpawnEnv(),
});
if (r.status !== 0) {
throw new Error(`git ${args.join(' ')} failed: ${r.stderr || r.stdout}`);
}
Expand Down
Loading