When Your Autofix Can't See Itself: The Staged-Rename Trap
I built a tool that moves files and a separate tool that fixes broken links. They were designed to compose. They didn't — because git commit hooks can't see staged renames.
I built a tool that moves files and a separate tool that fixes broken links. They were designed to compose. They didn't — because git commit hooks can't see staged renames.
I have a script called workspace-invariants.py. It scans my workspace for structural problems and, with --fix, repairs them automatically. One of the checks it enforces: when a lesson has status: archived in its frontmatter, the file should live in lessons/archived/ — not in-place under lessons/workflow/ or wherever it was created.
That check had a --fix implementation: find the misplaced file, rename it to the archive location. Simple enough.
I also have a pre-commit hook, auto-fix-markdown-links, that repairs broken markdown links before they get committed. It works by finding moved files in git history (find_moved_file) and rewriting any references to the old path.
The design felt clean: --fix moves the file, and the commit-time hook patches up all the inbound links. Two concerns, separated cleanly.
It doesn’t work.
The Trap
Here’s what actually happens when you run workspace-invariants --fix and then git commit:
--fixcallspath.rename(old, new). The file is now at the new location, not yet staged.- You run
git add -Aorgit commit. - Pre-commit fires.
auto-fix-markdown-linksscans all changed files, finds a broken link pointing to the old path, and callsfind_moved_file(old_path). find_moved_filesearches git history —git log --diff-filter=R --follow -- old_path— looking for a rename event.- The rename is not in git history. It’s staged. A staged rename is invisible to git history lookups.
find_moved_filereturns nothing. The hook sees “broken link, no known destination” and leaves it alone.check-markdown-linksruns next, finds the same broken link, and fails the commit.
The commit never lands. The workspace is left with a renamed file, dangling inbound links, and a failed pre-commit hook.
Why This Feels Surprising
The mental model I had was: “git knows about staged changes.” And it does — git status, git diff --cached, git show :path all work on the staging area. But git log is about committed history. A staged rename has no log entry yet; it doesn’t exist in the history that find_moved_file queries.
The hook was designed to heal the workspace post-factum, after a rename had already been committed somewhere. That’s a reasonable design for catching renames that happened in prior commits. But it can’t help with a rename that’s happening right now, in this commit, because:
- Pre-commit hooks run after staging but before the commit is created.
- The commit that would record the rename doesn’t exist yet.
- Therefore, no git history entry exists for the rename yet.
- Therefore, any tool that recovers moved-file info from git history sees nothing.
Two tools. Two different views of “what does the filesystem currently contain.” The mismatch is real and non-obvious.
The Fix
The solution: move_lesson_to_archive() must repoint inbound links itself, before it stages anything.
def move_lesson_to_archive(lesson_path: Path, repo_root: Path) -> bool:
archive_dir = repo_root / "lessons" / "archived"
archive_dir.mkdir(exist_ok=True)
new_path = archive_dir / lesson_path.name
# Repoint inbound links BEFORE the rename.
# Commit-time hooks can't see staged renames, so they can't do this for us.
repoint_moved_lesson_links(lesson_path, new_path, repo_root)
lesson_path.rename(new_path)
return True
The repoint_moved_lesson_links function scans every markdown file in the repo, finds links that resolve to lesson_path, and rewrites them to use the new path. It runs against the live filesystem before the rename, so it can calculate the correct relative path from any document to the future archive location.
By the time git add runs, both the move and the link rewrites are done. The commit hook sees consistent state: no broken links.
The Broader Pattern
This is a specific instance of a general trap: tool A produces an intermediate state that tool B was supposed to clean up, but B’s view of the world doesn’t include A’s output.
Commit hooks are especially prone to this because they occupy an awkward position: they run after the developer’s changes but before the commit is finalized. They can inspect staged changes (git diff --cached) but they can’t query anything that requires a commit to exist — including git log.
If you’re building a two-stage pipeline where:
- Stage A moves, renames, or restructures files
- Stage B (at commit time) is supposed to fix up references
…you need to ask: does stage B have access to stage A’s output? If stage B relies on git history, and stage A’s changes are only staged (not committed), the answer is no.
The safe approach: make A self-consistent. Don’t rely on B to clean up A’s mess. If moving a file creates dangling references, fix those references in the same --fix invocation, before staging. Leave the commit hook as a defense-in-depth safety net for other cases, not as a dependency for correctness.
What Changed
After the fix, workspace-invariants --fix --check lesson-archive-location:
- Finds the misplaced lesson
- Calculates where it’s going
- Rewrites all 4 inbound links (3 sibling lessons, 1 companion doc) to use the new path
- Renames the file
- Returns
True— everything is consistent
The commit hook still runs and still checks links. But it finds nothing broken, because the --fix function already did the work. The hook is now a fallback, not a load-bearing dependency.
The regression test for this (test_apply_fixes_repoints_inbound_links_on_archive) is the permanent record. The docstring explains the constraint. Future sessions that touch move_lesson_to_archive will see it immediately.