181 lines
5.1 KiB
Plaintext
Executable File
181 lines
5.1 KiB
Plaintext
Executable File
#!/usr/bin/env bun
|
|
|
|
import { accessSync, constants, existsSync, realpathSync } from 'node:fs';
|
|
import { dirname, join, normalize, resolve, sep } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import process from 'node:process';
|
|
import { analyzeGitExecution, evaluateGitPolicies } from '../scripts/git-policy';
|
|
|
|
const commandArgs = ['git', ...process.argv.slice(2)];
|
|
const context = analyzeGitExecution(commandArgs, process.cwd());
|
|
const gitBinary = findRealGitBinary();
|
|
if (!gitBinary) {
|
|
console.error('Unable to locate the system git binary outside the Sweetistics git shim.');
|
|
process.exit(1);
|
|
}
|
|
|
|
if (!shouldEnforcePolicies(context)) {
|
|
process.exit(await runGitPassthrough(gitBinary));
|
|
}
|
|
|
|
const evaluation = evaluateGitPolicies(context);
|
|
|
|
if (evaluation.requiresCommitHelper) {
|
|
console.error(
|
|
'Direct git add/commit is disabled. Use ./scripts/committer "chore(runner): describe change" "scripts/runner.ts" instead—see AGENTS.md and ./scripts/committer for details. The helper auto-stashes unrelated files before committing.'
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
if (evaluation.requiresExplicitConsent) {
|
|
if (process.env.RUNNER_THE_USER_GAVE_ME_CONSENT === '1') {
|
|
if (process.env.RUNNER_DEBUG === '1') {
|
|
console.error('[git-shim] Proceeding with guarded git command because RUNNER_THE_USER_GAVE_ME_CONSENT=1.');
|
|
}
|
|
} else {
|
|
console.error(
|
|
`Using git ${context.subcommand ?? ''} requires consent. Set RUNNER_THE_USER_GAVE_ME_CONSENT=1 after verifying with the user, or ask them explicitly before proceeding.`
|
|
);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
if (evaluation.isDestructive) {
|
|
console.error(
|
|
'Destructive git commands require explicit user consent. If you are trying to revert an individual file, carefully undo your changes from memory. Other agents work in this repository and a blanket reset would risk overriding their non-committed work.'
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
process.exit(await runGitPassthrough(gitBinary));
|
|
|
|
function findRealGitBinary(): string | null {
|
|
const scriptDir = normalize(dirname(fileURLToPath(import.meta.url)));
|
|
|
|
const pathCandidates = (process.env.PATH ?? '')
|
|
.split(':')
|
|
.map((segment) => segment.trim())
|
|
.filter((segment) => segment.length > 0);
|
|
|
|
for (const segment of pathCandidates) {
|
|
const normalized = normalize(resolve(segment));
|
|
if (normalized === scriptDir) {
|
|
continue;
|
|
}
|
|
const candidate = join(normalized, 'git');
|
|
if (isExecutable(candidate)) {
|
|
return candidate;
|
|
}
|
|
}
|
|
|
|
for (const fallback of ['/usr/bin/git', '/usr/local/bin/git', '/opt/homebrew/bin/git']) {
|
|
if (isExecutable(fallback)) {
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function isExecutable(candidate: string): boolean {
|
|
if (!existsSync(candidate)) {
|
|
return false;
|
|
}
|
|
try {
|
|
accessSync(candidate, constants.X_OK);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function runGitPassthrough(binary: string): Promise<number> {
|
|
const proc = Bun.spawn([binary, ...process.argv.slice(2)], {
|
|
stdin: 'inherit',
|
|
stdout: 'inherit',
|
|
stderr: 'inherit',
|
|
env: process.env,
|
|
});
|
|
return proc.exited;
|
|
}
|
|
|
|
function shouldEnforcePolicies(context: ReturnType<typeof analyzeGitExecution>): boolean {
|
|
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
|
let repoRoot: string | null = null;
|
|
try {
|
|
repoRoot = realpathSync(resolve(scriptDir, '..'));
|
|
} catch {
|
|
return false;
|
|
}
|
|
|
|
const candidatePaths = collectCandidatePaths(context);
|
|
|
|
for (const path of candidatePaths) {
|
|
if (path && isPathInsideRepo(path, repoRoot)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function collectCandidatePaths(context: ReturnType<typeof analyzeGitExecution>): Set<string> {
|
|
const candidates = new Set<string>();
|
|
|
|
if (context.workDir) {
|
|
candidates.add(context.workDir);
|
|
}
|
|
|
|
const argv = toArray(context.invocation?.argv);
|
|
for (let index = 1; index < argv.length; index += 1) {
|
|
const token = argv[index];
|
|
|
|
if (token === '-C' || token === '--git-dir' || token === '--work-tree') {
|
|
const value = argv[index + 1];
|
|
if (value) {
|
|
candidates.add(resolve(context.workDir, value));
|
|
}
|
|
index += 1;
|
|
continue;
|
|
}
|
|
|
|
if (token.startsWith('-C') && token.length > 2) {
|
|
candidates.add(resolve(context.workDir, token.slice(2)));
|
|
continue;
|
|
}
|
|
|
|
if (token.startsWith('--git-dir=')) {
|
|
candidates.add(resolve(context.workDir, token.slice('--git-dir='.length)));
|
|
continue;
|
|
}
|
|
|
|
if (token.startsWith('--work-tree=')) {
|
|
candidates.add(resolve(context.workDir, token.slice('--work-tree='.length)));
|
|
}
|
|
}
|
|
|
|
return candidates;
|
|
}
|
|
|
|
function isPathInsideRepo(candidate: string, repoRoot: string): boolean {
|
|
try {
|
|
const realCandidate = realpathSync(candidate);
|
|
if (realCandidate === repoRoot) {
|
|
return true;
|
|
}
|
|
return realCandidate.startsWith(repoRoot.endsWith(sep) ? repoRoot : `${repoRoot}${sep}`);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function toArray<T>(value: T | T[] | null | undefined): T[] {
|
|
if (value == null) {
|
|
return [];
|
|
}
|
|
if (Array.isArray(value)) {
|
|
return value;
|
|
}
|
|
return [value];
|
|
}
|