import { existsSync } from "node:fs"; import { resolve } from "node:path"; import { git } from "../git"; import { refreshWorkspaceDeps } from "./deps"; import { syncBaseFromOrigin } from "./git-state"; import { nextBranchName, worktreePathRedirected } from "./names"; import { shortBranch } from "./parse"; import type { WorkspaceDepsRefreshResult } from "agent-relay-sdk"; export interface IdleRefreshResult { workspaceId?: string; /** True when the worktree was successfully refreshed to origin/main. */ refreshed: boolean; /** New branch name after refresh (`nextBranchName` --N). Present when `refreshed`. */ newBranch?: string; /** SHA of origin/main the worktree now sits on. Present when `refreshed`. */ baseSha?: string; /** Human-readable skip reason when `refreshed` is false and there is no error. */ reason?: string; /** Git/system error that prevented the refresh. */ error?: string; depsRefresh?: WorkspaceDepsRefreshResult; } /** * Proactively refresh an idle branch-agent worktree to the current upstream tip * (origin/main or equivalent), making it current before the agent's next turn. * * Safety predicates — ALL must hold before the worktree is touched: * 1. Worktree is clean (zero uncommitted/untracked changes, re-verified live). * 2. No commits ahead of base (nothing to lose on a branch reset). * 3. A remote upstream is configured for the base branch (we know who to follow). * 4. HEAD is an ancestor of the upstream tip (FF-only; divergence = skip). * 5. HEAD is not already AT the upstream tip (skip if already current). * * On success: `checkout -B `, old branch deleted, deps refreshed. * On any predicate failure: returns `{ refreshed: false, reason }` — never mutates. */ export async function idleRefreshWorktree(input: { id?: string; worktreePath?: string; repoRoot?: string; branch?: string; baseRef?: string; baseSha?: string; }): Promise { if (!input.worktreePath) return { refreshed: false, error: "worktreePath required" }; const worktreePath = resolve(input.worktreePath); if (!existsSync(worktreePath)) return { workspaceId: input.id, refreshed: false, reason: "worktree missing" }; // #1502 — refuse a symlink-redirected worktree; this path runs rebase/reset git ops against it. if (worktreePathRedirected(worktreePath)) return { workspaceId: input.id, refreshed: false, reason: "worktree path is a symlink or unresolvable (#1502)" }; const repoRoot = input.repoRoot ? resolve(input.repoRoot) : worktreePath; // Predicate 1: re-check live dirty count (stored metadata may be stale). const status = await git(["status", "--porcelain"], worktreePath); if (!status.ok) return { workspaceId: input.id, refreshed: false, error: status.stderr || "git status failed" }; const dirty = status.stdout ? status.stdout.split("\n").filter(Boolean).length : 0; if (dirty > 0) return { workspaceId: input.id, refreshed: false, reason: "worktree has uncommitted changes" }; const base = input.baseRef; if (!base) return { workspaceId: input.id, refreshed: false, reason: "no base ref" }; // Predicate 2: no commits ahead of base. const countResult = await git(["rev-list", "--count", `${base}..HEAD`], worktreePath); const ahead = countResult.ok ? Number(countResult.stdout.trim()) : NaN; if (!Number.isFinite(ahead)) return { workspaceId: input.id, refreshed: false, reason: "could not determine ahead count" }; if (ahead > 0) return { workspaceId: input.id, refreshed: false, reason: "workspace has commits ahead of base" }; // Predicate 3: fetch origin and resolve an upstream ref. const startRef = await syncBaseFromOrigin(worktreePath, base); if (!startRef || startRef === base) { return { workspaceId: input.id, refreshed: false, reason: "no upstream configured for base branch" }; } // Predicate 5: skip if already current (HEAD SHA == upstream tip). const headSha = (await git(["rev-parse", "HEAD"], worktreePath)).stdout.trim(); const upstreamSha = (await git(["rev-parse", startRef], worktreePath)).stdout.trim(); if (headSha && headSha === upstreamSha) { return { workspaceId: input.id, refreshed: false, reason: "already current with origin" }; } // Predicate 4: FF-only guard — HEAD must be an ancestor of the upstream tip. // If not, the base has diverged; leave it for the conflict scan. if (!(await git(["merge-base", "--is-ancestor", "HEAD", startRef], worktreePath)).ok) { return { workspaceId: input.id, refreshed: false, reason: "diverged from origin — leaving for conflict scan" }; } // All predicates passed — safe to advance. const liveBranch = shortBranch((await git(["symbolic-ref", "--quiet", "--short", "HEAD"], worktreePath)).stdout || undefined); const branch = liveBranch ?? input.branch; if (!branch) return { workspaceId: input.id, refreshed: false, reason: "could not determine current branch" }; const fresh = await nextBranchName(repoRoot, branch); if (!(await git(["checkout", "-B", fresh, startRef], worktreePath)).ok) { return { workspaceId: input.id, refreshed: false, error: "git checkout failed" }; } // Old branch is now orphaned (no commits of its own) — safe to delete. await git(["branch", "-D", branch], repoRoot); const baseSha = (await git(["rev-parse", "HEAD"], worktreePath)).stdout.trim() || undefined; const depsRefresh = await refreshWorkspaceDeps(repoRoot, worktreePath); const reportDeps = depsRefresh.refreshed || depsRefresh.stale || depsRefresh.error; return { workspaceId: input.id, refreshed: true, newBranch: fresh, ...(baseSha ? { baseSha } : {}), ...(reportDeps ? { depsRefresh } : {}), }; }