singularity-forge/src/resources/extensions/sf/repo-identity.ts

720 lines
24 KiB
TypeScript

/**
* SF Repo Identity — external state directory primitives.
*
* Computes a stable per-repo identity hash, resolves the external
* `~/.sf/projects/<hash>/` state directory, and manages the
* `<project>/.sf → external` symlink.
*/
import { execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
import {
cpSync,
existsSync,
lstatSync,
mkdirSync,
readdirSync,
readFileSync,
realpathSync,
renameSync,
rmSync,
symlinkSync,
unlinkSync,
writeFileSync,
} from "node:fs";
import { homedir } from "node:os";
import { basename, dirname, join, resolve } from "node:path";
const sfHome = process.env.SF_HOME || join(homedir(), ".sf");
// ─── Repo Metadata ───────────────────────────────────────────────────────────
export interface RepoMeta {
version: number;
hash: string;
gitRoot: string;
remoteUrl: string;
createdAt: string;
}
function isRepoMeta(value: unknown): value is RepoMeta {
if (!value || typeof value !== "object") return false;
const v = value as Record<string, unknown>;
return (
typeof v.version === "number" &&
typeof v.hash === "string" &&
typeof v.gitRoot === "string" &&
typeof v.remoteUrl === "string" &&
typeof v.createdAt === "string"
);
}
/**
* Write (or refresh) repo metadata into the external state directory.
* Called on open so metadata tracks repo path moves while keeping createdAt stable.
* Non-fatal: a metadata write failure must never block project setup.
*/
function writeRepoMeta(
externalPath: string,
remoteUrl: string,
gitRoot: string,
): void {
const metaPath = join(externalPath, "repo-meta.json");
try {
let createdAt = new Date().toISOString();
let _existing: RepoMeta | null = null;
if (existsSync(metaPath)) {
try {
const parsed = JSON.parse(readFileSync(metaPath, "utf-8"));
if (isRepoMeta(parsed)) {
_existing = parsed;
createdAt = parsed.createdAt;
// Fast path: nothing changed.
if (
parsed.version === 1 &&
parsed.hash === basename(externalPath) &&
parsed.gitRoot === gitRoot &&
parsed.remoteUrl === remoteUrl
) {
return;
}
}
} catch {
// Fall through and rewrite invalid metadata.
}
}
const meta: RepoMeta = {
version: 1,
hash: basename(externalPath),
gitRoot,
remoteUrl,
createdAt,
};
// Keep file format stable even when refreshing.
writeFileSync(metaPath, JSON.stringify(meta, null, 2) + "\n", "utf-8");
} catch {
// Non-fatal — metadata write failure should not block project setup
}
}
/**
* Read repo metadata from the external state directory.
* Returns null if the file doesn't exist or can't be parsed.
*/
export function readRepoMeta(externalPath: string): RepoMeta | null {
const metaPath = join(externalPath, "repo-meta.json");
try {
if (!existsSync(metaPath)) return null;
const raw = readFileSync(metaPath, "utf-8");
const parsed = JSON.parse(raw);
return isRepoMeta(parsed) ? parsed : null;
} catch {
return null;
}
}
// ─── Inherited-Repo Detection ───────────────────────────────────────────────
/**
* Check whether `basePath` is inheriting a parent directory's git repo
* rather than being the git root itself.
*
* Returns true when ALL of:
* 1. basePath is inside a git repo (git rev-parse succeeds)
* 2. The resolved git root is a proper ancestor of basePath
* 3. There is no *project* `.sf` directory at the git root or any
* intermediate ancestor (the parent project has not been
* initialised with SF)
*
* When true, the caller should run `git init` at basePath so that
* `repoIdentity()` produces a hash unique to this directory, preventing
* cross-project state leaks (#1639).
*
* When the git root already has a project `.sf`, the directory is a
* legitimate subdirectory of an existing SF project — `cd src/ && /sf`
* should still load the parent project's milestones.
*/
export function isInheritedRepo(basePath: string): boolean {
try {
const root = resolveGitRoot(basePath);
const normalizedBase = canonicalizeExistingPath(basePath);
const normalizedRoot = canonicalizeExistingPath(root);
if (normalizedBase === normalizedRoot) return false; // basePath IS the root
// The git root is a proper ancestor. Check whether it already has .sf
// (i.e. the parent project was initialised with SF).
if (isProjectSf(join(root, ".sf"))) return false;
// Walk up from basePath's parent to the git root checking for .sf.
// Start at dirname(normalizedBase), NOT normalizedBase itself — finding
// .sf at basePath means SF state is set up for THIS project, which
// says nothing about whether the git repo is inherited from an ancestor.
let dir = dirname(normalizedBase);
while (dir !== normalizedRoot && dir !== dirname(dir)) {
if (isProjectSf(join(dir, ".sf"))) return false;
dir = dirname(dir);
}
return true;
} catch {
return false;
}
}
/**
* Distinguish a *project* `.sf` from the global `~/.sf` state directory.
*
* A project `.sf` is either:
* - A symlink to an external state directory (normal post-migration layout)
* - A legacy real directory that is NOT the global SF home
*
* When the user's home directory is itself a git repo (e.g. dotfile managers),
* `~/.sf` exists but is the global state directory — not a project `.sf`.
* Treating it as a project `.sf` would cause isInheritedRepo() to wrongly
* conclude that subdirectories are part of the home "project" (#2393).
*/
function isProjectSf(sfPath: string): boolean {
if (!existsSync(sfPath)) return false;
try {
const stat = lstatSync(sfPath);
// Symlinks are always project .sf (created by ensureSfSymlink).
if (stat.isSymbolicLink()) return true;
// For real directories, check that this isn't the global SF home.
// Recompute sfHome dynamically so env overrides (SF_HOME) are
// picked up at call time, not just at module load time.
if (stat.isDirectory()) {
const currentSfHome = process.env.SF_HOME || join(homedir(), ".sf");
const normalizedSfPath = canonicalizeExistingPath(sfPath);
const normalizedSfHome = canonicalizeExistingPath(currentSfHome);
if (normalizedSfPath === normalizedSfHome) return false;
return true;
}
} catch {
// lstat failed — treat as no .sf present
}
return false;
}
// ─── Repo Identity ──────────────────────────────────────────────────────────
/**
* Get the git remote URL for "origin", or "" if no remote is configured.
* Uses `git config` rather than `git remote get-url` for broader compat.
*/
function getRemoteUrl(basePath: string): string {
try {
return execFileSync("git", ["config", "--get", "remote.origin.url"], {
cwd: basePath,
encoding: "utf-8",
stdio: ["ignore", "pipe", "ignore"],
timeout: 5_000,
}).trim();
} catch {
return "";
}
}
/**
* Resolve the git toplevel (real root) for the given path.
* For worktrees this returns the main repo root, not the worktree path.
*/
function canonicalizeExistingPath(path: string): string {
try {
// Use native realpath on Windows to resolve 8.3 short paths (e.g. RUNNER~1)
return process.platform === "win32"
? realpathSync.native(path)
: realpathSync(path);
} catch {
return resolve(path);
}
}
function resolveGitCommonDir(basePath: string): string {
try {
return execFileSync(
"git",
["rev-parse", "--path-format=absolute", "--git-common-dir"],
{
cwd: basePath,
encoding: "utf-8",
stdio: ["ignore", "pipe", "ignore"],
timeout: 5_000,
},
).trim();
} catch {
const raw = execFileSync("git", ["rev-parse", "--git-common-dir"], {
cwd: basePath,
encoding: "utf-8",
stdio: ["ignore", "pipe", "ignore"],
timeout: 5_000,
}).trim();
return resolve(basePath, raw);
}
}
function resolveGitRoot(basePath: string): string {
try {
const commonDir = resolveGitCommonDir(basePath);
const normalizedCommonDir = commonDir.replaceAll("\\", "/");
// Normal repo or worktree with shared common dir pointing at <repo>/.git.
if (normalizedCommonDir.endsWith("/.git")) {
return canonicalizeExistingPath(resolve(commonDir, ".."));
}
// Some git setups may still expose <repo>/.git/worktrees/<name>.
const worktreeMarker = "/.git/worktrees/";
if (normalizedCommonDir.includes(worktreeMarker)) {
return canonicalizeExistingPath(resolve(commonDir, "..", ".."));
}
// Fallback for unusual layouts.
return canonicalizeExistingPath(
execFileSync("git", ["rev-parse", "--show-toplevel"], {
cwd: basePath,
encoding: "utf-8",
stdio: ["ignore", "pipe", "ignore"],
timeout: 5_000,
}).trim(),
);
} catch {
return resolve(basePath);
}
}
/**
* Validate a SF_PROJECT_ID value.
*
* Must contain only alphanumeric characters, hyphens, and underscores.
* Call this once at startup so the user gets immediate feedback on bad values.
*/
export function validateProjectId(id: string): boolean {
return /^[a-zA-Z0-9_-]+$/.test(id);
}
/**
* Compute a stable identity for a repository.
*
* If `SF_PROJECT_ID` is set, returns it directly (validation is expected
* to have already happened at startup via `validateProjectId`).
*
* For repos with a remote URL, returns SHA-256 of the remote URL only —
* this makes the identity stable across directory moves/renames (#2750).
*
* For local-only repos (no remote), includes the git root in the hash.
* Local repos use a `.sf-id` marker file for recovery after moves.
*
* Deterministic: same repo always produces the same hash regardless of
* which worktree the caller is inside.
*/
export function repoIdentity(basePath: string): string {
const projectId = process.env.SF_PROJECT_ID;
if (projectId) {
return projectId;
}
const remoteUrl = getRemoteUrl(basePath);
if (remoteUrl) {
// Remote URL alone uniquely identifies the repo — path is redundant.
// This makes moves transparent for repos with remotes (#2750).
return createHash("sha256").update(remoteUrl).digest("hex").slice(0, 12);
}
// Local-only repo: include git root since there's no remote to anchor identity.
const root = resolveGitRoot(basePath);
const input = `\n${root}`;
return createHash("sha256").update(input).digest("hex").slice(0, 12);
}
// ─── External State Directory ───────────────────────────────────────────────
/**
* Compute the external SF state directory for a repository.
*
* Returns `$SF_STATE_DIR/projects/<hash>` if `SF_STATE_DIR` is set,
* otherwise `~/.sf/projects/<hash>`.
*/
export function externalSfRoot(basePath: string): string {
const base = process.env.SF_STATE_DIR || sfHome;
return join(base, "projects", repoIdentity(basePath));
}
/**
* Resolve the root directory that stores project-scoped external state.
* Honors SF_STATE_DIR override before falling back to SF_HOME.
*/
export function externalProjectsRoot(): string {
const base = process.env.SF_STATE_DIR || sfHome;
return join(base, "projects");
}
// ─── Numbered Variant Cleanup ────────────────────────────────────────────────
/**
* macOS collision pattern: `.sf 2`, `.sf 3`, `.sf 4`, etc.
*
* When `symlinkSync` (or Finder) tries to create `.sf` but a real directory
* already exists at that path, macOS APFS silently renames the new entry to
* `.sf 2`, then `.sf 3`, and so on. These numbered variants confuse SF
* because the canonical `.sf` path no longer resolves to the external state
* directory, making tracked planning files appear deleted.
*
* This helper scans the project root for entries matching `.sf <digits>` and
* removes them. It is called early in `ensureSfSymlink()` so that the
* canonical `.sf` path is always the one in use.
*/
const SF_NUMBERED_VARIANT_RE = /^\.sf \d+$/;
export function cleanNumberedSfVariants(projectPath: string): string[] {
const removed: string[] = [];
try {
const entries = readdirSync(projectPath);
for (const entry of entries) {
if (SF_NUMBERED_VARIANT_RE.test(entry)) {
const fullPath = join(projectPath, entry);
try {
rmSync(fullPath, { recursive: true, force: true });
removed.push(entry);
} catch {
// Best-effort: if removal fails (e.g. permissions), continue with next
}
}
}
} catch {
// Non-fatal: readdir failure should not block symlink creation
}
return removed;
}
// ─── .sf-id Marker ─────────────────────────────────────────────────────────
/**
* Write a `.sf-id` marker file in the project root.
*
* This file records the identity hash used for the external state directory.
* For local-only repos (no remote), this marker survives directory moves and
* enables automatic recovery of orphaned state (#2750).
*
* The marker is gitignored by ensureGitignore(). Non-fatal: failure to write
* the marker must never block project setup.
*/
function writeSfIdMarker(projectPath: string, identity: string): void {
try {
const markerPath = join(projectPath, ".sf-id");
// Only write if content differs to avoid unnecessary disk writes.
if (existsSync(markerPath)) {
try {
if (readFileSync(markerPath, "utf-8").trim() === identity) return;
} catch {
/* fall through and overwrite */
}
}
writeFileSync(markerPath, identity + "\n", "utf-8");
} catch {
// Non-fatal — marker write failure should not block project setup
}
}
/**
* Read the `.sf-id` marker from the project root.
* Returns the identity hash, or null if the marker doesn't exist or is unreadable.
*/
function readSfIdMarker(projectPath: string): string | null {
try {
const markerPath = join(projectPath, ".sf-id");
if (!existsSync(markerPath)) return null;
const content = readFileSync(markerPath, "utf-8").trim();
return /^[a-zA-Z0-9_-]+$/.test(content) ? content : null;
} catch {
return null;
}
}
/**
* Check whether an external state directory has meaningful content.
* Returns true if the directory contains any files or subdirectories
* beyond just repo-meta.json.
*/
function hasProjectState(externalPath: string): boolean {
try {
if (!existsSync(externalPath)) return false;
const entries = readdirSync(externalPath);
return entries.some((e) => e !== "repo-meta.json");
} catch {
return false;
}
}
export function hasExternalProjectState(externalPath: string): boolean {
return hasProjectState(externalPath);
}
/**
* Resolve the external state directory, with recovery for relocated projects.
*
* For local-only repos where the computed identity produces an empty state dir,
* checks the `.sf-id` marker for the original identity hash and recovers
* the old state directory if it still exists and contains data (#2750).
*
* Returns the resolved external path (may differ from the computed identity).
*/
function resolveExternalPathWithRecovery(projectPath: string): string {
const computedPath = externalSfRoot(projectPath);
const computedId = repoIdentity(projectPath);
// Check if computed path already has state — fast path, no recovery needed.
if (hasProjectState(computedPath)) {
return computedPath;
}
// Check for .sf-id marker from a previous location.
const markerId = readSfIdMarker(projectPath);
if (markerId && markerId !== computedId) {
// The marker points to a different identity — the repo was likely moved.
const base = process.env.SF_STATE_DIR || sfHome;
const markerPath = join(base, "projects", markerId);
if (hasProjectState(markerPath)) {
// Recover: use the old state directory and update the marker to the new identity.
// Move the state from the old hash dir to the new one so future lookups work
// without the marker.
try {
mkdirSync(computedPath, { recursive: true });
const entries = readdirSync(markerPath);
for (const entry of entries) {
try {
const src = join(markerPath, entry);
const dst = join(computedPath, entry);
// Use rename for same-filesystem (fast) or fall back to copy.
try {
renameSync(src, dst);
} catch {
cpSync(src, dst, { recursive: true, force: true });
}
} catch {
/* continue with remaining entries */
}
}
// Clean up old directory after successful migration.
try {
rmSync(markerPath, { recursive: true, force: true });
} catch {
/* non-fatal */
}
} catch {
// If migration fails, just point at the old directory.
return markerPath;
}
}
}
return computedPath;
}
// ─── Symlink Management ─────────────────────────────────────────────────────
/**
* Ensure the `<project>/.sf` symlink points to the external state directory.
*
* 1. Clean up any macOS numbered collision variants (`.sf 2`, `.sf 3`, etc.)
* 2. Resolve external dir (with relocation recovery via `.sf-id` marker)
* 3. mkdir -p the external dir
* 4. If `<project>/.sf` doesn't exist → create symlink
* 5. If `<project>/.sf` is already the correct symlink → no-op
* 6. If `<project>/.sf` is a real directory → return as-is (migration handles later)
* 7. Write `.sf-id` marker for future relocation recovery
*
* Returns the resolved external path.
*/
export function ensureSfSymlink(projectPath: string): string {
const result = ensureSfSymlinkCore(projectPath);
// Write .sf-id marker so future relocations can recover this state (#2750).
// Only write for the project root (not subdirectories or worktrees that
// delegate to a parent .sf).
if (!isInsideWorktree(projectPath)) {
writeSfIdMarker(projectPath, repoIdentity(projectPath));
}
return result;
}
function ensureSfSymlinkCore(projectPath: string): string {
const externalPath = resolveExternalPathWithRecovery(projectPath);
const localSf = join(projectPath, ".sf");
const inWorktree = isInsideWorktree(projectPath);
// Guard: Never create a symlink at ~/.sf — that's the user-level SF home,
// not a project .sf. This can happen if resolveProjectRoot() or
// escapeStaleWorktree() returned ~ as the project root (#1676).
const localSfNormalized = localSf.replaceAll("\\", "/");
const sfHomePath = sfHome.replaceAll("\\", "/");
if (localSfNormalized === sfHomePath) {
return localSf;
}
// Guard: If projectPath is a plain subdirectory (not a worktree) of a git
// repo that already has a .sf at the git root, do not create a duplicate
// symlink in the subdirectory — that causes `.sf 2` collision variants on
// macOS (#2380). Worktrees are excluded because they legitimately need their
// own .sf symlink pointing at the shared external state dir.
if (!inWorktree) {
try {
const gitRoot = resolveGitRoot(projectPath);
const normalizedProject = canonicalizeExistingPath(projectPath);
const normalizedRoot = canonicalizeExistingPath(gitRoot);
if (normalizedProject !== normalizedRoot) {
const rootSf = join(gitRoot, ".sf");
if (existsSync(rootSf)) {
try {
const rootStat = lstatSync(rootSf);
if (rootStat.isSymbolicLink() || rootStat.isDirectory()) {
return rootStat.isSymbolicLink()
? realpathSync(rootSf)
: rootSf;
}
} catch {
// Fall through to normal logic if we can't stat root .sf
}
}
}
} catch {
// If git root detection fails, fall through to normal logic
}
}
// Clean up macOS numbered collision variants (.sf 2, .sf 3, etc.) before
// any existence checks — otherwise they accumulate and confuse state (#2205).
cleanNumberedSfVariants(projectPath);
// Ensure external directory exists
mkdirSync(externalPath, { recursive: true });
// Write repo metadata once so cleanup commands can identify this directory later.
writeRepoMeta(
externalPath,
getRemoteUrl(projectPath),
resolveGitRoot(projectPath),
);
const replaceWithSymlink = (): string => {
rmSync(localSf, { recursive: true, force: true });
// Defensive: remove any residual entry (e.g. dangling symlink) before creating.
try {
unlinkSync(localSf);
} catch {
/* already gone */
}
symlinkSync(externalPath, localSf, "junction");
return externalPath;
};
// Check for dangling symlinks (e.g. after relocation recovery removed the old
// state dir). existsSync follows symlinks, so it returns false for dangling ones.
// lstatSync does NOT follow, so we can detect the dangling symlink and replace it.
if (!existsSync(localSf)) {
try {
const stat = lstatSync(localSf);
if (stat.isSymbolicLink()) {
// Dangling symlink — replace with correct one (#2750).
return replaceWithSymlink();
}
} catch {
// lstat also failed — nothing exists at this path
}
// Nothing exists yet — create symlink.
// Defensive: remove any residual entry to avoid EEXIST race (#2750).
try {
unlinkSync(localSf);
} catch {
/* nothing to remove */
}
symlinkSync(externalPath, localSf, "junction");
return externalPath;
}
try {
const stat = lstatSync(localSf);
if (stat.isSymbolicLink()) {
// Already a symlink — verify it points to the right place
const target = realpathSync(localSf);
if (target === externalPath) {
return externalPath; // correct symlink, no-op
}
// In a worktree, mismatched symlinks are always stale. Heal them so
// the worktree points at the same external state dir as the main repo.
if (inWorktree) {
return replaceWithSymlink();
}
// After identity hash change (e.g. upgrade from path-based to remote-only
// hash, or relocation recovery), migrate data from old target to new path
// and update the symlink (#2750).
if (!hasProjectState(externalPath) && hasProjectState(target)) {
try {
mkdirSync(externalPath, { recursive: true });
const oldEntries = readdirSync(target);
for (const entry of oldEntries) {
try {
const src = join(target, entry);
const dst = join(externalPath, entry);
try {
renameSync(src, dst);
} catch {
cpSync(src, dst, { recursive: true, force: true });
}
} catch {
/* continue */
}
}
try {
rmSync(target, { recursive: true, force: true });
} catch {
/* non-fatal */
}
return replaceWithSymlink();
} catch {
// Migration failed — preserve old symlink
return target;
}
}
// Outside worktrees, preserve custom overrides or legacy symlinks.
return target;
}
if (stat.isDirectory()) {
// Real directory in the main repo — migration will handle this later.
// In worktrees, keep the directory in place and let syncSfStateToWorktree
// refresh its contents. Replacing a git-tracked .sf directory with a
// symlink makes git think tracked planning files were deleted.
return localSf;
}
} catch {
// lstat failed — path exists but we can't stat it
}
return localSf;
}
// ─── Worktree Detection ─────────────────────────────────────────────────────
/**
* Check if the given directory is a git worktree (not the main repo).
*
* Git worktrees have a `.git` *file* (not directory) containing a
* `gitdir:` pointer. This is git's native worktree indicator — no
* string marker parsing needed.
*/
export function isInsideWorktree(cwd: string): boolean {
const gitPath = join(cwd, ".git");
try {
const stat = lstatSync(gitPath);
if (!stat.isFile()) return false;
const content = readFileSync(gitPath, "utf-8").trim();
return content.startsWith("gitdir:");
} catch {
return false;
}
}