mirror of https://github.com/garrytan/gstack.git
Merge 13671be149 into 2be6c06ba8
This commit is contained in:
commit
56b3e5b7cb
|
|
@ -117,6 +117,8 @@ jobs:
|
|||
browse/test/server-sanitize-surrogates.test.ts \
|
||||
test/setup-windows-fallback.test.ts \
|
||||
test/bin-windows-bun-import-paths.test.ts \
|
||||
test/bin-context-windows-slug.test.ts \
|
||||
test/artifacts-allowlist-decisions.test.ts \
|
||||
test/build-script-shell-compat.test.ts \
|
||||
test/docs-config-keys.test.ts \
|
||||
test/brain-sync-windows-paths.test.ts \
|
||||
|
|
|
|||
|
|
@ -291,6 +291,14 @@ projects/*/*-design-*.md
|
|||
projects/*/*-test-plan-*.md
|
||||
projects/*/*-eng-review-test-plan-*.md
|
||||
projects/*/timeline.jsonl
|
||||
# The decision store. gstack-decision-log enqueues projects/<slug>/decisions.jsonl
|
||||
# after EVERY write, but no glob above matched it, so compute_paths_to_stage rejected
|
||||
# all of them at its "must match at least one allowlist glob" check -- a writer
|
||||
# enqueueing a path the syncer is guaranteed to drop. Without these the durable
|
||||
# decision ledger never leaves the machine, on any platform.
|
||||
projects/*/decisions.jsonl
|
||||
projects/*/decisions.active.json
|
||||
projects/*/decisions.archive.jsonl
|
||||
retros/*.md
|
||||
developer-profile.json
|
||||
builder-journey.md
|
||||
|
|
@ -318,6 +326,9 @@ cat > "$GSTACK_HOME/.brain-privacy-map.json" <<'EOF'
|
|||
{"pattern": "projects/*/*-design-*.md", "class": "artifact"},
|
||||
{"pattern": "projects/*/*-test-plan-*.md", "class": "artifact"},
|
||||
{"pattern": "projects/*/*-eng-review-test-plan-*.md", "class": "artifact"},
|
||||
{"pattern": "projects/*/decisions.jsonl", "class": "artifact"},
|
||||
{"pattern": "projects/*/decisions.active.json", "class": "artifact"},
|
||||
{"pattern": "projects/*/decisions.archive.jsonl", "class": "artifact"},
|
||||
{"pattern": "retros/*.md", "class": "artifact"},
|
||||
{"pattern": "builder-journey.md", "class": "artifact"},
|
||||
{"pattern": "projects/*/timeline.jsonl", "class": "behavioral"},
|
||||
|
|
|
|||
|
|
@ -6,12 +6,102 @@
|
|||
*/
|
||||
|
||||
import { spawnSync } from "child_process";
|
||||
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
|
||||
import { homedir } from "os";
|
||||
import { basename, join } from "path";
|
||||
|
||||
/** Resolve the project slug via the `gstack-slug` helper (parses `SLUG=...`). */
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Native port of bin/gstack-slug's resolution order, used when that script cannot be
|
||||
* spawned (see resolveSlug). Same three 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.
|
||||
*/
|
||||
export function slugFromEnvironment(gstackHome?: string, cwd: string = process.cwd()): string {
|
||||
const home = gstackHome || process.env.GSTACK_HOME || join(homedir(), ".gstack");
|
||||
const cacheDir = join(home, "slug-cache");
|
||||
const cacheFile = join(cacheDir, toMsysPath(cwd).replace(/\//g, "_"));
|
||||
|
||||
let slug = "";
|
||||
// 1. cached slug wins (guarantees consistency across sessions)
|
||||
if (existsSync(cacheFile)) {
|
||||
try {
|
||||
slug = sanitizeSlug(readFileSync(cacheFile, "utf-8").trim());
|
||||
} catch {
|
||||
slug = "";
|
||||
}
|
||||
}
|
||||
// 2. else derive from the git remote: [:/]<owner>/<repo>[.git] → owner-repo
|
||||
if (!slug) {
|
||||
const r = spawnSync("git", ["remote", "get-url", "origin"], { encoding: "utf-8", cwd });
|
||||
const m = (r.stdout || "").trim().match(/[:/]([^/]+\/[^/]+?)(?:\.git)?$/);
|
||||
if (m) slug = sanitizeSlug(m[1].replace(/\//g, "-"));
|
||||
}
|
||||
// 3. else the directory name
|
||||
if (!slug) slug = sanitizeSlug(basename(cwd));
|
||||
if (!slug) return "unknown";
|
||||
|
||||
// 4. cache it, as gstack-slug does — atomic, and failures stay silent (`|| true`)
|
||||
try {
|
||||
mkdirSync(cacheDir, { recursive: true });
|
||||
const tmp = `${cacheFile}.tmp.${process.pid}`;
|
||||
writeFileSync(tmp, slug, "utf-8");
|
||||
renameSync(tmp, cacheFile);
|
||||
} 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);
|
||||
return m ? m[1].trim() : "unknown";
|
||||
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. */
|
||||
|
|
|
|||
|
|
@ -0,0 +1,66 @@
|
|||
import { describe, test, expect } from "bun:test";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, "..");
|
||||
const INIT = fs.readFileSync(path.join(ROOT, "bin", "gstack-artifacts-init"), "utf-8");
|
||||
|
||||
/** Pull a quoted heredoc body out of gstack-artifacts-init by target filename. */
|
||||
function heredoc(target: string): string {
|
||||
const re = new RegExp(`cat > "\\$GSTACK_HOME/${target}" <<'EOF'\\n([\\s\\S]*?)\\nEOF\\n`);
|
||||
const m = INIT.match(re);
|
||||
if (!m) throw new Error(`heredoc for ${target} not found in gstack-artifacts-init`);
|
||||
return m[1];
|
||||
}
|
||||
|
||||
/** fnmatch.fnmatchcase semantics, as compute_paths_to_stage applies them:
|
||||
* `*` does not cross a path separator. */
|
||||
function globToRe(g: string): RegExp {
|
||||
return new RegExp("^" + g.split("*").map((s) => s.replace(/[.]/g, "[.]")).join("[^/]*") + "$");
|
||||
}
|
||||
|
||||
const DECISION_PATHS = [
|
||||
"projects/acme-widget/decisions.jsonl",
|
||||
"projects/acme-widget/decisions.active.json",
|
||||
"projects/acme-widget/decisions.archive.jsonl",
|
||||
];
|
||||
|
||||
/**
|
||||
* gstack-decision-log:40 enqueues projects/<slug>/decisions.jsonl after EVERY write,
|
||||
* but no managed glob matched it, so compute_paths_to_stage rejected all of them at
|
||||
* its "must match at least one allowlist glob" check. The writer and the syncer
|
||||
* disagreed silently: turning artifacts sync on backed up learnings, plans, designs
|
||||
* and timelines -- everything EXCEPT the durable decision ledger -- and nothing
|
||||
* anywhere reported a miss, because a dropped path prints exactly what a synced one
|
||||
* does when the queue is otherwise empty.
|
||||
*
|
||||
* Source-level rather than end-to-end: gstack-artifacts-init.test.ts drives the real
|
||||
* script through #!/bin/bash shims and a colon-separated PATH, so it cannot run on
|
||||
* Windows -- which is the platform where this bug bit.
|
||||
*/
|
||||
describe("the artifacts allowlist covers the decision store", () => {
|
||||
const globs = heredoc("\\.brain-allowlist")
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l && !l.startsWith("#"));
|
||||
|
||||
test("every decisions.* path matches at least one allowlist glob", () => {
|
||||
for (const p of DECISION_PATHS) {
|
||||
expect({ p, matched: globs.some((g) => globToRe(g).test(p)) }).toEqual({ p, matched: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("decisions.* are class artifact, so they sync in artifacts-only mode too", () => {
|
||||
const map = JSON.parse(heredoc("\\.brain-privacy-map\\.json"));
|
||||
for (const p of DECISION_PATHS) {
|
||||
const hit = map.find((e: { pattern: string; class: string }) => globToRe(e.pattern).test(p));
|
||||
expect({ p, cls: hit?.class }).toEqual({ p, cls: "artifact" });
|
||||
}
|
||||
});
|
||||
|
||||
test("the allowlist still ends with the user-additions marker", () => {
|
||||
// Additions below it survive re-init; a glob added above would be silently
|
||||
// overwritten the next time gstack-artifacts-init runs.
|
||||
expect(heredoc("\\.brain-allowlist").trimEnd()).toMatch(/# ---- USER ADDITIONS BELOW ----/);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
||||
import { spawnSync } from "child_process";
|
||||
import * as fs from "fs";
|
||||
import * as os from "os";
|
||||
import * as path from "path";
|
||||
import {
|
||||
toMsysPath,
|
||||
slugFromEnvironment,
|
||||
resolveSlug,
|
||||
NEEDS_NATIVE_SLUG_ON_WINDOWS,
|
||||
} from "../lib/bin-context";
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, "..");
|
||||
const read = (rel: string) => fs.readFileSync(path.join(ROOT, rel), "utf-8");
|
||||
|
||||
let tmp: string;
|
||||
beforeEach(() => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gstack-slug-")); });
|
||||
afterEach(() => { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {} });
|
||||
|
||||
/**
|
||||
* Windows cannot exec bin/gstack-slug -- a `#!/usr/bin/env bash` script with no file
|
||||
* extension -- so spawnSync fails ENOENT and resolveSlug used to return the literal
|
||||
* string "unknown". Every decision on the machine landed in one shared
|
||||
* ~/.gstack/projects/unknown/ bucket, while the bash-side Context Recovery preamble
|
||||
* resolved the real slug and silently found nothing there.
|
||||
*
|
||||
* These exercise the native fallback on EVERY platform (it is only the *gating* that
|
||||
* is win32-specific), so macOS/Linux CI catches a regression that would otherwise
|
||||
* only ever surface on a Windows user's disk.
|
||||
*/
|
||||
describe("native slug fallback mirrors bin/gstack-slug", () => {
|
||||
test("toMsysPath reproduces the git-bash cache key", () => {
|
||||
// gstack-slug does: CACHE_KEY=$(printf '%s' "$(pwd)" | tr '/' '_')
|
||||
// and git-bash `pwd` reports C:\Users\j\foo as /c/Users/j/foo.
|
||||
expect(toMsysPath("C:\\Users\\j\\foo")).toBe("/c/Users/j/foo");
|
||||
expect(toMsysPath("D:/Work/Repo")).toBe("/d/Work/Repo");
|
||||
expect(toMsysPath("/already/posix")).toBe("/already/posix");
|
||||
// The cache FILENAME is the real contract:
|
||||
expect(toMsysPath("C:\\Users\\j\\foo").replace(/\//g, "_")).toBe("_c_Users_j_foo");
|
||||
});
|
||||
|
||||
test("step 1: a cached slug wins over everything else", () => {
|
||||
const cwd = path.join(tmp, "proj");
|
||||
fs.mkdirSync(cwd);
|
||||
const cacheDir = path.join(tmp, "home", "slug-cache");
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(cacheDir, toMsysPath(cwd).replace(/\//g, "_")), "cached-wins");
|
||||
expect(slugFromEnvironment(path.join(tmp, "home"), cwd)).toBe("cached-wins");
|
||||
});
|
||||
|
||||
test("step 2: derives owner-repo from the git remote, https and ssh alike", () => {
|
||||
for (const [url, want] of [
|
||||
["https://github.com/acme/Widget.git", "acme-Widget"],
|
||||
["git@github.com:acme/Widget.git", "acme-Widget"],
|
||||
["https://gitlab.com/acme/Widget", "acme-Widget"],
|
||||
] as const) {
|
||||
const cwd = fs.mkdtempSync(path.join(tmp, "repo-"));
|
||||
spawnSync("git", ["init", "-q"], { cwd });
|
||||
spawnSync("git", ["remote", "add", "origin", url], { cwd });
|
||||
expect(slugFromEnvironment(path.join(tmp, "home2"), cwd)).toBe(want);
|
||||
}
|
||||
});
|
||||
|
||||
test("step 3: falls back to the sanitized directory name", () => {
|
||||
// `tr -cd 'a-zA-Z0-9._-'` DELETES disallowed characters rather than replacing them.
|
||||
const cwd = path.join(tmp, "My Proj+v2");
|
||||
fs.mkdirSync(cwd);
|
||||
expect(slugFromEnvironment(path.join(tmp, "home3"), cwd)).toBe("MyProjv2");
|
||||
});
|
||||
|
||||
test("the resolved slug is cached back, as the shell script does", () => {
|
||||
const cwd = path.join(tmp, "cacheme");
|
||||
fs.mkdirSync(cwd);
|
||||
const home = path.join(tmp, "home4");
|
||||
const slug = slugFromEnvironment(home, cwd);
|
||||
const key = path.join(home, "slug-cache", toMsysPath(cwd).replace(/\//g, "_"));
|
||||
expect(fs.existsSync(key)).toBe(true);
|
||||
// no trailing newline: gstack-slug writes with printf '%s'
|
||||
expect(fs.readFileSync(key, "utf-8")).toBe(slug);
|
||||
});
|
||||
|
||||
test("never returns the empty string", () => {
|
||||
expect(slugFromEnvironment(path.join(tmp, "h"), tmp).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the fallback stays win32-gated", () => {
|
||||
// Static tripwire in the style of gbrain-spawn-windows-shell.test.ts: POSIX CI
|
||||
// cannot observe the Windows branch at runtime, so pin the gate itself. Removing
|
||||
// it would silently change macOS/Linux behaviour, which today is byte-identical.
|
||||
test("NEEDS_NATIVE_SLUG_ON_WINDOWS is platform-gated", () => {
|
||||
expect(read("lib/bin-context.ts")).toMatch(
|
||||
/export const NEEDS_NATIVE_SLUG_ON_WINDOWS\s*=\s*process\.platform === "win32"/,
|
||||
);
|
||||
expect(NEEDS_NATIVE_SLUG_ON_WINDOWS).toBe(process.platform === "win32");
|
||||
});
|
||||
|
||||
test("resolveSlug no longer returns a bare literal on a failed spawn", () => {
|
||||
const src = read("lib/bin-context.ts");
|
||||
expect(src).not.toMatch(/return m \? m\[1\]\.trim\(\) : "unknown";/);
|
||||
expect(src).toMatch(/if \(NEEDS_NATIVE_SLUG_ON_WINDOWS\) return slugFromEnvironment\(\);/);
|
||||
});
|
||||
|
||||
test("a spawn that cannot run resolves to a real slug, not 'unknown'", () => {
|
||||
// The exact production failure: the helper path does not exist / cannot exec.
|
||||
const got = resolveSlug(path.join(tmp, "definitely-not-a-real-bin"));
|
||||
if (NEEDS_NATIVE_SLUG_ON_WINDOWS) {
|
||||
expect(got).not.toBe("unknown");
|
||||
} else {
|
||||
expect(got).toBe("unknown"); // POSIX behaviour deliberately unchanged
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue