Warns you when agents working in different git worktrees of one repository are about to step on each other — and whether their edits merely overlap or will actually conflict.
Running several coding agents at once usually means several git worktrees of the same repository, one
per herdr workspace. That works right up until two of them start editing the same file, and you find
out at merge time — and the more agents you run, the likelier that gets. collide watches every workspace that is backed by a git checkout, groups them by
repository, and tells you — while the work is still in flight — which sibling worktrees are touching
the same files, and whether those edits will merely overlap or will genuinely conflict on merge. It
also flags a runaway worktree whose change set has grown past a threshold you set, which is usually
the first visible sign that an agent has wandered off.
It never writes to your repositories. Every git invocation is read-only, passes --no-optional-locks
so it cannot contend with an agent's own git commands, and stages nothing through your real index.
Most tools would stop at "you both touched src/api.rs". That is usually a false alarm — two
checkouts editing opposite ends of one file merge without complaint. collide asks git what the
merge would actually do, so a warning means something:
That check runs for every pair of worktrees in the repository, not just two. Six pairs for four agents, forty-five for ten, and each workspace's badge rolls up whatever its own pairings found:
delta shares nothing with anybody, so it has no pairings and no badge. gamma overlaps two
different siblings and its badge counts both. Pairs with no files in common are dropped before any
expensive work happens, which is what keeps the comparison cheap as the number of agents grows.
In the sidebar, each workspace picks up a short badge next to its branch name:
api feature/api ✘ 2
ui feature/ui ✘ 2
web feature/web ⧉ 1
docs docs/readme ? 1
spike spike/parser ⚠ 4.2k
vendored vendor/import ? 1
deploy chore/bump
✘ 2 means two files are predicted to conflict and ⧉ 1 means one file is shared but merges cleanly.
⚠ 4.2k is a runaway: 4200 changed lines in one worktree, which is a count of the change set rather
than of shared files — a runaway agent is usually one that shares nothing with anybody. ? 1 is the
badge that matters most: it means collide could not work out an answer for that file, and it is
deliberately not folded into ⧉, whose whole meaning is "I checked, and it merges clean". A clean
workspace shows nothing at all. Numbers abbreviate once they get long — 1.2k, 12k, 1.2M — so a
badge never grows wide enough to push the branch name off the row.
The full picture lives in the interactive Collide: shared files overlay pane. It refreshes on the configured interval, groups worktrees by repository, and lists every pairing that shares anything:
Collide: shared files
repo /tmp/collide-demo/app
api [feature/api] (no agent) ✘ 2
app [main] (no agent)
docs [docs/readme] (no agent) ? 1
salvage [wip/salvage] (no agent)
degraded: `wip/salvage` does not exist, so this checkout has no commit —
left out of pairing: there is nothing to merge against.
spike [spike/parser] (no agent) runaway ⚠ 4.2k
ui [feature/ui] (no agent) ✘ 2
vendored [vendor/import] (no agent) ? 1
degraded: no common ancestor with `refs/heads/main` — so there is no range
to measure against, and only uncommitted work is counted.
api <-> ui
✘ conflict src/collide.rs
✘ conflict src/git.rs
⧉ overlap src/model.rs
api <-> vendored
? unknown README.md
docs <-> vendored
? unknown README.md
api <-> docs
⧉ overlap README.md
legend
✘ conflict predicted on merge
⧉ same file, merges clean
? conflict prediction unavailable
⚠ runaway change set (lines, or f = files)
That block is a capture from a real run against a six-worktree fixture, not a mock-up — which is why
vendored is there: it is an orphan branch with no common ancestor, so its pairings honestly say
? unknown rather than guessing. Pairings sort worst first, long paths are trimmed from the left so
the informative tail survives, a checkout that could only be read in part says which part and what
follows from it, and the view reflows down to very narrow panes — at 40 columns the badge is the last
thing given up, not the first.
The pane inherits the terminal theme for ordinary text. Conflict tags are red, overlaps yellow, runaways magenta, and unknown stays at the default foreground: missing information is not painted as a severity. The cursor reverses the whole row, and checkout details appear in a centered bordered modal.
↑ / k previous row; scroll up in a hunk view
↓ / j next row; scroll down in a hunk view
mouse wheel previous / next row, or scroll hunks
left click move the cursor to that row (never open it)
Enter checkout detail, or why/hunks for a shared-file row
R refresh immediately
q / Esc back from detail/hunks; quit from the top-level list
Automatic and R refreshes carry the cursor by stable row identity. An open
hunk view stays open by path and is re-read from the refreshed cycle's retained
merge tree; if the path vanished, the pane returns to the list with a note.
Enter never runs a second merge for the cycle on screen.
herdr plugin install moneycaringcoder/herdr-collideInstalling runs the plugin's build step for you, so you end up with a compiled
target/release/collide and nothing further to do.
To develop against a local checkout instead:
git clone https://github.com/moneycaringcoder/herdr-collide
cd herdr-collide
cargo build --release # required: `link` does NOT run the build step
herdr plugin link .herdr plugin link deliberately skips the [[build]] hook, so the binary every command in
herdr-plugin.toml points at will not exist until you build it yourself. Rebuild by hand after every
change.
Removal:
herdr plugin unlink moneycaringcoder.collideLogs are kept in the server rather than on disk:
herdr plugin log list --plugin moneycaringcoder.collideNothing renders in the sidebar until you do this. herdr's default sidebar rows do not name any of
this plugin's tokens, so a freshly installed collide will happily compute everything and display
none of it.
The quickest route is the bundled action — run Collide: set up sidebar (start here). It splices
the rows below into your config.toml, takes a config.toml.collide-backup alongside it first, and
reloads herdr; if the reload does not come back clean it puts the backup back byte for byte.
Collide: undo sidebar setup restores that backup.
Setup adds only the rows your config is missing, and tells you which ones it added — so running it again after an upgrade picks up a newly introduced token without disturbing anything else, and running it when everything is already there does nothing at all. If you removed a row deliberately, setup will put it back; Collide: undo sidebar setup reverses the whole edit. When your config is a shape the splice cannot safely edit, it says which shape and exits non-zero rather than reporting that there was nothing to do.
To do it by hand, add the four tokens to [ui.sidebar.spaces] in ~/.config/herdr/config.toml:
[ui.sidebar.spaces]
rows = [
["state_icon", "workspace"],
["branch",
{ token = "$collide_overlap", fg = "#FFC799" },
{ token = "$collide_runaway", fg = "#FFB27F" },
{ token = "$collide_unknown", fg = "#9399B2" },
{ token = "$collide_conflict", fg = "#FF8080" }],
]Then reload:
herdr server reload-configSidebar rows reload live — no restart, and no losing your panes.
herdr renders a token's value as flat text and cannot colour it by content. A single
$collide_status token could say ✘ 2, but it could never say it in red. So severity is encoded in
the token name: the plugin lights exactly one of collide_overlap, collide_runaway,
collide_unknown or collide_conflict at a time and clears the others, and each name carries its
own fg in your config. The $ prefix belongs to herdr's config row syntax only; the names sent
over the wire have no $.
collide_unknown is grey rather than a warning colour on purpose. It means the plugin could not
work out an answer — a conflict prediction that failed, or a checkout git would not let it read —
and an absence of information is not a severity. It exists because the alternative is worse: before
it, a prediction that failed was rolled into the overlap badge, whose legend reads "same file,
merges clean". That is not a missing answer, it is the opposite one.
There is deliberately no token for a clean workspace. A workspace with nothing to report clears its badge instead of writing one, so its sidebar cell is empty by design — an empty cell means "no collisions", not "the plugin is broken".
Change the colours to taste. The names must stay exactly as written, and all four should be present — if you leave one out, workspaces at that severity simply show nothing.
| Action | What it does |
|---|---|
| Collide: open shared files | Opens the interactive detail pane |
| Collide: set up sidebar (start here) | Adds the tokens above to config.toml, backs it up, reloads herdr |
| Collide: undo sidebar setup | Restores the backup that setup took |
| Collide: report | One-shot collision report for the focused repo |
| Collide: JSON snapshot | The same data, machine-readable, for scripting |
| Collide: enable badge updater | Starts the background updater that pushes badges |
| Collide: disable badge updater | Stops it and clears every badge this plugin set |
| Collide: toggle badge updater | Whichever of the two applies |
There is one pane, Collide: shared files, placed as an overlay. The
open-detail action is its front door. A herdr keybinding can open it directly:
type = "plugin_action"
command = "moneycaringcoder.collide.open-detail"The pane refreshes on the configured interval. q or Esc closes it from the
top-level list; it also exits cleanly on SIGINT, SIGTERM, and SIGHUP, and
restores raw mode, the alternate screen, mouse capture, and the cursor on every
return and panic path.
Herdr supplies HERDR_PLUGIN_CONTEXT_JSON to the report, JSON action, and
detail pane. Collide tries the focused pane cwd first, then the workspace cwd,
and selects the first readable Git repository represented by the live session.
Those surfaces include every sibling checkout of that verified repository and
no other repository. The installed plugin root is never an invocation
repository candidate. A direct shell invocation has no plugin context and uses
its process cwd as its single repository candidate. The badge daemon remains
session-wide.
The badge updater is off until you enable it. Once enabled it survives a herdr restart and a
herdr update --handoff: a startup hook re-spawns it, but only if you had it enabled when herdr went
away. Worktree/workspace lifecycle hooks wake it immediately; ordinary file edits still use the
configured poll interval. A GitHub reinstall replaces the running daemon automatically on its next
cycle, so newly installed code does not wait for a herdr restart. Disabling stops the updater, waits
for it to finish, and sweeps every current workspace so no stale badge remains.
Everything is also available from the command line, which is handy when the plugin is misbehaving:
collide --once # one-shot report
collide --json # the same report as JSON
collide --why path/to/file # show the real conflicting hunks for one shared path
collide --watch # the live detail view
collide --history # recurring conflict episodes, most frequent first
collide --history-clear
collide --enable | --disable | --toggle
collide --setup | --setup-rollback
collide --interval 10 --watch
collide --base-ref origin/main --once
collide --help
Options may come before or after the verb, so collide --base-ref main --once and
collide --once --base-ref main are the same command.
--why is CLI-only. It needs a non-empty path supplied at invocation time, while every plugin
action is a fixed argument array and has no runtime path substitution. It runs the same prediction
pass as --once, then reads conflict content from the retained temporary merge tree rather than
running a second merge. Clean overlaps are named without a diff. Advisory predictions from a merge
already in progress and approximate predictions with no single merge base are labelled before the
verdict they qualify. A conflicted blob is inspected for kind and size before it is read; blobs over
8 MiB are reported as unknown instead of being loaded into the editor-side plugin process.
collide --json includes an integer schema at the top level. Its current value is 2. The key
deliberately keeps the name shipped in 0.1.0: renaming the field consumers use to detect incompatible
changes would itself be incompatible and spend a version bump on cosmetics.
Schema 2 has these keys:
- top level:
schema,checkouts,pairings,statuses,notes - each
checkoutselement:workspace_id,label,repo_key,repo_root,checkout_path,branch,agent,is_linked_worktree,changed_files,lines_added,lines_removed,has_rename,degraded,degraded_reason,target_ref,target_verdict,target_reason,target_approximate,target_advisory - each
pairingselement:left,right,conflict_count,unknown_count,approximate,shared - each
pairings[].sharedelement:path,verdict - each
statuseselement:workspace_id,severity,token,badge,overlap_count,conflict_count,unknown_count,runaway,lines_changed,changed_files notesis an array of strings
Two of those values are enumerations, and they are the reason the version is at 2 rather than 1:
severity is one of clean, overlap, runaway, unknown, conflict, and verdict is one of
overlap, conflict, unknown. A consumer matching either of them exhaustively is the consumer a
new value breaks, which is what the version exists to warn.
Adding a key, or adding an element to an array, does not bump the version. Removing or renaming a
key, changing a value's type, or adding a value to the severity or verdict enum does bump it.
Array order is not part of the contract, so consumers must not depend on pairings or any other
array retaining its current order.
Configuration is a JSON file at $HERDR_PLUGIN_CONFIG_DIR/config.json. herdr injects that directory
when it runs the plugin; when you run the binary yourself it resolves to the same place herdr would
use, ~/.config/herdr/plugins/config/moneycaringcoder.collide/config.json, so both routes read one
file. The daemon's own state lives alongside it under ~/.local/state/herdr/plugins/, which is why
collide --disable typed at a shell stops the updater a plugin action started. Every key is optional and overrides just that default, and unknown keys are
ignored, so a config written for a newer version will not break an older binary. A missing file is the
normal case; a malformed one prints a warning and falls back to the defaults rather than taking the
badge down.
{
"interval_seconds": 5,
"runaway_files": 40,
"runaway_lines": 2000,
"ignore_suffixes": [
"Cargo.lock",
"package-lock.json",
"pnpm-lock.yaml",
"yarn.lock",
"poetry.lock",
"go.sum"
],
"ignore_globs": [],
"predict_conflicts": true,
"conflict_history": false,
"notifications_enabled": false,
"base_ref": "origin/HEAD",
"git_timeout_seconds": 10,
"cycle_timeout_seconds": 30
}interval_seconds— how often the badge updater and the detail pane refresh. Default 5, clamped to 1–3600.--interval <SECS>overrides it for a single run.runaway_files/runaway_lines— a workspace is flagged as a runaway once its change set passes either threshold. Defaults 40 files and 2000 changed lines. The badge reports the changed-line count, sorunaway_linesis also the number the⚠badge is measured against.ignore_suffixes— paths ending in any of these never count as a change. Lockfiles overlap constantly and mean nothing, so they are excluded by default. Setting the key replaces the whole list rather than adding to it.ignore_globs— repository-relative path globs, matched as whole paths from the repository root.*matches within one path component and**may cross/, but**does not absorb a literal slash beside it:**/node_modules/matchesapp/node_modules/xbut notnode_modules/x, and**/*.gen.rsdoes not match the root filea.gen.rs. Use**.gen.rsto match that suffix at every depth, includinga.gen.rs,src/a.gen.rs, andsrc/deep/a.gen.rs. A trailing/selects a matched directory and everything below it. A literal trailing-slash rule also matches a file with that exact repository-relative name so changed submodule pointers are covered:vendor/matches a regular file or submodule pointer atvendor, whilevendor/**andvendor/*do not. There is no?, character class, brace expansion, or negation; those characters are literal. For example,vendor/**matchesvendor/a/bbut notmy-vendor/a. The default is an empty list. Setting the key replaces the whole list rather than adding to it.predict_conflicts— set tofalseto report shared paths only in the regular reports. Cheaper, and it stops distinguishing a real conflict from a plain overlap there. The on-demand--whycommand always predicts because it cannot explain a path honestly without doing so.conflict_history— opt in to an append-only record of real predicted-conflict episodes. Defaultfalse: until enabled, no history file is created. Each transition record stores the repository key, shared path, both worktrees' stable ids, display labels and branch names, the first-seen timestamp, and a last-seen timestamp on the closing transition. Records live only in$HERDR_PLUGIN_STATE_DIR/conflict-history.jsonl(normally~/.local/state/herdr/plugins/moneycaringcoder.collide/conflict-history.jsonl), never in a repository, and are kept private to the user. The newest complete records are retained when the file crosses 1 MiB. An unavailable (Unknown) prediction neither starts nor ends an episode; only a known-clean overlap, the path leaving the shared set, or the pair disappearing closes one.--historyfolds start and closing transitions, summarizes repeat paths and worktree pairs, and reports a real last sighting or that the latest episode remains open.--history-cleardeletes the file.notifications_enabled— opt in to desktop notifications when a workspace becomes conflicting after a non-conflicting baseline. Defaultfalse. The first cycle establishes a baseline, unchanged conflicts do not repeat, and a workspace cannot show notifications more than once per minute. Notification bodies identify the affected branch names, checkout paths, and conflicting paths. Transitions into runaway, unknown, or overlap do not notify; neither does a transition from conflict to clean.base_ref— the local ref each checkout's change set and integration-target prediction are measured against. Defaultorigin/HEAD; where that does not resolve,collidetries, in order,origin/main,origin/master, localmain,master, andtrunk, then the symbolicHEADof each non-originremote in alphabetical order, and finally localinit.defaultBranch. If none resolves, the checkout is visibly degraded and its target verdict isunknown;collidedoes not fabricate aHEADfallback.--base-ref <REF>overrides the probe for a single run, and a configured ref that does not resolve is likewise reported as unknown. Refs are read only from the local ref store:collidenever fetches, so a verdict against a staleorigin/mainexplicitly describes where that ref was, not where the remote branch is now.git_timeout_seconds— cap on any single Git invocation. Default 10 seconds.cycle_timeout_seconds— wall-clock budget for one complete refresh. Default 30 seconds, clamped to 1–3600. When the budget expires, outstanding Git children inherit only the remaining time. The session-wide daemon reports observed checkouts asunknownwith acycle-timeoutnote; a repository-scoped report fails clearly instead of widening to unrelated repositories.
Each cycle takes one session.snapshot over herdr's socket for a consistent
workspace, pane, and agent summary. Herdr 0.8.2 workspace summaries omit the
deprecated worktree metadata, so Collide resolves repositories through the
public worktree.list method. One response maps every open sibling it names;
Collide consumes those mappings before querying another unresolved workspace,
avoiding one list call per workspace in the same repository. Repository
identity is then re-derived from Git — the canonicalized common Git directory —
rather than trusted from Herdr, so two checkouts are compared only when they
are genuinely the same repository.
For each checkout, collide reads a change set — staged, unstaged, untracked, conflicted, and
committed since the merge base — with read-only git plumbing. Pairs of checkouts within a repository
are then intersected: a pair with no files in common cannot conflict and is dropped for free.
Survivors go through conflict prediction. Uncommitted work on either side is first captured as a tree
through a throwaway index, so a prediction covers what the agents have actually written rather than
only what they have committed.
flowchart LR
S["session.snapshot<br/><small>consistent session summary</small>"] --> W["worktree.list<br/><small>deduplicated per repository</small>"]
W --> G["group by repository<br/><small>verified git-common-dir</small>"]
G --> C["change set per checkout<br/><small>dirty + committed since base</small>"]
C --> I{"share<br/>files?"}
I -- no --> D["dropped for free"]
I -- yes --> M["merge-tree<br/><small>uncommitted work snapshotted first</small>"]
M --> V["verdict per file<br/><small>overlap or conflict</small>"]
V --> B["badge, with a TTL"]
V --> P["detail pane"]
There used to be a cheaper first pass here — merge-tree --quiet as a boolean oracle, fifteen times
faster — until it was found reporting clean for merges that genuinely conflict. It is not used at any
stage now; docs/git-plumbing.md records exactly when it lies.
The result is one badge per workspace, pushed with a TTL of roughly three refresh intervals. That TTL is what makes the display self-healing: if the updater is killed, herdr expires the badges on its own within a cycle or two rather than leaving a stale warning on screen forever.
Worth knowing before you trust it:
- It polls. herdr exposes no filesystem events, so changes are noticed on the refresh interval and not before. A five-second badge is a five-second-old badge.
- Conflict prediction is a prediction. It merges the two sides' current state in a temporary index
and reports what git says now. Commit, rebase, or keep typing and the answer can change. A worktree
with a merge already in progress is trickier still: its snapshot is staged from files that still
contain conflict markers. The detail pane labels those pairings
advisory:and names the side responsible, rather than presenting the verdict as if the trees were clean. - Lockfiles are ignored by default.
Cargo.lock,package-lock.json, and friends overlap in almost every pair of worktrees and almost never mean anything. If you want them counted, overrideignore_suffixes. - Non-UTF-8 paths are rendered lossily. Git reports raw bytes; anything that is not valid UTF-8,
and anything that could take control of your terminal, is replaced before display — so such a path
renders differently from how it appears on disk. A short digest keeps distinct byte names distinct.
The raw bytes remain attached internally for line counting and snapshot pathspecs; only CLI
--whyrefuses such a display surrogate because it cannot safely name the tree path back to Git. - Content filters are not run.
git addwould otherwise execute whateverfilter.*.cleanorfilter.*.processprogram your repository configures, on every refresh — and for git-lfs that writes into your own.git/lfs, which is not something a read-only tool may do. They are disabled for the snapshot instead. The consequence is that a filtered file is compared as its raw bytes: for git-lfs that changes nothing useful, and for a filter that rewrites text it makes that file's line count reflect the unfiltered content. - Custom merge drivers are not executed. A repository-configured
merge.<name>.driveris an arbitrary program. Any checkout configuring one makes its pair and target predictionsunknownrather than running repository code inside an unattended refresh. Built-in textual behavior remains available. - Dirty direct submodules are compared one repository deep. When the same submodule path has
modified or untracked content in two open superproject worktrees, collide snapshots each nested
checkout through a scratch index and runs a nested merge. A clean nested merge earns
~ overlap; a nested conflict makes the superproject-relative submodule path! conflictand names the conflicting nested paths in the detail notes. If either nested checkout is not initialised, has no readable HEAD, times out, or otherwise cannot be compared, the path stays? unknownrather than being guessed clean. This does not recurse into submodules of the submodule. A change to the recorded gitlink is still compared normally. Direct nested changed-file and line volume contributes to runaway thresholds; work below a second-level submodule does not. - A checkout can be readable only in part. An unborn branch, a branch deleted underneath a
worktree, a base ref that does not resolve, or two histories with no common ancestor all limit what
can be compared. Rather than quietly reporting such a checkout as clean, the detail pane marks it
degraded:and states which of those it was and what the consequence is — excluded from pairing, or counted on uncommitted work only. A pair whose prediction could not run is shown as? unknowninstead of being downgraded to a plain overlap, and the workspace badges?rather than going quiet. Two histories with no common ancestor are refused outright rather than guessed at: there is no merge to predict, so the answer is "cannot tell" and not "everything conflicts". - Linux and macOS only. The daemon relies on Unix process and signal behaviour, and the plugin declares those two platforms.
Bug reports, questions, documentation fixes and code are all welcome — see CONTRIBUTING.md for how to build it, what makes a change easy to merge, and what is deliberately out of scope. The project is maintained by one person, so review is careful rather than instant.
The one rule worth knowing up front: collide is strictly read-only against your
repositories, and tests/read_only.rs enforces that by fingerprinting every
index, ref and object before and after a run.
Security issues go through private reporting rather than public issues.
MIT. See LICENSE.