mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-02 15:04:37 +01:00
* feat: a .gstack-project marker makes a nested repo or umbrella folder its own project (#2649, #1923) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: marker tests ignore an ambient GSTACK_STATE_ROOT Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (per-repo records ignore every marker, worktree binding in remote-slug and under a git HOME) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(slug): a linked worktree finds its main checkout's marker on its first run The worktree binding was written only when the main checkout resolved, so a worktree outside an umbrella resolved first kept its old bucket and later switched. Walk up from the main checkout on a miss instead (under its own $HOME rule) and drop the binding file. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs/test: pregate user policy names REPO_SLUG; pin greptile consent to REPO_SLUG Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(slug): no-clobber checkpoint recovery, unambiguous marker cache keys, ignore unusable folder names; remote-slug resolves once Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(slug): remote-slug reads a quoted marker path; repo mode stays per repository Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(slug): the review log and /ship spend-cap pointer stay with the repository under a marker; recovered checkpoints keep their repository; a linked worktree's own marker stands for its main checkout Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(captain): approvals live under the repository's own slug Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
581 lines
25 KiB
TypeScript
581 lines
25 KiB
TypeScript
/**
|
|
* bin-context — tiny shared helpers for non-interactive gstack bins that need the
|
|
* project slug, current branch, and argv flags. Extracted from the decision bins
|
|
* (gstack-decision-log / gstack-decision-search) so the slug/branch/flag plumbing
|
|
* lives in one audited place instead of being copy-pasted per bin.
|
|
*/
|
|
|
|
import { spawnSync } from "child_process";
|
|
import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync, writeFileSync } from "fs";
|
|
import { basename, dirname, isAbsolute, join, resolve, sep } from "path";
|
|
import { resolveStateRoot } from "./state-root";
|
|
import { legacyRemoteSlug, remoteSlug } from "./remote-identity";
|
|
|
|
/** Keep the slug inside the [a-zA-Z0-9._-] alphabet gstack-slug promises (`tr -cd`). */
|
|
function sanitizeSlug(s: string): string {
|
|
return s.replace(/[^a-zA-Z0-9._-]/g, "");
|
|
}
|
|
|
|
/**
|
|
* A Windows path in the MSYS form git-bash's `pwd` reports:
|
|
* `C:\Users\j\foo` → `/c/Users/j/foo`. gstack-slug keys its cache on THAT form
|
|
* (`tr '/' '_'`), so a native lookup must reproduce it exactly or it misses the very
|
|
* entry gstack-slug wrote and silently re-derives instead of staying consistent.
|
|
* Exported for the cache-key test; non-Windows paths pass through unchanged.
|
|
*/
|
|
export function toMsysPath(p: string): string {
|
|
const drive = p.match(/^([A-Za-z]):[\\/]/);
|
|
const body = (drive ? p.slice(2) : p).replace(/\\/g, "/");
|
|
return drive ? `/${drive[1].toLowerCase()}${body}` : body;
|
|
}
|
|
|
|
/** `-f` in bash terms: a regular file (following symlinks), never a directory. */
|
|
function isFile(p: string): boolean {
|
|
try {
|
|
return statSync(p).isFile();
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Marker tiers mirror bin/gstack-slug's `_outermost_project_root` exactly.
|
|
// STRONG = canonical version-control / language project files ("this directory
|
|
// is a real project of its own"); .git is checked separately because it can be
|
|
// a directory (normal repo) or a file (worktree / submodule pointer).
|
|
// WEAK = content-only project signals (markdown bundles, asset collections).
|
|
const STRONG_FILE_MARKERS = [".project.yaml", "package.json", "pyproject.toml", "Cargo.toml", "Gemfile", "go.mod"];
|
|
const WEAK_FILE_MARKERS = ["README.md", "README", "README.rst", "LICENSE", "LICENSE.md"];
|
|
|
|
/**
|
|
* Native port of bin/gstack-slug's `_outermost_project_root`: walk UP
|
|
* from `startDir` tracking the OUTERMOST ancestor holding a strong marker and
|
|
* the outermost holding a weak marker. Outermost STRONG wins; else outermost
|
|
* WEAK; else "". Build/deploy artifacts (.vercel, node_modules, dist, ...) are
|
|
* deliberately NOT markers, so they can't establish a phantom project root.
|
|
*
|
|
* Termination mirrors the bash fix for windows-free-tests: break on dirname's
|
|
* FIXED POINT (drive roots `C:\`, relative `.`, UNC `//srv` never reach the
|
|
* literal "/"), with a 64-depth belt-and-braces cap. Exported for the
|
|
* hostile-path termination tests.
|
|
*/
|
|
export function outermostProjectRoot(startDir: string): string {
|
|
let dir = startDir;
|
|
let outermostStrong = "";
|
|
let outermostWeak = "";
|
|
let depth = 0;
|
|
while (dir && dir !== "/" && depth < 64) {
|
|
if (existsSync(join(dir, ".git")) || STRONG_FILE_MARKERS.some((m) => isFile(join(dir, m)))) {
|
|
outermostStrong = dir;
|
|
} else if (WEAK_FILE_MARKERS.some((m) => isFile(join(dir, m)))) {
|
|
outermostWeak = dir;
|
|
}
|
|
const parent = dirname(dir);
|
|
if (parent === dir) break; // dirname fixed point (C:\, ., //srv)
|
|
dir = parent;
|
|
depth += 1;
|
|
}
|
|
// Strong markers win over weak; either wins over nothing.
|
|
return outermostStrong || outermostWeak;
|
|
}
|
|
|
|
/**
|
|
* Native port of bin/gstack-slug's `_outermost_remote_repo` (step 1a): walk UP
|
|
* from `startDir` tracking the OUTERMOST ancestor that has a `.git` entry
|
|
* (directory for normal clones, FILE for git-worktrees/submodules — `git -C`
|
|
* resolves a worktree's remote through its main clone) AND whose `origin`
|
|
* remote resolves. This is the canonical-identity walk: a marker-only
|
|
* ancestor with no resolvable origin (stray empty ~/.git, stray package.json)
|
|
* cannot win here, so it cannot hijack remote-derived identity the way it can
|
|
* hijack the marker walk above. Nested-repo semantics preserved: an inner
|
|
* repo under an outer canonical-remote repo still resolves to the OUTER
|
|
* repo's remote (outermost wins). git spawns only at `.git`-bearing ancestors
|
|
* — typically one. Exported for the parity tests.
|
|
*/
|
|
export function outermostRemoteRepo(startDir: string): { root: string; url: string } {
|
|
let dir = startDir;
|
|
let root = "";
|
|
let url = "";
|
|
let depth = 0;
|
|
while (dir && dir !== "/" && depth < 64) {
|
|
if (existsSync(join(dir, ".git"))) {
|
|
const r = spawnSync("git", ["-C", dir, "remote", "get-url", "origin"], { encoding: "utf-8" });
|
|
const u = r.status === 0 ? (r.stdout || "").trim() : "";
|
|
if (u) {
|
|
root = dir;
|
|
url = u;
|
|
}
|
|
}
|
|
const parent = dirname(dir);
|
|
if (parent === dir) break; // dirname fixed point (C:\, ., //srv)
|
|
dir = parent;
|
|
depth += 1;
|
|
}
|
|
return { root, url };
|
|
}
|
|
|
|
/**
|
|
* Native port of bin/gstack-slug's resolution order, used when that script cannot be
|
|
* spawned (see resolveSlug). Same steps, same alphabet, same cache file — so this and
|
|
* the shell path always agree. They must: the bins WRITE using this, while the
|
|
* Context Recovery preamble READS using the script.
|
|
*
|
|
* Resolution order (parity with the bash script, pinned by
|
|
* test/bin-context-windows-slug.test.ts against test/gstack-slug-cwd-walk-up.test.ts
|
|
* and test/gstack-slug-parity.test.ts):
|
|
* 0. $GSTACK_PROJECT_SLUG env override — wins over everything, never cached.
|
|
* 0m. A counted .gstack-project marker (see projectMarkerRoot): the marked
|
|
* folder's own remote slug, else its basename, cached in its own entry so
|
|
* removing the marker leaves the repo's entry as it was. Scope
|
|
* "repository" ignores every marker.
|
|
* 1. Walk UP to the OUTERMOST project root (see outermostProjectRoot). Without
|
|
* the walk, a nested/vendored repo derived its slug from the INNERMOST
|
|
* `git remote get-url origin`, splitting the store the bash side keeps whole.
|
|
* 2. Cached slug is sticky (#2212) — EXCEPT two provable bug shapes. Entries
|
|
* are versioned ("v2:<slug>"); an unversioned entry is recomputed once and
|
|
* moves only when it equals the pre-#3003 last-two slug of a 3+-segment
|
|
* remote:
|
|
* - old-bug shape (#1125): cached value equals basename(cwd) while the
|
|
* walk-up says cwd is NOT the project root; that cache came from the
|
|
* pre-walk-up resolver, so recompute and heal.
|
|
* - degraded-ancestor shape (2026-08-17), STRAY-REPO shape ONLY: cached
|
|
* equals the marker root's basename, the marker root is anchored by a
|
|
* .git entry whose origin does NOT resolve (the stray empty ~/.git
|
|
* live bug), and a remote-bearing repo BELOW it exists — the
|
|
* pre-remote-first resolver degraded to that stray ancestor's basename
|
|
* (SLUG=<username>). A marker root anchored by package.json /
|
|
* pyproject etc. with NO .git is legit #2212 sticky identity and must
|
|
* NOT be healed. Legit remote-adopting stickiness is safe too: there
|
|
* the repo that adopted the remote IS the marker root (remote root ==
|
|
* project root), so the heal never fires.
|
|
* 3. Canonical remote-derived slug from the OUTERMOST remote-bearing repo
|
|
* (see outermostRemoteRepo — never PROJECT_ROOT, which may be a
|
|
* marker-only ancestor with no remote): remoteSlug() from
|
|
* lib/remote-identity.ts — owner-repo for 2-segment and local remotes,
|
|
* "<last-two>-<16 hex>" for 3+-segment hosted remotes (#3003) —
|
|
* byte-parity with browse/bin/remote-slug. Degenerate slugs
|
|
* ("", ".", "..", anything with "/") are rejected — a hostile origin
|
|
* like `url = ..` must never escape ~/.gstack/projects/<slug>.
|
|
* 4. Project root's basename; else basename(cwd) for plain non-project folders.
|
|
*/
|
|
/** gstack-slug's per-path cache file: the cwd in MSYS form with "/" → "_". */
|
|
export function slugCacheFile(stateRoot: string, cwd: string): string {
|
|
return join(stateRoot, "slug-cache", toMsysPath(cwd).replace(/\//g, "_"));
|
|
}
|
|
|
|
/**
|
|
* Twin of gstack-slug's `_git_common_dir` (#2767): the physical git common dir
|
|
* of the repository holding `cwd` (nearest `.git` entry), read from `.git`
|
|
* files without spawning git; "" outside git. A linked worktree's `.git` file
|
|
* names its gitdir, whose `commondir` file points at the shared dir.
|
|
*/
|
|
export function gitCommonDir(cwd: string): string {
|
|
let dir = cwd;
|
|
for (let depth = 0; dir && depth < 64; depth++) {
|
|
const dotGit = join(dir, ".git");
|
|
try {
|
|
const st = statSync(dotGit);
|
|
if (st.isDirectory()) return realpathSync(dotGit);
|
|
if (st.isFile()) {
|
|
const m = readFileSync(dotGit, "utf-8").match(/^gitdir: *(.+)$/m);
|
|
if (!m) return "";
|
|
const gitdir = isAbsolute(m[1].trim()) ? m[1].trim() : resolve(dir, m[1].trim());
|
|
let common = gitdir;
|
|
try {
|
|
const rel = readFileSync(join(gitdir, "commondir"), "utf-8").split("\n")[0].trim();
|
|
if (rel) common = isAbsolute(rel) ? rel : resolve(gitdir, rel);
|
|
} catch {
|
|
// no commondir file: a submodule or plain gitdir is its own common dir
|
|
}
|
|
return realpathSync(common);
|
|
}
|
|
} catch {
|
|
// no .git here (or unreadable) — keep walking
|
|
}
|
|
const parent = dirname(dir);
|
|
if (parent === dir) break;
|
|
dir = parent;
|
|
}
|
|
return "";
|
|
}
|
|
|
|
/**
|
|
* The cache entries gstack-slug consults, in its deterministic read order:
|
|
* the repository-scoped entry (keyed on the git common dir), then the main
|
|
* checkout's pre-#2767 per-path entry, then this directory's own per-path
|
|
* entry. Outside git all three collapse to the per-path entry.
|
|
*/
|
|
export function slugCacheReadOrder(stateRoot: string, cwd: string): string[] {
|
|
const legacy = slugCacheFile(stateRoot, cwd);
|
|
const common = gitCommonDir(cwd);
|
|
if (!common) return [legacy];
|
|
const files = [slugCacheFile(stateRoot, common)];
|
|
if (basename(common) === ".git") files.push(slugCacheFile(stateRoot, dirname(common)));
|
|
files.push(legacy);
|
|
return [...new Set(files)];
|
|
}
|
|
|
|
/** Cache entries written since #3003 carry this version prefix ("v2:<slug>"). */
|
|
export const SLUG_CACHE_VERSION_PREFIX = "v2:";
|
|
|
|
/**
|
|
* Project marker (#2649, #1923), twin of bin/gstack-slug's step 0m: the innermost
|
|
* counted `.gstack-project` on the cwd chain makes its folder the project root.
|
|
* A marker counts when it is a regular file (not a link or a directory) owned by
|
|
* this user, below $HOME when the cwd is under $HOME, and its folder is a git
|
|
* toplevel or not inside any git work tree. Stat-only: never spawns git.
|
|
*/
|
|
export const PROJECT_MARKER_FILE = ".gstack-project";
|
|
|
|
function homeForms(): string[] {
|
|
const home = process.env.HOME || process.env.USERPROFILE || "";
|
|
if (!home) return [];
|
|
let phys = home;
|
|
try {
|
|
phys = realpathSync(home);
|
|
} catch {
|
|
// keep the logical form only
|
|
}
|
|
return [...new Set([home, phys])].filter((h) => h !== "/");
|
|
}
|
|
|
|
function isUnder(child: string, dir: string): boolean {
|
|
return child.startsWith(dir.endsWith(sep) ? dir : dir + sep);
|
|
}
|
|
|
|
function isHomeOrAbove(dir: string, homes: string[]): boolean {
|
|
return dirname(dir) === dir || homes.some((h) => h === dir || isUnder(h, dir));
|
|
}
|
|
|
|
/** Why the marker in `dir` does not count, or "" when it counts. */
|
|
export function markerIgnoredReason(dir: string, underHome: boolean, homes: string[] = homeForms()): string {
|
|
const marker = join(dir, PROJECT_MARKER_FILE);
|
|
let st;
|
|
try {
|
|
st = lstatSync(marker);
|
|
} catch {
|
|
return "not a regular file";
|
|
}
|
|
if (st.isSymbolicLink()) return "a symbolic link";
|
|
if (st.isDirectory()) return `a directory, not a file: rmdir '${marker}' && touch '${marker}'`;
|
|
if (!st.isFile()) return "not a regular file";
|
|
if (process.platform !== "win32" && typeof process.geteuid === "function" && st.uid !== process.geteuid()) {
|
|
return "owned by another user";
|
|
}
|
|
if (["", ".", ".."].includes(sanitizeSlug(basename(dir)))) {
|
|
return "its folder name has no letter or digit gstack can use in a project name; rename the folder or set GSTACK_PROJECT_SLUG";
|
|
}
|
|
if (underHome && isHomeOrAbove(dir, homes)) return "at or above $HOME";
|
|
if (existsSync(join(dir, ".git"))) return "";
|
|
let d = dirname(dir);
|
|
for (let depth = 0; depth < 64; depth++) {
|
|
if (underHome && isHomeOrAbove(d, homes)) break;
|
|
if (existsSync(join(d, ".git"))) {
|
|
return `inside the git work tree at ${d}; a marker counts only at a repository's top folder`;
|
|
}
|
|
const parent = dirname(d);
|
|
if (parent === d) break;
|
|
d = parent;
|
|
}
|
|
return "";
|
|
}
|
|
|
|
/** The innermost counted marker's folder (physical) on the cwd chain, plus every marker ignored on the way. */
|
|
export function findProjectMarker(cwd: string): { root: string; ignored: string[] } {
|
|
const homes = homeForms();
|
|
const underHome = homes.some((h) => isUnder(cwd, h));
|
|
const ignored: string[] = [];
|
|
let dir = cwd;
|
|
for (let depth = 0; dir && depth < 64; depth++) {
|
|
const marker = join(dir, PROJECT_MARKER_FILE);
|
|
let present = false;
|
|
try {
|
|
lstatSync(marker);
|
|
present = true;
|
|
} catch {
|
|
// no marker here
|
|
}
|
|
if (present) {
|
|
const why = markerIgnoredReason(dir, underHome, homes);
|
|
if (!why) {
|
|
try {
|
|
return { root: realpathSync(dir), ignored };
|
|
} catch {
|
|
ignored.push(`${marker} (unreadable folder)`);
|
|
}
|
|
} else {
|
|
ignored.push(`${marker} (${why})`);
|
|
}
|
|
}
|
|
if (underHome && isHomeOrAbove(dir, homes)) break;
|
|
const parent = dirname(dir);
|
|
if (parent === dir) break;
|
|
dir = parent;
|
|
}
|
|
return { root: "", ignored };
|
|
}
|
|
|
|
function markerCacheFile(stateRoot: string, root: string): string {
|
|
return join(stateRoot, "slug-cache", "marker", toMsysPath(root).replace(/%/g, "%25").replace(/_/g, "%5f").replace(/\//g, "_"));
|
|
}
|
|
|
|
/**
|
|
* The marked folder that sets this cwd's identity, or "". A linked worktree
|
|
* outside the marked folder follows its main checkout: on a miss the walk
|
|
* repeats from there, as bin/gstack-slug does.
|
|
*/
|
|
export function projectMarkerRoot(cwd: string): string {
|
|
let root = findProjectMarker(cwd).root;
|
|
if (!root) {
|
|
const common = gitCommonDir(cwd);
|
|
const main = common && basename(common) === ".git" ? dirname(common) : "";
|
|
root = main && main !== cwd && !isUnder(cwd, main) ? findProjectMarker(main).root : "";
|
|
}
|
|
if (root && isFile(join(root, ".git"))) {
|
|
const common = gitCommonDir(root);
|
|
if (common && basename(common) === ".git") return dirname(common);
|
|
}
|
|
return root;
|
|
}
|
|
|
|
function writeCacheEntry(file: string, content: string): void {
|
|
try {
|
|
let current: string | undefined;
|
|
try {
|
|
current = readFileSync(file, "utf-8");
|
|
} catch {
|
|
// no entry yet
|
|
}
|
|
if (current === content) return;
|
|
mkdirSync(dirname(file), { recursive: true });
|
|
const tmp = `${file}.tmp.${process.pid}`;
|
|
writeFileSync(tmp, content, "utf-8");
|
|
renameSync(tmp, file);
|
|
} catch {
|
|
// best-effort cache; a miss only costs a re-derive on the next call
|
|
}
|
|
}
|
|
|
|
/** The marked folder's slug: its own canonical remote, else its basename; cached in its own entry. */
|
|
function markerSlug(stateRoot: string, cwd: string, root: string): string {
|
|
const file = markerCacheFile(stateRoot, root);
|
|
let slug = "";
|
|
try {
|
|
const raw = readFileSync(file, "utf-8");
|
|
if (raw.startsWith(SLUG_CACHE_VERSION_PREFIX)) slug = sanitizeSlug(raw.slice(SLUG_CACHE_VERSION_PREFIX.length).trim());
|
|
} catch {
|
|
// miss
|
|
}
|
|
if (slug === "." || slug === "..") slug = "";
|
|
if (!slug) {
|
|
if (existsSync(join(root, ".git"))) {
|
|
const r = spawnSync("git", ["-C", root, "remote", "get-url", "origin"], { encoding: "utf-8" });
|
|
const url = r.status === 0 ? (r.stdout || "").trim() : "";
|
|
const candidate = url ? remoteSlug(url) : "";
|
|
if (candidate && candidate !== "." && candidate !== ".." && !candidate.includes("/")) slug = candidate;
|
|
}
|
|
if (!slug) slug = sanitizeSlug(basename(root));
|
|
if (!slug || slug === "." || slug === "..") return "";
|
|
writeCacheEntry(file, `${SLUG_CACHE_VERSION_PREFIX}${slug}`);
|
|
}
|
|
return slug;
|
|
}
|
|
|
|
|
|
/**
|
|
* The slug a versioned cache entry records for `cwd`, or undefined when there is
|
|
* no entry, the entry predates the versioned format, or its value is unusable
|
|
* (empty, "." or ".." after sanitizing). Never spawns git: hot-path readers
|
|
* (the AskUserQuestion preference hook) rely on gstack-slug, which every skill
|
|
* preamble runs, having already written the entry.
|
|
*/
|
|
export function readVersionedSlugCache(stateRoot: string, cwd: string): string | undefined {
|
|
const marker = projectMarkerRoot(cwd);
|
|
const file = marker ? markerCacheFile(stateRoot, marker) : slugCacheReadOrder(stateRoot, cwd).find((f) => existsSync(f));
|
|
if (!file) return undefined;
|
|
let raw: string;
|
|
try {
|
|
raw = readFileSync(file, "utf-8");
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
if (!raw.startsWith(SLUG_CACHE_VERSION_PREFIX)) return undefined;
|
|
const slug = sanitizeSlug(raw.slice(SLUG_CACHE_VERSION_PREFIX.length).trim());
|
|
return slug && slug !== "." && slug !== ".." ? slug : undefined;
|
|
}
|
|
|
|
export function slugFromEnvironment(
|
|
gstackHome?: string,
|
|
cwd: string = process.cwd(),
|
|
opts: { scope?: "memory" | "repository" } = {},
|
|
): string {
|
|
const home = gstackHome || resolveStateRoot();
|
|
const cacheDir = join(home, "slug-cache");
|
|
const readOrder = slugCacheReadOrder(home, cwd);
|
|
const cacheFile = readOrder.find((f) => existsSync(f));
|
|
const writeTargets = [readOrder[0], slugCacheFile(home, cwd)];
|
|
|
|
// 0. explicit env override — per-invocation escape hatch, never persisted
|
|
// (caching it would rebind THIS cwd's slug for every later env-less run).
|
|
const envSlug = sanitizeSlug((process.env.GSTACK_PROJECT_SLUG || "").trim());
|
|
if (envSlug && envSlug !== "." && envSlug !== "..") return envSlug;
|
|
|
|
// 0m. project marker (#2649) — its own cache entry; the repo's entry below is never touched.
|
|
// Scope "repository" (pregate policy and scratch, approvals, /verify) ignores
|
|
// every marker, so a branch that commits one can't move those records
|
|
// (twin of gstack-slug --get REPO_SLUG).
|
|
const markerRoot = opts.scope === "repository" ? "" : projectMarkerRoot(cwd);
|
|
if (markerRoot) {
|
|
const slug = markerSlug(home, cwd, markerRoot);
|
|
if (slug) return slug;
|
|
}
|
|
|
|
// 1. outermost project root along the cwd ancestor chain (may be "").
|
|
const projectRoot = outermostProjectRoot(cwd);
|
|
|
|
// Lazy, memoized remote discovery (mirrors gstack-slug's _resolve_remote):
|
|
// needed on exactly two paths — fresh resolution and the degraded-ancestor
|
|
// heal check — so ordinary cache hits stay git-spawn-free.
|
|
let remote: { root: string; url: string } | null = null;
|
|
const resolveRemote = () => (remote ??= outermostRemoteRepo(cwd));
|
|
|
|
let slug = "";
|
|
// 2. cached slug is sticky (#2212), except the two provable bug shapes
|
|
// (old-bug #1125 and degraded-ancestor 2026-08-17 — see the doc above).
|
|
if (cacheFile) {
|
|
try {
|
|
const raw = readFileSync(cacheFile, "utf-8");
|
|
const versioned = raw.startsWith(SLUG_CACHE_VERSION_PREFIX);
|
|
const cached = sanitizeSlug((versioned ? raw.slice(SLUG_CACHE_VERSION_PREFIX.length) : raw).trim());
|
|
if (cached) {
|
|
const pwdBase = sanitizeSlug(basename(cwd));
|
|
const rootBase = projectRoot ? sanitizeSlug(basename(projectRoot)) : "";
|
|
const oldBugShape = cached === pwdBase && projectRoot !== "" && projectRoot !== cwd;
|
|
const degradedAncestorShape =
|
|
!oldBugShape &&
|
|
projectRoot !== "" &&
|
|
cached === rootBase &&
|
|
// STRAY-REPO shape only: the marker root must be anchored by a .git
|
|
// entry whose origin does NOT resolve. A root anchored by
|
|
// package.json etc. (no .git) is legit #2212 sticky identity.
|
|
existsSync(join(projectRoot, ".git")) &&
|
|
(() => {
|
|
const rootOrigin = spawnSync("git", ["-C", projectRoot, "remote", "get-url", "origin"], {
|
|
encoding: "utf-8",
|
|
});
|
|
const rootUrl = rootOrigin.status === 0 ? (rootOrigin.stdout || "").trim() : "";
|
|
if (rootUrl) return false; // marker root's own origin resolves — not the stray shape
|
|
const r = resolveRemote();
|
|
return r.url !== "" && r.root !== projectRoot;
|
|
})();
|
|
if (!oldBugShape && !degradedAncestorShape) {
|
|
slug = cached;
|
|
// Unversioned (pre-#3003) entry: recompute once. Keep the sticky
|
|
// value unless it is exactly the old last-two slug of a 3+-segment
|
|
// remote (mirrors bin/gstack-slug).
|
|
if (!versioned) {
|
|
const { url } = resolveRemote();
|
|
if (url) {
|
|
const upgraded = remoteSlug(url);
|
|
const legacy = legacyRemoteSlug(url);
|
|
if (upgraded && upgraded !== legacy && cached === legacy) slug = upgraded;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
slug = "";
|
|
}
|
|
}
|
|
// 3. canonical remote-derived slug from the outermost remote-bearing repo,
|
|
// via the shared rule in lib/remote-identity.ts (twin of
|
|
// bin/gstack-remote-identity.sh): legacy last-two parse for 2-segment and
|
|
// local remotes, "<last-two>-<16 hex>" for 3+-segment hosted remotes.
|
|
if (!slug) {
|
|
const { url } = resolveRemote();
|
|
if (url) {
|
|
const candidate = remoteSlug(url);
|
|
// Dot-only / degenerate guard (mirrors bin/gstack-slug): a hostile
|
|
// origin like `url = ..` yields "." or ".." here, which would file
|
|
// state OUTSIDE ~/.gstack/projects/. Reject and let the basename
|
|
// fallback below anchor identity instead.
|
|
if (candidate && candidate !== "." && candidate !== ".." && !candidate.includes("/")) {
|
|
slug = candidate;
|
|
}
|
|
}
|
|
}
|
|
// 4. project root's basename, else pwd basename for plain folders.
|
|
if (!slug && projectRoot) slug = sanitizeSlug(basename(projectRoot));
|
|
if (!slug) slug = sanitizeSlug(basename(cwd));
|
|
if (!slug) return "unknown";
|
|
|
|
// 5. cache it, as gstack-slug does — atomic, self-healing (only rewrites when
|
|
// the value changed — single-shot, key-local), and failures stay silent.
|
|
// Both the repository-scoped entry and this path's entry (#2767).
|
|
for (const target of new Set(writeTargets)) {
|
|
try {
|
|
let current = "";
|
|
try {
|
|
current = readFileSync(target, "utf-8");
|
|
} catch {
|
|
// no cache yet — write below
|
|
}
|
|
if (current !== `${SLUG_CACHE_VERSION_PREFIX}${slug}`) {
|
|
mkdirSync(cacheDir, { recursive: true });
|
|
const tmp = `${target}.tmp.${process.pid}`;
|
|
writeFileSync(tmp, `${SLUG_CACHE_VERSION_PREFIX}${slug}`, "utf-8");
|
|
renameSync(tmp, target);
|
|
}
|
|
} catch {
|
|
// best-effort cache; a miss only costs a re-derive on the next call
|
|
}
|
|
}
|
|
return slug;
|
|
}
|
|
|
|
/** Windows cannot exec an extensionless `#!/usr/bin/env bash` script (no shebang, no
|
|
* PATHEXT match for an explicit path), so gstack-slug spawns ENOENT there. */
|
|
export const NEEDS_NATIVE_SLUG_ON_WINDOWS = process.platform === "win32";
|
|
|
|
/**
|
|
* Resolve the project slug via the `gstack-slug` helper (parses `SLUG=...`).
|
|
*
|
|
* On Windows that spawn fails ENOENT (see NEEDS_NATIVE_SLUG_ON_WINDOWS) and `r.stdout`
|
|
* is undefined — the same class of hazard as the gbrain shim spawns in lib/gbrain-exec.ts
|
|
* (#1731). Returning the literal "unknown" filed every decision under
|
|
* ~/.gstack/projects/unknown/ — one bucket shared by every project on the machine —
|
|
* while the bash-side Context Recovery preamble resolved the real slug, found no
|
|
* decisions.active.json there, and skipped through a bare `if [ -f … ]` with no else.
|
|
*
|
|
* Nothing failed, for ten days: BOTH decision bins (log and search) missed identically,
|
|
* so writes and searches stayed consistent with each other, and the only component that
|
|
* resolved correctly was silent by design.
|
|
*
|
|
* `shell: true` is NOT the fix here, unlike #1731: cmd.exe cannot run a bash script
|
|
* either. Nor is re-spawning through `bash` — on Windows that frequently resolves to
|
|
* WSL, whose $HOME and /mnt/c paths yield a different slug AND a different cache
|
|
* directory, trading one split store for another.
|
|
*
|
|
* POSIX behaviour is unchanged: the fallback is win32-only, where the previous result
|
|
* was unconditionally wrong and so has nothing to regress.
|
|
*/
|
|
export function resolveSlug(slugBinPath: string): string {
|
|
const r = spawnSync(slugBinPath, { encoding: "utf-8" });
|
|
const m = (r.stdout || "").match(/^SLUG=(.+)$/m);
|
|
if (m) return m[1].trim();
|
|
if (NEEDS_NATIVE_SLUG_ON_WINDOWS) return slugFromEnvironment();
|
|
return "unknown";
|
|
}
|
|
|
|
/** Current git branch, or undefined on detached HEAD / outside a repo. */
|
|
export function gitBranch(): string | undefined {
|
|
const r = spawnSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { encoding: "utf-8" });
|
|
const b = (r.stdout || "").trim();
|
|
return b && b !== "HEAD" ? b : undefined;
|
|
}
|
|
|
|
/** The value following `--flag` in argv, or undefined if absent. */
|
|
export function flagValue(args: string[], name: string): string | undefined {
|
|
const i = args.indexOf(name);
|
|
return i >= 0 ? args[i + 1] : undefined;
|
|
}
|