Early collision warnings (Git only)
Alpha: not for production. APIs, CLI, storage and schemas may change without migration. See the README for status and the changelog for known limits.
agentscm collisions tells you, before a merge, which in-flight pieces of work in an existing Git repository change the same files. It is read-only, needs no migration and does not use the AgentSCM data directory. Status: implemented for local Git repositories; not a lock, not a prevention guarantee and not part of the native store.
Run it only on repositories you trust. It runs git status and git merge-tree, which execute any clean filters and custom merge drivers configured in the repository (the same as git status or git merge in your shell). Hooks and fsmonitor are turned off, but filters and merge drivers are not.
agentscm collisions # the repository in the current directoryagentscm collisions --git-dir ~/dev/app --worktreesOptions:
-
--git-dir <dir>: a working directory of the repository. Default: the current directory. -
--base <ref>: what “in flight” is measured against. Defaultorigin/HEAD, thenmain. -
--refs <ref>...: check only these refs (anything that resolves to a commit). Default: every local and remote branch with commits not in the base. -
--active-days <n>: without--refs, skip branches whose tip was committed more thanndays ago. Default 14;0checks every branch. The report counts them ininactive_refs_excluded. -
--include-merged: also scan branches whose changes the base already has through a squash or rebase merge. By default they are skipped, because a branch that has already landed cannot collide with new work; the report counts them inmerged_refs_excluded, and the text report says “N branches skipped: already merged”. Branches merged by ancestry (plain merge or fast-forward) never appear, with or without this flag. -
--worktrees: also check each worktree’s uncommitted changes (staged, unstaged and untracked), found withgit worktree list. This is the many-agents-on-one-machine case. -
--git <path>: absolute Git executable. Default: the firstgitonPATH, then/usr/bin/git. Git 2.43 or newer is required; older Git is reported as an error, not guessed around. -
--json/--text: choose the output. On a terminal the default is a short text report; when stdout is not a terminal (a pipe, a script, the container) the default is JSON, as before.--jsonalways prints the JSON below;--textforces the text report.
The text report
Checked 2 branches/worktrees against main · 1 pair touches the same files · 1 would conflict in git
Would conflict (git merge-tree): feat-invoices ↔ feat-rounding billing.rs
Same file is a warning, not proof of conflict. Uncommitted changes are never checked for conflicts. Add --worktrees to include uncommitted work, --json for the full report.It lists conflicting pairs first (with the files that conflict), then pairs that only touch the same files (with the files both changed), up to 10 of each and 3 paths per pair, then the files that appear in the most pairs. It says when the scan was cut short or refs were skipped. Names are shown without control, invisible or bidirectional characters, with " shown as ', and cut at 300 characters. The numbers are the same as in the JSON report.
What it reports (JSON)
One JSON value on stdout, {"ok": true, "collisions": {...}}. The exit status is nonzero only on errors, never because collisions were found.
{ "ok": true, "collisions": { "profile": "agentscm-git-collisions/v1", "git_version": "git version 2.50.1 (Apple Git-155)", "base": {"name": "main", "commit": "<oid>"}, "active_days": 14, "inactive_refs_excluded": 0, "merged_refs_excluded": 0, "items": [ {"id": "feat-a", "kind": "ref", "commit": "<oid>", "refs": ["refs/heads/feat-a"], "worktree": null, "paths_total": 3}, {"id": "worktree:/work/app-2", "kind": "worktree", "commit": "<HEAD oid>", "refs": [], "worktree": "/work/app-2", "paths_total": 2} ], "collisions": [ { "level": "conflict", "items": ["feat-c", "feat-d"], "shared_paths_total": 1, "shared_paths": ["h"], "git_merge": {"result": "conflict", "conflicted_paths_total": 1, "conflicted_paths": ["h"]} }, { "level": "same-file", "items": ["feat-a", "worktree:/work/app-2"], "shared_paths_total": 1, "shared_paths": ["f"], "git_merge": {"result": "not-evaluated", "reason": "uncommitted changes have no commit to merge"} } ], "truncated": null, "skipped": [{"name": "feat-x", "reason": "..."}], "notes": ["..."] }}- An item is a branch tip (several refs at the same commit are one item) or a worktree with uncommitted changes. Branch paths are
git diff base...tip; worktree paths come fromgit status. Renames count as both paths. Lists are capped at 50 paths, with the full count in*_total. same-file(warning): two items changed the same path (or one adds a file where the other has a directory of that name). This is not proof that Git would conflict; edits to different parts of a file usually merge cleanly.conflict:git merge-tree --write-treebetween the two branch tips reported a conflict.git_merge.resultisclean,conflict,not-evaluated(a side is uncommitted) orerror(with a reason).- Items on one line of history (one branch contains the other) and a worktree on or above a branch’s tip (that branch’s own author) are not reported against each other.
- Items that could not be analysed (for example no merge base with
--base) are listed inskipped, not dropped silently. - Items and collisions are sorted deterministically;
conflictentries come first. truncatedisnullwhen the scan finished. The CLI never truncates. The service’sGET /v1/collisionsstops after 60 seconds ("deadline"; items it had no time to list are inskipped) or before its JSON would exceed 4 MiB ("output"), and returns the collisions found so far, in scan order, so a cut can drop aconflictwhile keeping an earliersame-file. A truncated report is not “nothing more found”; run the CLI for the full one. If the deadline passes before the branch or worktree list is read, the request fails instead.
Limits
- Unreported work is invisible. Only branches the repository already has (run
git fetchfor remote ones) and worktreesgit worktree listknows about are checked. A teammate’s unpushed clone, or uncommitted work in a clone you cannot see, will not appear. No result means “nothing found here”, not “no collision”. - It is a snapshot at the moment you run it, with no notifications, intent declarations or locks. Run it often (see the hooks below).
- Same-file is a path-level signal only. Semantic breakage (a caller of a function another branch changed) is not detected.
- Uncommitted changes are never evaluated for Git conflicts.
- Cost grows with the number of items that share paths: each such pair runs one merge simulation. A repository with a hundred stale branches took about 17 seconds to scan in a debug build; narrow with
--refsor--basefor hooks. - It runs through the same hardened Git layer as
git-snapshot(cleared environment, no hooks, literal pathspecs, bounded output and time) and never takes refs or paths through a shell. Refs are resolved to commit IDs before use and names starting with-are rejected.git merge-treewrites unreferenced objects into the repository’s object database;git gcremoves them.git statusin each worktree runs as it would in your shell, so only point this at repositories you trust. - Per-worktree
git statushonours.gitignore,assume-unchangedandskip-worktree: ignored or hidden changes are not reported. - The default scope skips refs already merged into the base (by ancestry, or by a squash or rebase merge: every commit has a patch-equal commit in the base, the base holds every file the branch changed exactly as the branch has it, or one base commit made the branch’s whole change;
--include-mergedscans the squash and rebase ones anyway) and branches idle for more than--active-days(14; the service always uses 14), and stops with an error beyond 200 refs/items (narrow with--refsor a smaller--active-days). A stale branch can still collide with live work; run with--active-days 0to see it. An empty repository, or one with neitherorigin/HEADnormain, needs--base. - Non-UTF-8 file names are shown lossily, so two names that differ only in invalid bytes can look equal.
- In the service, each Git command runs in its own process group, so a timeout also kills merge drivers and clean filters it started. Only a timeout does: a background child left by a command that exits normally, or one that leaves the group (for example with
setsid), keeps running. The CLI keeps Git in the terminal’s process group so Ctrl-C reaches it; its 60-second per-command timeout kills Git but not what Git started. Git’s own--versionprobe has no timeout. - The output budget bounds the report, not the scan: each item’s full path list (up to 16 MiB of Git output) is held in memory while pairs are compared.
--active-daysuses the tip’s committer date, which whoever writes the branch controls; a backdated branch drops out of the default scan (it is still counted ininactive_refs_excluded).merge-treefailures (timeout, unwritable object database) appear asgit_merge.result: "error"with the pair still reportedsame-file; the command’s exit status stays 0.
Running in a container
The published image runs the same scan, so nothing needs installing. Choose a version from
Releases, replace <version> below with its
version without the leading v, and run from your main checkout:
docker run --rm --network none --user "$(id -u):$(id -g)" -v "$PWD:/repo" \ "ghcr.io/agentscm/agentscm:<version>" collisions --git-dir /repo --worktrees- Only run it on repositories you trust: Git runs the repository’s configured filters and merge drivers.
--usermakes the container the owner of the mount, which Git requires.- The mount is read-write only because
git merge-treewrites unreferenced objects (git gcremoves them). Your files, refs and index are not changed. - Run it from the main checkout, not from inside a linked worktree: a linked worktree’s
.gitfile points outside the mounted directory, so Git cannot open it in the container. - Other linked worktrees outside the mount are listed under
skippedin the report, so their uncommitted edits are not covered. Mount their common parent directory to include them.
Serving the report with Compose
docker compose up from a checkout of this repository serves GET /v1/collisions on http://127.0.0.1:7331 (see the HTTP API). Compose mounts the checkout you ran it from (or AGENTSCM_REPO=/path/to/repo) and starts serve --collisions-repo /repo. The mount is read-write for the same git merge-tree reason.
The service runs as uid 10001, and Git refuses a repository owned by another user, so the request may fail unless that uid owns and can write the checkout; use the docker run --user form above otherwise. The container binds 0.0.0.0 inside the container (compose.yaml, like the image, passes --public), and Compose publishes the port on 127.0.0.1 only. Compose always builds the image from this checkout (the first build takes a few minutes because SQLite is compiled in), so it does not need the published image.
Telling your agent
The script below prints only the collisions that involve the current checkout (its branch or its uncommitted changes) and always exits 0, so it advises and never blocks. Save it as agentscm-collisions.sh and chmod +x it. It needs agentscm and jq on PATH.
#!/bin/sh# Advisory: print collision warnings that involve this checkout.top=$(git rev-parse --show-toplevel) || exit 0branch=$(git symbolic-ref --quiet --short HEAD)agentscm collisions --git-dir "$top" --worktrees 2>/dev/null | jq -r \ --arg wt "worktree:$top" --arg br "refs/heads/$branch" ' .collisions as $c | [$c.items[] | select(.id == $wt or any(.refs[]; . == $br)) | .id] as $mine | $c.collisions[] | select(any(.items[]; . as $i | $mine | index($i))) | "agentscm \(.level): \(.items | join(" and ")) both change \(.shared_paths | join(", ")) (same-file is not proof of conflict)"'exit 0Two places to run it:
- Any agent that commits (Claude Code, Codex, others): call it from
.git/hooks/pre-commit, e.g.exec /path/to/agentscm-collisions.sh. Agents read the output of the commit they run. Hooks are per clone and not committed. - Claude Code, every prompt: a
UserPromptSubmithook whose standard output is added to the agent’s context. In.claude/settings.json:
{ "hooks": { "UserPromptSubmit": [ {"hooks": [{"type": "command", "command": "/path/to/agentscm-collisions.sh"}]} ] }}The script itself was exercised against a temporary repository; wiring it into a particular agent’s hook system should be checked against that tool’s current documentation before relying on it.