Morph alongside the Git repo you already use
You have a Python project on GitHub, the team uses Git, and you want
the things Morph adds — runs, traces, metric-bearing commits —
without changing anyone else's workflow. This walkthrough sets up
reference mode: Git stays the source of truth, Morph rides
on top, and every git commit is automatically tied to a Morph commit
by hash.
Who is this for?
If any of these sounds like you, the rest of this tutorial is worth your 20 minutes:
Curious about Morph
You want to try Morph on a real project — not a toy
sandbox — without making a commitment for the whole team.
Reference mode is local to your clone; you can rip it out at any
time with rm -rf .morph && rm .git/hooks/post-*
and Git keeps working as if Morph never existed.
Starting agentic coding
You're about to let an agent loose on an existing repo and you want runs, traces, and metric-bearing commits captured locally before any of that work goes to your teammates. Morph records every agent session as an immutable trace tied to the Git commits it produced.
Don't want to bother teammates
The rest of the team is on plain Git and you don't want to
change that yet. Stowaway submode is invisible to anyone else
— .morph/ is auto-excluded from Git, the four
installed hooks live in untracked .git/hooks/, and
git push/git pull ship only the Git
tree. Teammates see exactly what they always have.
When you eventually do want to bring teammates onto Morph — sharing runs, traces, and certifications across machines — there's a clean path for that too. We cover it in §11, after you've gotten comfortable with the solo flow.
01Before you start
This tutorial assumes you've at least skimmed Tutorial 1 — we won't re-teach the eval-run-then-commit flow, just show how it interacts with a real Git repo, a remote, and (eventually) teammates.
You'll need:
morphandmorph-mcpon yourPATH(install guide).morph --versionshould print0.48.0or later.- An existing Git project with at least one commit. If you don't have one handy, follow Tutorial 1 first — it walks through scaffolding a tiny project with
git init -b mainand gives you a couple of commits to backfill against here. - Configured Git identity:
git config --global user.nameandgit config --global user.emailshould each return something. Morph mirrors every commit throughgit commit, which refuses without an identity. - Python 3 with
pytest— the example commands assume the same currency-converter shape as Tutorial 1, but any test runner Morph can parse (cargo,pytest,vitest,jest,go) works. - A GitHub remote (or any Git remote). We'll demonstrate that
git pushworks unchanged.
02How it works, in plain English
Reference mode is a single sentence: Git owns your code; Morph watches Git through hooks and writes a parallel history of behavioral metadata next to it. Concretely:
-
Git is the only thing your team sees.
Teammates clone, pull, push exactly like before. The hooks Morph
installs are local to your clone (Git never tracks
.git/hooks/), so other developers don't get them and don't notice anything. -
Morph is your local sidecar.
All Morph state lives in
.morph/, which the installer adds to.git/info/excludeso a straygit add .can't accidentally commit it..morph/never travels. -
Hashes are tied.
Every Morph commit object carries a
git_origin_shafield equal to the Git SHA it mirrors. Git's hash is the truth; Morph's hash is a content-addressed pointer to "the behavioral record for that Git commit." No special database, no separate ID space. -
Recording is automatic.
A
post-commithook fires after everygit commitand mirrors the new Git commit into Morph.post-checkout,post-merge, andpost-rewritehandle the other Git events. All four are passive — if Morph is broken or uninstalled, your Git keeps working.
pre-merge-commit hook gates plain
git merge on behavioral dominance). This tutorial covers
Stowaway end-to-end. We touch Solo briefly at the end —
the full story is in
docs/reference-mode.md.
03morph init
From the root of your Git working tree, run:
Four lines worth reading carefully. Each is a real promise:
- "bound to git HEAD <sha>" — Morph snapshots which Git commit it started watching from. Pre-existing history before this point isn't mirrored automatically; we'll handle that in the next step.
- "installed 4 git hook(s)" —
post-commit,post-checkout,post-merge,post-rewrite. Each one is a tiny shell stub that callsmorph hook <event>and exits zero whatever happens. - ".morph/ added to .git/info/exclude" — local-only.
git statuswon't see.morph/, even with no.gitignorechange. - "teammates not using morph are unaffected" — the whole design goal in one sentence.
Peek at one of the hooks if you're curious:
That's the whole machinery. The MORPH_INTERNAL=1 escape is how
morph commit and morph merge shell out to Git without
the hook recursing into itself, and the trailing || true is the
guarantee that a broken Morph never blocks a Git commit.
.git/ yet), morph init will detect that and
interactively prompt: "morph requires a git repository here. Run
`git init` for you? [y/N]". Pass morph init --git-init
for the same shortcut without the prompt — convenient in CI or
shell scripts. Either way Morph runs git init -b main
(anchored to main so the rest of Morph's contract holds
regardless of your host's init.defaultBranch setting).
04Mirror your existing history
Right after init, morph status tells you about the elephant in
the room: any Git commits that existed before you ran
morph init aren't mirrored yet.
This is drift, the central concept of reference mode. Whenever Git moves and Morph hasn't seen it, you're in drift. It's not an error; it's a state. Morph names it explicitly so you can decide what to do.
For pre-existing history, the right answer is --backfill:
Two notes on what just happened:
-
Backfill mirrored from the init point forward, not all of history.
That older "scaffold" commit before
morph initstayed in Git only. This is intentional — behavioral evidence for code that existed before you adopted Morph would be made up. -
The mirror is "uncertified." It carries the Git tree's
shape but no metrics.
morph certifyattaches metrics retroactively if you want them; we'll see the more common pattern (commit with metrics in a single step) shortly.
morph status in reference mode: you may see
a "Changes not staged for commit" block listing files that Git already
has committed. That's a known UX wart — Morph commits in reference
mode have tree: null (the Git tree is the truth), so the
working-tree diff has nothing to compare against. Trust git status
for whether your tree is clean. The "Reference mode (git ↔ morph)"
block at the bottom is the one that matters here.
05Plain git commit — automatic mirror
From here on, just commit through Git the way you always have. Edit a file, stage, commit:
That looks like an ordinary Git commit because it is an ordinary Git
commit. The post-commit hook fired silently, and Morph now has
a mirror:
Morph's hash 258a7e26 is different from Git's df21819 —
they're hashing different things (Git: tree + parents + message; Morph:
all of that plus the behavioral metadata Morph adds). The tie between
them is git_origin_sha on the Morph commit:
That's the tying-of-hashes. Two important details:
-
tree: null. Morph doesn't duplicate Git's file tree in reference mode — the Git tree is canonical. Saves space, avoids two-systems-of-truth bugs. -
morph_origin: "git-hook". This commit was created passively by the post-commit hook, not by anyone runningmorph commit. Morph keeps these labelled separately so the merge gate can tell active claims from passive ones.
06Pulling from teammates who don't use Morph
The whole point of Stowaway is that only you have Morph. Your
teammates clone, commit, push exactly like before. So what happens
when one of them pushes a slew of commits and you git pull
them down?
Short answer: Morph mirrors every new commit automatically, with the
right parent edges, and morph status stays accurate. The
two pull flavors — fast-forward and rebase — route through
different hooks but both Just Work.
Fast-forward pull (git pull --ff-only)
Suppose I'm on v1, the only commit. A teammate pushes
five commits (v2..v6). I pull:
Git fast-forwards the ref and fires the post-merge hook.
That hook calls morph hook post-merge →
sync_to_head, which walks first-parent ancestry from the
new HEAD back to the last mirrored commit and mirrors every commit in
the unmirrored span in topo order. The result:
One git pull, five new Morph commits, parent chain
matches Git's. Each new mirror has morph_origin: "git-hook"
(passive, no metrics) and a git_origin_sha tying it to
the corresponding teammate commit. From here, you can decide which of
those commits deserve evidence (run tests + morph certify)
and which stay as bare mirrors.
Rebase pull (git pull --rebase)
The other common case: I have a local commit
(me: add b.txt) that hasn't been pushed yet. Teammate
pushes three commits to origin/main. I do
git pull --rebase so my local commit gets replayed on
top of theirs:
Rebase doesn't fire post-merge — it fires
post-rewrite, because the existing local commit
gets a new SHA when it's replayed. Morph handles both halves:
- The three teammate commits are mirrored as fresh
git-hookcommits, same as the FF case. - My local commit's old Morph mirror is annotated with
kind: "rewritten", recording theold_git_sha → new_git_shaand the new Morph commit as itssuccessor. The old object stays in the store (immutability is load-bearing) but is no longer on the branch. - A new Morph commit replaces it on the branch tip, with parents pointing at the most recent teammate commit.
One thing to call out: if my local commit had a certification
(because I'd done morph commit earlier with metrics
attached), that certification points at the old Morph hash.
The rewritten annotation marks it stale — morph status
will surface it as an "uncertified-by-rewrite" commit, and you re-run
tests on the rebased commit and use morph certify to
re-attach evidence. The merge gate refuses to honor stale
certifications, which is exactly the protection you want: rewritten
history can't silently inherit evidence it didn't earn.
07Commit through Morph when you want evidence
Plain git commit works forever, but the resulting Morph
mirror has empty metrics. When you want the commit to carry behavioral
evidence — pass rates, test counts, a link back to the Run that
produced them — commit through Morph instead. morph commit
is a thin wrapper: it shells out to git commit, then attaches
metrics to the resulting Morph mirror as a certification annotation.
The pattern is identical to Tutorial 1, but shorter: tell Morph
your test suite once with
morph config commit.test_command "…", then
plain morph commit runs it, parses the metrics,
and attaches them to both sides:
Prefer the manual flow? Run
morph eval run -- python3 -m pytest first; the
breadcrumb it leaves behind is what
commit.test_command would have produced, and
morph commit picks it up the same way. Use
--no-test to skip the auto-run for a single
commit (e.g. a quick chore) and --rerun to force
a fresh run when the breadcrumb is stale.
Two hashes printed side-by-side: the new Morph commit and the new Git commit it wraps. Both happened in one step. Verify:
That commit has morph_origin: "cli" (active, not passively
mirrored), its certification carries the metrics, and morph gate
confirms it satisfies the policy we tightened earlier
(required_metrics: [tests_total, tests_passed]).
Fresh repos start with an empty required_metrics list so the
first commit goes through; opt into enforcement with
morph policy init or
morph policy require-metrics….
git commit for
throwaway in-progress work, and use morph commit when you want
the commit to carry evidence — landing a feature, fixing a bug,
finishing a refactor. There's no penalty for either: every Git commit
becomes a Morph commit. The only difference is whether the Morph side
carries metrics.
08When the hooks don't fire — drift
The previous two sections showed the happy paths: git commit
fires post-commit, git pull fires
post-merge or post-rewrite, and Morph mirrors
everything automatically. But a handful of git operations bypass hooks
entirely. The realistic ones:
git fetchfollowed bygit reset --hard origin/main— resets don't fire any of the four hooks Morph installs.- You disabled the hooks (
chmod -x .git/hooks/post-merge) or deleted them by accident. - You cloned the repo fresh and forgot to run
morph initin the new clone — hooks live in.git/hooks/, which Git never tracks, so a fresh clone has none.
When that happens, Morph doesn't blow up — it falls behind Git, and says so. The drift detector walks first-parent ancestry from git HEAD until it finds a mirrored commit, so it catches gaps that span many commits, not just an unmirrored tip.
Simulate it by committing with the hook suppressed via
MORPH_INTERNAL=1 (the same escape hatch morph commit
uses internally):
(You'd see "1 unmirrored" if you only suppressed one commit; the
example shows what real-world drift looks like — multiple commits
appearing in one go from a git fetch + git reset.)
morph eval gaps --json surfaces the same state as a
structured event so agents and CI can detect it programmatically:
Resolve it with one command, regardless of how many commits are missing:
morph reference-sync walks first-parent ancestry from
git HEAD back to the most recent mirrored commit (or the root) and
mirrors every commit in the unmirrored span in topo order. It's
idempotent — running it twice when already in sync prints
"Already up to date." and exits zero. (--backfill is the
rarer cousin: it walks from init_at_git_sha instead, and
you only need it for late adoption when you missed pre-init history.)
Make this part of your muscle memory: any time you've done
something to Git that bypassed the normal commit / pull flow, run
morph status. If it shows drift, sync. If you
forget, nothing breaks — the next time Morph notices the gap
(next status, next eval-gaps, next merge), it'll tell you again.
Other staleness Morph tracks
Drift is the most visible kind of staleness, but Morph tracks four
in total. They all surface in morph status; the
structured ones also show up in morph eval gaps --json
so agents and CI can key off them.
| Kind | What it means | Surfaces |
|---|---|---|
| Drift | git HEAD has commits that aren't mirrored in morph (covered above). | morph status; morph eval gaps as kind: "git_morph_drift" |
| Stale certifications | A morph commit had a kind: "certification" annotation, then git rebase/amend rewrote the underlying git SHA. The certification now describes superseded code. |
morph status; morph eval gaps as kind: "stale_certifications" |
| Uncertified git-hook commits | Passive mirrors created by post-commit/post-merge with empty observed_metrics. Fine on their own — just means there's no behavioral evidence on those commits yet. |
morph status only |
| Merge in progress | A morph merge hit a Git conflict; .morph/MERGE_REF.json is the breadcrumb. Resolve with morph merge --continue or --abort. |
morph status only |
Two of those four (drift and stale certifications) emit structured events that an agent or CI job can detect with one command:
A reasonable habit if you're using Morph day-to-day: alias
git pull to git pull && morph status
(or whatever shell hook you prefer), and run the
morph eval gaps --json filter above in your CI pipeline.
The first catches anything you'd miss visually; the second catches
anything stuck on someone else's machine that lands in CI.
09Pushing to GitHub
There's no morph push in reference mode. Git is the only
thing with a remote, and your remote setup is already done.
That's it. .morph/ stays local because of the
.git/info/exclude entry; only your Git history reaches the
remote. A teammate cloning the repo gets the same Git tree they
always would, with no idea Morph exists on your machine.
morph certify --metrics-file … on each merged commit, so
metrics live in a shared .morph/ on a server; or (b) flip the
whole project to Solo submode when every developer has Morph
installed, so plain git merge is gated team-wide. Both are
covered in docs/reference-mode.md.
10Recording agent work
The IDE side is identical to Tutorial 1: morph setup cursor
(or claude-code / opencode) wires up the MCP server
and recording hooks. Those hooks live in .cursor/ (or
equivalent) and are independent of the Git hooks reference mode just
installed — they don't conflict, and you can install them in any
order.
Once an agent has worked on the project in your IDE, the standard inspection commands work exactly as they did in Tutorial 1:
Same data, same commands. The only difference from Tutorial 1 is that
when you click into a Morph commit in morph serve, it now
shows the git_origin_sha so you can cross-reference back to
GitHub.
morph show <commit>:
-
morph commit --from-run <run>folds the human author intocommit.contributorsalongside the agent. You'll see{ "id": "...", "role": "human-author" }next to{ "id": "claude-opus-4", "role": "primary" }— downstream tooling that filterscontributors[?role=='human-author']can find every commit a person had a hand in, even when the agent did most of the typing. -
morph commitauto-detects new acceptance cases by diffing the registered eval suite against the parent's, so you don't need to hand-pass--new-cases foo:bar,foo:bazevery time you add YAML cases to.morph/evals/. The resultingkind: "introduces_cases"annotation is whatmorph mergereads when it builds the "introduced on this branch" column of its plan.
11Bringing teammates onto Morph
So far this tutorial has been about you, alone, on a team that uses plain Git. That's the right starting point. But what happens when one of your teammates says "I want what you have", or you decide to move the whole team to Morph as a group? Stowaway is designed to scale up gracefully, and the path has three stages.
Stage 1: stand up a Morph server
So far .morph/ has lived only on your laptop. To
share runs, traces, and certifications across machines you need
a place those objects can travel to — the same way a Git
repo needs a remote. Morph servers are bare repos accessed over
SSH; you can stand one up on any box you can ssh
into:
A bare repo has no working tree and no .morph/
wrapper — its files (objects/,
refs/, config.json) live directly at
the path. That's the only kind of repo you can
morph push to from multiple clients.
Now wire your existing reference-mode clone up to it:
A morph push ships the reachable
closure of the new branch tip: every Morph commit
object, the eval suites they reference, the runs and traces
attached as evidence, the certification annotations —
the works. The server runs verify_closure on
receive, so a partial push that's missing a referenced object
is rejected; either everything lands and the branch ref moves,
or nothing does.
morph push is independent
of git push. Most reference-mode users alias them
back-to-back so a single command pushes both:
git push origin main && morph push origin main.
The two pushes carry different things — Git ships the
file tree, Morph ships the behavioral evidence.
Stage 2: a teammate joins
Your teammate is in their existing Git clone, still on plain Git. They run two commands:
That last command is the magic. morph fetch walks
the closure on the server side, ships every object the client
doesn't already have, and updates remote-tracking refs at
refs/remotes/origin/<branch>. After it runs, your
teammate has every Morph commit, run, trace, and certification
you've ever pushed — locally, in their own
.morph/objects/, fully indexable.
From here on, both of you are using Morph. Your teammate's
git commits fire their post-commit
hook and produce Morph mirrors; your git pulls
(now bringing in their work) fire your post-merge
hook and mirror their commits on your machine. The behavioral
evidence flows through morph push /
morph fetch alongside the Git tree.
Stage 3: flip everyone to Solo
Once every committer on the project has Morph installed and
you all want the merge gate enforced for plain
git merge too — not just for
morph merge — flip each clone to Solo
submode:
The new fifth hook (pre-merge-commit) is the only
difference: it fires before Git records a merge
commit, runs the dominance gate against both parents'
certified metrics, and exits non-zero if the merged result
would regress. Plain git merge is now gated on
the same behavioral contract as morph merge. To
flip back: morph install-hooks --stowaway.
Submode is local to each clone, recorded in
.morph/config.json. There's no team-wide setting,
no central authority, no surprise gate that appears for someone
who didn't opt in. Each developer makes the flip on their own
machine when they're ready.
Honest scope: the evidence horizon
One thing to call out: when your teammate runs
morph fetch origin in Stage 2, they receive the
closure of refs you've pushed. Local-only branches,
uncertified work that hasn't been pushed yet, and any
experiments living in .morph/ that you never
decided to share won't be there. The merge gate's "no morph
claim" rule (a parent without behavioral evidence triggers no
violations) means this is operationally fine going forward
— but it does mean a regular morph push
rhythm is what makes evidence visible to the team. Treat
morph push with the same seriousness as
git push.
12What you have, what you don't
Stowaway is deliberately scoped. Naming the trade-offs explicitly so there are no surprises:
What you get
Tied-hash mirroring on every Git commit; metric-bearing commits via
morph commit; agent runs and traces with full inspection;
morph gate as a local CI-style check; drift detection
and explicit recovery via morph reference-sync; everything
completely local to your clone.
What you don't get (until you opt in)
Team-wide merge gating. Stowaway only protects the merges
you perform with morph merge. A teammate doing
plain git merge on another machine bypasses it. The
fix is the Solo flip described in
§11 Stage 3 — once every
developer's clone is in Solo submode, plain git merge
is gated everywhere too.
One more honest scope note: certifications attached to a Morph commit
can become stale if you later git commit --amend or
git rebase the underlying Git commit. Morph won't lose the
old certification (object immutability is load-bearing), but it'll
flag it as stale — both in morph status and in
morph eval gaps's structured output as
kind: "stale_certifications" — and ask you to
re-certify the rewritten commit. That's a feature, not a bug:
rewritten history can't silently inherit evidence it didn't earn.
13Where to next
You now have Morph living next to Git in a real project. Every
git commit is a behavioral commit, every
morph commit carries evidence, and git push works
unchanged. From here:
-
docs/MERGE.md
and docs/EVAL-DRIVEN.md:
behavioral merge gating in action, including what happens when two branches genuinely conflict.
morph mergeis the canonical merge driver. (A standalone "Tutorial 3" page is on the roadmap; for now those two docs are the authoritative writeup.) -
docs/reference-mode.md:
the comprehensive reference. Covers Solo submode, the
pre-merge-commitgate, conflict resolution withmorph merge --continue/--abort, stale certifications, recovery, and CI integration. -
docs/MORPH-AND-GIT.md:
the long-form story of running Morph and Git side-by-side — how the two layers cooperate, what crosses the wire on
git pushvsmorph push, and how legacy repos that previously tracked.morph/in git can recover.