current
lord
Keep a repo’s private, unversioned files without committing them.
Every repo accumulates files that belong to it but not in it: .env,
.claude/, editor state, local overrides. Delete the checkout and they are
gone. lord moves them into a store keyed by the repo’s origin URL and
symlinks them back into the worktree. A fresh clone recovers them with one
command.
How it works
A .lord manifest in the repo root lists one literal path per line:
# private files, never committed
.env
.claude/
notes.md
Running lord with no arguments converges the repo to that manifest:
- adopts listed files found in the worktree: each moves into the store and leaves a symlink behind
- links store entries missing from the worktree, so a fresh clone gets its private files back
- relinks a worktree copy identical to its store entry, eg a tool that rewrote the file unchanged and replaced the symlink
- restores files whose line was removed: each moves back into the worktree as a real file
- regenerates a fenced block in
.git/info/excludeso listed paths never appear in git status, leaving.gitignorealone - installs pre-commit, post-checkout, post-merge and post-rewrite hooks that rerun lord, so a staged secret blocks the commit and a checkout that materializes a listed file is surfaced immediately. A hook that is not a shell script gets no lord block and a warning
- links a private stash into the git dir and keeps the repo’s git notes there, see Stash and notes
- refuses on conflicts instead of guessing
The manifest itself is managed the same way: it lives in the store and
appears in the worktree as a symlink. A repo with no manifest anywhere gets
one initialized, copied from $LORD_CONFIG/skel if that file exists.
With nothing to do it prints nothing and exits 0. The only destructive operations are the adoption rename, replacing an identical copy with its link and moving a renamed repo’s store.
One caveat: a repo that sets core.hooksPath, husky for example, never runs
hooks from .git/hooks, so the guards are silent there. Run lord by hand in
those repos, call it from the custom hooks or let lord all cover them
once they are registered.
Usage
usage: lord [-n] [dir]
lord all [-n]
lord list
lord prune [-n]
lord eject [-n] [dir]
lord id [dir]
lord -h | -V
With no command lord converges one repo. The commands:
all: converge every checkout in the registry, see Checkoutslist: list the registry, see Checkoutsprune: drop registry paths that are no longer lord checkoutseject: move every managed file and the stash back, remove the exclude and hook blocks, and delete the repo’s store dir if emptiedid: print the origin URL, store key, root commit and the provider’s repo id, looked up now, and change nothing
The options:
-n: dry run, report what a run would do and change nothing. It applies to everything that writes, so not tolistandid-h: print usage-V: print the version
A command is the first word. To name a directory called list, write
./list. -- ends the options.
dir defaults to the current directory and may be anywhere inside the repo. The commands that act on the registry take no dir.
$ printf '.env\n' > .lord
$ lord
adopt .lord
adopt .env
$ ls -l .env
lrwxrwxrwx 1 t t 54 .env -> /home/t/.local/share/lord/github.com/acme/app/.env
Store
Files live under $LORD_DIR, default ~/.local/share/lord, keyed by the
origin URL as <host>/<owner>/<repo>. Both ssh and https remotes normalize
to the same key, so reclones and remote rewrites find the same store. A repo
needs an origin remote; it need not exist on the host yet.
The store is plain files. lord is a tool for one machine: it keeps no machine identity in the store and never merges two copies of it. Back the store up with the tools you already use.
A renamed repo keeps its store, see Renames and transfers.
Stash and notes
.git/lord is a private directory for the repo, kept in the store as
.lord-stash and linked into the git dir. Git neither tracks nor pushes
anything there, and it outlives the checkout like any listed file. In a
linked worktree it sits in the common git dir. A real directory found at
.git/lord is adopted.
lord keeps the repo’s git notes in the stash as notes.bundle, every ref
under refs/notes/. Each run compares them ref by ref:
- a ref only here, or ahead here, is saved to the bundle
- a ref only in the bundle, or ahead there, is loaded by a fast-forward, so a fresh clone gets its notes back on the first run
- a ref that diverged is a conflict, E17. The bundle stays as it is, saves
of other refs included, until the ref is merged. The error names the
commands: fetch the bundle’s ref into
refs/notes/lord-stash, merge it withgit notes mergeand delete it. lord never syncs that scratch ref - a ref deleted here comes back from the bundle, since lord never drops notes. Delete it from the bundle too to drop it for good
$ git notes add -m 'reviewed'
$ lord
save refs/notes/commits
Notes trouble fails a run by hand. In a run from lord’s hooks it only warns, so diverged notes never block a commit.
A lock in the stash keeps two runs on one store from losing each other’s saves. A run waits five seconds for it, then fails with E16. A lock left by a killed run stays until removed by hand.
A pre-push hook refuses every push of notes: a ref under refs/notes/ on
either side of a refspec, or an object a notes ref contains, as when a notes
commit is pushed by its id. A mirror push, a copied push refspec or a
renamed destination cannot publish them. Deleting a remote notes ref passes.
git push --no-verify skips the guard.
The guard goes first in the pre-push hook. It reads the refs on stdin and
hands them on, so the hook’s own code, git lfs pre-push for example, reads
them as git sent them.
A repo that sets core.hooksPath never runs the guard. Nor does one whose
pre-push hook is not a shell script, since lord cannot put sh in it. lord
leaves such a repo’s notes alone in both directions and warns when there are
any.
Notes are keyed by commit, and an amend or rebase makes new commits. Set
git config --global notes.rewriteRef refs/notes/commits and git moves each
note to the new commit; the post-rewrite hook then saves the result.
Renames and transfers
Each store holds .lord-id, lord’s own record, never linked into the
worktree:
url git@github.com:acme/app.git
root 5f7e675...
id github github.com 1283757050
root is the repo’s oldest root commit. id is the hosting provider’s own
repo id, which survives renames and transfers. lord looks it up once, when
it writes the record, so hook runs stay offline. A failed lookup records
id none; delete the file to retry.
When the store for the origin URL is missing, lord looks for the store this repo used before:
- a store carrying the same provider id is the same repo: lord moves it to the new key and re-points the links
- without an id, a store this checkout’s
.lordstill links into moves too, unless its record names another id or another root commit - a different id means a fork or a copy, which never takes the store
- several stores with the same id stop the run with E17
So a rename is one remote change and one run:
$ git remote set-url origin git@github.com:acme/app2.git
$ lord
move github.com/acme/app -> github.com/acme/app2
relink .lord
relink .env
A fresh clone under the new name finds the store by id alone. The move leaves a symlink at the old key, so clones still on the old URL, which the host keeps redirecting, share the store. If a different repo later answers at the old URL, told apart by its root commit, lord drops that forward and gives it a store of its own. A store moved by hand works too: links that point at a missing path in the store are re-pointed.
lord id prints the URL, key, root commit and the id as looked up now,
next to the recorded one.
Providers
| provider | hosts | id | token |
|---|---|---|---|
github |
github.com | id |
GITHUB_TOKEN, GH_TOKEN, else gh auth token |
gitlab |
gitlab.com | project id |
GITLAB_TOKEN |
forgejo, gitea |
codeberg.org | id |
GITEA_TOKEN, FORGEJO_TOKEN |
bitbucket |
bitbucket.org | uuid |
BITBUCKET_TOKEN |
bitbucket-dc |
none built in | id |
BITBUCKET_TOKEN |
azure |
dev.azure.com, ssh.dev.azure.com | id |
AZURE_DEVOPS_PAT |
sourcehut |
git.sr.ht | rid |
SRHT_TOKEN, required even for public repos |
Public repos need no token, except on SourceHut. Tokens reach curl through
its config on stdin, never the command line. Self-hosted instances go in
$LORD_CONFIG/hosts, one <host> <provider> [api-base] per line:
git.example.org gitlab
code.example.org forgejo
github.example.com github https://github.example.com/api/v3
Without curl, or for an unknown host, there is no id and only the link rule
applies. LORD_ID_CMD replaces the lookup entirely: a command that gets the
origin URL as $1 and prints <provider> <host> <id>, or nothing.
Checkouts
Every real run records its checkout in a registry, one line per work tree:
the UTC time of the last run, then the path. A linked git worktree counts as
its own checkout. -n and id record nothing and eject removes the entry.
The registry lives in $LORD_STATE, never in the store, because paths
belong to one machine.
$ lord list
drift 1d ~/src/api
ok 5m ~/src/app
dead 9w ~/src/old
On a terminal the rows are sorted by path and $HOME shows as ~. The
second column is the age of the last run: now, 12m, 5h, 6d, 9w or
2y. In a pipe or a $(...) the output is for scripts instead: three tab
separated fields in registry order, newest first, with the UTC time in ISO
form and the absolute path last.
$ lord list | cat
ok 2026-10-13T14:11:55Z /home/t/src/app
The state column reads:
ok: a dry run changes nothing, warnings asidedrift: a dry run would change somethingerror: a dry run fails, eg on a conflictdead: the path is gone, or no longer a lord checkout
list only reads. lord all runs lord in every live checkout and prints
the path followed by the output of each one that had something to say. A
failure in one checkout never stops the rest. The exit status is the first
nonzero status of any checkout. A dead path prints as skip <path>. A path
that holds a repo without a manifest counts as dead, so all never
initializes one. all -n reports the same and changes nothing.
all never edits the registry. lord prune drops the dead paths and prints
a drop <path> line for each. prune -n only prints them.
The registry is a convenience. If it cannot be written, the run goes on without it and prints nothing.
Manifest rules
Paths are relative to the repo root and taken literally, no globs. Blank
lines and # comments are ignored. A trailing slash is stripped, so
directories may be listed either way. Absolute paths and paths containing
.. are rejected. A path listed before it exists is excluded at once and
adopted on the run after it appears.
Conflicts
lord never overwrites data. A path present in both worktree and store with different contents, a symlink pointing somewhere unexpected or a listed path tracked in git each produce an error naming the path, and the run exits nonzero. Orphaned store entries and unlisted symlinks into the store produce warnings only.
Errors print as E<code>: message with errno-flavored codes:
| code | meaning |
|---|---|
| 1 | listed path is tracked in git |
| 2 | no origin remote, or store file missing |
| 16 | notes bundle locked by another run |
| 17 | worktree and store conflict, notes that diverged from the stash, or several stores with this repo’s id |
| 21 | manifest is not a regular file |
| 22 | invalid flag, manifest entry or directory, or a manifest listing .lord-id or .lord-stash or a path inside them |
Environment
| variable | default |
|---|---|
LORD_DIR |
$XDG_DATA_HOME/lord, ie ~/.local/share/lord |
LORD_CONFIG |
$XDG_CONFIG_HOME/lord, ie ~/.config/lord |
LORD_STATE |
$XDG_STATE_HOME/lord, ie ~/.local/state/lord |
LORD_ID_CMD |
unset: the built-in provider lookups |
LORD_HOOK |
unset; lord’s hooks set it, and notes trouble then warns instead of failing |
$LORD_CONFIG/skel seeds the manifest for repos that have none.
$LORD_CONFIG/hosts names the provider of self-hosted instances.
$LORD_STATE/checkouts is the registry.
Install
make install
Installs to ~/.local/bin/lord. Set PREFIX to install elsewhere. lord is
a single POSIX sh script; it needs git, awk and standard utilities, plus
curl for id lookups.
Test
make check
Runs the end-to-end suite: 317 checks across adoption, fresh clones,
conflicts, hooks, eject, dry runs, renames, store moves, the registry, the
stash and notes. Id lookups run offline through LORD_ID_CMD.
License
MIT