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
58 changes: 58 additions & 0 deletions packages/storage/src/__tests__/git-exec.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
});
79 changes: 79 additions & 0 deletions packages/storage/src/git-exec.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<Uint8Array> {
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;
}
43 changes: 8 additions & 35 deletions packages/storage/src/git-worktree-child-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,24 +17,21 @@
* 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,
type ProvisionSubagentWorktreeInput,
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;
Expand Down Expand Up @@ -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,
[
Expand All @@ -133,7 +130,7 @@ class GitWorktreeChildExecutor implements SubagentWorktreeExecutor {
binding.baseCommit,
'--',
],
env,
gitOptions,
);
} finally {
await rm(temporary, { recursive: true, force: true });
Expand Down Expand Up @@ -482,41 +479,17 @@ async function gitCurrentBranch(cwd: string): Promise<string | undefined> {
async function runGit(
cwd: string,
args: readonly string[],
overrides: Readonly<Record<string, string>> = {},
options: GitExecOptions = {},
): Promise<string> {
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<Record<string, string>> = {},
options: GitExecOptions = {},
): Promise<Uint8Array> {
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<Record<string, string>>): 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 {
Expand Down
32 changes: 5 additions & 27 deletions packages/storage/src/project-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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';
Expand Down Expand Up @@ -912,29 +909,10 @@ function isPathWithin(root: string, candidate: string): boolean {
async function resolveGitLocation(
canonicalPath: string,
): Promise<NonNullable<ResolvedProjectLocation['git']>> {
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) {
Expand Down
24 changes: 5 additions & 19 deletions packages/storage/src/workspace-identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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;
Expand Down Expand Up @@ -152,21 +149,10 @@ async function createWorkspaceMarker(
async function ensureWorkspaceMarkerIgnored(workspacePath: string): Promise<void> {
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)) {
Expand Down