Files
gstack/runtime/identity.js
T

109 lines
3.7 KiB
JavaScript

import fs from "node:fs/promises";
import path from "node:path";
import { createHash } from "node:crypto";
import { execFile as execFileCallback } from "node:child_process";
import { promisify } from "node:util";
const execFile = promisify(execFileCallback);
export function stableId(namespace, value, length = 20) {
const digest = createHash("sha256")
.update(`gstack:${namespace}:v2\0`, "utf8")
.update(String(value), "utf8")
.digest("hex")
.slice(0, length);
return `${namespace}_${digest}`;
}
export async function discoverProjectIdentity(cwd = process.cwd(), options = {}) {
const absoluteCwd = await canonicalPath(cwd);
const git = options.git ?? runGit;
try {
const [worktreeRootRaw, commonDirRaw, gitDirRaw] = await Promise.all([
git(["rev-parse", "--show-toplevel"], absoluteCwd),
git(["rev-parse", "--git-common-dir"], absoluteCwd),
git(["rev-parse", "--git-dir"], absoluteCwd),
]);
const worktreeRoot = await canonicalPath(resolveGitPath(worktreeRootRaw, absoluteCwd));
const commonDir = await canonicalPath(resolveGitPath(commonDirRaw, worktreeRoot));
const gitDir = await canonicalPath(resolveGitPath(gitDirRaw, worktreeRoot));
return identityFromPaths({ worktreeRoot, commonDir, gitDir, isGit: true });
} catch (error) {
if (options.requireGit) throw error;
if (!isNotGitRepository(error)) throw error;
return identityFromPaths({
worktreeRoot: absoluteCwd,
commonDir: absoluteCwd,
gitDir: absoluteCwd,
isGit: false,
});
}
}
export function identityFromPaths({ worktreeRoot, commonDir, gitDir, isGit = true }) {
const resolvedRoot = path.resolve(worktreeRoot);
const resolvedCommon = path.resolve(commonDir);
const resolvedGitDir = path.resolve(gitDir);
const normalizedRoot = normalizeIdentityPath(resolvedRoot);
const normalizedCommon = normalizeIdentityPath(resolvedCommon);
const repoId = stableId("repo", normalizedCommon);
// Linked worktrees have a durable git-dir slot under the common repository.
// The Git slot is stable when a linked checkout moves and unique within the
// common repository. Non-Git folders have no slot, so their canonical path
// remains the identity boundary.
const gitSlot = path.relative(resolvedCommon, resolvedGitDir) || ".";
const worktreeId = stableId(
"worktree",
isGit ? `${repoId}\0${normalizeIdentityPath(gitSlot)}` : `${repoId}\0${normalizedRoot}`,
);
const projectId = stableId("project", `${repoId}\0${worktreeId}`, 24);
return Object.freeze({
projectId,
repoId,
worktreeId,
worktreeRoot: resolvedRoot,
repoCommonDir: resolvedCommon,
gitDir: resolvedGitDir,
isGit,
});
}
function isNotGitRepository(error) {
if (error?.code === "NOT_GIT") return true;
const detail = `${error?.stderr ?? ""}\n${error?.message ?? ""}`;
return (error?.code === 128 || error?.exitCode === 128) && /not a git repository/i.test(detail);
}
async function runGit(args, cwd) {
const { stdout } = await execFile("git", args, {
cwd,
encoding: "utf8",
timeout: 5_000,
maxBuffer: 1024 * 1024,
windowsHide: true,
});
return stdout.replace(/[\r\n]+$/, "");
}
function resolveGitPath(value, cwd) {
if (!value) throw new Error("git returned an empty path");
return path.isAbsolute(value) ? value : path.resolve(cwd, value);
}
async function canonicalPath(value) {
const absolute = path.resolve(value);
try {
return await fs.realpath(absolute);
} catch (error) {
if (error?.code === "ENOENT") return absolute;
throw error;
}
}
function normalizeIdentityPath(value) {
let normalized = path.normalize(String(value)).replaceAll(path.sep, "/");
if (process.platform === "win32") normalized = normalized.toLowerCase();
return normalized;
}