Move or rename Markdown files and update the relative links that point to and from them, plus an audit command for broken internal links.
Project description
docmv
Move or rename Markdown files and update the relative links that point to and from them.
docmv is a command-line tool for moving and renaming Markdown files without
breaking relative links. When you move a file or a folder, it updates links in
two places: links elsewhere that point at the moved file, and links inside the
moved file that point back out. It also has an audit command that reports
broken internal links.
Every command can emit JSON (--json) for use in scripts. apply refuses to run
when a move is ambiguous or would break a link, rather than guessing, and the
explain command shows how a given link resolves without changing anything.
Why
Editors like VS Code, JetBrains, and Obsidian update Markdown links on move, but
only when they perform the move. The moment a file is moved by git mv, a
script, or an automated agent, those updates never fire and links rot. The usual
fallback is a manual git grep for the old path, which misses the links inside
the moved file entirely. docmv closes that gap for command-line and automated
workflows.
Install
# from PyPI (recommended)
uv tool install docmv # or: pipx install docmv
# from a local clone
uv tool install . # or: pipx install .
# run without installing
uv run docmv --help
Pure standard library, no runtime dependencies. Requires Python 3.10+.
Usage
docmv audit [paths...] # read-only: every internal link must resolve
docmv plan SRC DST # dry-run: show the move + link edits, write nothing
docmv apply SRC DST # perform the move + edits (refuses on ambiguity)
docmv explain FILE[:LINE] # show how each link in FILE resolves
SRC/DST may be files or directories. Add --json to any command for stable,
machine-readable output.
apply moves files on disk and rewrites links, then leaves staging and
committing to you (git detects the rename in the resulting diff). Pass --stage
to also stage the move with git mv, or --no-stage to force a plain move.
To make staging the default without typing the flag each time, set it in git config (a repo setting overrides the global one), or via an env var for one-offs:
git config --global docmv.stage true # stage in every repo
git config docmv.stage true # stage in this repo only
DOCMV_STAGE=1 docmv apply SRC DST # stage for a single invocation
Precedence is --stage/--no-stage > DOCMV_STAGE > git config docmv.stage >
off. docmv apply --help documents this too.
Examples
# What would break / change if I moved this doc into a new folder?
docmv plan docs/architecture/loot-tables.md docs/architecture/loot/loot-tables.md
# Do it (only if the working tree is clean and nothing would break)
docmv apply docs/architecture/loot-tables.md docs/architecture/loot/loot-tables.md
# Move an entire folder
docmv apply docs/meta docs/meta/docs-and-rules
# CI link check
docmv audit # exits non-zero if any internal link is broken
How it stays correct
- Matches by resolved absolute target, not by raw string.
../x.mdmeans different files from different folders, so resolving first is what makes cross-folder and folder moves correct. - One recompute pass handles inbound, outbound, intra-folder, and cross-depth links the same way: for every link, recompute the relative path from its post-move container to its post-move target.
- Code-aware, CommonMark-faithful parsing. Links inside fenced blocks, inline
code spans, HTML comments, raw HTML tags, and autolinks are treated as opaque,
so the example links that fill docs-about-docs are never rewritten or falsely
flagged. The destination, title, and label scanners are ported from
markdown-it-pyand tested against the full CommonMark 0.30 spec corpus with zero false positives (see Limitations). - Display-path detection. When the visible link text mirrors the path (e.g.
[../meta/x.md](../meta/x.md)), the text is rewritten too, but only on an exact match. Fuzzy matches are surfaced for review rather than auto-applied. - Fragments and queries preserved.
./b.md#sectionkeeps its#section. - Post-move verification. After planning, a simulation confirms no link is left dangling, and separates breakage the move would introduce (a bug, shown loudly) from pre-existing rot.
Agent safety
applyrefuses and changes nothing if there are blockers (destination exists, dirty working tree), if the move would introduce any broken link, or if low-confidence review items exist (override with--allow-low-confidence).- All output is available as
--jsonwith a flat, stable schema. - Exit codes:
0ok,1audit found broken links,2apply was blocked,3usage error. explainis read-only and shows the base dir, normalized path, and existence for each link, so an agent or human can verify a flagged link instead of trusting a bare verdict.
Scope and safety
docmv only writes Markdown files. It never edits source code.
- The only files whose contents change are
.mdand.markdown. Code files (.py,.ts, and so on) are touched only when you name one as theSRCorDSTof a move, and then they're relocated, not content-edited. - One consequence: doc references embedded inside code, like a path in a Python docstring or comment, are not auto-updated. That's the safe side of the trade; docmv would rather miss a code-embedded reference than risk corrupting code.
- Scanning code isn't reliable here anyway: a doc-path string in code is just data, and there's no way to know whether it points at this repo's own docs or somewhere else. Any future code scanning would be opt-in and review-only.
Limitations
docmv's scanner is a small, line-oriented stdlib tokenizer, not a full
block-level CommonMark parser. It's validated against the official CommonMark
0.30 spec corpus using markdown-it-py as a reference oracle. The trade-off is
deliberately asymmetric:
- Zero false positives (enforced). Every destination docmv extracts is one the reference parser also recognises. It won't mistake code, an escaped bracket, an autolink, or raw HTML for a rewritable link, so it can't corrupt a file by rewriting a non-link.
- A few safe false negatives (tolerated). A handful of valid-but-rare
constructs aren't rewritten. A missed link is surfaced by
auditrather than corrupted. These are:- multi-line reference definitions, and reference definitions nested inside blockquotes or lists;
- inline links whose destination or title wraps across lines;
- nested links and images: a clickable badge like
[](/url)updates the outer/urlbut not the innerlogo.png; - email autolinks (
<me@example.com>), which are external and never rewritten.
- Heading-anchor (
#fragment) checks use an approximate GitHub slug algorithm and are reported as best-effort warnings, not hard failures. - Setext headings (
===/---) are not collected for anchor checks.
Development
uv run --extra dev pytest
If you change the parser or link-resolution logic, please add adversarial test cases, not just the happy path.
License
MIT — see LICENSE.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file docmv-0.1.0.tar.gz.
File metadata
- Download URL: docmv-0.1.0.tar.gz
- Upload date:
- Size: 63.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.8
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
502d09b1fdd9b51c4fe9b47e46f9053c32a45c96fc1d2726ce510aea32af53a1
|
|
| MD5 |
a23f044f9cf19432b5240efa4dee524a
|
|
| BLAKE2b-256 |
c357ead8bf2c666a5818d0a1fc8e6bf2dde34472b23493a05e83b3baacaba878
|
File details
Details for the file docmv-0.1.0-py3-none-any.whl.
File metadata
- Download URL: docmv-0.1.0-py3-none-any.whl
- Upload date:
- Size: 28.8 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.8
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
470b1e7a814e87a92b6e3b879cfe133d38a9a6060612b757d23c1cb1c5c965f0
|
|
| MD5 |
470c684cd0308301d878e954c652264a
|
|
| BLAKE2b-256 |
fb116465ec73931e86268015e1dc06d19b4c29d158d1284c62fdb3f1f144a10b
|