Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -651,6 +651,17 @@ rewritten by every setup. Your hand edit was saved first.

**Fix.** Fix permissions on `<state root>/backups`, then `./setup --host <host>`.

<a id="setup-runtime-root-holds-checkout"></a>
### `error: refusing to replace <root>: it holds the gstack checkout setup is running from (<source>). The checkout is unchanged; move it out of <root> and run setup from there.`

**Meaning.** Your gstack checkout lives where a host's runtime root goes, such
as `~/.cursor/skills/gstack` from an older Cursor install. Setup replaces that
root on every run, so it stops instead of deleting the checkout.

**Fix.** Move the checkout out of the host's skills directory (the README
installs it at `~/.claude/skills/gstack`), then run `./setup --host <host>` from
its new place for each host you use.

<a id="upgrade-not-a-checkout"></a>
### `ERROR: <dir> is not a gstack checkout; nothing was changed. Re-run Step 2.` / `INSTALL_DIR is not set: re-run Step 2 ...`

Expand Down
15 changes: 13 additions & 2 deletions setup
Original file line number Diff line number Diff line change
Expand Up @@ -2730,9 +2730,20 @@ create_agents_sidecar() {
# when the build succeeded. A failed refresh leaves the previous runtime root,
# and so the host's last working install, in place. The swap is two renames;
# a run killed between them leaves "$ROOT.gstack-old.<pid>", which the next
# run restores when ROOT is missing.
# run restores when ROOT is missing. A ROOT that is, or contains, SOURCE (a
# checkout kept in a host's skills directory) is refused before anything moves,
# because the swap would delete the checkout (#3084); a symlinked ROOT only
# loses the link.
_activate_runtime_root() {
local fn="$1" src="$2" root="$3" new="$3.gstack-new.$$" old="$3.gstack-old.$$" rc leftover
local fn="$1" src="$2" root="$3" new="$3.gstack-new.$$" old="$3.gstack-old.$$" rc leftover real_root real_src
if [ -d "$root" ] && [ ! -L "$root" ] \
&& real_root="$(cd "$root" 2>/dev/null && pwd -P)" && real_src="$(cd "$src" 2>/dev/null && pwd -P)"; then
case "$real_src/" in
"$real_root"/*)
echo " error: refusing to replace $root: it holds the gstack checkout setup is running from ($src). The checkout is unchanged; move it out of $root and run setup from there." >&2
return 1 ;;
esac
fi
for leftover in "$root".gstack-old.*; do
[ -e "$leftover" ] || [ -L "$leftover" ] || continue
if [ ! -e "$root" ] && [ ! -L "$root" ]; then mv "$leftover" "$root"; else rm -rf "$leftover"; fi
Expand Down
19 changes: 19 additions & 0 deletions test/host-conformance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,25 @@ describe.skipIf(process.platform === 'win32')('host conformance kit', () => {
expect(registryRows(f).find(r => r[0] === 'codex')![6]).toBe('1.91.12.0');
}, 90_000);

// #3084: an older install can keep its checkout where a host's runtime root
// goes (~/.cursor/skills/gstack), and ./setup --status suggests running
// ./setup --host <host> from there. Claude's checkout is its standard install,
// Codex moves such a checkout to ~/.gstack/repos/gstack first, and Kiro builds
// its root in place instead of swapping it.
for (const host of installable.filter(h => !['claude', 'codex', 'kiro'].includes(h))) {
test(`${host}: a checkout kept at its runtime root is refused, never replaced (#3084)`, () => {
const f = makeFixture();
const src = makeSource(f, join(f.home, DISCOVERY[host], 'gstack'));
const entries = readdirSync(src);
const setupScript = readFileSync(join(src, 'setup'), 'utf8');
const r = runSetup(f, join(src, 'setup'), ['--host', host], { cwd: src });
expect(r.status, r.stdout + r.stderr).toBe(1);
expect(r.stderr).toContain(`refusing to replace ${src}`);
expect(readdirSync(src)).toEqual(expect.arrayContaining(entries));
expect(readFileSync(join(src, 'setup'), 'utf8')).toBe(setupScript);
}, 90_000);
}

test('first run from the published instructions ends in a skill that starts', () => {
const f = makeFixture();
// README: git clone … ~/.claude/skills/gstack && cd ~/.claude/skills/gstack && ./setup
Expand Down
49 changes: 49 additions & 0 deletions test/setup-skill-copies.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,52 @@ describe.skipIf(process.platform === 'win32')('setup: real-file SKILL.md copies
}
});
});

// #3084: activation moves the old runtime root aside and deletes it, so a root
// that is, or contains, the checkout setup runs from is refused before anything
// moves. A symlinked root only loses the link.
describe.skipIf(process.platform === 'win32')('setup: a runtime root that holds the checkout (#3084)', () => {
/** A checkout's top-level entries, for "nothing changed" checks. */
function makeCheckout(dir: string) {
fs.mkdirSync(path.join(dir, '.git'), { recursive: true });
for (const name of ['setup', 'VERSION', 'router.md']) fs.writeFileSync(path.join(dir, name), `${name}\n`);
return () => Object.fromEntries(fs.readdirSync(dir).sort().map(n => {
const p = path.join(dir, n);
return [n, fs.statSync(p).isDirectory() ? 'dir' : fs.readFileSync(p, 'utf8')];
}));
}

test('a root that is, or contains, the checkout is refused and the checkout is unchanged', () => {
// [layout, checkout path under the root, bash that respells ROOT]
for (const [layout, under, respell] of [
['root is the checkout', '', ''],
['root contains the checkout', '/checkout', ''],
['root is the checkout, named through a linked parent', '', 'ln -s "$T/home" "$T/alias"; ROOT="$T/alias/.cursor/skills/gstack"'],
]) {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-skill-copies-'));
tmps.push(tmp);
const root = path.join(tmp, 'home/.cursor/skills/gstack');
const snapshot = makeCheckout(root + under);
const before = snapshot();
const r = run(tmp, `T="${tmp}"; SRC="${root}${under}"; ROOT="${root}"; ${respell}\n_activate_runtime_root build "$SRC" "$ROOT" || echo "rc=$?"`);
expect(r.stdout, layout).toContain('rc=1');
expect(r.stderr, layout).toContain('refusing to replace');
expect(snapshot(), layout).toEqual(before);
}
});

test('a symlinked root is replaced and the checkout it pointed at survives', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-skill-copies-'));
tmps.push(tmp);
const snapshot = makeCheckout(path.join(tmp, 'src'));
const before = snapshot();
const root = path.join(tmp, 'home/.cursor/skills/gstack');
fs.mkdirSync(path.dirname(root), { recursive: true });
fs.symlinkSync(path.join(tmp, 'src'), root);
const r = run(tmp, `ROOT="${root}"; _activate_runtime_root build "$SRC" "$ROOT"`);
expect(r.status, r.stderr).toBe(0);
expect(fs.lstatSync(root).isSymbolicLink()).toBe(false);
expect(fs.readFileSync(path.join(root, 'SKILL.md'), 'utf8')).toBe('router.md\n');
expect(snapshot()).toEqual(before);
});
});
Loading