diff --git a/packages/storage/src/__tests__/git-exec.test.ts b/packages/storage/src/__tests__/git-exec.test.ts new file mode 100644 index 0000000000..e37ec33df1 --- /dev/null +++ b/packages/storage/src/__tests__/git-exec.test.ts @@ -0,0 +1,58 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { mkdtemp, realpath, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { promisify } from 'node:util'; +import { execGitBytes, execGitText } from '../git-exec.js'; + +const execFileAsync = promisify(execFile); + +test('Git execution ignores ambient repository variables for text and byte output', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-git-exec-')); + const repository = join(root, 'repository'); + await execFileAsync('git', ['init', '--quiet', repository]); + + const keys = ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_COMMON_DIR', 'GIT_INDEX_FILE'] as const; + const previous = Object.fromEntries(keys.map((key) => [key, process.env[key]])); + try { + process.env.GIT_DIR = join(root, 'wrong-git-dir'); + process.env.GIT_WORK_TREE = join(root, 'wrong-work-tree'); + process.env.GIT_COMMON_DIR = join(root, 'wrong-common-dir'); + process.env.GIT_INDEX_FILE = join(root, 'wrong-index'); + + const expected = await realpath(repository); + const text = await execGitText(repository, ['rev-parse', '--show-toplevel']); + const bytes = await execGitBytes(repository, ['rev-parse', '--show-toplevel']); + + assert.equal(text.trim(), expected); + assert.equal(new TextDecoder().decode(bytes).trim(), expected); + } finally { + for (const key of keys) { + const value = previous[key]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/packages/storage/src/git-exec.ts b/packages/storage/src/git-exec.ts new file mode 100644 index 0000000000..488dddd58e --- /dev/null +++ b/packages/storage/src/git-exec.ts @@ -0,0 +1,79 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); +const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000; +const DEFAULT_TEXT_MAX_BUFFER = 1024 * 1024; +const DEFAULT_BYTES_MAX_BUFFER = Number.MAX_SAFE_INTEGER; + +export interface GitExecOptions { + readonly timeoutMs?: number; + readonly maxBuffer?: number; + readonly gitIndexFile?: string; +} + +/** + * Runs Git with repository discovery isolated from ambient Git environment + * variables. Callers may provide a temporary index for patch construction. + */ +export async function execGitText( + cwd: string, + args: readonly string[], + options: GitExecOptions = {}, +): Promise { + const { stdout } = await execFileAsync('git', ['-C', cwd, ...args], { + env: gitEnvironment(options), + encoding: 'utf8', + maxBuffer: options.maxBuffer ?? DEFAULT_TEXT_MAX_BUFFER, + timeout: options.timeoutMs ?? DEFAULT_TIMEOUT_MS, + windowsHide: true, + }); + return stdout; +} + +export async function execGitBytes( + cwd: string, + args: readonly string[], + options: GitExecOptions = {}, +): Promise { + const { stdout } = await execFileAsync('git', ['-C', cwd, ...args], { + env: gitEnvironment(options), + encoding: 'buffer', + maxBuffer: options.maxBuffer ?? DEFAULT_BYTES_MAX_BUFFER, + timeout: options.timeoutMs ?? DEFAULT_TIMEOUT_MS, + windowsHide: true, + }); + return new Uint8Array(stdout); +} + +function gitEnvironment(options: GitExecOptions): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { ...process.env, GIT_OPTIONAL_LOCKS: '0' }; + delete env.GIT_DIR; + delete env.GIT_WORK_TREE; + delete env.GIT_COMMON_DIR; + if (options.gitIndexFile === undefined) { + delete env.GIT_INDEX_FILE; + } else { + env.GIT_INDEX_FILE = options.gitIndexFile; + } + return env; +} diff --git a/packages/storage/src/git-worktree-child-executor.ts b/packages/storage/src/git-worktree-child-executor.ts index 8aa80617aa..e5ef906414 100644 --- a/packages/storage/src/git-worktree-child-executor.ts +++ b/packages/storage/src/git-worktree-child-executor.ts @@ -17,11 +17,9 @@ * under the License. */ -import { execFile } from 'node:child_process'; import { copyFile, mkdir, mkdtemp, readdir, realpath, rm, stat } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { isAbsolute, join, normalize, resolve } from 'node:path'; -import { promisify } from 'node:util'; import { SUBAGENT_WORKSPACE_BINDING_SCHEMA_VERSION, isSubagentWorkspaceBinding, @@ -29,12 +27,11 @@ import { type SubagentWorkspaceBinding, type SubagentWorktreeExecutor, } from '@maka/core/subagent-workspace'; +import { execGitBytes, execGitText, type GitExecOptions } from './git-exec.js'; import { resolveProjectLocation } from './project-catalog.js'; -const execFileAsync = promisify(execFile); const LEASE_PATTERN = /^subagent_worktree_([a-f0-9]{32})$/; const WORKTREE_DIRECTORY_PATTERN = /^[a-f0-9]{32}$/; -const GIT_TIMEOUT_MS = 2 * 60 * 1_000; export interface CreateGitWorktreeChildExecutorInput { storageRoot: string; @@ -118,8 +115,8 @@ class GitWorktreeChildExecutor implements SubagentWorktreeExecutor { isAbsolute(currentIndex) ? currentIndex : resolve(binding.worktreePath, currentIndex), indexPath, ); - const env = { GIT_INDEX_FILE: indexPath }; - await runGit(binding.worktreePath, ['add', '--all', '--'], env); + const gitOptions = { gitIndexFile: indexPath }; + await runGit(binding.worktreePath, ['add', '--all', '--'], gitOptions); return await runGitBytes( binding.worktreePath, [ @@ -133,7 +130,7 @@ class GitWorktreeChildExecutor implements SubagentWorktreeExecutor { binding.baseCommit, '--', ], - env, + gitOptions, ); } finally { await rm(temporary, { recursive: true, force: true }); @@ -482,41 +479,17 @@ async function gitCurrentBranch(cwd: string): Promise { async function runGit( cwd: string, args: readonly string[], - overrides: Readonly> = {}, + options: GitExecOptions = {}, ): Promise { - const env = gitEnvironment(overrides); - const { stdout } = await execFileAsync('git', ['-C', cwd, ...args], { - env, - encoding: 'utf8', - maxBuffer: 1024 * 1024, - timeout: GIT_TIMEOUT_MS, - windowsHide: true, - }); - return stdout; + return execGitText(cwd, args, options); } async function runGitBytes( cwd: string, args: readonly string[], - overrides: Readonly> = {}, + options: GitExecOptions = {}, ): Promise { - const { stdout } = await execFileAsync('git', ['-C', cwd, ...args], { - env: gitEnvironment(overrides), - encoding: 'buffer', - maxBuffer: Number.MAX_SAFE_INTEGER, - timeout: GIT_TIMEOUT_MS, - windowsHide: true, - }); - return new Uint8Array(stdout); -} - -function gitEnvironment(overrides: Readonly>): NodeJS.ProcessEnv { - const env: NodeJS.ProcessEnv = { ...process.env, GIT_OPTIONAL_LOCKS: '0', ...overrides }; - delete env.GIT_DIR; - delete env.GIT_WORK_TREE; - delete env.GIT_COMMON_DIR; - if (overrides.GIT_INDEX_FILE === undefined) delete env.GIT_INDEX_FILE; - return env; + return execGitBytes(cwd, args, options); } function gitExitCode(error: unknown): number | undefined { diff --git a/packages/storage/src/project-catalog.ts b/packages/storage/src/project-catalog.ts index 7424bf6d05..4f38aa5494 100644 --- a/packages/storage/src/project-catalog.ts +++ b/packages/storage/src/project-catalog.ts @@ -17,14 +17,13 @@ * under the License. */ -import { execFile } from 'node:child_process'; import { randomUUID } from 'node:crypto'; import { realpath, stat } from 'node:fs/promises'; import { basename, dirname, isAbsolute, join, normalize, relative, resolve, sep } from 'node:path'; -import { promisify } from 'node:util'; import type { ProjectLocation, ProjectRecord } from '@maka/core/project'; import type { SessionHeader } from '@maka/core/session'; import { markPersisted } from '@maka/core/persisted-value'; +import { execGitText } from './git-exec.js'; import { hasEnclosingGitEntry } from './git-entry.js'; import { acquireOperationalStateDatabase, @@ -34,8 +33,6 @@ import { decodePersistedSessionHeader, normalizeSessionHeader } from './session- export type { ProjectLocation, ProjectRecord } from '@maka/core/project'; -const execFileAsync = promisify(execFile); - export class ProjectPathMismatchError extends Error { readonly name = 'ProjectPathMismatchError'; readonly code = 'project_path_mismatch'; @@ -912,29 +909,10 @@ function isPathWithin(root: string, candidate: string): boolean { async function resolveGitLocation( canonicalPath: string, ): Promise> { - const env: NodeJS.ProcessEnv = { ...process.env, GIT_OPTIONAL_LOCKS: '0' }; - delete env.GIT_DIR; - delete env.GIT_WORK_TREE; - delete env.GIT_INDEX_FILE; - delete env.GIT_COMMON_DIR; - const { stdout: locationOutput } = await execFileAsync( - 'git', - [ - '-C', - canonicalPath, - 'rev-parse', - '--path-format=absolute', - '--show-toplevel', - '--git-dir', - '--git-common-dir', - ], - { - env, - encoding: 'utf8', - maxBuffer: 64 * 1024, - timeout: 3_000, - windowsHide: true, - }, + const locationOutput = await execGitText( + canonicalPath, + ['rev-parse', '--path-format=absolute', '--show-toplevel', '--git-dir', '--git-common-dir'], + { maxBuffer: 64 * 1024, timeoutMs: 3_000 }, ); const [worktreeRootRaw, gitDirRaw, commonDirRaw] = locationOutput.trim().split(/\r?\n/); if (!worktreeRootRaw || !gitDirRaw || !commonDirRaw) { diff --git a/packages/storage/src/workspace-identity.ts b/packages/storage/src/workspace-identity.ts index b682dafd31..3dcb76e272 100644 --- a/packages/storage/src/workspace-identity.ts +++ b/packages/storage/src/workspace-identity.ts @@ -17,13 +17,12 @@ * under the License. */ -import { execFile } from 'node:child_process'; import { randomUUID } from 'node:crypto'; import { constants as fsConstants, type BigIntStats } from 'node:fs'; import { lstat, open, realpath, stat } from 'node:fs/promises'; import { isAbsolute, join, normalize, parse, resolve } from 'node:path'; -import { promisify } from 'node:util'; +import { execGitText } from './git-exec.js'; import { hasEnclosingGitEntry } from './git-entry.js'; import { publishMarkerFile, readBoundedMarkerFile } from './marker-file.js'; @@ -32,8 +31,6 @@ export const WORKSPACE_MARKER_SCHEMA_VERSION = 1 as const; export const WORKSPACE_IDENTITY_PREFIX = 'workspace:v1:' as const; const MAX_WORKSPACE_MARKER_BYTES = 4_096; const MAX_GIT_EXCLUDE_BYTES = 1024 * 1024; -const execFileAsync = promisify(execFile); - interface WorkspaceMarker { schemaVersion: typeof WORKSPACE_MARKER_SCHEMA_VERSION; workspaceId: string; @@ -152,21 +149,10 @@ async function createWorkspaceMarker( async function ensureWorkspaceMarkerIgnored(workspacePath: string): Promise { if (!(await hasEnclosingGitEntry(workspacePath))) return; - const env: NodeJS.ProcessEnv = { ...process.env, GIT_OPTIONAL_LOCKS: '0' }; - delete env.GIT_DIR; - delete env.GIT_WORK_TREE; - delete env.GIT_INDEX_FILE; - delete env.GIT_COMMON_DIR; - const { stdout } = await execFileAsync( - 'git', - ['-C', workspacePath, 'rev-parse', '--path-format=absolute', '--git-path', 'info/exclude'], - { - env, - encoding: 'utf8', - maxBuffer: 64 * 1024, - timeout: 3_000, - windowsHide: true, - }, + const stdout = await execGitText( + workspacePath, + ['rev-parse', '--path-format=absolute', '--git-path', 'info/exclude'], + { maxBuffer: 64 * 1024, timeoutMs: 3_000 }, ); const excludePath = stdout.trim(); if (!isAbsolute(excludePath)) {