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)):