garrytan / garrytan/gstack

gstack-settings-hook renames onto $SETTINGS_FILE without resolving it — replaces a symlinked settings.json with a regular file, and the write misses the real file

Open
#2,830 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
TypeScript
Stars
133k
Forks
19.9k
Avg merge
18h 46m
Merged PRs (30d)
26

Description

## Summary

`gstack-settings-hook` writes `settings.json` with tmp-file + `rename()` but never resolves the target path first. When `~/.claude/settings.json` is a **symlink** (the standard dotfiles pattern — the real file lives in a version-controlled repo), the rename replaces the symlink with a regular file.

Two things then go wrong silently:

1. Live config is decoupled from version control. `git status` in the dotfiles repo stays clean, because the tracked file is no longer the file Claude Code reads.
2. The write itself lands in the wrong place. The repo copy never receives it, so the next `bootstrap.sh`-style re-link overwrites the runtime changes with a stale copy.

Nothing reports an error. Version tested: **1.81.0.0**.

## Reproduction

```bash
mkdir -p /tmp/gs-demo/repo /tmp/gs-demo/claude
printf '{\n "hooks": {}\n}\n' > /tmp/gs-demo/repo/settings.json
ln -s /tmp/gs-demo/repo/settings.json /tmp/gs-demo/claude/settings.json

GSTACK_SETTINGS_FILE=/tmp/gs-demo/claude/settings.json \
gstack-settings-hook add-event \
--event PostToolUse --command /tmp/demo-hook --source demo-tag --timeout 5

ls -la /tmp/gs-demo/claude/settings.json
grep -c demo-tag /tmp/gs-demo/repo/settings.json # the version-controlled copy
grep -c demo-tag /tmp/gs-demo/claude/settings.json # the new regular file
```

Actual:

```
before: lrwxr-xr-x settings.json -> /tmp/gs-demo/repo/settings.json
after: -rw-r--r-- settings.json # symlink gone, now a regular file

repo copy received the write: 0
live file received the write: 1
```

Expected: the symlink survives and the write lands in `/tmp/gs-demo/repo/settings.json`.

## Cause

`SETTINGS_FILE` is taken as given and never resolved:

```sh
# bin/gstack-settings-hook:70
SETTINGS_FILE="${GSTACK_SETTINGS_FILE:-${CLAUDE_CONFIG_DIR:-$HOME/.claude}/settings.json}"
```

and the write renames straight onto it:

```js
// bin/gstack-settings-hook:242, in gsWriteIfChanged()
fs.renameSync(tmp, path);
```

`rename(2)` operates on the link itself, not its target, so the symlink is replaced.

Same defect in the `rollback` path, which is a second call site:

```sh
# bin/gstack-settings-hook:~815
_RB_TMP="$SETTINGS_FILE.tmp.$$.$RANDOM"
cp "$LATEST" "$_RB_TMP"
mv "$_RB_TMP" "$SETTINGS_FILE"
```

## Suggested fix

Resolve the real path before writing, and derive the temp path from the **resolved** directory so the rename stays atomic and on the same filesystem. `GSTACK_TMP_PATH` (line 325), `GSTACK_BACKUP_PATH` and `GSTACK_BAK_LATEST` are all built from the unresolved `$SETTINGS_FILE`, so they need the same treatment:

```sh
if [ -L "$SETTINGS_FILE" ]; then
SETTINGS_FILE="$(readlink -f "$SETTINGS_FILE" 2>/dev/null || python3 -c 'import os,sys;print(os.path.realpath(sys.argv[1]))' "$SETTINGS_FILE")"
fi
```

(macOS `readlink` has no `-f` on older systems, hence the fallback.)

A note on why this looks like an oversight rather than a design choice: `gsWriteIfChanged` already goes out of its way to preserve the file **mode** across the rename —

```js
// Preserve the live file mode across the tmp+rename (settings.json can
// carry API keys in its env block -- a user-tightened 0600 must never be
// silently broadened to the default 0644).
```

Symlink-ness is the same class of user-side property, and it is currently the one that gets destroyed.

## Impact in practice

This has clobbered my setup six times since 2026-05-31. The path that triggers it most often is indirect: Claude Code's own settings writes (`/effort`, `/config`, a theme or plugin toggle) re-serialize the `hooks` arrays and drop the `_gstack_source` keys. `prune-stale` then legitimately restores them at the next `./setup` — and that restoring write is what replaces the symlink. So the more correctly gstack heals its own tags, the more reliably it breaks a symlinked `settings.json`.

Because `./setup` is invoked from `/gstack-upgrade`, `bootstrap.sh`, `/setup-gbrain` and the auto-upgrade preamble inside other skills, there is no single moment a user can watch for.

Contributor guide

Open the contributing guide

Research direction

Start with bin/gstack-settings-hook, especially the SETTINGS_FILE setup, gsWriteIfChanged(), GSTACK_TMP_PATH, GSTACK_BACKUP_PATH, GSTACK_BAK_LATEST, and the rollback path around line 815. Run the supplied symlink reproduction, then verify that both normal writes and rollback preserve the symlink and update its target without leaving temporary paths on another filesystem.

Written by the indexing model from the issue text.

Assessment

Tech stack
javascript, python, shell
Domain
cli, tooling
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
72/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.