Something went wrong. Try again.
source dump of claude code forked from oppi.li/claude-code
Something went wrong. Try again.
30 kB · 926 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927import { createHash } from 'crypto'import { readFileSync, realpathSync, statSync } from 'fs'import { open, readFile, realpath, stat } from 'fs/promises'import memoize from 'lodash-es/memoize.js'import { basename, dirname, join, resolve, sep } from 'path'import { hasBinaryExtension, isBinaryContent } from '../constants/files.js'import { getCwd } from './cwd.js'import { logForDebugging } from './debug.js'import { logForDiagnosticsNoPII } from './diagLogs.js'import { execFileNoThrow } from './execFileNoThrow.js'import { getFsImplementation } from './fsOperations.js'import { getCachedBranch, getCachedDefaultBranch, getCachedHead, getCachedRemoteUrl, getWorktreeCountFromFs, isShallowClone as isShallowCloneFs, resolveGitDir,} from './git/gitFilesystem.js'import { logError } from './log.js'import { memoizeWithLRU } from './memoize.js'import { whichSync } from './which.js'
const GIT_ROOT_NOT_FOUND = Symbol('git-root-not-found')
const findGitRootImpl = memoizeWithLRU( (startPath: string): string | typeof GIT_ROOT_NOT_FOUND => { const startTime = Date.now() logForDiagnosticsNoPII('info', 'find_git_root_started')
let current = resolve(startPath) const root = current.substring(0, current.indexOf(sep) + 1) || sep let statCount = 0
while (current !== root) { try { const gitPath = join(current, '.git') statCount++ const stat = statSync(gitPath) // .git can be a directory (regular repo) or file (worktree/submodule) if (stat.isDirectory() || stat.isFile()) { logForDiagnosticsNoPII('info', 'find_git_root_completed', { duration_ms: Date.now() - startTime, stat_count: statCount, found: true, }) return current.normalize('NFC') } } catch { // .git doesn't exist at this level, continue up } const parent = dirname(current) if (parent === current) { break } current = parent }
// Check root directory as well try { const gitPath = join(root, '.git') statCount++ const stat = statSync(gitPath) if (stat.isDirectory() || stat.isFile()) { logForDiagnosticsNoPII('info', 'find_git_root_completed', { duration_ms: Date.now() - startTime, stat_count: statCount, found: true, }) return root.normalize('NFC') } } catch { // .git doesn't exist at root }
logForDiagnosticsNoPII('info', 'find_git_root_completed', { duration_ms: Date.now() - startTime, stat_count: statCount, found: false, }) return GIT_ROOT_NOT_FOUND }, path => path, 50,)
/** * Find the git root by walking up the directory tree. * Looks for a .git directory or file (worktrees/submodules use a file). * Returns the directory containing .git, or null if not found. * * Memoized per startPath with an LRU cache (max 50 entries) to prevent * unbounded growth — gitDiff calls this with dirname(file), so editing many * files across different directories would otherwise accumulate entries forever. */export const findGitRoot = createFindGitRoot()
function createFindGitRoot(): { (startPath: string): string | null cache: typeof findGitRootImpl.cache} { function wrapper(startPath: string): string | null { const result = findGitRootImpl(startPath) return result === GIT_ROOT_NOT_FOUND ? null : result } wrapper.cache = findGitRootImpl.cache return wrapper}
/** * Resolve a git root to the canonical main repository root. * For a regular repo this is a no-op. For a worktree, follows the * `.git` file → `gitdir:` → `commondir` chain to find the main repo's * working directory. * * Submodules (`.git` is a file but no `commondir`) fall through to the * input root, which is correct since submodules are separate repos. * * Memoized with a small LRU to avoid repeated file reads on the hot * path (permission checks, prompt building). */const resolveCanonicalRoot = memoizeWithLRU( (gitRoot: string): string => { try { // In a worktree, .git is a file containing: gitdir: <path> // In a regular repo, .git is a directory (readFileSync throws EISDIR). const gitContent = readFileSync(join(gitRoot, '.git'), 'utf-8').trim() if (!gitContent.startsWith('gitdir:')) { return gitRoot } const worktreeGitDir = resolve( gitRoot, gitContent.slice('gitdir:'.length).trim(), ) // commondir points to the shared .git directory (relative to worktree gitdir). // Submodules have no commondir (readFileSync throws ENOENT) → fall through. const commonDir = resolve( worktreeGitDir, readFileSync(join(worktreeGitDir, 'commondir'), 'utf-8').trim(), ) // SECURITY: The .git file and commondir are attacker-controlled in a // cloned/downloaded repo. Without validation, a malicious repo can point // commondir at any path the victim has trusted, bypassing the trust // dialog and executing hooks from .claude/settings.json on startup. // // Validate the structure matches what `git worktree add` creates: // 1. worktreeGitDir is a direct child of <commonDir>/worktrees/ // → ensures the commondir file we read lives inside the resolved // common dir, not inside the attacker's repo // 2. <worktreeGitDir>/gitdir points back to <gitRoot>/.git // → ensures an attacker can't borrow a victim's existing worktree // entry by guessing its path // Both are required: (1) alone fails if victim has a worktree of the // trusted repo; (2) alone fails because attacker controls worktreeGitDir. if (resolve(dirname(worktreeGitDir)) !== join(commonDir, 'worktrees')) { return gitRoot } // Git writes gitdir with strbuf_realpath() (symlinks resolved), but // gitRoot from findGitRoot() is only lexically resolved. Realpath gitRoot // so legitimate worktrees accessed via a symlinked path (e.g. macOS // /tmp → /private/tmp) aren't rejected. Realpath the directory then join // '.git' — realpathing the .git file itself would follow a symlinked .git // and let an attacker borrow a victim's back-link. const backlink = realpathSync( readFileSync(join(worktreeGitDir, 'gitdir'), 'utf-8').trim(), ) if (backlink !== join(realpathSync(gitRoot), '.git')) { return gitRoot } // Bare-repo worktrees: the common dir isn't inside a working directory. // Use the common dir itself as the stable identity (anthropics/claude-code#27994). if (basename(commonDir) !== '.git') { return commonDir.normalize('NFC') } return dirname(commonDir).normalize('NFC') } catch { return gitRoot } }, root => root, 50,)
/** * Find the canonical git repository root, resolving through worktrees. * * Unlike findGitRoot, which returns the worktree directory (where the `.git` * file lives), this returns the main repository's working directory. This * ensures all worktrees of the same repo map to the same project identity. * * Use this instead of findGitRoot for project-scoped state (auto-memory, * project config, agent memory) so worktrees share state with the main repo. */export const findCanonicalGitRoot = createFindCanonicalGitRoot()
function createFindCanonicalGitRoot(): { (startPath: string): string | null cache: typeof resolveCanonicalRoot.cache} { function wrapper(startPath: string): string | null { const root = findGitRoot(startPath) if (!root) { return null } return resolveCanonicalRoot(root) } wrapper.cache = resolveCanonicalRoot.cache return wrapper}
export const gitExe = memoize((): string => { // Every time we spawn a process, we have to lookup the path. // Let's instead avoid that lookup so we only do it once. return whichSync('git') || 'git'})
export const getIsGit = memoize(async (): Promise<boolean> => { const startTime = Date.now() logForDiagnosticsNoPII('info', 'is_git_check_started')
const isGit = findGitRoot(getCwd()) !== null
logForDiagnosticsNoPII('info', 'is_git_check_completed', { duration_ms: Date.now() - startTime, is_git: isGit, }) return isGit})
export function getGitDir(cwd: string): Promise<string | null> { return resolveGitDir(cwd)}
export async function isAtGitRoot(): Promise<boolean> { const cwd = getCwd() const gitRoot = findGitRoot(cwd) if (!gitRoot) { return false } // Resolve symlinks for accurate comparison try { const [resolvedCwd, resolvedGitRoot] = await Promise.all([ realpath(cwd), realpath(gitRoot), ]) return resolvedCwd === resolvedGitRoot } catch { return cwd === gitRoot }}
export const dirIsInGitRepo = async (cwd: string): Promise<boolean> => { return findGitRoot(cwd) !== null}
export const getHead = async (): Promise<string> => { return getCachedHead()}
export const getBranch = async (): Promise<string> => { return getCachedBranch()}
export const getDefaultBranch = async (): Promise<string> => { return getCachedDefaultBranch()}
export const getRemoteUrl = async (): Promise<string | null> => { return getCachedRemoteUrl()}
/** * Normalizes a git remote URL to a canonical form for hashing. * Converts SSH and HTTPS URLs to the same format: host/owner/repo (lowercase, no .git) * * Examples: * - git@github.com:owner/repo.git -> github.com/owner/repo * - https://github.com/owner/repo.git -> github.com/owner/repo * - ssh://git@github.com/owner/repo -> github.com/owner/repo * - http://local_proxy@127.0.0.1:16583/git/owner/repo -> github.com/owner/repo */export function normalizeGitRemoteUrl(url: string): string | null { const trimmed = url.trim() if (!trimmed) return null
// Handle SSH format: git@host:owner/repo.git const sshMatch = trimmed.match(/^git@([^:]+):(.+?)(?:\.git)?$/) if (sshMatch && sshMatch[1] && sshMatch[2]) { return `${sshMatch[1]}/${sshMatch[2]}`.toLowerCase() }
// Handle HTTPS/SSH URL format: https://host/owner/repo.git or ssh://git@host/owner/repo const urlMatch = trimmed.match( /^(?:https?|ssh):\/\/(?:[^@]+@)?([^/]+)\/(.+?)(?:\.git)?$/, ) if (urlMatch && urlMatch[1] && urlMatch[2]) { const host = urlMatch[1] const path = urlMatch[2]
// CCR git proxy URLs use format: // Legacy: http://...@127.0.0.1:PORT/git/owner/repo (github.com assumed) // GHE: http://...@127.0.0.1:PORT/git/ghe.host/owner/repo (host encoded in path) // Strip the /git/ prefix. If the first segment contains a dot, it's a // hostname (GitHub org names cannot contain dots). Otherwise assume github.com. if (isLocalHost(host) && path.startsWith('git/')) { const proxyPath = path.slice(4) // Remove "git/" prefix const segments = proxyPath.split('/') // 3+ segments where first contains a dot → host/owner/repo (GHE format) if (segments.length >= 3 && segments[0]!.includes('.')) { return proxyPath.toLowerCase() } // 2 segments → owner/repo (legacy format, assume github.com) return `github.com/${proxyPath}`.toLowerCase() }
return `${host}/${path}`.toLowerCase() }
return null}
/** * Returns a SHA256 hash (first 16 chars) of the normalized git remote URL. * This provides a globally unique identifier for the repository that: * - Is the same regardless of SSH vs HTTPS clone * - Does not expose the actual repository name in logs */export async function getRepoRemoteHash(): Promise<string | null> { const remoteUrl = await getRemoteUrl() if (!remoteUrl) return null
const normalized = normalizeGitRemoteUrl(remoteUrl) if (!normalized) return null
const hash = createHash('sha256').update(normalized).digest('hex') return hash.substring(0, 16)}
export const getIsHeadOnRemote = async (): Promise<boolean> => { const { code } = await execFileNoThrow(gitExe(), ['rev-parse', '@{u}'], { preserveOutputOnError: false, }) return code === 0}
export const hasUnpushedCommits = async (): Promise<boolean> => { const { stdout, code } = await execFileNoThrow( gitExe(), ['rev-list', '--count', '@{u}..HEAD'], { preserveOutputOnError: false }, ) return code === 0 && parseInt(stdout.trim(), 10) > 0}
export const getIsClean = async (options?: { ignoreUntracked?: boolean}): Promise<boolean> => { const args = ['--no-optional-locks', 'status', '--porcelain'] if (options?.ignoreUntracked) { args.push('-uno') } const { stdout } = await execFileNoThrow(gitExe(), args, { preserveOutputOnError: false, }) return stdout.trim().length === 0}
export const getChangedFiles = async (): Promise<string[]> => { const { stdout } = await execFileNoThrow( gitExe(), ['--no-optional-locks', 'status', '--porcelain'], { preserveOutputOnError: false, }, ) return stdout .trim() .split('\n') .map(line => line.trim().split(' ', 2)[1]?.trim()) // Remove status prefix (e.g., "M ", "A ", "??") .filter(line => typeof line === 'string') // Remove empty entries}
export type GitFileStatus = { tracked: string[] untracked: string[]}
export const getFileStatus = async (): Promise<GitFileStatus> => { const { stdout } = await execFileNoThrow( gitExe(), ['--no-optional-locks', 'status', '--porcelain'], { preserveOutputOnError: false, }, )
const tracked: string[] = [] const untracked: string[] = []
stdout .trim() .split('\n') .filter(line => line.length > 0) .forEach(line => { const status = line.substring(0, 2) const filename = line.substring(2).trim()
if (status === '??') { untracked.push(filename) } else if (filename) { tracked.push(filename) } })
return { tracked, untracked }}
export const getWorktreeCount = async (): Promise<number> => { return getWorktreeCountFromFs()}
/** * Stashes all changes (including untracked files) to return git to a clean porcelain state * Important: This function stages untracked files before stashing to prevent data loss * @param message - Optional custom message for the stash * @returns Promise<boolean> - true if stash was successful, false otherwise */export const stashToCleanState = async (message?: string): Promise<boolean> => { try { const stashMessage = message || `Claude Code auto-stash - ${new Date().toISOString()}`
// First, check if we have untracked files const { untracked } = await getFileStatus()
// If we have untracked files, add them to the index first // This prevents them from being deleted if (untracked.length > 0) { const { code: addCode } = await execFileNoThrow( gitExe(), ['add', ...untracked], { preserveOutputOnError: false }, )
if (addCode !== 0) { return false } }
// Now stash everything (staged and unstaged changes) const { code } = await execFileNoThrow( gitExe(), ['stash', 'push', '--message', stashMessage], { preserveOutputOnError: false }, ) return code === 0 } catch (_) { return false }}
export type GitRepoState = { commitHash: string branchName: string remoteUrl: string | null isHeadOnRemote: boolean isClean: boolean worktreeCount: number}
export async function getGitState(): Promise<GitRepoState | null> { try { const [ commitHash, branchName, remoteUrl, isHeadOnRemote, isClean, worktreeCount, ] = await Promise.all([ getHead(), getBranch(), getRemoteUrl(), getIsHeadOnRemote(), getIsClean(), getWorktreeCount(), ])
return { commitHash, branchName, remoteUrl, isHeadOnRemote, isClean, worktreeCount, } } catch (_) { // Fail silently - git state is best effort return null }}
export async function getGithubRepo(): Promise<string | null> { const { parseGitRemote } = await import('./detectRepository.js') const remoteUrl = await getRemoteUrl() if (!remoteUrl) { logForDebugging('Local GitHub repo: unknown') return null } // Only return results for github.com — callers (e.g. issue submission) // assume the result is a github.com repository. const parsed = parseGitRemote(remoteUrl) if (parsed && parsed.host === 'github.com') { const result = `${parsed.owner}/${parsed.name}` logForDebugging(`Local GitHub repo: ${result}`) return result } logForDebugging('Local GitHub repo: unknown') return null}
/** * Preserved git state for issue submission. * Uses remote base (e.g., origin/main) which is rarely force-pushed, * unlike local commits that can be GC'd after force push. */export type PreservedGitState = { /** The SHA of the merge-base with the remote branch */ remote_base_sha: string | null /** The remote branch used (e.g., "origin/main") */ remote_base: string | null /** Patch from merge-base to current state (includes uncommitted changes) */ patch: string /** Untracked files with their contents */ untracked_files: Array<{ path: string; content: string }> /** git format-patch output for committed changes between merge-base and HEAD. * Used to reconstruct the actual commit chain (author, date, message) in * replay containers. null when there are no commits between merge-base and HEAD. */ format_patch: string | null /** The current HEAD SHA (tip of the feature branch) */ head_sha: string | null /** The current branch name (e.g., "feat/my-feature") */ branch_name: string | null}
// Size limits for untracked file captureconst MAX_FILE_SIZE_BYTES = 500 * 1024 * 1024 // 500MB per fileconst MAX_TOTAL_SIZE_BYTES = 5 * 1024 * 1024 * 1024 // 5GB totalconst MAX_FILE_COUNT = 20000
// Initial read buffer for binary detection + content reuse. 64KB covers// most source files in a single read; isBinaryContent() internally scans// only its first 8KB for the binary heuristic, so the extra bytes are// purely for avoiding a second read when the file turns out to be text.const SNIFF_BUFFER_SIZE = 64 * 1024
/** * Find the best remote branch to use as a base. * Priority: tracking branch > origin/main > origin/staging > origin/master */export async function findRemoteBase(): Promise<string | null> { // First try: get the tracking branch for the current branch const { stdout: trackingBranch, code: trackingCode } = await execFileNoThrow( gitExe(), ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], { preserveOutputOnError: false }, )
if (trackingCode === 0 && trackingBranch.trim()) { return trackingBranch.trim() }
// Second try: check for common default branch names on origin const { stdout: remoteRefs, code: remoteCode } = await execFileNoThrow( gitExe(), ['remote', 'show', 'origin', '--', 'HEAD'], { preserveOutputOnError: false }, )
if (remoteCode === 0) { // Parse the default branch from remote show output const match = remoteRefs.match(/HEAD branch: (\S+)/) if (match && match[1]) { return `origin/${match[1]}` } }
// Third try: check which common branches exist const candidates = ['origin/main', 'origin/staging', 'origin/master'] for (const candidate of candidates) { const { code } = await execFileNoThrow( gitExe(), ['rev-parse', '--verify', candidate], { preserveOutputOnError: false }, ) if (code === 0) { return candidate } }
return null}
/** * Check if we're in a shallow clone by looking for <gitDir>/shallow. */function isShallowClone(): Promise<boolean> { return isShallowCloneFs()}
/** * Capture untracked files (git diff doesn't include them). * Respects size limits and skips binary files. */async function captureUntrackedFiles(): Promise< Array<{ path: string; content: string }>> { const { stdout, code } = await execFileNoThrow( gitExe(), ['ls-files', '--others', '--exclude-standard'], { preserveOutputOnError: false }, )
const trimmed = stdout.trim() if (code !== 0 || !trimmed) { return [] }
const files = trimmed.split('\n').filter(Boolean) const result: Array<{ path: string; content: string }> = [] let totalSize = 0
for (const filePath of files) { // Check file count limit if (result.length >= MAX_FILE_COUNT) { logForDebugging( `Untracked file capture: reached max file count (${MAX_FILE_COUNT})`, ) break }
// Skip binary files by extension - zero I/O if (hasBinaryExtension(filePath)) { continue }
try { const stats = await stat(filePath) const fileSize = stats.size
// Skip files exceeding per-file limit if (fileSize > MAX_FILE_SIZE_BYTES) { logForDebugging( `Untracked file capture: skipping ${filePath} (exceeds ${MAX_FILE_SIZE_BYTES} bytes)`, ) continue }
// Check total size limit if (totalSize + fileSize > MAX_TOTAL_SIZE_BYTES) { logForDebugging( `Untracked file capture: reached total size limit (${MAX_TOTAL_SIZE_BYTES} bytes)`, ) break }
// Empty file - no need to open if (fileSize === 0) { result.push({ path: filePath, content: '' }) continue }
// Binary sniff on up to SNIFF_BUFFER_SIZE bytes. Caps binary-file reads // at SNIFF_BUFFER_SIZE even though MAX_FILE_SIZE_BYTES allows up to 500MB. // If the file fits in the sniff buffer we reuse it as the content; for // larger text files we fall back to readFile with encoding so the runtime // decodes to a string without materializing a full-size Buffer in JS. const sniffSize = Math.min(SNIFF_BUFFER_SIZE, fileSize) const fd = await open(filePath, 'r') try { const sniffBuf = Buffer.alloc(sniffSize) const { bytesRead } = await fd.read(sniffBuf, 0, sniffSize, 0) const sniff = sniffBuf.subarray(0, bytesRead)
if (isBinaryContent(sniff)) { continue }
let content: string if (fileSize <= sniffSize) { // Sniff already covers the whole file content = sniff.toString('utf-8') } else { // readFile with encoding decodes to string directly, avoiding a // full-size Buffer living alongside the decoded string. The extra // open/close is cheaper than doubling peak memory for large files. content = await readFile(filePath, 'utf-8') }
result.push({ path: filePath, content }) totalSize += fileSize } finally { await fd.close() } } catch (err) { // Skip files we can't read logForDebugging(`Failed to read untracked file ${filePath}: ${err}`) } }
return result}
/** * Preserve git state for issue submission. * Uses remote base for more stable replay capability. * * Edge cases handled: * - Detached HEAD: falls back to merge-base with default branch directly * - No remote: returns null for remote fields, uses HEAD-only mode * - Shallow clone: falls back to HEAD-only mode */export async function preserveGitStateForIssue(): Promise<PreservedGitState | null> { try { const isGit = await getIsGit() if (!isGit) { return null }
// Check for shallow clone - fall back to simpler mode if (await isShallowClone()) { logForDebugging('Shallow clone detected, using HEAD-only mode for issue') const [{ stdout: patch }, untrackedFiles] = await Promise.all([ execFileNoThrow(gitExe(), ['diff', 'HEAD']), captureUntrackedFiles(), ]) return { remote_base_sha: null, remote_base: null, patch: patch || '', untracked_files: untrackedFiles, format_patch: null, head_sha: null, branch_name: null, } }
// Find the best remote base const remoteBase = await findRemoteBase()
if (!remoteBase) { // No remote found - use HEAD-only mode logForDebugging('No remote found, using HEAD-only mode for issue') const [{ stdout: patch }, untrackedFiles] = await Promise.all([ execFileNoThrow(gitExe(), ['diff', 'HEAD']), captureUntrackedFiles(), ]) return { remote_base_sha: null, remote_base: null, patch: patch || '', untracked_files: untrackedFiles, format_patch: null, head_sha: null, branch_name: null, } }
// Get the merge-base with remote const { stdout: mergeBase, code: mergeBaseCode } = await execFileNoThrow( gitExe(), ['merge-base', 'HEAD', remoteBase], { preserveOutputOnError: false }, )
if (mergeBaseCode !== 0 || !mergeBase.trim()) { // Merge-base failed - fall back to HEAD-only logForDebugging('Merge-base failed, using HEAD-only mode for issue') const [{ stdout: patch }, untrackedFiles] = await Promise.all([ execFileNoThrow(gitExe(), ['diff', 'HEAD']), captureUntrackedFiles(), ]) return { remote_base_sha: null, remote_base: null, patch: patch || '', untracked_files: untrackedFiles, format_patch: null, head_sha: null, branch_name: null, } }
const remoteBaseSha = mergeBase.trim()
// All 5 commands below depend only on remoteBaseSha — run them in parallel. // ~5×90ms serial → ~90ms parallel on Bun native (used by /issue and /share). const [ { stdout: patch }, untrackedFiles, { stdout: formatPatchOut, code: formatPatchCode }, { stdout: headSha }, { stdout: branchName }, ] = await Promise.all([ // Patch from merge-base to current state (including staged changes) execFileNoThrow(gitExe(), ['diff', remoteBaseSha]), // Untracked files captured separately captureUntrackedFiles(), // format-patch for committed changes between merge-base and HEAD. // Preserves the actual commit chain (author, date, message) so replay // containers can reconstruct the branch with real commits instead of a // squashed diff. Uses --stdout to emit all patches as a single text stream. execFileNoThrow(gitExe(), [ 'format-patch', `${remoteBaseSha}..HEAD`, '--stdout', ]), // HEAD SHA for replay execFileNoThrow(gitExe(), ['rev-parse', 'HEAD']), // Branch name for replay execFileNoThrow(gitExe(), ['rev-parse', '--abbrev-ref', 'HEAD']), ])
let formatPatch: string | null = null if (formatPatchCode === 0 && formatPatchOut && formatPatchOut.trim()) { formatPatch = formatPatchOut }
const trimmedBranch = branchName?.trim() return { remote_base_sha: remoteBaseSha, remote_base: remoteBase, patch: patch || '', untracked_files: untrackedFiles, format_patch: formatPatch, head_sha: headSha?.trim() || null, branch_name: trimmedBranch && trimmedBranch !== 'HEAD' ? trimmedBranch : null, } } catch (err) { logError(err) return null }}
function isLocalHost(host: string): boolean { const hostWithoutPort = host.split(':')[0] ?? '' return ( hostWithoutPort === 'localhost' || /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostWithoutPort) )}
/** * Checks if the current working directory appears to be a bare git repository * or has been manipulated to look like one (sandbox escape attack vector). * * SECURITY: Git's is_git_directory() function (setup.c:417-455) checks for: * 1. HEAD file - Must be a valid ref * 2. objects/ directory - Must exist and be accessible * 3. refs/ directory - Must exist and be accessible * * If all three exist in the current directory (not in a .git subdirectory), * Git treats the current directory as a bare repository and will execute * hooks/pre-commit and other hook scripts from the cwd. * * Attack scenario: * 1. Attacker creates HEAD, objects/, refs/, and hooks/pre-commit in cwd * 2. Attacker deletes or corrupts .git/HEAD to invalidate the normal git directory * 3. When user runs 'git status', Git treats cwd as the git dir and runs the hook * * @returns true if the cwd looks like a bare/exploited git directory *//* eslint-disable custom-rules/no-sync-fs -- sync permission-eval check */export function isCurrentDirectoryBareGitRepo(): boolean { const fs = getFsImplementation() const cwd = getCwd()
const gitPath = join(cwd, '.git') try { const stats = fs.statSync(gitPath) if (stats.isFile()) { // worktree/submodule — Git follows the gitdir reference return false } if (stats.isDirectory()) { const gitHeadPath = join(gitPath, 'HEAD') try { // SECURITY: check isFile(). An attacker creating .git/HEAD as a // DIRECTORY would pass a bare statSync but Git's setup_git_directory // rejects it (not a valid HEAD) and falls back to cwd discovery. if (fs.statSync(gitHeadPath).isFile()) { // normal repo — .git/HEAD valid, Git won't fall back to cwd return false } // .git/HEAD exists but is not a regular file — fall through } catch { // .git exists but no HEAD — fall through to bare-repo check } } } catch { // no .git — fall through to bare-repo indicator check }
// No valid .git/HEAD found. Check if cwd has bare git repo indicators. // Be cautious — flag if ANY of these exist without a valid .git reference. // Per-indicator try/catch so an error on one doesn't mask another. try { if (fs.statSync(join(cwd, 'HEAD')).isFile()) return true } catch { // no HEAD } try { if (fs.statSync(join(cwd, 'objects')).isDirectory()) return true } catch { // no objects/ } try { if (fs.statSync(join(cwd, 'refs')).isDirectory()) return true } catch { // no refs/ } return false}/* eslint-enable custom-rules/no-sync-fs */