From d590aa1b1011cf81942d36f3e90518d39858108d Mon Sep 17 00:00:00 2001 From: Rudy Mizrahi Celekli <47457359+rudycelekli@users.noreply.github.com> Date: Sun, 18 Oct 2026 01:33:41 -0400 Subject: [PATCH] fix(reset): clear document entries while preserving scaffolds (#580) * fix(reset): clear document entries while preserving scaffolds * fix(reset): preserve scaffolds across Bash versions * docs(reset): keep document inventory before presentation heading --- .claude/commands/reset.md | 37 +++++++---- CHANGELOG.md | 2 + tests/test_reset_command.py | 31 ++++++--- tests/test_reset_documents.py | 120 ++++++++++++++++++++++++++++++++++ 4 files changed, 170 insertions(+), 20 deletions(-) create mode 100644 tests/test_reset_documents.py diff --git a/.claude/commands/reset.md b/.claude/commands/reset.md index 61a90a2e..06dbe70c 100644 --- a/.claude/commands/reset.md +++ b/.claude/commands/reset.md @@ -86,7 +86,11 @@ cv/main_example.tex. This scope covers skill files only. ### If scope includes `documents`: -Use Glob to list all files present in `documents/cv/`, `documents/linkedin/`, `documents/diplomas/`, `documents/references/`, `documents/projects/`, `documents/postings/`, and `documents/applications/`. Present as: +Use Glob to list all files present in `documents/cv/`, `documents/linkedin/`, `documents/diplomas/`, `documents/references/`, `documents/projects/`, `documents/postings/`, and `documents/applications/`. + +Include nested and hidden user entries in the inventory. Each subfolder's tracked `.gitkeep` is a scaffold placeholder: exclude it from the deletion list and from the empty/non-empty decision, and preserve it. + +Present as: ``` ## Documents reset will delete: @@ -251,16 +255,27 @@ Leave the rest of the file intact: the portal-CLI and WebSearch-fallback explana ### Documents reset -For each non-empty document subfolder, delete all files within it using Bash `rm`. Do not delete the folder itself, and do not delete `documents/README.md`. +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. ```bash -rm -f documents/cv/* -rm -f documents/linkedin/* -rm -f documents/diplomas/* -rm -f documents/references/* -rm -f documents/projects/* -rm -f documents/postings/* -rm -rf documents/applications/*/ +( + set -e + for directory in documents documents/{cv,linkedin,diplomas,references,projects,postings,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 + done +) ``` --- @@ -287,7 +302,7 @@ Then tell the user what to do next based on what was reset: > Note that `CLAUDE.md` and `cv/main_example.tex` are outside the `profile` scope and still hold your personal data. If you are handing this fork over or making it public, clear them by hand. **If documents were reset:** -> The `documents/` folder is now empty. Add your career documents and run `/setup` to populate your profile. See `documents/README.md` for instructions on what to put where. +> Your user documents are now cleared. The folder structure, `.gitkeep` placeholders, and `documents/README.md` are preserved. Add your career documents and run `/setup` to populate your profile. **If both were reset:** -> Both your profile files and documents folder are now empty. Add documents to `documents/` (or skip and use the CV import / interview path), then run `/setup`. +> Your profile data and user documents are now cleared; the document folders, `.gitkeep` placeholders, and `documents/README.md` are preserved. Add documents to `documents/` (or skip and use the CV import / interview path), then run `/setup`. diff --git a/CHANGELOG.md b/CHANGELOG.md index cb3410bd..cb700e6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,8 @@ per-file diff commands. ### Fixed +- **`/reset documents` clears nested and hidden user entries while preserving scaffolds** - cleanup retains tracked `.gitkeep` files and the README, with the complete inventory shown before confirmation (#580). + - **`jobindex-search` examples follow the search CLI contract** - the posted-today example includes a required query, unsupported flags are removed, and regression checks accept LF and CRLF checkouts (#579). - **`convert_salary_excel.py` converts the IDA workbook again** (#615, `tools/convert_salary_excel.py`, diff --git a/tests/test_reset_command.py b/tests/test_reset_command.py index acea544c..bbe4cf20 100644 --- a/tests/test_reset_command.py +++ b/tests/test_reset_command.py @@ -28,13 +28,16 @@ tree, the profile files from /setup Step 3's own headings - so a new drop folder or a new /setup target fails this test until /reset covers it. """ import re +import shutil import subprocess import unittest from pathlib import Path +from tempfile import TemporaryDirectory REPO = Path(__file__).resolve().parent.parent RESET = REPO / ".claude" / "commands" / "reset.md" SETUP = REPO / ".claude" / "commands" / "setup.md" +BASH = shutil.which("bash") def tracked_document_subfolders(): @@ -73,16 +76,26 @@ class TestResetCoversEveryDocumentsSubfolder(unittest.TestCase): f"so the user confirms a deletion list that omits them: {missing}", ) + @unittest.skipUnless(BASH, "requires Bash to replay the documented /reset command") def test_delete_block_removes_every_subfolder(self): - deleted = set(re.findall(r"rm -r?f documents/(\w+)/", self.text)) - missing = sorted(self.folders - deleted) - self.assertEqual( - missing, - [], - "reset.md's delete block has no rm line for these documents/ " - 'subfolders, yet the command then claims "The `documents/` ' - f'folder is now empty.": {missing}', - ) + # Exercise the deletion contract, rather than requiring one particular + # spelling of rm. Keep deriving the folder set from git so a newly + # tracked document root still has to be handled by the real command. + section = self.text.split("### Documents reset", 1)[1] + script = re.search(r"```bash\n(.*?)```", section, re.S).group(1) + with TemporaryDirectory() as directory: + root = Path(directory) + for folder in self.folders: + path = root / "documents" / folder + path.mkdir(parents=True) + (path / ".gitkeep").write_text("Keep scaffold.\n") + (path / "user.txt").write_text("Remove document.\n") + result = subprocess.run([BASH, "-c", script], cwd=root, capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + for folder in self.folders: + path = root / "documents" / folder + self.assertEqual([entry.name for entry in path.iterdir()], [".gitkeep"]) + self.assertEqual((path / ".gitkeep").read_text(), "Keep scaffold.\n") def section(text: str, start: str, end: str) -> str: diff --git a/tests/test_reset_documents.py b/tests/test_reset_documents.py new file mode 100644 index 00000000..4e26cf31 --- /dev/null +++ b/tests/test_reset_documents.py @@ -0,0 +1,120 @@ +"""Replay /reset's actual Bash commands against isolated document files.""" +import errno +import os +import re +import shutil +import subprocess +import unittest +from pathlib import Path +from tempfile import TemporaryDirectory + +COMMAND = Path(__file__).resolve().parent.parent / ".claude/commands/reset.md" +FOLDERS = ("cv", "linkedin", "diplomas", "references", "projects", "postings", "applications") +BASH = shutil.which("bash") + + +def symlinks_available(): + """Probe only the filesystem capability needed by the scope controls.""" + with TemporaryDirectory() as directory: + root = Path(directory) + target = root / "target" + target.touch() + try: + (root / "link").symlink_to(target) + except NotImplementedError: + return False + except OSError as exc: + if exc.errno in (errno.ENOSYS, errno.ENOTSUP, errno.EPERM): + return False + if os.name == "nt" and getattr(exc, "winerror", None) in (5, 1314): + return False + raise + return True + + +SYMLINKS = bool(BASH) and symlinks_available() + + +@unittest.skipUnless(BASH, "requires Bash to replay the documented /reset command") +class ResetDocuments(unittest.TestCase): + def reset_script(self): + section = COMMAND.read_text(encoding="utf-8").split("### Documents reset", 1)[1] + return re.search(r"```bash\n(.*?)```", section, re.S).group(1) + + def run_reset(self, populated): + with TemporaryDirectory() as directory: + root = Path(directory) + documents = root / "documents" + documents.mkdir() + readme = documents / "README.md" + readme.write_text("Keep these framework instructions.\n") + sibling = root / "outside-documents.md" + sibling.write_text("Keep this external file.\n") + for name in FOLDERS: + folder = documents / name + folder.mkdir() + (folder / ".gitkeep").write_text("Keep this scaffold.\n") + if populated: + (folder / "summary.md").write_text("A document\n") + (folder / ".source.md").write_text("A hidden document\n") + 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) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stderr, "") + self.assertEqual(readme.read_text(), "Keep these framework instructions.\n") + self.assertEqual(sibling.read_text(), "Keep this external file.\n") + self.assertEqual({path.name for path in documents.iterdir()}, {*FOLDERS, "README.md"}) + for name in FOLDERS: + self.assertEqual([path.name for path in (documents / name).iterdir()], [".gitkeep"]) + self.assertEqual((documents / name / ".gitkeep").read_text(), "Keep this scaffold.\n") + + def test_all_document_entries_are_removed_without_removing_the_scaffold(self): + self.run_reset(populated=True) + + def test_empty_document_folders_remain_usable(self): + self.run_reset(populated=False) + + @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)): + with self.subTest(linked_root=linked_root), TemporaryDirectory() as directory: + root = Path(directory) + outside = root / "outside" + outside.mkdir() + sentinel = outside / "keep.md" + sentinel.write_text("External document.\n") + documents = root / "documents" + documents.mkdir() + for name in FOLDERS: + folder = documents / name + folder.mkdir() + (folder / "local.md").write_text("Local document.\n") + target = root / linked_root + shutil.rmtree(target) + target.symlink_to(outside, target_is_directory=True) + locals_before = [path for name in FOLDERS for path in (documents / name).glob("local.md")] + result = subprocess.run([BASH, "-c", self.reset_script()], cwd=root, capture_output=True, text=True) + self.assertNotEqual(result.returncode, 0) + self.assertIn("symbolic link", result.stderr) + self.assertEqual(sentinel.read_text(), "External document.\n") + for path in locals_before: + self.assertEqual(path.read_text(), "Local document.\n") + + @unittest.skipUnless(SYMLINKS, "requires filesystem symlinks and Windows symlink privilege") + def test_internal_symlink_is_removed_without_following_its_target(self): + with TemporaryDirectory() as directory: + root = Path(directory) + outside = root / "outside" + outside.mkdir() + sentinel = outside / "keep.md" + sentinel.write_text("External document.\n") + for name in FOLDERS: + (root / "documents" / name).mkdir(parents=True) + link = root / "documents/projects/external" + link.symlink_to(outside, target_is_directory=True) + result = subprocess.run([BASH, "-c", self.reset_script()], cwd=root, capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertFalse(link.is_symlink()) + self.assertEqual(sentinel.read_text(), "External document.\n")