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_graft v0.0.1 publish candidate. The Hex package and OTP app are mix_graft; the public Mix task namespace remains mix graft.*. M1 (status + transactional link.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:

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}]
end

Or 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/workspace

Quickstart: 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.git

mix 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"}
  ]
}

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.exs

list 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:

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 output

Behavior:

  1. Compute a plan (hashes recorded for every consumer mix.exs).
  2. Acquire the workspace lock at .graft/lock.
  3. Verify every consumer's current hash matches the plan's pre-rewrite hash.
  4. Apply each literal String.replace atomically (write-tmp + rename).
  5. Verify each post-write hash matches the plan's proposed hash.
  6. Merge new entries into .graft/state.json (preserving entries for other targets).
  7. 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 failure

Use quick mode when you only need manifest/workspace health:

mix graft.validate --quick
mix graft.validate req_llm --quick
mix graft.validate --quick --json

Quick mode checks only:

It never runs mix deps.get, mix compile, or mix test.

Behavior:

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:

Current limitations

The architectural surface is intentionally narrow at M1. Known gaps:

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.