In a large project .md files get forgotten: the code moves on, the docs stay
put. md-track lists every .md in the repository with how many commits the
code has had since that doc was last touched.
Plain Bash, git and column. No dependencies, no install, no config.
C_BEHIND is measured in commits, not days. An old doc sitting on frozen
code is fine; a recent doc on code that moved 100 commits is not.
Copy-paste commands: RUN.md.
$ md-track
FILE C_BEHIND COMMIT SUBJECT N_UPDATES
docs/draft.md NEW — never committed 0
src/auth/README.md 105 ! c2950da docs: login flow 2
docs/deploy.md 102 ! e0a797c chore: move deploy 1
A modification date on its own says little: a doc from 2019 may be perfectly accurate if its code hasn't changed since. The useful signal is the distance between the two.
The baseline is the last commit that touched the .md. From there, md-track
counts code commits. C_BEHIND 3: probably current. 155: almost certainly
lying.
The scope is always the whole repository, with one exclusion: commits that only
touch *.md don't count as new code. Editing another doc doesn't age this one.
git rev-list --count <md_sha>..HEAD -- . ':(exclude)*.md'One rule for every file, so all rows are comparable to each other. The price is that the number is an upper bound: a doc may rank high because the project moved a lot, not because its subject changed. Read the table as a relative ranking, not as a verdict per file.
How many commits have ever touched that .md:
git rev-list --count HEAD -- <file.md>It gives C_BEHIND its context. N_UPDATES 1 means the file was written once
and never revised — a high C_BEHIND there is an abandoned doc. N_UPDATES 30
with a high C_BEHIND is a different story: someone maintains that doc, the
project is simply moving fast.
COMMIT is the hash of the .md's last commit. There is no date column:
mtime becomes the time of git clone and would lie in CI, and the commit date
is noise when the metric's unit is commits. When you need it, git show <hash>.
NEW is a trackable .md that was never committed. No baseline, so no
C_BEHIND, and N_UPDATES is 0. It sorts to the top because it's the most
actionable: just commit it.
SUBJECT is the title of the .md's last commit, cut at 40 characters, with
| replaced by / (a | in the title would shift every column on that row).
N_UPDATES comes last. On a narrow terminal the trailing truncation (below)
can now clip it instead of SUBJECT — a tradeoff against having the update
count easy to grep/awk for as the final field on the line.
Past 99 commits the row gets a !, and that's all. md-track always
exits 0.
A built-in limit that fails the pipeline on its own becomes md-track || true
in the .yml within a week, and then it may as well not exist. React however
you like:
- run: md-track | grep -q ' ! ' && echo "::warning::stale docs" || trueWhen the output isn't a terminal, lines aren't clipped to the width — you can
grep and awk over it without losing columns.
Every .md tracked by git, plus uncommitted ones that .gitignore doesn't
hide. Since the list comes from git, node_modules and vendor disappear
without a blocklist. A file still in the index but gone from the worktree —
deleted, or a dead symlink — is left out: there is no doc there to bring up to
date, so a lag number for it would be noise. The script anchors itself at the
repo root, so it works from any subdirectory. Outside a git repo: error and
exit 1, no degraded mode.
./test.sh builds a temporary repository with a known lag and checks
C_BEHIND with the *.md exclusion, N_UPDATES, the NEW case, the 99 mark,
a | in the commit subject, and the behaviour outside a git repo. No framework,
~4s (it creates 100 commits).
Three git calls per file, serially: instant up to a few hundred docs. In a
monorepo with thousands, parallelise with xargs -P or switch to a single
git log --name-only pass. Marked in the code as ponytail:.