Graft
Transactional workspace tooling for Elixir OSS contributors.
Graft treats a directory of cloned sibling Elixir repos as a single workspace. You can add siblings to a manifest, inspect what is present, run a quick workspace health check, see git posture, link dependencies for local development, and remove siblings again. Every command derives from the same workspace snapshot, so status, link.on, and link.off see the same world.
Status:
mix_graftv0.0.1 publish candidate. The Hex package and OTP app aremix_graft; the public Mix task namespace remainsmix graft.*. M1 (status + transactionallink.on/link.off+ validation + safe remove) is feature-frozen, with public JSON contracts pinned by golden tests.
Why
If you maintain multiple sibling Elixir libraries — phoenix + phoenix_live_view, jido + jido_ai + jido_chat, any hex package family — you've felt this pain:
-
Edit
mix.exsin repo B to usepath:for repo A so you can test changes locally. - Repeat for repos C and D, both of which also depend on A.
- Forget which files you edited.
-
Accidentally commit a
path:dep to a PR.
Graft's link.on does this transitively, atomically, with bulletproof revert.
Install
Install the publish-ready Hex package as a dev-only dependency:
def deps do
[{:mix_graft, "~> 0.0.1", only: :dev, runtime: false}]
endOr use the source checkout:
git clone https://github.com/dl-alexandre/mix_graft.git
cd mix_graft
mix deps.get
mix compile
Then from any workspace directory containing a graft.exs:
mix graft.status --root path/to/workspaceQuickstart: Empty Workspace to Cleanup
Start with an empty workspace directory:
mkdir /tmp/my-oss-workspace
cd /tmp/my-oss-workspace
Add a GitHub repository and register it in graft.exs:
mix graft.add elixir-lang/flow --to-manifest --root .Inspect the manifest inventory:
mix graft.list --root .
Run the lightweight health check. This only checks the manifest, paths,
mix.exs, git repository presence, and declared origin remotes. It does not
run mix deps.get, mix compile, or mix test.
mix graft.validate --quick --root .
Inspect the workspace snapshot, including branch, dirty/clean state, and
remote mismatch diagnostics when origin is known:
mix graft.status --root .Remove the sibling from the manifest:
mix graft.remove flow --dry-run --root .
mix graft.remove flow --root .Remove the manifest entry and delete the sibling directory only when you explicitly ask for filesystem deletion:
mix graft.remove flow --delete --root .Common failures are reported with the sibling name and the failed check:
ghost [failed]
- path_missing: Path does not exist: /tmp/my-oss-workspace/ghost
flow [failed]
- mix_exs_missing: mix.exs does not exist: /tmp/my-oss-workspace/flow/mix.exs
flow [failed]
- origin_mismatch: Expected origin https://github.com/elixir-lang/flow.git, got https://github.com/example/flow.gitmix graft.remove flow --delete refuses to delete a dirty git repo. Commit or
stash the changes, or pass --force when you have intentionally decided to
discard the directory.
The same happy path is captured in scripts/graft_quickstart_smoke.sh.
Manifest: graft.exs
Drop a graft.exs file at the workspace root that declares the sibling repos:
%{
root: ".",
siblings: [
%{name: :req_llm, path: "req_llm"},
%{name: :jido, path: "jido"},
%{name: :jido_ai, path: "jido_ai"},
%{name: :jido_chat, path: "jido_chat", origin: "https://github.com/agentjido/jido_chat.git"}
]
}root— workspace root, interpreted relative to the manifest file. Use".".siblings[].name— atom; the OTP application name of that repo.siblings[].path— directory name underroot; must resolve insideroot.siblings[].origin— optional git remote URL.graft.add --to-manifestrecords this automatically for GitHub repos.validate --quickandstatuscompare it to the repo's currentoriginremote when present.
Sibling directories that don't exist yet (or lack a mix.exs) are still valid manifest entries — status will surface them as missing.
Commands
mix graft.add OWNER/REPO [OWNER/REPO …]
Clones GitHub repos into the workspace. With --to-manifest, it creates
graft.exs when absent and records the sibling path and expected origin.
mix graft.add elixir-lang/flow --to-manifest
mix graft.add elixir-lang/flow --to-manifest --root path/to/workspace
If the destination directory already exists and has a matching origin, Graft
uses it idempotently. If the destination is a symlink that resolves outside the
workspace, the add is rejected before cloning or writing the manifest.
mix graft.list
Reads graft.exs and prints a lightweight inventory:
$ mix graft.list
Graft workspace: /Users/me/oss
Siblings (3):
req_llm req_llm [present]
jido jido [missing] path does not exist
jido_ai jido_ai [invalid] missing mix.exslist does not shell out to git and does not parse dependencies. It only
classifies each manifest entry as present, missing, or basically invalid.
mix graft.status
Snapshots the workspace and prints per-sibling status, dep counts, and live git posture (branch, upstream, ahead/behind, dirty/in-progress).
$ mix graft.status
Graft workspace
Root: /Users/me/oss
Repos: 4
jido
status: ok
git: main ↑ origin/main clean
deps: hex=0 path=0 git=0 unknown=0
jido_ai
status: ok
git: feature/extract-provider ↑ origin/feature/extract-provider (3 ahead) dirty
deps: hex=2 path=1 git=0 unknown=0
jido_chat
status: ok
git: main ↑ origin/main (1 behind)
deps: hex=3 path=0 git=0 unknown=0
req_llm
status: ok
git: main (no upstream)
deps: hex=4 path=0 git=0 unknown=0
The header includes a Validation: line that consults .graft/validate.result.json if present — showing pass/fail, the target closure, and a [stale] flag if any affected mix.exs has changed since the last run.
The git line collapses six signals into one human-readable summary:
-
branch name (or
detached) -
upstream tracking (
↑ origin/branchor(no upstream)) - ahead/behind commit counts
-
working-tree state (
dirty) -
in-progress operations (
merge,rebase,cherry_pick,revert,bisect)
JSON mode emits the full structured git record per repo (is_git_repo, branch, upstream, ahead, behind, dirty, detached_head, head_sha, in_progress):
mix graft.status --json | jq '.repos[] | {name, git}'
When origin is declared for a sibling, text output includes remote: origin ok
or a remote mismatch line; JSON output includes origin.expected,
origin.actual, and origin.matches.
The JSON contract is pinned by golden tests. Graft.GitState is the underlying read-only inspector — observational only, no caching, no background refresh, no mutation. Agents can call it directly to read git posture for any path.
mix graft.link.on TARGET [TARGET …]
Rewrites every consumer's mix.exs dep tuple for TARGET from {:target, "~> ..."} (or git) into {:target, path: "../target"}. The closure is transitive — if req_llm is the target and jido_ai depends on req_llm and jido_chat depends on jido_ai, all three rewrites land in one plan.
mix graft.link.on req_llm --dry-run # show the plan, write nothing
mix graft.link.on req_llm # apply transactionally
mix graft.link.on req_llm --json # agent-friendly outputBehavior:
- Compute a plan (hashes recorded for every consumer mix.exs).
-
Acquire the workspace lock at
.graft/lock. - Verify every consumer's current hash matches the plan's pre-rewrite hash.
-
Apply each literal
String.replaceatomically (write-tmp + rename). - Verify each post-write hash matches the plan's proposed hash.
-
Merge new entries into
.graft/state.json(preserving entries for other targets). - Release the lock.
If anything fails, every applied write is rolled back to its byte-identical preimage and the lock is released.
mix graft.validate TARGET [TARGET …]
Runs mix deps.get → mix compile --warnings-as-errors → mix test across the target and every consumer that depends on it, in topological dependency order. Answers a single question:
did my cross-repo change actually work?
mix graft.validate req_llm # run; text; fail-fast
mix graft.validate req_llm --dry-run # show plan only, no execution
mix graft.validate req_llm --json # JSONL stream for agents
mix graft.validate req_llm --continue # run every repo even after a failureUse quick mode when you only need manifest/workspace health:
mix graft.validate --quick
mix graft.validate req_llm --quick
mix graft.validate --quick --jsonQuick mode checks only:
- the manifest loads
- each selected sibling path exists
-
each selected sibling has a
mix.exs - each selected sibling is a git repository
-
the repo
originmatchessiblings[].originwhen that field is present
It never runs mix deps.get, mix compile, or mix test.
Behavior:
- Closure — target + every transitive consumer in
workspace.deps. Same closure shape link.on uses. - Execution order — topological: dependencies validate before their consumers, alphabetic within a layer. The first compile failure surfaces at the root cause, not at a downstream symptom.
- No lock — validate doesn't acquire
.graft/lock. It only touches_build/,deps/, andmix.lock, none of which are part of Graft's mutation trust contract. Runninglink.on req_llm && graft.validate req_llmfrom one shell is the common case and must not deadlock. - Fail-fast by default — the first failure halts further work. Every downstream repo is reported as
:skipped, not:failed.--continueruns every repo regardless. - Single first-failure pointer — the result envelope's
first_failurefield is the earliest topological failure: one place to look first, not a list. - JSONL stream —
--jsonemits newline-delimited JSON events:plan_started,repo_planned× N,validation_planned× M,plan_completed, then (during a real run)command× M, thenrun_result. Agents can act on partial streams; a killed run leaves a well-formed prefix. - Run log — full merged stdout+stderr of every command is written to
.graft/validate.log. The JSON envelope'soutput_tailis the last ~20 lines per command; the log has everything. - Result persistence — every completed run (pass or fail) writes
.graft/validate.result.jsonatomically. It's a lean envelope: verdict, per-repo statuses, headline first failure, schema version, and amix.exsfingerprint per affected repo. Dry-runs do not write. Corruption is not fatal —Workspace.snapshot/1treats unreadable files as "no validation recorded" rather than refusing to operate. - Staleness — the persisted result's fingerprint lets any consumer call
Graft.Validate.ResultFile.stale?(workspace, persisted)to detect whether the validated state is still current.mix graft.statusconsults it automatically and marks stale validations.
Exit code is 0 iff passed == true. A pre-flight refusal (unknown target, no manifest) is non-zero but distinct from a validation failure — the JSON envelope's passed and first_failure fields make the distinction.
mix graft.link.off TARGET [TARGET …]
Restores the recorded preimages for TARGET from .graft/state.json. Restoration is always literal — Graft never reasons about AST shape on the way out.
mix graft.link.off req_llm --dry-run
mix graft.link.off req_llm
mix graft.link.off req_llm --json
If the file's current hash differs from the recorded post-link hash (someone hand-edited it), link.off aborts before mutating with a :off_hash_mismatch error. State entries are pruned only after successful restoration; when no entries remain, .graft/state.json is deleted.
mix graft.remove TARGET [TARGET …]
Removes siblings from graft.exs.
mix graft.remove req_llm --dry-run
mix graft.remove req_llm
mix graft.remove req_llm --delete
mix graft.remove req_llm --delete --force
mix graft.remove req_llm --json
Default behavior only edits the manifest. Filesystem deletion requires
--delete. Dirty git repositories are refused for deletion unless --force is
supplied. Use --dry-run to see exactly what would be removed before changing
anything.
Trust guarantees
These are first-class promises, not implementation niceties. They're checked by tests and pinned by golden JSON fixtures.
See docs/trust_guarantees.md for the full document — each guarantee tied to a concrete failure scenario, plus an honest "when to use scripts instead" decision rule. Summary:
- Workspace fence — no mutation ever lands outside the declared workspace root, both at plan time and at apply time.
- Pre-flight refusal — if
.graft/state.jsonis unreadable (malformed JSON, unsupported version, unknown atom, missing field),link.onrefuses to mutate any file. Returns:corrupt_statewith the underlying cause. - Hash-anchored writes — every mix.exs rewrite verifies pre- and post-write SHA-256 against the plan. Drift between planning and applying triggers
:runner_hash_mismatchand aborts before any file is touched. - Literal preimage restore —
link.offreplaces the recorded path-link tuple with the byte-identical original dep tuple. No AST round-trip, no inverse rewrite, no reformatting. - Atomic file writes — every change goes through write-temp + rename, atomic on POSIX within a filesystem.
- LIFO transactional rollback — if any consumer write or the state save fails, prior writes are restored byte-for-byte from in-memory preimages in reverse order. End state is either "all committed and state saved" or "nothing changed."
- Single-writer lock —
.graft/lockis acquired before any mutation viaO_CREAT | O_EXCL. A second concurrent invocation fails fast with:workspace_locked. Lock is released on success, error, and raise. - State merge, not state overwrite —
link.onof one target preserves recorded entries for every other target. Re-runninglink.onfor an already-linked target is a deterministic byte-identical no-op. - Deterministic output — repos, deps, plan changes, state entries, and JSON keys are sorted at every level. Identical input yields identical bytes (modulo
generated_at). - Structured machine-readable errors — every user-visible failure surfaces as
%Graft.Error{kind, message, details}. The JSON contract for these is frozen by golden tests.
Current limitations
The architectural surface is intentionally narrow at M1. Known gaps:
- Single-target
Plan.buildfor shared consumers — building one plan with multiple targets where two targets share the same consumer relies on independent before-hashes. Run targets in separatelink.oninvocations (state merging handles accumulation). - No
mix deps.unlock/mix deps.get— Graft only rewritesmix.exs. You'll still run those yourself after a link change (intentional; we don't shell out to Mix). - No git mutation — Graft inspects
.gitfor status and remote diagnostics, but never mutates git state. Stash, commit, or revert as you'd normally do. - No stale-lock detection — if a process crashes mid-run, the
.graft/lockfile remains. Remove it manually after confirming no other Graft process is running. Liveness checks are deferred. - No concurrency on a single workspace — by design. The lock is the boundary.
- No
link.off--force — hash mismatch is a hard abort. Inspect the file, decide what's right, restore manually if needed. - Hardcoded validate sequence —
deps.get,compile --warnings-as-errors,test. A future iteration will read an optional:validatekey fromgraft.exsfor project-configured commands. - No streaming during a single mix command — validate captures merged stdout+stderr and emits the JSONL
commandevent when the command finishes. Live per-line streaming during execution is deferred. The run log gets the full transcript regardless. - No retries, no parallelism, no caching, no
--watch— validate runs once, sequentially, top-to-bottom. By design. - State schema is v1 —
state.jsonis versioned;:state_unsupported_versionis raised if you point Graft at a state file from a future schema. Amigrate/1dispatcher will land alongside v2.
What it is not
Graft is not a monorepo replacement, package manager, CI orchestrator, deployment system, GitHub automation platform, polyglot workspace manager, or background daemon. It is local-first, git-native, Mix-native, snapshot-driven, and CLI-first. Anything outside that fence is intentionally out of scope.
License
Apache-2.0. See LICENSE.