From a95f5fa209e8e62ec3eb55ba7bcd17f5c88de82a Mon Sep 17 00:00:00 2001 From: garrytan Date: Sat, 3 Oct 2026 21:14:47 +0000 Subject: [PATCH] W6 T3 (eng), T2 (DX): gstack-config lock, unset and transcript_repos Every config.yaml mutation (set, unset, the allowlist pair, the user-slug writer) holds one mkdir lock (pid file, stale after 10 s or a dead holder) and replaces the file with one rename. set validates the full transcript_ingest_mode grammar (new@, +repos) with the existing error shape; transcript_repos stores canonical remotes, rejects an empty list and keeps the +repos marker in step in the same write; unset removes a key. --- bin/gstack-config | 239 +++++++++++++++++--- test/gstack-config-key-locale.test.ts | 4 +- test/gstack-config-transcript-scope.test.ts | 161 +++++++++++++ 3 files changed, 374 insertions(+), 30 deletions(-) create mode 100644 test/gstack-config-transcript-scope.test.ts diff --git a/bin/gstack-config b/bin/gstack-config index 5a37b3a0e..ad81fa70f 100755 --- a/bin/gstack-config +++ b/bin/gstack-config @@ -7,6 +7,7 @@ # config file (get returns DEFAULTS for absent keys, # so callers that need provenance use this instead) # gstack-config set — write a config value +# gstack-config unset — remove a key from the config file # gstack-config list — show all config (values + defaults) # gstack-config defaults — show just the defaults table # @@ -92,17 +93,24 @@ CONFIG_HEADER='# gstack configuration — edit freely, changes take effect on ne # # Flip back to false to be re-prompted. # # ─── Transcript ingest (gbrain memory) ─────────────────────────────── -# transcript_ingest_mode: off # recent | all | off — consent for /sync-gbrain -# # to ingest Claude Code and Codex session -# # transcripts (every project repo policy allows) -# # into your brain (local or Supabase). +# transcript_ingest_mode: off # recent | all | off | new@ — consent for +# # /sync-gbrain to ingest Claude Code and Codex +# # session transcripts (every project repo policy +# # allows) into your brain (local or Supabase). # # recent — last 90 days # # all — all history +# # new@2026-10-03T17:00:00Z — only sessions that +# # start after that UTC time # # off — never (other memory still syncs) +# # A +repos suffix is kept automatically while +# # transcript_repos is set. # # Absent, legacy (A-E, incremental) or unknown # # values are not consent: transcripts are skipped # # and /sync-gbrain asks once. An invalid value is # # REJECTED and the stored value kept. +# transcript_repos: # comma-separated git remotes; only transcripts +# # from these repos are ingested. Clear it with +# # `gstack-config unset transcript_repos`. # # Docs: setup-gbrain/memory.md#transcripts # # ─── Timeline Stop hook ────────────────────────────────────────────── @@ -211,7 +219,8 @@ lookup_default() { # the callers already assume in their own `|| echo ""` fallback. question_tuning) echo "false" ;; team_mode) echo "false" ;; - transcript_ingest_mode) echo "off" ;; # recent | all | off — transcript consent; absent = not consented (use `has`) + transcript_ingest_mode) echo "off" ;; # recent | all | off | new@ [+repos] — transcript consent; absent = not consented (use `has`) + transcript_repos) echo "" ;; # empty = every repo; comma-separated canonical remotes narrow transcript ingest # repo_mode: EMPTY is load-bearing — gstack-repo-mode treats any non-empty # answer as a user override and skips its own classification entirely, so # a synthesized "unknown" default turns the classifier into dead code. @@ -339,14 +348,16 @@ resolve_user_slug() { _slug="anonymous-$(sha8_of "$(hostname 2>/dev/null || echo unknown)")" fi - # Persist via direct file write (avoid recursion into gstack-config set) - mkdir -p "$STATE_DIR" - if [ ! -f "$CONFIG_FILE" ]; then - printf '%s' "$CONFIG_HEADER" > "$CONFIG_FILE" - fi - if ! grep -qE "^user_slug_at_${_hash}:" "$CONFIG_FILE" 2>/dev/null; then - echo "user_slug_at_${_hash}: ${_slug}" >> "$CONFIG_FILE" + # Persist via the locked read-modify-write (no recursion into `set`). + config_lock + config_edit_begin + if [ -z "$(_cfg_get "user_slug_at_${_hash}")" ]; then + _cfg_put "user_slug_at_${_hash}" "$_slug" + config_edit_commit + else + rm -f "$_cfg_tmp" fi + config_unlock printf '%s' "$_slug" } @@ -357,6 +368,142 @@ read_config_value() { gstack_read_config_key "$1" } +# ── Config writes: one lock, one read-modify-write rename ────────────────── +# Every mutation of config.yaml (set, unset, the transcript_repos pair, the +# user-slug writer) holds an atomic mkdir lock and replaces the file with one +# rename, so concurrent writers never lose each other's keys and an +# interrupted write leaves the old file. The pid file holds " "; +# a lock whose holder is dead or older than 10 s is taken over. No flock(1): +# stock macOS does not ship it. +CONFIG_LOCK="$STATE_DIR/.config-yaml.lock" +_config_locked="" + +config_lock() { + mkdir -p "$STATE_DIR" + local waited=0 held pid ts stale + while ! mkdir "$CONFIG_LOCK" 2>/dev/null; do + if [ ! -w "$STATE_DIR" ]; then + echo "Error: cannot write to $STATE_DIR (its config lock could not be created). Existing value left unchanged." >&2 + exit 1 + fi + waited=$(( waited + 1 )) + if [ "$waited" -gt 400 ]; then + echo "Error: $CONFIG_FILE is locked by another gstack-config ($(cat "$CONFIG_LOCK/pid" 2>/dev/null || echo 'unknown holder')). Existing value left unchanged. fix: retry, or remove $CONFIG_LOCK if no gstack-config is running." >&2 + exit 1 + fi + held=$(cat "$CONFIG_LOCK/pid" 2>/dev/null || true) + pid=""; ts=""; stale="" + case "$held" in *" "*) pid="${held%% *}"; ts="${held#* }"; ts="${ts%%[!0-9]*}" ;; esac + if [ -n "$pid" ]; then + if ! kill -0 "$pid" 2>/dev/null; then stale=1 + elif [ -n "$ts" ] && [ $(( $(date +%s) - ts )) -gt 10 ]; then stale=1; fi + elif [ "$waited" -gt 200 ]; then + stale=1 + fi + # Take over only the lock we judged: a holder that changed meanwhile is live. + if [ -n "$stale" ] && [ "$(cat "$CONFIG_LOCK/pid" 2>/dev/null || true)" = "$held" ]; then + rm -rf "$CONFIG_LOCK" 2>/dev/null || true + continue + fi + sleep 0.05 + done + printf '%s %s\n' "$$" "$(date +%s)" > "$CONFIG_LOCK/pid" + _config_locked=1 + trap 'config_unlock' EXIT +} + +config_unlock() { + [ -n "$_config_locked" ] || return 0 + rm -rf "$CONFIG_LOCK" 2>/dev/null || true + _config_locked="" +} + +# config_edit_begin — copy config.yaml (or the annotated header for a new +# file) to a temp file beside it; edits go there until config_edit_commit. +config_edit_begin() { + _cfg_tmp="$(mktemp "$STATE_DIR/.config.yaml.XXXXXX")" + if [ -f "$CONFIG_FILE" ]; then cat "$CONFIG_FILE" > "$_cfg_tmp"; else printf '%s' "$CONFIG_HEADER" > "$_cfg_tmp"; fi +} + +config_edit_commit() { + mv "$_cfg_tmp" "$CONFIG_FILE" +} + +# _cfg_get KEY — last value of KEY in the temp file ("" when absent). +_cfg_get() { + awk -v k="$1" 'BEGIN { n = length(k) + 1 } substr($0, 1, n) == k ":" { v = substr($0, n + 1) } END { sub(/^[ \t]+/, "", v); sub(/[ \t\r]+$/, "", v); print v }' "$_cfg_tmp" +} + +# _cfg_put KEY VALUE — replace every KEY line in the temp file, or append one. +# The value travels through ENVIRON so awk applies no escape processing. +_cfg_put() { + _CFG_V="$2" awk -v k="$1" 'BEGIN { n = length(k) + 1; v = ENVIRON["_CFG_V"] } substr($0, 1, n) == k ":" { print k ": " v; done = 1; next } { print } END { if (!done) print k ": " v }' "$_cfg_tmp" > "$_cfg_tmp.n" + mv "$_cfg_tmp.n" "$_cfg_tmp" +} + +_cfg_del() { + awk -v k="$1" 'BEGIN { n = length(k) + 1 } substr($0, 1, n) != k ":"' "$_cfg_tmp" > "$_cfg_tmp.n" + mv "$_cfg_tmp.n" "$_cfg_tmp" +} + +# ── Transcript consent grammar ───────────────────────────────────────────── +# transcript_ingest_mode = [@][+repos]; base is +# recent, all, off or new. `@` is required for new and invalid otherwise; off +# never carries +repos. `set` takes the canonical lowercase form (readers in +# lib/transcript-consent.ts are case-insensitive). +valid_utc_second() { + [[ "$1" =~ ^([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2}):([0-9]{2})Z$ ]] || return 1 + local y=$((10#${BASH_REMATCH[1]})) m=$((10#${BASH_REMATCH[2]})) d=$((10#${BASH_REMATCH[3]})) + local hh=$((10#${BASH_REMATCH[4]})) mm=$((10#${BASH_REMATCH[5]})) ss=$((10#${BASH_REMATCH[6]})) dim=31 + [ "$m" -ge 1 ] && [ "$m" -le 12 ] && [ "$hh" -le 23 ] && [ "$mm" -le 59 ] && [ "$ss" -le 59 ] || return 1 + case "$m" in + 4|6|9|11) dim=30 ;; + 2) dim=28; if [ $((y % 4)) -eq 0 ] && { [ $((y % 100)) -ne 0 ] || [ $((y % 400)) -eq 0 ]; }; then dim=29; fi ;; + esac + [ "$d" -ge 1 ] && [ "$d" -le "$dim" ] +} + +# valid_transcript_mode VALUE — canonical grammar check (exit 0 = valid). +valid_transcript_mode() { + local v="$1" base + base="${v%+repos}" + case "$base" in + recent|all) ;; + off) [ "$base" = "$v" ] || return 1 ;; + new@*) valid_utc_second "${base#new@}" || return 1 ;; + *) return 1 ;; + esac +} + +# canonical_remote URL — same rules as canonicalizeRemote in +# lib/gstack-memory-helpers.ts (test/transcript-consent.test.ts keeps them equal). +canonical_remote() { + local s="$1" + s="${s#"${s%%[![:space:]]*}"}"; s="${s%"${s##*[![:space:]]}"}" + s="${s#[\"\']}"; s="${s%[\"\']}" + if [[ "$s" =~ ^[^@[:space:]]+@([^:]+):(.+)$ ]]; then + s="${BASH_REMATCH[1]}/${BASH_REMATCH[2]}" + else + s=$(printf '%s' "$s" | sed -E 's#^[a-zA-Z][a-zA-Z0-9+.-]*://##; s#^[^@/]+@##') + fi + printf '%s' "$s" | sed -E 's#/+$##; s#\.[gG][iI][tT]$##; s#/+$##; s#/{2,}#/#g' | tr '[:upper:]' '[:lower:]' +} + +# transcript_marker_sync — keep the +repos marker on transcript_ingest_mode in +# step with transcript_repos inside the temp file: present while an allowlist +# exists and the base consents, absent otherwise. Unrecognized values are +# left untouched (they are not consent either way). +transcript_marker_sync() { + local mode list base + mode=$(_cfg_get transcript_ingest_mode) + [ -n "$mode" ] || return 0 + valid_transcript_mode "$mode" || return 0 + list=$(_cfg_get transcript_repos) + base="${mode%+repos}" + if [ -n "$list" ] && [ "$base" != "off" ]; then _cfg_put transcript_ingest_mode "$base+repos" + else _cfg_put transcript_ingest_mode "$base"; fi +} + _STATE_DOC="https://github.com/garrytan/gstack/blob/main/docs/state-root.md" _SELF="$(cd "$(dirname "$0")" && pwd)/gstack-config" @@ -500,12 +647,31 @@ case "${1:-}" in echo "Error: cross_project_learnings '$VALUE' not recognized. Valid values: true, false. Existing value left unchanged." >&2 exit 1 fi - # transcript_ingest_mode is a CONSENT key: recent/all let /sync-gbrain + # transcript_ingest_mode is a CONSENT key: recent/all/new@ let /sync-gbrain # ingest coding-agent transcripts. Reject a typo; never coerce or store it. - if [ "$KEY" = "transcript_ingest_mode" ] && [ "$VALUE" != "recent" ] && [ "$VALUE" != "all" ] && [ "$VALUE" != "off" ]; then + if [ "$KEY" = "transcript_ingest_mode" ] && ! valid_transcript_mode "$VALUE"; then echo "Error: transcript_ingest_mode '$VALUE' not recognized. Valid values: recent, all, off. Existing value left unchanged." >&2 + echo "Also valid: new@ (only sessions that start after that UTC time, e.g. new@$(date -u +%Y-%m-%dT%H:%M:%SZ)). The +repos suffix follows transcript_repos; off never carries it. Docs: setup-gbrain/memory.md#transcripts" >&2 exit 1 fi + # transcript_repos narrows transcript consent to these repos. Each entry is + # stored as its canonical remote (host/owner/repo); an empty list would + # deny every repo, so it is rejected: `unset` clears the allowlist. + if [ "$KEY" = "transcript_repos" ]; then + _clean="" + _ifs="$IFS"; IFS=',' + for _x in $VALUE; do + _x="$(canonical_remote "$_x")" + [ -n "$_x" ] || continue + case ",$_clean," in *",$_x,"*) ;; *) _clean="${_clean:+$_clean,}$_x" ;; esac + done + IFS="$_ifs" + if [ -z "$_clean" ]; then + echo "Error: transcript_repos '$VALUE' names no repo. Valid values: comma-separated git remotes (e.g. github.com/owner/repo). Existing value left unchanged. To allow every repo: gstack-config unset transcript_repos" >&2 + exit 1 + fi + VALUE="$_clean" + fi # memorable_recall is a CONSENT key: `on` lets a Claude Code hook hand every # prompt to a third-party binary. Reject like codex_reviews -- a typo must # never flip consent in either direction, so nothing is coerced or stored. @@ -572,21 +738,23 @@ case "${1:-}" in done done fi - mkdir -p "$STATE_DIR" - # Write annotated header on first creation - if [ ! -f "$CONFIG_FILE" ]; then - printf '%s' "$CONFIG_HEADER" > "$CONFIG_FILE" + # Locked read-modify-write; the annotated header is written on first creation. + config_lock + config_edit_begin + if [ "$KEY" = "transcript_ingest_mode" ] && [ "${VALUE%+repos}" != "$VALUE" ] && [ -z "$(_cfg_get transcript_repos)" ]; then + rm -f "$_cfg_tmp" + echo "Error: transcript_ingest_mode '$VALUE' needs a repo allowlist, but transcript_repos is not set. Existing value left unchanged. fix: gstack-config set transcript_repos , then set transcript_ingest_mode ${VALUE%+repos}" >&2 + exit 1 fi - # Escape sed replacement metacharacters (newlines were rejected above). + _cfg_put "$KEY" "$VALUE" + # The allowlist and its +repos marker change together, in this one rename. + if [ "$KEY" = "transcript_ingest_mode" ] || [ "$KEY" = "transcript_repos" ]; then + transcript_marker_sync + if [ "$KEY" = "transcript_ingest_mode" ]; then VALUE="$(_cfg_get transcript_ingest_mode)"; fi + fi + config_edit_commit + config_unlock SAFE_VALUE="$VALUE" - ESC_VALUE="$(printf '%s' "$SAFE_VALUE" | sed 's/[&/\]/\\&/g')" - if grep -qE "^${KEY}:" "$CONFIG_FILE" 2>/dev/null; then - # Portable in-place edit (BSD sed uses -i '', GNU sed uses -i without arg) - _tmpfile="$(mktemp "${CONFIG_FILE}.XXXXXX")" - sed "/^${KEY}:/s/.*/${KEY}: ${ESC_VALUE}/" "$CONFIG_FILE" > "$_tmpfile" && mv "$_tmpfile" "$CONFIG_FILE" - else - echo "${KEY}: ${SAFE_VALUE}" >> "$CONFIG_FILE" - fi # Report only when another root still overrides the value just written. gstack_config_select "$KEY" if [ -n "$_gstack_cfg_root" ] && [ "$_gstack_cfg_root" != "$STATE_DIR" ] && [ "$_gstack_cfg_value" != "$SAFE_VALUE" ]; then @@ -607,6 +775,21 @@ case "${1:-}" in fi fi ;; + unset) + KEY="${2:?Usage: gstack-config unset }" + if ! printf '%s' "$KEY" | LC_ALL=C grep -qE '^[a-zA-Z0-9_]+(@[a-zA-Z0-9]+)?$'; then + echo "Error: key must contain only alphanumeric characters, underscores, and an optional @ suffix" >&2 + exit 1 + fi + [ -f "$CONFIG_FILE" ] || exit 0 + config_lock + config_edit_begin + _cfg_del "$KEY" + # Clearing the allowlist drops the +repos marker in the same write. + if [ "$KEY" = "transcript_repos" ]; then transcript_marker_sync; fi + config_edit_commit + config_unlock + ;; list) if [ -f "$CONFIG_FILE" ]; then cat "$CONFIG_FILE" @@ -744,7 +927,7 @@ case "${1:-}" in esac ;; *) - echo "Usage: gstack-config {get|set|list|defaults|endpoint-hash|resolve-user-slug|gbrain-refresh} [key] [value]" + echo "Usage: gstack-config {get|has|set|unset|list|defaults|endpoint-hash|resolve-user-slug|gbrain-refresh} [key] [value]" exit 1 ;; esac diff --git a/test/gstack-config-key-locale.test.ts b/test/gstack-config-key-locale.test.ts index 599a0c168..b313f1513 100644 --- a/test/gstack-config-key-locale.test.ts +++ b/test/gstack-config-key-locale.test.ts @@ -47,13 +47,13 @@ afterEach(() => { }); describe("gstack-config key validation is locale-independent", () => { - test("get, has, and set all validate ASCII ranges under the C locale", () => { + test("get, has, set and unset all validate ASCII ranges under the C locale", () => { const source = fs.readFileSync(CONFIG, "utf8"); const guardedValidators = source.match( /LC_ALL=C grep -qE '\^\[a-zA-Z0-9_\]\+\(@\[a-zA-Z0-9\]\+\)\?\$'/g, ); - expect(guardedValidators).toHaveLength(3); + expect(guardedValidators).toHaveLength(4); }); test("round-trips existing keys that contain i", () => { diff --git a/test/gstack-config-transcript-scope.test.ts b/test/gstack-config-transcript-scope.test.ts new file mode 100644 index 000000000..965e3d16e --- /dev/null +++ b/test/gstack-config-transcript-scope.test.ts @@ -0,0 +1,161 @@ +/** + * bin/gstack-config writes: the transcript_repos allowlist and its +repos + * marker change together in one locked rename, `unset` removes a key, and + * every mutation goes through one mkdir lock with a read-modify-write rename, + * so concurrent writers lose nothing and an interrupted write leaves the old + * file. + */ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { spawn, spawnSync } from 'child_process'; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +const CONFIG_BIN = join(import.meta.dir, '..', 'bin', 'gstack-config'); +let root: string; +const env = () => ({ ...process.env, GSTACK_STATE_ROOT: root }); +const cfg = (...args: string[]) => { + const r = spawnSync('bash', [CONFIG_BIN, ...args], { encoding: 'utf-8', timeout: 30_000, env: env() }); + return { status: r.status, out: r.stdout ?? '', err: r.stderr ?? '' }; +}; +const file = () => join(root, 'config.yaml'); +const snapshot = () => (existsSync(file()) ? readFileSync(file(), 'utf-8') : null); +const keyLines = (key: string) => (snapshot() ?? '').split('\n').filter((l) => l.startsWith(`${key}:`)); + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'gstack-config-scope-')); +}); +afterEach(() => { + rmSync(root, { recursive: true, force: true }); +}); + +describe('transcript_repos and the +repos marker', () => { + test('setting the allowlist stores canonical remotes and marks the mode in the same write', () => { + expect(cfg('set', 'transcript_ingest_mode', 'recent').status).toBe(0); + expect(cfg('set', 'transcript_repos', 'https://github.com/Acme/App.git, git@github.com:acme/api.git,github.com/acme/app').status).toBe(0); + expect(cfg('get', 'transcript_repos').out).toBe('github.com/acme/app,github.com/acme/api'); + expect(cfg('get', 'transcript_ingest_mode').out).toBe('recent+repos'); + }); + + test('a mode change keeps the marker while the allowlist exists; off never carries it', () => { + cfg('set', 'transcript_repos', 'github.com/acme/app'); + expect(cfg('get', 'transcript_ingest_mode').status).toBe(0); + expect(keyLines('transcript_ingest_mode')).toEqual([]); + cfg('set', 'transcript_ingest_mode', 'all'); + expect(cfg('get', 'transcript_ingest_mode').out).toBe('all+repos'); + cfg('set', 'transcript_ingest_mode', 'new@2026-10-03T17:00:00Z'); + expect(cfg('get', 'transcript_ingest_mode').out).toBe('new@2026-10-03T17:00:00Z+repos'); + cfg('set', 'transcript_ingest_mode', 'off'); + expect(cfg('get', 'transcript_ingest_mode').out).toBe('off'); + cfg('set', 'transcript_ingest_mode', 'recent+repos'); + expect(cfg('get', 'transcript_ingest_mode').out).toBe('recent+repos'); + }); + + test('unset transcript_repos drops the marker in the same write; unset of an absent key is a no-op', () => { + cfg('set', 'transcript_ingest_mode', 'recent'); + cfg('set', 'transcript_repos', 'github.com/acme/app'); + expect(cfg('unset', 'transcript_repos').status).toBe(0); + expect(cfg('has', 'transcript_repos').status).toBe(1); + expect(cfg('get', 'transcript_ingest_mode').out).toBe('recent'); + const before = snapshot(); + expect(cfg('unset', 'transcript_repos').status).toBe(0); + expect(snapshot()).toBe(before); + expect(cfg('unset', 'bad key!').status).toBe(1); + }); + + test('unset removes only the named key', () => { + cfg('set', 'proactive', 'false'); + cfg('set', 'skill_prefix', 'true'); + expect(cfg('unset', 'proactive').status).toBe(0); + expect(cfg('has', 'proactive').status).toBe(1); + expect(cfg('get', 'skill_prefix').out).toBe('true'); + }); + + test('a +repos value without an allowlist and an empty allowlist are rejected with the file unchanged', () => { + cfg('set', 'transcript_ingest_mode', 'recent'); + const before = snapshot(); + const marked = cfg('set', 'transcript_ingest_mode', 'all+repos'); + expect(marked.status).toBe(1); + expect(marked.err).toContain('Existing value left unchanged'); + for (const value of [' , ', ',']) { + const r = cfg('set', 'transcript_repos', value); + expect(r.status).toBe(1); + expect(r.err).toContain('Existing value left unchanged'); + expect(r.err).toContain('gstack-config unset transcript_repos'); + } + expect(snapshot()).toBe(before); + }); +}); + +describe('one lock and one rename for every mutation', () => { + test('concurrent unrelated sets lose nothing', async () => { + cfg('set', 'proactive', 'true'); + const keys = Array.from({ length: 12 }, (_, i) => `scope_test_key_${i}`); + const codes = await Promise.all( + keys.map( + (key, i) => + new Promise((resolve) => { + const child = spawn('bash', [CONFIG_BIN, 'set', key, `value-${i}`], { env: env(), stdio: 'ignore' }); + child.on('exit', resolve); + }), + ), + ); + expect(codes).toEqual(keys.map(() => 0)); + for (const [i, key] of keys.entries()) expect(keyLines(key)).toEqual([`${key}: value-${i}`]); + expect(keyLines('proactive')).toEqual(['proactive: true']); + expect(existsSync(join(root, '.config-yaml.lock'))).toBe(false); + }, 60_000); + + test('an interrupted write leaves the old file, and the next writer takes over the dead lock', () => { + cfg('set', 'transcript_ingest_mode', 'recent'); + cfg('set', 'transcript_repos', 'github.com/acme/app'); + const before = snapshot(); + // A mv that kills gstack-config right before the rename over config.yaml. + const shim = join(root, 'shim'); + mkdirSync(shim); + writeFileSync(join(shim, 'mv'), '#!/bin/sh\nfor last; do :; done\ncase "$last" in */config.yaml) kill -9 $PPID; exit 1 ;; esac\nexec /bin/mv "$@"\n'); + chmodSync(join(shim, 'mv'), 0o755); + const crashed = spawnSync('bash', [CONFIG_BIN, 'unset', 'transcript_repos'], { + encoding: 'utf-8', + timeout: 30_000, + env: { ...env(), PATH: `${shim}:${process.env.PATH}` }, + }); + expect(crashed.status).not.toBe(0); + expect(snapshot()).toBe(before); + expect(existsSync(join(root, '.config-yaml.lock'))).toBe(true); + const t0 = Date.now(); + expect(cfg('unset', 'transcript_repos').status).toBe(0); + expect(Date.now() - t0).toBeLessThan(10_000); + expect(cfg('get', 'transcript_ingest_mode').out).toBe('recent'); + }, 60_000); + + test('a lock held longer than 10 s is stale even when its pid is alive', () => { + mkdirSync(join(root, '.config-yaml.lock')); + writeFileSync(join(root, '.config-yaml.lock', 'pid'), `${process.pid} ${Math.floor(Date.now() / 1000) - 20}\n`); + expect(cfg('set', 'proactive', 'false').status).toBe(0); + expect(cfg('get', 'proactive').out).toBe('false'); + }); + + test('a live, fresh lock makes the writer wait instead of writing alongside it', async () => { + mkdirSync(join(root, '.config-yaml.lock')); + writeFileSync(join(root, '.config-yaml.lock', 'pid'), `${process.pid} ${Math.floor(Date.now() / 1000)}\n`); + const child = spawn('bash', [CONFIG_BIN, 'set', 'proactive', 'false'], { env: env(), stdio: 'ignore' }); + const exited = new Promise((resolve) => child.on('exit', resolve)); + await Bun.sleep(500); + expect(snapshot()).toBeNull(); + rmSync(join(root, '.config-yaml.lock'), { recursive: true, force: true }); + expect(await exited).toBe(0); + expect(keyLines('proactive')).toEqual(['proactive: false']); + }, 30_000); + + test('the user-slug writer goes through the same lock', () => { + const r = spawnSync('bash', [CONFIG_BIN, 'resolve-user-slug'], { + encoding: 'utf-8', + timeout: 30_000, + env: { ...env(), HOME: root, USER: 'scope-tester' }, + }); + expect(r.stdout.length).toBeGreaterThan(0); + expect(keyLines('user_slug_at_local')).toEqual([`user_slug_at_local: ${r.stdout}`]); + expect(existsSync(join(root, '.config-yaml.lock'))).toBe(false); + }); +});