Here's a problem that sounds trivial until you've felt it at scale: you publish a documentation page, an agent cites it, someone bookmarks it — and then the author renames the file or moves it into a different folder. The content is the same. The link is dead.
In a docs-as-code world this happens constantly. Files get reorganized, folders get restructured, a getting-started.md becomes onboarding/intro.md. Every one of those moves quietly breaks links, citations, and any agent answer that pointed at the old path. If your link is just "the file path," then the link is only as stable as the file path — which is to say, not stable at all.
We needed links that stay valid even when content moves in source control. Here's how we built them.
The core idea: identity that isn't the path
A path is a location, not an identity. The fix is to give every doc a stable identity that travels with the content, and to build the public link on that identity instead of the path.
So a deeplink looks like this:
https://{host}/cid/{contentId}/fid/{fileId}
No folder structure. No filename. No extension. Just two stable identifiers: which content source the doc came from, and a fileId that uniquely and durably names the document itself. Move the file anywhere you like — the link still resolves, because the link was never about where the file lived.
The whole trick is making fileId stable across moves. That's where git earns its keep.
Deriving a stable file id from git
The naive version is easy: hash the file path.
fileId = sha256(repositoryFilePath);
That gives you a clean, deterministic id — but it has the exact flaw we're trying to avoid: move the file and the hash changes, so you get a new id and a new link. Useless.
The real work is detecting when a "new" path is actually an existing document that simply moved, and carrying its id forward. Git already knows this — every commit diff records renames as a source → target pair. So instead of hashing paths in isolation, we walk the commit history and let the diffs tell us what moved.
Walking from one commit to the next, for every change we ask: did this file exist before under a different path?
const changes = commitDiff.changes || [];
for (const change of changes) {
const previousPath = change.sourceServerItem; // where it used to live
const currentPath = change.item?.path; // where it lives now
// If we already had an id for the old path, carry it forward.
// Otherwise, mint a fresh one from the path hash.
const fileId = previousPathToId.has(previousPath)
? previousPathToId.get(previousPath)
: sha256(previousPath);
if (fileId && currentPath) {
pathToId.set(currentPath, fileId);
}
}
The key line is the carry-forward: when a file moves, its new path inherits the old path's id. The document keeps its identity through the move, and therefore keeps its link.
Files that didn't change in this commit simply keep whatever id they already had:
for (const item of allFilesAtThisCommit) {
if (!pathToId.has(item.path)) {
pathToId.set(item.path, previousPathToId.get(item.path) ?? sha256(item.path));
}
}
Do this commit-by-commit across the history and you end up with a path → fileId map where the id is anchored to the document's lineage, not its current location.
Handling the awkward cases
Two real-world wrinkles are worth calling out, because they're where naive implementations fall over:
Collisions. Two different documents can, in edge cases, resolve to the same hash-derived id. We detect duplicates and disambiguate the colliding entries by prefixing the commit id, so two distinct docs never share a link:
if (duplicateFileIds.has(fileId) && sha256(repoFilePath) === fileId) {
pathToId.set(repoFilePath, targetCommitId + fileId);
}
Cost. Walking commit diffs across a large repository is not free — it's the
Links that don't break when your docs move (you are here)
Sai Pramod Upadhyayula is a Senior Software Engineer at Microsoft working on AI-powered enterprise knowledge platforms, and a contributor to the DocFX open-source ecosystem.
SOCIAL SHARE CARD GENERATOR