import { createHash } from "node:crypto"; import { existsSync, realpathSync } from "node:fs"; import { basename, join, resolve } from "node:path"; import { sanitizeFsName } from "agent-relay-sdk/fs-name"; import { git } from "../git"; import type { WorkspaceResolutionInput } from "./types"; // #1502 — is this worktree path a SYMLINK redirecting to a differently-named target? `resolve()` is // lexical, so a final-component symlink (e.g. `/` → a sibling victim checkout, or an // escape outside the managed root) would otherwise be followed by every git op. A legitimate managed // worktree is a real directory whose canonical final segment equals its lexical one; realpath and // compare. An existing-but-unresolvable path (broken/dangling link) also counts as redirected // (fail-closed). A non-existent path is left to the normal missing-worktree handling. Ancestor // symlinks (e.g. /home → /data/home) are tolerated: only the FINAL segment name is compared. export function worktreePathRedirected(worktreePath: string): boolean { const path = resolve(worktreePath); if (!existsSync(path)) return false; try { return basename(realpathSync(path)) !== basename(path); } catch { return true; } } export async function availableBranch(repoRoot: string, base: string): Promise { for (let i = 0; i < 50; i++) { const candidate = i === 0 ? base : `${base}-${i + 1}`; const existing = await git(["show-ref", "--verify", "--quiet", `refs/heads/${candidate}`], repoRoot); if (!existing.ok) return candidate; } throw new Error(`could not find available branch name for ${base}`); } export function workspaceId(input: WorkspaceResolutionInput): string { const raw = input.spawnRequestId || input.automationRunId || crypto.randomUUID(); return safeSegment(raw, 80); } // The branch a workspace lands INTO. Normally the source checkout's current branch // (`main`, or a deliberate feature branch). But when an agent is (re-)spawned from inside // a managed worktree — e.g. one recycled onto `agent//-N` after a land — the // source HEAD is that transient session branch. Chaining a new workspace onto it strands // the work one hop short of the real base: an `agent/...` branch has no remote upstream, // so the land advances a local-only branch that never reaches origin/main while still // reporting "✅ landed" (#285). Resolve to the repo's terminal base in that case; only fall // back to the session branch if no default base can be found (no worse than before). export async function terminalBaseRef(repoRoot: string, currentBranch: string | undefined): Promise { if (!currentBranch || !currentBranch.startsWith("agent/")) return currentBranch; return await repoDefaultBranch(repoRoot) ?? currentBranch; } // The repo's mainline: origin/HEAD's target when set (and present locally), else local // `main`/`master`. Returns undefined when none resolve. async function repoDefaultBranch(repoRoot: string): Promise { const head = await git(["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], repoRoot); if (head.ok && head.stdout) { const name = head.stdout.replace(/^[^/]+\//, ""); // strip the `origin/` remote prefix if (name && (await git(["show-ref", "--verify", "--quiet", `refs/heads/${name}`], repoRoot)).ok) return name; } for (const candidate of ["main", "master"]) { if ((await git(["show-ref", "--verify", "--quiet", `refs/heads/${candidate}`], repoRoot)).ok) return candidate; } return undefined; } export function branchName(input: WorkspaceResolutionInput, id: string): string { const owner = input.policyName || input.label || input.automationId || "manual"; // 40 fits a full UUID (36) plus the `sp_`-stripped slack. A tighter cap (was 24) // sliced the session UUID mid-string, leaving an unaddressable, dangling ref (#282). return `agent/${safeSegment(owner, 48)}/${safeSegment(id.replace(/^sp[_-]?/, ""), 40)}`; } /** Next free `-N` cycle name for a recycled worktree (#206). Strips any * existing -N suffix so cycles increment instead of nesting. */ export async function nextBranchName(repoRoot: string, branch: string): Promise { const stem = branch.replace(/-\d+$/, ""); for (let i = 2; i < 1000; i++) { const candidate = `${stem}-${i}`; if (!(await git(["show-ref", "--verify", "--quiet", `refs/heads/${candidate}`], repoRoot)).ok) return candidate; } return `${stem}-${Date.now()}`; } export function workspacesRoot(baseDir: string): string { return join(resolve(baseDir), ".agent-relay", "workspaces"); } export function repoSlug(repoRoot: string): string { const hash = createHash("sha1").update(resolve(repoRoot)).digest("hex").slice(0, 10); return `${safeSegment(basename(repoRoot), 60)}-${hash}`; } function safeSegment(value: string, max: number): string { return sanitizeFsName(value, { replacement: "-", trimWhitespace: true, trimEdge: true, maxLen: max, fallback: "workspace" }); }