Ctrl K
ComunidadGuías…

src/workspace/repo-store.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { lstat, mkdir, realpath, rm, stat } from 'node:fs/promises';
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
import { git } from './git.ts';
import type { Trace } from '../harness/trace.ts';
import { patchpawPaths } from '../config/paths.ts';
import type { WorkspaceHeadFetch } from '../scm/types.ts';
import { withFileLock } from '../platform/lock.ts';
import { safeStorageDirectory } from '../scm/identity.ts';

// One GitHub repository = one persistent bare object store under repos; runs get Git
// worktrees, never clones. The cache outlives runs, so it must never live under a run directory.
export function repoCachePath(runtimeHome: string, repo: string) {
  const directory = repo.startsWith('gitlab:') ? safeStorageDirectory(repo) : encodeURIComponent(repo.toLowerCase());
  return join(patchpawPaths(runtimeHome).repos, `${directory}.git`);
}
export function runWorkspacePath(runtimeHome: string, runId: string) {
  return join(patchpawPaths(runtimeHome).workspaces, runId);
}

const lockTimeoutMs = 600_000;

// Git pathspecs used by read-only evidence tools are repository paths, never host paths.
// Normalize Windows separators for the Git argv while rejecting traversal before a revision:path
// expression is constructed.
export function validateRepoRelativePath(value: string): string {
  const normalized = value.replaceAll('\\', '/');
  if (!normalized || normalized.includes('\0') || isAbsolute(normalized) || /^[A-Za-z]:\//.test(normalized)) {
    throw new Error(`Repository path must be relative: ${value}`);
  }
  if (normalized.split('/').includes('..')) throw new Error(`Repository path traversal is not allowed: ${value}`);
  return normalized;
}

// Repo metadata lock: guards operations that mutate the SHARED repository (init, remote
// setup/update, fetch, worktree add/remove/prune). It is held only for metadata operations,
// never during Agent work, so different PR worktrees run concurrently. The portable lock
// reclaims only owner records whose PID is no longer alive.
export async function withRepoLock<T>(cache: string, task: () => Promise<T>): Promise<T> {
  const repoDirectory = dirname(cache);
  await mkdir(repoDirectory, { recursive: true });
  // Keep lock bookkeeping out of the bare repository directory. The portable
  // lock uses a directory sidecar, and placing it beside a cache would make
  // discovery mistake that sidecar for another repository.
  const runtimeHome = dirname(repoDirectory);
  const lockPath = join(runtimeHome, 'locks', 'repos', `${basename(cache)}.lock`);
  return withFileLock(lockPath, 'exclusive', { timeoutMs: lockTimeoutMs, reentrant: false }, task);
}

export async function ensureRepo(root: string, repo: string, cloneUrl: string, trace: Trace) {
  const cache = repoCachePath(root, repo);
  await withRepoLock(cache, async () => {
    const probe = await git(dirname(cache), ['--git-dir', cache, 'rev-parse', '--is-bare-repository'], trace, undefined, true);
    if (probe.exitCode !== 0 || probe.stdout.trim() !== 'true') {
      await git(dirname(cache), ['init', '--bare', cache], trace);
      trace.emit('repo_cache_initialized', { repo, cache, clone_url: cloneUrl });
    }
    // Converge remote and bot identity on EVERY ensure: a crash between the init steps must
    // heal on the next run instead of wedging the cache forever. Recovery never deletes or
    // reclones the shared object store.
    const origin = await git(cache, ['remote', 'get-url', 'origin'], trace, undefined, true);
    if (origin.exitCode !== 0) {
      await git(cache, ['remote', 'add', 'origin', cloneUrl], trace);
      trace.emit('repo_cache_remote_added', { repo, clone_url: cloneUrl });
    } else if (origin.stdout.trim() !== cloneUrl) {
      // Repository transfer/rename: reconcile the URL without recreating anything.
      await git(cache, ['remote', 'set-url', 'origin', cloneUrl], trace);
      trace.emit('repo_cache_remote_updated', { repo, clone_url: cloneUrl });
    }
    await git(cache, ['config', 'user.name', 'patchpawwww[bot]'], trace);
    await git(cache, ['config', 'user.email', 'patchpawwww[bot]@users.noreply.github.com'], trace);
  });
  return cache;
}

// Every run refreshes the shared store before the resume/fresh-workspace decision. No pull,
// no branch checkout: the exact PR head object plus the current target branch tip, forcing the
// remote-tracking ref so a rewound base branch is still reflected. Automatic GC stays off while
// worktrees are active; explicit maintenance is a separate later concern.
export async function fetchPRState(root: string, repo: string, input: { headSha: string; baseRef: string; headFetch: WorkspaceHeadFetch }, trace: Trace, env?: NodeJS.ProcessEnv) {
  const cache = repoCachePath(root, repo);
  return withRepoLock(cache, async () => {
    if (input.headFetch.kind === 'remote') {
      await git(cache, ['-c', 'gc.auto=0', 'fetch', '--no-tags', input.headFetch.url, input.headSha], trace, env);
      await git(cache, ['-c', 'gc.auto=0', 'fetch', '--no-tags', 'origin',
        `+refs/heads/${input.baseRef}:refs/remotes/origin/${input.baseRef}`], trace, env);
    } else {
      await git(cache, ['-c', 'gc.auto=0', 'fetch', '--no-tags', 'origin', input.headSha,
        `+refs/heads/${input.baseRef}:refs/remotes/origin/${input.baseRef}`], trace, env);
    }
    const currentBaseTipSha = (await git(cache, ['rev-parse', `refs/remotes/origin/${input.baseRef}`], trace)).stdout.trim();
    trace.emit('repo_cache_fetched', { repo, head_sha: input.headSha, base_ref: input.baseRef, current_base_tip_sha: currentBaseTipSha });
    return { baseRef: input.baseRef, currentBaseTipSha };
  });
}

export async function createWorktree(root: string, repo: string, path: string, headSha: string, trace: Trace) {
  const cache = repoCachePath(root, repo);
  await withRepoLock(cache, async () => {
    await mkdir(dirname(path), { recursive: true });
    await git(cache, ['worktree', 'add', '--detach', path, headSha], trace);
    trace.emit('worktree_created', { repo, workspace: path, head_sha: headSha });
  });
}

// Git-aware cleanup: registered worktrees are removed through Git, then metadata is pruned.
// Removal must CONVERGE: if the Git-aware remove fails while the controlled path still exists,
// the directory is removed explicitly and the metadata pruned; a path that still exists at the
// end is an error, never a silent success. Missing paths are tolerated (crash convergence).
// The trace is optional: mechanical lifecycle callers (e.g. /close) journal their own audit facts.
export async function removeWorktree(root: string, repo: string, path: string, trace?: Trace) {
  const cache = repoCachePath(root, repo);
  await withRepoLock(cache, async () => {
    const removed = await git(cache, ['worktree', 'remove', '--force', path], trace, undefined, true);
    if (await stat(path).then(() => true, () => false)) await rm(path, { recursive: true, force: true });
    await git(cache, ['worktree', 'prune'], trace);
    if (await stat(path).then(() => true, () => false)) throw new Error(`Worktree removal did not converge: ${path}`);
    trace?.emit('worktree_removed', { repo, workspace: path, git_removed: removed.exitCode === 0 });
  });
}

// Structural deletion boundary (handoff core invariant: /close and every lifecycle cleanup can
// NEVER touch the shared repository). Disposable paths are exactly:
//   new layout:  <runtimeHome>/workspaces/<workspace-id>          (the workspace root itself)
//   legacy:      <runtimeHome>/runs/<run-id>/workspace             (pre-shared-repo paused clones)
// Everything else — repos/**, data/memory/**, data/state/**, snapshots/**, a run directory
// itself, the runtime home itself, or anything outside — is refused by structured path arithmetic, not by a
// broad prefix match, so a corrupted paused pointer or journal can never aim disposal at them.
export function isDisposableViewspace(runtimeHome: string, workspace: string) {
  if (!isAbsolute(workspace)) return false;
  const target = resolve(workspace);
  const escapes = (rel: string) => rel === '' || rel === '..' || rel.startsWith(`..${sep}`);
  const paths = patchpawPaths(runtimeHome);
  const inWorkspaces = relative(paths.workspaces, target);
  if (!escapes(inWorkspaces) && !inWorkspaces.includes(sep)) return true;
  const inRuns = relative(paths.runs, target);
  if (escapes(inRuns)) return false;
  const parts = inRuns.split(sep);
  return parts.length === 2 && parts[1] === 'workspace';
}

/**
 * Verify that a retained approval workspace is the real, registered linked worktree owned by
 * this repository cache. Lexical path checks alone are insufficient because a path inside
 * workspaces can be replaced by a symlink or an unrelated checkout between runs.
 */
export async function isManagedWorktree(root: string, repo: string, workspace: string, expectedHead?: string, trace?: Trace) {
  if (!isDisposableViewspace(root, workspace)) return false;
  try {
    const target = resolve(workspace);
    const entry = await lstat(target);
    if (!entry.isDirectory() || entry.isSymbolicLink()) return false;
    const [workspaceRoot, targetReal] = await Promise.all([realpath(patchpawPaths(root).workspaces), realpath(target)]);
    const relativeTarget = relative(workspaceRoot, targetReal);
    if (!relativeTarget || relativeTarget.includes(sep) || relativeTarget.startsWith(`..${sep}`) || relativeTarget === '..') return false;
    const listed = await git(repoCachePath(root, repo), ['worktree', 'list', '--porcelain'], trace, undefined, true);
    if (listed.exitCode !== 0) return false;
    for (const block of listed.stdout.trim().split(/\n\n+/)) {
      const pathLine = block.split('\n').find(line => line.startsWith('worktree '));
      const headLine = block.split('\n').find(line => line.startsWith('HEAD '));
      if (!pathLine || !headLine) continue;
      const listedPath = resolve(pathLine.slice('worktree '.length));
      if (listedPath === targetReal && (!expectedHead || headLine.slice('HEAD '.length).trim() === expectedHead)) return true;
    }
    return false;
  } catch { return false; }
}

// Dispose any PatchPaw-managed workspace path: a linked worktree goes through Git-aware
// removal, a legacy standalone clone or unregistered leftover is removed recursively.
// Enforces the structural workspace boundary and verifies convergence, so callers can never
// claim cleanup while the directory still exists — and never delete the shared repository.
export async function disposeWorkspacePath(root: string, repo: string, workspace: string, trace?: Trace) {
  if (!isDisposableViewspace(root, workspace)) throw new Error(`Refusing to dispose a path outside the workspace boundary: ${workspace}`);
  if (!await stat(workspace).then(() => true, () => false)) return;
  const linked = await stat(join(workspace, '.git')).then(entry => entry.isFile(), () => false);
  if (linked) await removeWorktree(root, repo, workspace, trace);
  else await rm(workspace, { recursive: true, force: true });
  if (await stat(workspace).then(() => true, () => false)) throw new Error(`Workspace disposal did not converge: ${workspace}`);
  trace?.emit('workspace_path_disposed', { repo, workspace, linked });
}