Monorepo management for opam overlays
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

ocaml-git: implement Repository.is_dirty + 4 adversarial pull tests

Replace the stub Repository.is_dirty (which always returned false) with
a real walk of HEAD's tree against the working directory: any tracked
file whose blob hash differs from the WT contents, or that's missing
from the WT, makes the repo dirty. This is a one-pass walk over the
HEAD tree using Blob.digest, no index file needed.

monopam pull now also checks the mono itself (not just the per-package
checkouts under src/) and refuses to merge when there are uncommitted
edits that would be silently overwritten by the working-tree write.

Four new adversarial cram tests pin previously-undefined behaviour:

- pull_dirty.t: alice edits a subtree file but doesn't commit; pull
must exit 2 with "Dirty packages: lib" and the WIP edit must be
preserved.
- pull_no_trailing_newline.t: conflict in a file with no trailing
newline. Naive line splitters drop the last character; this pins
that diff3 still produces correct conflict markers.
- pull_both_same_edit.t: alice and bob make the EXACT same edit
independently. The diff3 algorithm must recognise "both changed
identically" and merge cleanly with no markers — the canary for
the rule that prevents content false positives.
- pull_deleted_checkout.t: a user deletes src/lib manually (cache
free / debugging / corruption recovery); pull must re-clone the
checkout from the upstream URL and continue the merge.

+385 -1
+16 -1
lib/pull.ml
··· 488 488 else begin 489 489 Log.info (fun m -> m "Checking status of %d packages" (List.length pkgs)); 490 490 let statuses = Status.compute_all ~sw ~fs:fs_t ~config pkgs in 491 - let dirty = 491 + let dirty_checkouts = 492 492 List.filter Status.has_local_changes statuses 493 493 |> List.map (fun s -> s.Status.package) 494 494 in 495 + (* Refuse to pull when the mono itself has uncommitted changes that 496 + overlap any package's prefix. Pull writes the merged tree back to 497 + the working directory, which would silently overwrite WIP edits. *) 498 + let monorepo = Config.Paths.monorepo config in 499 + let mono_repo = Git.Repository.open_repo ~sw ~fs:fs_t monorepo in 500 + let dirty_in_mono = 501 + if Git.Repository.is_dirty mono_repo then 502 + List.filter 503 + (fun pkg -> 504 + let prefix = Package.subtree_prefix pkg in 505 + Ctx.is_directory ~fs:fs_t Fpath.(monorepo / prefix)) 506 + pkgs 507 + else [] 508 + in 509 + let dirty = dirty_checkouts @ dirty_in_mono in 495 510 if dirty <> [] then Error (Ctx.Dirty_state dirty) 496 511 else begin 497 512 (* Pull mono inner subtrees first (depth-first); collect any
+93
test/pull_both_same_edit.t/run.t
··· 1 + monopam pull: same edit on both sides is a clean merge 2 + ========================================================= 3 + 4 + When alice and bob make the EXACT same edit independently, the 5 + merge must succeed cleanly — no conflict markers, no exit 4. 6 + This is the canary for the diff3 algorithm's ability to detect 7 + "both changed identically" rather than treating it as a generic 8 + content conflict. 9 + 10 + Setup 11 + ----- 12 + 13 + $ export NO_COLOR=1 14 + $ export GIT_AUTHOR_NAME="Alice" 15 + $ export GIT_AUTHOR_EMAIL="alice@example.com" 16 + $ export GIT_AUTHOR_DATE="2025-01-01T00:00:00+00:00" 17 + $ export GIT_COMMITTER_NAME="Alice" 18 + $ export GIT_COMMITTER_EMAIL="alice@example.com" 19 + $ export GIT_COMMITTER_DATE="2025-01-01T00:00:00+00:00" 20 + $ export HOME="$PWD/home" 21 + $ mkdir -p "$HOME" 22 + $ export GIT_CONFIG_GLOBAL="$HOME/.gitconfig" 23 + $ printf '[init]\n\tdefaultBranch = main\n[user]\n\tname = Alice\n\temail = alice@example.com\n' > "$GIT_CONFIG_GLOBAL" 24 + $ TROOT=$(pwd) 25 + $ git init -q --bare lib.git 26 + $ cat > lib.opam << OPAM 27 + > opam-version: "2.0" 28 + > name: "lib" 29 + > version: "dev" 30 + > synopsis: "L" 31 + > dev-repo: "git+file://$TROOT/lib.git" 32 + > OPAM 33 + $ mkdir -p opam-repo/packages/lib/lib.dev 34 + $ cp lib.opam opam-repo/packages/lib/lib.dev/opam 35 + $ cd opam-repo && git init -q && git add . && git commit -q -m "init" && cd "$TROOT" 36 + $ cat > opamverse.toml << EOF 37 + > [workspace] 38 + > root = "$TROOT" 39 + > [identity] 40 + > handle = "alice.example.org" 41 + > knot = "git.example.org" 42 + > EOF 43 + $ mkdir -p mono && cd mono 44 + $ git init -q 45 + $ mkdir -p lib/src 46 + $ cp "$TROOT/lib.opam" lib/lib.opam 47 + $ printf 'let banner = "v1"\n' > lib/src/main.ml 48 + $ git add . && git commit -q -m "initial" 49 + $ monopam push lib > /dev/null 2>&1 50 + 51 + Stage 1: alice and bob make the IDENTICAL edit independently 52 + ------------------------------------------------------------- 53 + 54 + $ export GIT_AUTHOR_DATE="2025-02-01T00:00:00+00:00" 55 + $ export GIT_COMMITTER_DATE="2025-02-01T00:00:00+00:00" 56 + $ printf 'let banner = "v2-shared"\n' > lib/src/main.ml 57 + $ git add -A && git commit -q -m "alice's banner update" 58 + 59 + Bob makes the same string change in his own clone: 60 + 61 + $ cd "$TROOT" 62 + $ git clone -q lib.git lib-other 2>/dev/null 63 + $ cd lib-other 64 + $ export GIT_AUTHOR_NAME="Bob" 65 + $ export GIT_AUTHOR_EMAIL="bob@example.com" 66 + $ export GIT_AUTHOR_DATE="2025-02-01T01:00:00+00:00" 67 + $ export GIT_COMMITTER_NAME="Bob" 68 + $ export GIT_COMMITTER_EMAIL="bob@example.com" 69 + $ export GIT_COMMITTER_DATE="2025-02-01T01:00:00+00:00" 70 + $ printf 'let banner = "v2-shared"\n' > src/main.ml 71 + $ git add -A && git commit -q -m "bob's banner update" 72 + $ git push -q origin main 2>/dev/null 73 + $ cd "$TROOT/mono" 74 + 75 + Stage 2: pull merges cleanly — no conflict 76 + -------------------------------------------- 77 + 78 + $ export GIT_AUTHOR_NAME="Alice" 79 + $ export GIT_AUTHOR_EMAIL="alice@example.com" 80 + $ export GIT_COMMITTER_NAME="Alice" 81 + $ export GIT_COMMITTER_EMAIL="alice@example.com" 82 + $ monopam pull lib 2>&1 \ 83 + > | grep -F "Monorepo updated" \ 84 + > | sed 's/ ([0-9.]*s)//' 85 + ✓ Monorepo updated. 86 + 87 + The file has the agreed value with no conflict markers: 88 + 89 + $ cat lib/src/main.ml 90 + let banner = "v2-shared" 91 + $ grep -c "<<<<<<< " lib/src/main.ml 92 + 0 93 + [1]
+94
test/pull_deleted_checkout.t/run.t
··· 1 + monopam pull: recovers when src/<pkg> was deleted manually 2 + ============================================================= 3 + 4 + The src/ checkouts are a derived cache. A user might delete one 5 + to free disk space, debug a stale state, or recover from 6 + corruption. Pull must re-clone it from the upstream and continue 7 + the merge — not crash with "checkout has no HEAD". 8 + 9 + Setup 10 + ----- 11 + 12 + $ export NO_COLOR=1 13 + $ export GIT_AUTHOR_NAME="Alice" 14 + $ export GIT_AUTHOR_EMAIL="alice@example.com" 15 + $ export GIT_AUTHOR_DATE="2025-01-01T00:00:00+00:00" 16 + $ export GIT_COMMITTER_NAME="Alice" 17 + $ export GIT_COMMITTER_EMAIL="alice@example.com" 18 + $ export GIT_COMMITTER_DATE="2025-01-01T00:00:00+00:00" 19 + $ export HOME="$PWD/home" 20 + $ mkdir -p "$HOME" 21 + $ export GIT_CONFIG_GLOBAL="$HOME/.gitconfig" 22 + $ printf '[init]\n\tdefaultBranch = main\n[user]\n\tname = Alice\n\temail = alice@example.com\n' > "$GIT_CONFIG_GLOBAL" 23 + $ TROOT=$(pwd) 24 + $ git init -q --bare lib.git 25 + $ cat > lib.opam << OPAM 26 + > opam-version: "2.0" 27 + > name: "lib" 28 + > version: "dev" 29 + > synopsis: "L" 30 + > dev-repo: "git+file://$TROOT/lib.git" 31 + > OPAM 32 + $ mkdir -p opam-repo/packages/lib/lib.dev 33 + $ cp lib.opam opam-repo/packages/lib/lib.dev/opam 34 + $ cd opam-repo && git init -q && git add . && git commit -q -m "init" && cd "$TROOT" 35 + $ cat > opamverse.toml << EOF 36 + > [workspace] 37 + > root = "$TROOT" 38 + > [identity] 39 + > handle = "alice.example.org" 40 + > knot = "git.example.org" 41 + > EOF 42 + $ mkdir -p mono && cd mono 43 + $ git init -q 44 + $ mkdir -p lib/src 45 + $ cp "$TROOT/lib.opam" lib/lib.opam 46 + $ printf 'let banner = "v1"\n' > lib/src/main.ml 47 + $ git add . && git commit -q -m "initial" 48 + $ monopam push lib > /dev/null 2>&1 49 + 50 + Stage 1: bob pushes a divergent edit 51 + -------------------------------------- 52 + 53 + $ cd "$TROOT" 54 + $ git clone -q lib.git lib-other 2>/dev/null 55 + $ cd lib-other 56 + $ export GIT_AUTHOR_NAME="Bob" 57 + $ export GIT_AUTHOR_EMAIL="bob@example.com" 58 + $ export GIT_AUTHOR_DATE="2025-02-01T00:00:00+00:00" 59 + $ export GIT_COMMITTER_NAME="Bob" 60 + $ export GIT_COMMITTER_EMAIL="bob@example.com" 61 + $ export GIT_COMMITTER_DATE="2025-02-01T00:00:00+00:00" 62 + $ printf 'let banner = "v2"\n' > src/main.ml 63 + $ git add -A && git commit -q -m "bob's banner" 64 + $ git push -q origin main 2>/dev/null 65 + 66 + Stage 2: nuke alice's local checkout 67 + ------------------------------------- 68 + 69 + $ rm -rf "$TROOT/src/lib" 70 + $ test -d "$TROOT/src/lib" && echo "still here" || echo "gone" 71 + gone 72 + 73 + Stage 3: pull must re-clone the checkout and update the subtree 74 + ----------------------------------------------------------------- 75 + 76 + $ cd "$TROOT/mono" 77 + $ export GIT_AUTHOR_NAME="Alice" 78 + $ export GIT_AUTHOR_EMAIL="alice@example.com" 79 + $ export GIT_COMMITTER_NAME="Alice" 80 + $ export GIT_COMMITTER_EMAIL="alice@example.com" 81 + $ monopam pull lib 2>&1 \ 82 + > | grep -F "Monorepo updated" \ 83 + > | sed 's/ ([0-9.]*s)//' 84 + ✓ Monorepo updated. 85 + 86 + The checkout has been recreated: 87 + 88 + $ test -d "$TROOT/src/lib" && echo "present" || echo "missing" 89 + present 90 + 91 + The mono now has bob's content: 92 + 93 + $ cat lib/src/main.ml 94 + let banner = "v2"
+78
test/pull_dirty.t/run.t
··· 1 + monopam pull: refuses to merge into a dirty working tree 2 + ========================================================== 3 + 4 + Pull must refuse to run when the user has uncommitted edits in 5 + a subtree. Merging into a dirty working tree could destroy the 6 + in-progress work without recourse — the standard git behaviour 7 + is to refuse, and so does monopam. 8 + 9 + Setup 10 + ----- 11 + 12 + $ export NO_COLOR=1 13 + $ export GIT_AUTHOR_NAME="Alice" 14 + $ export GIT_AUTHOR_EMAIL="alice@example.com" 15 + $ export GIT_AUTHOR_DATE="2025-01-01T00:00:00+00:00" 16 + $ export GIT_COMMITTER_NAME="Alice" 17 + $ export GIT_COMMITTER_EMAIL="alice@example.com" 18 + $ export GIT_COMMITTER_DATE="2025-01-01T00:00:00+00:00" 19 + $ export HOME="$PWD/home" 20 + $ mkdir -p "$HOME" 21 + $ export GIT_CONFIG_GLOBAL="$HOME/.gitconfig" 22 + $ printf '[init]\n\tdefaultBranch = main\n[user]\n\tname = Alice\n\temail = alice@example.com\n' > "$GIT_CONFIG_GLOBAL" 23 + $ TROOT=$(pwd) 24 + 25 + $ git init -q --bare lib.git 26 + $ cat > lib.opam << OPAM 27 + > opam-version: "2.0" 28 + > name: "lib" 29 + > version: "dev" 30 + > synopsis: "L" 31 + > dev-repo: "git+file://$TROOT/lib.git" 32 + > OPAM 33 + $ mkdir -p opam-repo/packages/lib/lib.dev 34 + $ cp lib.opam opam-repo/packages/lib/lib.dev/opam 35 + $ cd opam-repo && git init -q && git add . && git commit -q -m "init" && cd "$TROOT" 36 + $ cat > opamverse.toml << EOF 37 + > [workspace] 38 + > root = "$TROOT" 39 + > [identity] 40 + > handle = "alice.example.org" 41 + > knot = "git.example.org" 42 + > EOF 43 + 44 + Stage 1: bootstrap a clean lib subtree and push it 45 + ---------------------------------------------------- 46 + 47 + $ mkdir -p mono && cd mono 48 + $ git init -q 49 + $ mkdir -p lib/src 50 + $ cp "$TROOT/lib.opam" lib/lib.opam 51 + $ printf 'let banner = "v1"\n' > lib/src/main.ml 52 + $ git add . && git commit -q -m "initial mono" 53 + $ monopam push lib > /dev/null 2>&1 54 + 55 + Stage 2: leave the working tree dirty (uncommitted edit) 56 + ---------------------------------------------------------- 57 + 58 + $ printf 'let banner = "v2-wip"\n' > lib/src/main.ml 59 + $ git status --short 60 + M lib/src/main.ml 61 + 62 + Stage 3: even with no upstream changes, pull MUST refuse 63 + --------------------------------------------------------- 64 + 65 + The pull operation reads HEAD and writes the merged tree back 66 + into the working directory. With uncommitted edits, that write 67 + would silently overwrite the user's WIP. Pull must exit non-zero 68 + without touching anything. 69 + 70 + $ monopam pull lib > /tmp/pull.out 2>&1 71 + [2] 72 + $ grep -F "Dirty" /tmp/pull.out 73 + Error: Dirty packages: lib 74 + 75 + The user's edit is preserved unchanged: 76 + 77 + $ cat lib/src/main.ml 78 + let banner = "v2-wip"
+104
test/pull_no_trailing_newline.t/run.t
··· 1 + monopam pull: conflict in a file with no trailing newline 2 + ============================================================ 3 + 4 + Files without a trailing newline are a notorious source of 5 + diff/merge bugs — many naive splitters drop the last character 6 + or treat the line as missing. The merge result must still be 7 + correct: clean merge for non-overlapping edits, conflict 8 + markers for overlapping edits. 9 + 10 + Setup 11 + ----- 12 + 13 + $ export NO_COLOR=1 14 + $ export GIT_AUTHOR_NAME="Alice" 15 + $ export GIT_AUTHOR_EMAIL="alice@example.com" 16 + $ export GIT_AUTHOR_DATE="2025-01-01T00:00:00+00:00" 17 + $ export GIT_COMMITTER_NAME="Alice" 18 + $ export GIT_COMMITTER_EMAIL="alice@example.com" 19 + $ export GIT_COMMITTER_DATE="2025-01-01T00:00:00+00:00" 20 + $ export HOME="$PWD/home" 21 + $ mkdir -p "$HOME" 22 + $ export GIT_CONFIG_GLOBAL="$HOME/.gitconfig" 23 + $ printf '[init]\n\tdefaultBranch = main\n[user]\n\tname = Alice\n\temail = alice@example.com\n' > "$GIT_CONFIG_GLOBAL" 24 + $ TROOT=$(pwd) 25 + $ git init -q --bare lib.git 26 + $ cat > lib.opam << OPAM 27 + > opam-version: "2.0" 28 + > name: "lib" 29 + > version: "dev" 30 + > synopsis: "L" 31 + > dev-repo: "git+file://$TROOT/lib.git" 32 + > OPAM 33 + $ mkdir -p opam-repo/packages/lib/lib.dev 34 + $ cp lib.opam opam-repo/packages/lib/lib.dev/opam 35 + $ cd opam-repo && git init -q && git add . && git commit -q -m "init" && cd "$TROOT" 36 + $ cat > opamverse.toml << EOF 37 + > [workspace] 38 + > root = "$TROOT" 39 + > [identity] 40 + > handle = "alice.example.org" 41 + > knot = "git.example.org" 42 + > EOF 43 + $ mkdir -p mono && cd mono 44 + $ git init -q 45 + $ mkdir -p lib/src 46 + $ cp "$TROOT/lib.opam" lib/lib.opam 47 + 48 + Stage 1: a file with NO trailing newline (printf, not echo) 49 + ------------------------------------------------------------- 50 + 51 + $ printf 'let v = 1' > lib/src/main.ml 52 + $ git add . && git commit -q -m "initial mono" 53 + $ monopam push lib > /dev/null 2>&1 54 + 55 + Verify the file really has no trailing newline: 56 + 57 + $ wc -c < lib/src/main.ml 58 + 9 59 + 60 + Stage 2: alice edits, still no trailing newline 61 + ------------------------------------------------- 62 + 63 + $ export GIT_AUTHOR_DATE="2025-02-01T00:00:00+00:00" 64 + $ export GIT_COMMITTER_DATE="2025-02-01T00:00:00+00:00" 65 + $ printf 'let v = 2' > lib/src/main.ml 66 + $ git add -A && git commit -q -m "alice edit" 67 + 68 + Stage 3: bob pushes a divergent edit, also no trailing newline 69 + ---------------------------------------------------------------- 70 + 71 + $ cd "$TROOT" 72 + $ git clone -q lib.git lib-other 2>/dev/null 73 + $ cd lib-other 74 + $ export GIT_AUTHOR_NAME="Bob" 75 + $ export GIT_AUTHOR_EMAIL="bob@example.com" 76 + $ export GIT_AUTHOR_DATE="2025-02-01T01:00:00+00:00" 77 + $ export GIT_COMMITTER_NAME="Bob" 78 + $ export GIT_COMMITTER_EMAIL="bob@example.com" 79 + $ export GIT_COMMITTER_DATE="2025-02-01T01:00:00+00:00" 80 + $ printf 'let v = 3' > src/main.ml 81 + $ git add -A && git commit -q -m "bob edit" 82 + $ git push -q origin main 2>/dev/null 83 + $ cd "$TROOT/mono" 84 + 85 + Stage 4: alice pulls — must get conflict markers 86 + -------------------------------------------------- 87 + 88 + $ export GIT_AUTHOR_NAME="Alice" 89 + $ export GIT_AUTHOR_EMAIL="alice@example.com" 90 + $ export GIT_COMMITTER_NAME="Alice" 91 + $ export GIT_COMMITTER_EMAIL="alice@example.com" 92 + $ monopam pull lib > /tmp/pull.out 2>&1 93 + [4] 94 + $ grep -E "^CONFLICT" /tmp/pull.out 95 + CONFLICT in lib/src/main.ml 96 + 97 + Both versions are visible in the merged file: 98 + 99 + $ grep -F "let v = 2" lib/src/main.ml 100 + let v = 2 101 + $ grep -F "let v = 3" lib/src/main.ml 102 + let v = 3 103 + $ grep -c "<<<<<<< " lib/src/main.ml 104 + 1