diff --git a/.claude/commands/reset.md b/.claude/commands/reset.md
index 06dbe70c..55d99951 100644
--- a/.claude/commands/reset.md
+++ b/.claude/commands/reset.md
@@ -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
)
```
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5482378b..cd75315b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
-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
diff --git a/tests/test_reset_documents.py b/tests/test_reset_documents.py
index 4373d5f8..5a19ce91 100644
--- a/tests/test_reset_documents.py
+++ b/tests/test_reset_documents.py
@@ -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)):