fix(reset): make the documents reset block POSIX so it runs under zsh (#699)

* fix(reset): make the documents reset block POSIX so it runs under zsh

The block merged in #580 used the bash-only builtin
"shopt -s dotglob nullglob". Claude Code's Bash tool runs the user's
shell, which is zsh on macOS, where the line fails with "command not
found: shopt", and under set -e the subshell exits 127 before its
deletion loop: the reset reported success and deleted nothing. The
replay tests ran the block under bash only, so they stayed green.

Rewrite the block as POSIX shell: explicit directory names instead of
brace expansion, and
"find <dir> -mindepth 1 -maxdepth 1 ! -name .gitkeep -exec rm -rf -- {} +",
which lists hidden entries and nested folders without any glob setting.
The replay tests now run the block under every shell present (bash,
zsh, sh, dash) and guard against bashisms returning. The zsh and dash
subtests fail on master.

* changelog: place the #699 entry under [Unreleased]

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Mads Lorentzen <madslorentzen_17@hotmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-21 07:10:50 +02:00
1 parent 183c8fda20
commit 217206f9db
3 files changed
+59 -10

No files matched your search

+4 -9
View File
@@ -255,25 +255,20 @@ Leave the rest of the file intact: the portal-CLI and WebSearch-fallback explana
### Documents reset
For each non-empty document subfolder, delete all user entries, including nested folders and hidden files. Preserve the seven top-level subfolders, their `.gitkeep` placeholders, and `documents/README.md`. Run the following Bash block only after the confirmation above; its glob settings stay inside the subshell. If `documents/` or any of its seven roots is a symbolic link, stop before deleting anything and ask the user to replace that linked root with a local document folder.
For each non-empty document subfolder, delete all user entries, including nested folders and hidden files. Preserve the seven top-level subfolders, their `.gitkeep` placeholders, and `documents/README.md`. Run the following block only after the confirmation above. It is POSIX shell on purpose: the Bash tool runs whatever shell the user has, which is zsh on macOS, and a bash-only builtin such as `shopt` fails there with `command not found` before anything is deleted. `find` lists hidden entries and nested folders without any glob setting. If `documents/` or any of its seven roots is a symbolic link, stop before deleting anything and ask the user to replace that linked root with a local document folder.
```bash
(
set -e
for directory in documents documents/{cv,linkedin,diplomas,references,projects,postings,applications}; do
for directory in documents documents/cv documents/linkedin documents/diplomas documents/references documents/projects documents/postings documents/applications; do
if [ -L "$directory" ]; then
printf 'Reset stopped: %s is a symbolic link.\n' "$directory" >&2
exit 1
fi
done
unset GLOBIGNORE
shopt -s dotglob nullglob
for directory in documents/cv documents/linkedin documents/diplomas documents/references documents/projects documents/postings documents/applications; do
for entry in "$directory"/*; do
if [ "${entry##*/}" != ".gitkeep" ]; then
rm -rf -- "$entry"
fi
done
[ -d "$directory" ] || continue
find "$directory" -mindepth 1 -maxdepth 1 ! -name .gitkeep -exec rm -rf -- {} +
done
)
```
+13
View File
@@ -341,6 +341,19 @@ per-file diff commands.
canonical identity link, which 404s in a browser, and the employer page is `applyUrl` (search) or
the `Apply:` line of `detail`.
- **`/reset documents` deletes the documents again under zsh** (#699, `.claude/commands/reset.md`
Step 3, `tests/test_reset_documents.py`) - the block merged in #580 used the bash-only builtin
`shopt -s dotglob nullglob`. Claude Code's Bash tool runs the user's shell, which is zsh on
macOS, where the line fails with `command not found: shopt`, and under `set -e` the subshell
exits 127 before its deletion loop: the reset reported success and deleted nothing. The
replay tests ran the block under `bash` only, so they stayed green. The block is now POSIX
shell - explicit directory names instead of brace expansion, and
`find <dir> -mindepth 1 -maxdepth 1 ! -name .gitkeep -exec rm -rf -- {} +`, which lists
hidden entries and nested folders without any glob setting, skipping a folder that is
missing instead of stopping the loop there - and the replay tests run it under every shell
present on the machine (bash, zsh, sh, dash), with a missing-folder case and a guard that no
bashism returns. The zsh subtests fail on master.
### Security
- **CI no longer leaves the job token in `.git/config` or expands a PR-controlled name into a
+42 -1
View File
@@ -16,6 +16,11 @@ except ImportError: # unittest discover -s tests imports modules top-level
COMMAND = Path(__file__).resolve().parent.parent / ".claude/commands/reset.md"
FOLDERS = ("cv", "linkedin", "diplomas", "references", "projects", "postings", "applications")
BASH = find_bash()
# The block is POSIX shell by design: Claude Code's Bash tool runs the user's
# shell, which is zsh on macOS, and the bash-only `shopt` it used to carry
# exited 127 there before deleting anything. Replay it under every shell on
# this machine, not only bash.
SHELLS = [path for path in (BASH, shutil.which("zsh"), shutil.which("sh"), shutil.which("dash")) if path]
def symlinks_available():
@@ -47,6 +52,11 @@ class ResetDocuments(unittest.TestCase):
return re.search(r"```bash\n(.*?)```", section, re.S).group(1)
def run_reset(self, populated):
for shell in SHELLS:
with self.subTest(shell=shell):
self.run_reset_with(shell, populated)
def run_reset_with(self, shell, populated):
with TemporaryDirectory() as directory:
root = Path(directory)
documents = root / "documents"
@@ -65,7 +75,7 @@ class ResetDocuments(unittest.TestCase):
nested = folder / "project-or-application"
nested.mkdir()
(nested / "README.md").write_text("A nested document\n")
result = subprocess.run([BASH, "-c", self.reset_script()], cwd=root, capture_output=True, text=True)
result = subprocess.run([shell, "-c", self.reset_script()], cwd=root, capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stderr, "")
self.assertEqual(readme.read_text(), "Keep these framework instructions.\n")
@@ -81,6 +91,37 @@ class ResetDocuments(unittest.TestCase):
def test_empty_document_folders_remain_usable(self):
self.run_reset(populated=False)
def test_a_missing_folder_is_skipped_and_the_rest_are_still_cleared(self):
# `find` on a missing folder exits 1, and under `set -e` that stopped
# the loop there, leaving every later folder uncleared; master under
# bash skipped the folder. The block must skip it too.
for shell in SHELLS:
with self.subTest(shell=shell):
with TemporaryDirectory() as directory:
root = Path(directory)
documents = root / "documents"
documents.mkdir()
(documents / "README.md").write_text("Keep.\n")
present = [name for name in FOLDERS if name != "diplomas"]
for name in present:
folder = documents / name
folder.mkdir()
(folder / ".gitkeep").write_text("Keep.\n")
(folder / "summary.md").write_text("A document\n")
result = subprocess.run([shell, "-c", self.reset_script()], cwd=root, capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stderr, "")
for name in present:
self.assertEqual([path.name for path in (documents / name).iterdir()], [".gitkeep"], name)
self.assertFalse((documents / "diplomas").exists())
def test_block_uses_no_bash_only_builtins(self):
# `shopt`, `declare`, `[[` and brace expansion are what break under
# zsh/sh; a POSIX block needs none of them.
script = self.reset_script()
for bashism in ("shopt", "declare ", "[[", "{cv,"):
self.assertNotIn(bashism, script, f"the documents reset block must stay POSIX: found {bashism!r}")
@unittest.skipUnless(SYMLINKS, "requires filesystem symlinks and Windows symlink privilege")
def test_symlinked_roots_stop_before_any_deletion(self):
for linked_root in ("documents", *(f"documents/{name}" for name in FOLDERS)):