Skip to content

Commit 6b208da

Browse files
committed
feat: add diff, tag, stash, revert, MCP read/merge tools, and comprehensive test coverage
New features for "git for agentic coding": - morph diff: compare two commits/branches, show file-level changes (A/M/D) - morph tag: create, list, and delete lightweight tags - morph stash: save/pop/list staged changes without committing - morph revert: create a new commit undoing a previous commit's changes - MCP read tools: morph_status, morph_log, morph_show, morph_diff - MCP merge tool: morph_merge (behavioral dominance via MCP) Test coverage expanded from 332 to 434 tests (all passing): - morph-core: 206 → 233 (+27) — new modules + gap-filling for morphignore, record_run, record_eval_metrics, migrate_0_2_to_0_3 - morph-cli: 87 → 122 (+35) — specs for diff, tag, stash, revert, run list/show, prompt show, trace show, error paths, morphignore - morph-mcp: 0 → 16 (+16) — first-ever MCP tests covering all 14 tools Docs updated: TESTING.md, README.md, CURSOR-SETUP.md. Made-with: Cursor
1 parent f77fb98 commit 6b208da

24 files changed

Lines changed: 2352 additions & 12 deletions

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,27 @@ morph setup cursor # writes .cursor/ (MCP, hooks, rules)
2424

2525
Then open the project in Cursor. Ensure `morph` and `morph-mcp` are on your PATH. The MCP server and hooks will record prompts/responses and let the agent commit via Morph.
2626

27+
## Core commands
28+
29+
```bash
30+
morph init # initialize a morph repo
31+
morph add . # stage files
32+
morph commit -m "message" # create a behavioral commit
33+
morph log # view commit history
34+
morph diff <ref1> <ref2> # compare two commits/branches
35+
morph branch <name> # create a branch
36+
morph checkout <name> # switch branches
37+
morph merge <branch> ... # behavioral merge (dominance required)
38+
morph tag <name> # tag the current commit
39+
morph stash save # save staged work for later
40+
morph stash pop # restore most recent stash
41+
morph revert <hash> # undo a commit
42+
morph status # show working directory state
43+
morph run record-session # record an agent prompt/response
44+
morph certify # certify a commit against policy
45+
morph gate # check if a commit passes policy
46+
```
47+
2748
## Hosted service (team inspection)
2849

2950
Run the Morph hosted service for shared, browser-based inspection of behavioral history:

docs/CURSOR-SETUP.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,11 @@ When you want a snapshot:
154154
| `morph_annotate` | Attach metadata to any object |
155155
| `morph_branch` | Create a branch at HEAD |
156156
| `morph_checkout` | Switch HEAD and restore working tree |
157+
| `morph_status` | Show working-space status (new/tracked files) |
158+
| `morph_log` | Show commit history from HEAD or a named ref |
159+
| `morph_show` | Show a stored object as pretty JSON |
160+
| `morph_diff` | Compare two commits/refs and show file-level changes |
161+
| `morph_merge` | Merge a branch (requires behavioral dominance) |
157162

158163
All tools accept optional `workspace_path`. To get a run's prompt from the CLI, run **`morph prompt show [REF]`** in the repo (e.g. `morph prompt show latest~1`). Ref is like Git: **`latest`** (default), **`latest~N`** or **`latest-N`** (Nth run back), or a **64-char run hash**. If the trace is missing, pass **`--run-upgrade`** to run `morph upgrade` and retry once. If omitted, uses the resolved workspace (see section 2).
159164

docs/TESTING.md

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,23 @@
44

55
| Crate | Tests | Location |
66
|-------|-------|----------|
7-
| **morph-core** | 182 unit tests across 16 modules | `#[cfg(test)]` blocks in each source file |
8-
| **morph-cli** | 76 integration tests | YAML specs in `morph-cli/tests/specs/*.yaml`, compiled by `build.rs` |
9-
| **morph-e2e** | 25 Cucumber e2e scenarios | `morph-e2e/features/*.feature`, step defs in `morph-e2e/tests/cucumber.rs` |
10-
| **morph-mcp** | None yet | -- |
11-
| **morph-serve** | 34+ unit/API tests (views, service, handlers, org policy, multi-repo) | `morph-serve/src/tests.rs` + `org_policy::tests` |
7+
| **morph-core** | 233 unit tests across 20 modules | `#[cfg(test)]` blocks in each source file |
8+
| **morph-cli** | 113 integration tests + 9 unit tests | YAML specs in `morph-cli/tests/specs/*.yaml`, compiled by `build.rs`; unit tests in `setup.rs` |
9+
| **morph-e2e** | 29 Cucumber e2e scenarios | `morph-e2e/features/*.feature`, step defs in `morph-e2e/tests/cucumber.rs` |
10+
| **morph-mcp** | 16 integration tests | `#[cfg(test)]` in `morph-mcp/src/main.rs` |
11+
| **morph-serve** | 34 unit/API tests (views, service, handlers, org policy, multi-repo) | `morph-serve/src/tests.rs` + `org_policy::tests` |
1212

1313
### morph-core unit test modules
1414

15-
`hash` (including paper-aligned fields: review nodes, per-node env, set-valued attribution), `store` (FsStore + GixStore), `repo`, `working`, `commit` (including merge with union suite, **provenance from run**, evidence_refs, env_constraints, contributors), `metrics` (direction-aware thresholds, metric retirement), `merge` (merge planning, dominance explanation, direction-aware reference bar, metric retirement in dominance checks, execute merge), `annotate`, `identity`, `record`, `index`, `tree`, `migrate`, `extract` (pipeline extraction from runs: graph shape, provenance, attribution, error paths), **`sync`** (remote config round-trip, reachable object collection, ancestry checks, push to empty remote, push non-fast-forward rejection, push idempotency, hash preservation across sync, fetch creates remote-tracking refs, fetch does not overwrite local branches, fetch transfers only missing objects, pull fast-forward, pull divergence rejection, evidence-backed commit sync, remote store open/validation, ref listing), **`policy`** (policy round-trip, config preservation, default policy, certification pass/fail with required metrics/thresholds/directions, gate pass/fail for certified/uncertified commits, gate failure reason identification, certification annotation recording).
15+
`hash` (including paper-aligned fields: review nodes, per-node env, set-valued attribution), `store` (FsStore + GixStore), `repo`, `working`, `commit` (including merge with union suite, **provenance from run**, evidence_refs, env_constraints, contributors), `metrics` (direction-aware thresholds, metric retirement), `merge` (merge planning, dominance explanation, direction-aware reference bar, metric retirement in dominance checks, execute merge), `annotate`, `identity`, `record` (**record_run** with trace validation/mismatch/artifacts/error paths, **record_eval_metrics** with validation/error paths, record_session defaults), `index`, `tree`, `migrate` (**0.0→0.2** hash rewriting, **0.2→0.3** version bump, idempotency, empty/missing objects dir), `extract` (pipeline extraction from runs: graph shape, provenance, attribution, error paths), **`sync`** (remote config round-trip, reachable object collection, ancestry checks, push/fetch/pull scenarios, evidence-backed sync), **`policy`** (policy round-trip, certification pass/fail, gate pass/fail, annotation recording), **`morphignore`** (load/match patterns, glob, directory, negation, nested paths, multiple patterns, paths outside repo), **`diff`** (empty trees, added/deleted/modified/mixed changes, nested trees, store-backed diff, None-to-tree and tree-to-None), **`tag`** (create/list/delete, duplicate tag error, nonexistent delete error, empty repo), **`stash`** (save/pop roundtrip, empty index error, empty pop error, LIFO ordering, list, no-message save), **`revert`** (parent tree restoration, root commit → empty tree, non-commit error, branch ref update).
1616

1717
### morph-cli integration tests
1818

19-
`init`, `status`, `add`, `prompt create/materialize`, `pipeline create/show`, `commit + log`, `run record + eval record`, `annotate + annotations`, `branch`, `checkout`, `merge` (including auto-union suite, explained metric failure, retirement), `merge_plan` (parent inspection, reference bar, retirement preview, incompatible suites), `rollup`, `upgrade`, `errors`, **`provenance`** (evidence-backed commits with `--from-run`, `morph show`), **`pipeline_extract`** (trace-backed pipeline extraction with `--from-run`, reviewer attribution, reuse in commits, error paths), **`remote`** (add/list remotes, push to empty remote, push idempotent, push to missing remote fails, refs listing), **`push_pull`** (fetch creates remote-tracking refs, pull fast-forwards, push non-fast-forward fails, evidence-backed commit closure transfer, refs show remote-tracking after fetch), **`policy`** (round-trip set/show, set-default-eval, default empty policy), **`certify_gate`** (certify with metrics file, certify specific commit, certify fail on missing metrics, certify fail below threshold, certify with runner, gate pass for certified commit, gate fail on missing metrics, gate fail below threshold, gate fail uncertified, JSON output for certify and gate).
19+
`init`, `status`, `add`, `prompt create/materialize`, **`prompt show`** (latest, by run hash, no-runs error), `pipeline create/show`, `commit + log`, `run record + eval record`, **`run list`** (populated + empty), **`run show`** (summary, JSON, with-trace, invalid hash), **`trace show`** (event display), `annotate + annotations`, `branch`, `checkout`, `merge`, `merge_plan`, `rollup`, `upgrade`, **`diff`** (added files, modified files, no changes, HEAD shorthand), **`tag`** (create/list, duplicate error, delete, delete nonexistent, list empty), **`stash`** (save/pop, list, empty save error, empty pop error), **`revert`** (undo commit, invalid hash error), **`error_paths`**, **`morphignore`**, `errors`, **`provenance`**, **`pipeline_extract`**, **`remote`**, **`push_pull`**, **`policy`**, **`certify_gate`**.
20+
21+
### morph-mcp integration tests
22+
23+
All 14 MCP tools tested: **init** (success + already-initialized error), **record_session** (hash return), **record_run**, **record_eval** (file-based metrics), **stage** (explicit paths + default `.`), **commit** (basic, with metrics, with `--from-run` provenance), **branch** (success + no-commit error), **checkout** (branch switch), **annotate** (annotation creation), **status** (file listing), **log** (commit history), **show** (object JSON), **diff** (between commits), **merge** (behavioral dominance), **repo_store** (not-found error message).
2024

2125
---
2226

@@ -26,6 +30,7 @@
2630
cargo test # all workspace tests (unit + integration)
2731
cargo test -p morph-core # core library only
2832
cargo test -p morph-cli # CLI integration tests only
33+
cargo test -p morph-mcp # MCP server tests
2934
cargo test -p morph-e2e --test cucumber # e2e (Cucumber; runs real morph CLI)
3035
cargo test --lib # unit tests only (no integration)
3136
```
@@ -70,12 +75,9 @@ Each YAML spec supports: file/directory setup (`files`, `dirs`), sequenced CLI c
7075

7176
## Known gaps
7277

73-
- **morph-mcp**: No tests. An integration harness that speaks MCP over stdio would cover the primary write path.
74-
- **morph-serve**: 34+ tests covering repo list/summary, branch listing, commit list/detail, run/trace/pipeline detail, annotations, policy, gate status, behavioral status (certification/merge), org policy CRUD, multi-repo, backward-compatible endpoints, and error paths.
7578
- **GixStore-specific paths**: `status()` and `record_session()` are now backend-aware (use `store.hash_object()`), but explicit GixStore integration tests would catch backend-specific regressions.
7679
- **proptest**: In dev-dependencies but not yet used. Good candidate for property-based tests on hash determinism and serialization round-trips.
77-
- **Error paths**: Many functions have untested error branches (malformed JSON, permission errors, missing refs).
78-
- **CLI gaps**: `branch`, `checkout`, `merge`, `rollup`, `upgrade`, `errors`, `provenance`, `remote`, `push_pull`, `policy`, and `certify_gate` now have YAML specs. MCP integration tests are still missing.
79-
- **Direction-aware dominance**: `check_dominance()` assumes all metrics are "maximize". The new `merge` module's `MergePlan::check_dominance()` and `check_dominance_with_suite()` respect per-metric direction. Tests cover both maximize and minimize directions during merge planning.
8080
- **Network transport**: Phase 5 sync uses local filesystem paths only. Network transport (HTTP, SSH) tests will be needed when that transport is added.
8181
- **MCP certification/gating**: The certification and gate flows are available via CLI only. MCP exposure would allow IDE-driven certification workflows.
82+
- **`morph blame`**: Per-file/per-line attribution showing which commit/agent modified each part. Planned but not yet implemented.
83+
- **E2E hosted service**: 3 Cucumber scenarios are skipped due to server binding constraints in CI.

morph-cli/src/main.rs

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,35 @@ enum Command {
209209
#[command(subcommand)]
210210
sub: SetupCmd,
211211
},
212+
/// Compare two commits (or refs) and show file-level changes
213+
Diff {
214+
/// First ref (branch, tag, or commit hash). Default: parent of HEAD.
215+
old_ref: String,
216+
/// Second ref. Default: HEAD.
217+
#[arg(default_value = "HEAD")]
218+
new_ref: String,
219+
},
220+
/// Create, list, or delete tags
221+
Tag {
222+
/// Tag name to create (omit to list all tags)
223+
name: Option<String>,
224+
/// Delete the named tag
225+
#[arg(short, long)]
226+
delete: bool,
227+
},
228+
/// Stash staged changes (save/pop/list)
229+
Stash {
230+
#[command(subcommand)]
231+
sub: StashCmd,
232+
},
233+
/// Create a new commit that undoes a previous commit's changes
234+
Revert {
235+
/// Commit hash to revert
236+
commit: String,
237+
/// Author identity
238+
#[arg(long)]
239+
author: Option<String>,
240+
},
212241
/// Upgrade the repo store to the latest version (required before using MCP on older repos).
213242
Upgrade,
214243
/// Inspect traces (prompt/response events)
@@ -252,6 +281,19 @@ enum TraceCmd {
252281
},
253282
}
254283

284+
#[derive(clap::Subcommand)]
285+
enum StashCmd {
286+
/// Save staged changes and clear the index
287+
Save {
288+
#[arg(short, long)]
289+
message: Option<String>,
290+
},
291+
/// Restore the most recent stash
292+
Pop,
293+
/// List all stashes
294+
List,
295+
}
296+
255297
#[cfg(feature = "cursor-setup")]
256298
#[derive(clap::Subcommand)]
257299
enum SetupCmd {
@@ -452,6 +494,76 @@ fn main() -> anyhow::Result<()> {
452494
println!(" .cursor/mcp.json: {}", if report.mcp_json_updated { "updated" } else { "unchanged" });
453495
}
454496
},
497+
Command::Diff { old_ref, new_ref } => {
498+
let (_repo_root, store) = get_store(verbose)?;
499+
let resolve = |r: &str| -> Result<morph_core::Hash, anyhow::Error> {
500+
if r.len() == 64 && r.chars().all(|c| c.is_ascii_hexdigit()) {
501+
Ok(morph_core::Hash::from_hex(r)?)
502+
} else if r == "HEAD" {
503+
morph_core::resolve_head(&store)?
504+
.ok_or_else(|| anyhow::anyhow!("HEAD has no commits"))
505+
} else {
506+
store.ref_read(&format!("heads/{}", r))?
507+
.or_else(|| store.ref_read(&format!("tags/{}", r)).ok().flatten())
508+
.ok_or_else(|| anyhow::anyhow!("unknown ref: {}", r))
509+
}
510+
};
511+
let old_hash = resolve(&old_ref)?;
512+
let new_hash = resolve(&new_ref)?;
513+
let entries = morph_core::diff_commits(&store, &old_hash, &new_hash)?;
514+
if entries.is_empty() {
515+
verbose_msg(verbose, "no changes");
516+
}
517+
for e in &entries {
518+
println!("{} {}", e.status, e.path);
519+
}
520+
}
521+
Command::Tag { name, delete } => {
522+
let (_repo_root, store) = get_store(verbose)?;
523+
if delete {
524+
let name = name.ok_or_else(|| anyhow::anyhow!("tag name required with -d"))?;
525+
morph_core::delete_tag(&store, &name)?;
526+
println!("Deleted tag {}", name);
527+
} else if let Some(name) = name {
528+
let head = morph_core::resolve_head(&store)?
529+
.ok_or_else(|| anyhow::anyhow!("no commit yet"))?;
530+
morph_core::create_tag(&store, &name, &head)?;
531+
println!("Tagged {} as {}", head, name);
532+
} else {
533+
let tags = morph_core::list_tags(&store)?;
534+
for (name, hash) in &tags {
535+
println!("{} {}", name, hash);
536+
}
537+
}
538+
}
539+
Command::Stash { sub } => match sub {
540+
StashCmd::Save { message } => {
541+
let (repo_root, _store) = get_store(verbose)?;
542+
let morph_dir = repo_root.join(".morph");
543+
let entry = morph_core::stash_save(&morph_dir, message.as_deref())?;
544+
println!("Saved stash {}{}", entry.id, entry.message.as_ref().map(|m| format!(": {}", m)).unwrap_or_default());
545+
}
546+
StashCmd::Pop => {
547+
let (repo_root, _store) = get_store(verbose)?;
548+
let morph_dir = repo_root.join(".morph");
549+
let entry = morph_core::stash_pop(&morph_dir)?;
550+
println!("Restored stash {}{}", entry.id, entry.message.as_ref().map(|m| format!(": {}", m)).unwrap_or_default());
551+
}
552+
StashCmd::List => {
553+
let (repo_root, _store) = get_store(verbose)?;
554+
let morph_dir = repo_root.join(".morph");
555+
let stashes = morph_core::stash_list(&morph_dir)?;
556+
for (i, entry) in stashes.iter().enumerate() {
557+
println!("stash@{{{}}}: {}", i, entry.message.as_deref().unwrap_or("(no message)"));
558+
}
559+
}
560+
}
561+
Command::Revert { commit, author } => {
562+
let (_repo_root, store) = get_store(verbose)?;
563+
let hash = parse_hash(&commit)?;
564+
let revert_hash = morph_core::revert_commit(&store, &hash, author)?;
565+
println!("{}", revert_hash);
566+
}
455567
Command::Upgrade => {
456568
let cwd = std::env::current_dir()?;
457569
verbose_msg(verbose, &format!("looking for repo from {}", cwd.display()));

morph-cli/tests/specs/diff.yaml

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# Tests for `morph diff`
2+
- name: diff_shows_added_files
3+
files:
4+
a.txt: "hello"
5+
steps:
6+
- morph: [add, .]
7+
- morph: [commit, -m, first]
8+
capture: c1
9+
- write_file:
10+
path: b.txt
11+
content: "world"
12+
- morph: [add, b.txt]
13+
- morph: [commit, -m, second]
14+
capture: c2
15+
- morph: [diff, "${c1}", "${c2}"]
16+
stdout_contains:
17+
- "A"
18+
- "b.txt"
19+
20+
- name: diff_shows_modified_files
21+
files:
22+
a.txt: "version1"
23+
steps:
24+
- morph: [add, .]
25+
- morph: [commit, -m, first]
26+
capture: c1
27+
- write_file:
28+
path: a.txt
29+
content: "version2"
30+
- morph: [add, a.txt]
31+
- morph: [commit, -m, second]
32+
capture: c2
33+
- morph: [diff, "${c1}", "${c2}"]
34+
stdout_contains:
35+
- "M"
36+
- "a.txt"
37+
38+
- name: diff_no_changes_empty_output
39+
files:
40+
a.txt: "same"
41+
steps:
42+
- morph: [add, .]
43+
- morph: [commit, -m, first]
44+
capture: c1
45+
- morph: [diff, "${c1}", "${c1}"]
46+
47+
- name: diff_head_shorthand
48+
files:
49+
a.txt: "v1"
50+
steps:
51+
- morph: [add, .]
52+
- morph: [commit, -m, first]
53+
capture: c1
54+
- write_file:
55+
path: b.txt
56+
content: "new"
57+
- morph: [add, b.txt]
58+
- morph: [commit, -m, second]
59+
- morph: [diff, "${c1}", HEAD]
60+
stdout_contains: "b.txt"
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Additional error-path tests beyond errors.yaml
2+
- name: checkout_no_commits_fails
3+
steps:
4+
- morph: [checkout, main]
5+
expect_exit: 1
6+
7+
- name: show_invalid_hash_fails
8+
steps:
9+
- morph: [show, not-a-hash]
10+
expect_exit: 1
11+
12+
- name: show_missing_object_fails
13+
steps:
14+
- morph: [show, aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa]
15+
expect_exit: 1
16+
17+
- name: pipeline_show_missing_fails
18+
steps:
19+
- morph: [pipeline, show, aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa]
20+
expect_exit: 1
21+
22+
- name: rollup_no_commits_fails
23+
steps:
24+
- morph: [rollup, HEAD, HEAD, -m, squash]
25+
expect_exit: 1
26+
27+
- name: annotate_invalid_target_fails
28+
steps:
29+
- morph: [annotate, not-a-hash, -k, note, -d, "{}"]
30+
expect_exit: 1
31+
32+
- name: commit_empty_message_still_works
33+
files:
34+
hello.txt: "hello"
35+
steps:
36+
- morph: [add, hello.txt]
37+
- morph: [commit, -m, ""]
38+
capture: hash
39+
assert_hash: true
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# Tests for .morphignore behavior
2+
- name: morphignore_excludes_files_from_status
3+
files:
4+
.morphignore: "*.log"
5+
code.txt: "fn main() {}"
6+
debug.log: "log output"
7+
steps:
8+
- morph: [status]
9+
stdout_contains: "code.txt"
10+
stdout_not_contains: "debug.log"
11+
12+
- name: morphignore_excludes_files_from_add
13+
files:
14+
.morphignore: "*.tmp"
15+
code.txt: "fn main() {}"
16+
scratch.tmp: "temporary"
17+
steps:
18+
- morph: [add, .]
19+
- morph: [status]
20+
stdout_not_contains: "scratch.tmp"
21+
22+
- name: morphignore_directory_pattern
23+
files:
24+
.morphignore: "build/"
25+
src/main.rs: "fn main() {}"
26+
dirs:
27+
- build
28+
steps:
29+
- write_file:
30+
path: build/output.bin
31+
content: binary
32+
- morph: [status]
33+
stdout_contains: "src/main.rs"
34+
stdout_not_contains: "build/output.bin"

0 commit comments

Comments
 (0)