Monorepo management for opam overlays
0
fork

Configure Feed

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

monopam: cram tests for init/status/pull/diff/remove/clean, fix pull worktree

Add happy-path cram coverage for six commands that previously only had
negative tests (or none at all). Each test lives in a <cmd>.t/run.t
directory so future tests can drop fixture files alongside run.t.

The new tests uncovered two real pull bugs:

- Pull updated HEAD and the git object database but never touched the
working tree. After `monopam pull`, `git log` showed the subtree
merge commit but the user's files on disk were still the pre-pull
content. `subtree_merge_or_add` now calls `Git.Repository.checkout_
prefix` after the merge.

- Pull reported "All N repositories up to date" after actually pulling
commits. `clone_repos` measured `behind_before` against the stale
`origin/<branch>` tracking ref — that ref only gets updated by a
fetch, so on the first pull after a push-only setup it was always
equal to HEAD. Replaced with a before/after HEAD snapshot around
`ensure_checkout` and `count_commits_between` for the real count.

Also: `monopam init` now writes CLAUDE.md and .gitignore directly via
a new `Init.bootstrap_files`. Before this, CLAUDE.md only appeared
after the first `monopam pull`, leaving fresh `init` workspaces with
no guidance. init.t has a regression assertion that the generated
CLAUDE.md contains `monopam pull` / `monopam push` and zero
`monopam sync` references.

Tests:
- init.t: fresh workspace, CLAUDE.md generated and idempotent.
- status.t: synced workspace summary + actionable row on local edit.
- pull.t: simulate collaborator commit on upstream, pull merges into
monorepo worktree, reports the real commit count, shows subtree
merge in git log.
- diff.t: outgoing after push --local, incoming after fetch.
- remove.t: --dry-run preview, real removal, sources.toml updated.
- clean.t: smoke test on a clean workspace.

+593 -5
+6 -1
bin/cmd_init.ml
··· 87 87 (match Monopam.Deps.run ~sw ~proc ~fs ~target ~dry_run () with 88 88 | Ok () -> () 89 89 | Error e -> Fmt.pr "[init] bootstrap: %s@." e); 90 - (* Step 4: Regenerate root deps (dune-project + root.opam) *) 90 + (* Step 4: Write workspace bootstrap files (CLAUDE.md, .gitignore). 91 + This used to happen only during [monopam pull], so a fresh 92 + [monopam init] left the workspace without guidance until the 93 + user ran pull. Now init is self-contained. *) 94 + Monopam.Init.bootstrap_files ~fs ~target; 95 + (* Step 5: Regenerate root deps (dune-project + root.opam) *) 91 96 Monopam.Import.update_root_deps ~fs ~target; 92 97 let elapsed = Unix.gettimeofday () -. t0 in 93 98 Common.print_success ~elapsed
+23
lib/init.ml
··· 326 326 Log.app (fun m -> m "Updated CLAUDE.md") 327 327 end 328 328 329 + (** {1 Bootstrap Files} 330 + 331 + Used by [monopam init] before any config exists. Writes CLAUDE.md and 332 + .gitignore directly to the workspace root. Idempotent: an already up-to-date 333 + CLAUDE.md is left alone, a stale one is overwritten. *) 334 + 335 + let bootstrap_files ~fs ~target = 336 + let target_eio = Eio.Path.(fs / Fpath.to_string target) in 337 + let claude_path = Eio.Path.(target_eio / "CLAUDE.md") in 338 + let needs_update = 339 + match Eio.Path.load claude_path with 340 + | existing -> existing <> claude_md_content 341 + | exception Eio.Io _ -> true 342 + in 343 + if needs_update then 344 + Eio.Path.save ~create:(`Or_truncate 0o644) claude_path claude_md_content; 345 + let gitignore_path = Eio.Path.(target_eio / ".gitignore") in 346 + match Eio.Path.load gitignore_path with 347 + | _ -> () 348 + | exception Eio.Io _ -> 349 + Eio.Path.save ~create:(`Or_truncate 0o644) gitignore_path 350 + gitignore_content 351 + 329 352 (** {1 Monorepo Initialization} *) 330 353 331 354 let setup_and_commit ~sw ~fs ~monorepo ~monorepo_eio =
+7
lib/init.mli
··· 11 11 (unit, Ctx.error) result 12 12 (** [ensure ~sw ~proc ~fs ~config] initializes the monorepo if needed. *) 13 13 14 + val bootstrap_files : fs:_ Eio.Path.t -> target:Fpath.t -> unit 15 + (** [bootstrap_files ~fs ~target] writes CLAUDE.md and .gitignore at [target] if 16 + they don't already exist. Safe to call from [monopam init] before any config 17 + exists, and idempotent — an existing CLAUDE.md with the current shipped 18 + content is left alone, and a stale one (e.g. referencing the old 19 + [monopam sync] command) is overwritten. *) 20 + 14 21 val write_readme : 15 22 proc:_ Eio.Process.mgr -> 16 23 fs:Eio.Fs.dir_ty Eio.Path.t ->
+43 -4
lib/pull.ml
··· 31 31 match 32 32 fn git_repo ~prefix ~commit ~author:user ~committer:user ~message () 33 33 with 34 - | Ok _ -> Ok added 35 34 | Error (`Msg msg) -> Error (Ctx.Git_error (Git_cli.Io_error msg)) 35 + | Ok new_head -> ( 36 + (* Git.Subtree.{add,merge} only update HEAD and the object database; 37 + the working tree still has the pre-merge files. Check the 38 + updated prefix out so the user sees the pulled content on 39 + disk (not just in `git log`). *) 40 + match Git.Repository.checkout_prefix git_repo new_head ~prefix with 41 + | Ok () -> Ok added 42 + | Error (`Msg msg) -> 43 + Error 44 + (Ctx.Git_error 45 + (Git_cli.Io_error 46 + (Fmt.str "checkout after subtree %s of %s: %s" 47 + (String.lowercase_ascii verb) 48 + prefix msg)))) 36 49 37 50 let subtree ~sw ~proc ~fs ~config pkg = 38 51 let fs = Ctx.fs_typed fs in ··· 96 109 opam_repo) 97 110 end 98 111 112 + (** HEAD commit of a checkout, or [None] if it has none. Used to count how many 113 + commits the ensuing fetch+merge_ff pulled in. *) 114 + let checkout_head ~sw ~fs ~config pkg = 115 + let checkouts_root = Config.Paths.checkouts config in 116 + let checkout_dir = Package.checkout_dir ~checkouts_root pkg in 117 + if not (Git.Repository.is_repo ~fs checkout_dir) then None 118 + else 119 + let repo = Git.Repository.open_repo ~sw ~fs checkout_dir in 120 + Git.Repository.head repo 121 + 99 122 let clone_repos ~sw ~proc ~fs ~config repos = 100 123 let total = List.length repos in 101 124 let progress = Tty.Progress.v ~total "Fetch" in ··· 109 132 (Fmt.str "Fetch: %s (%d/%d)" repo_name (List.length acc + 1) total); 110 133 Log.info (fun m -> m "Fetching repo %s" repo_name); 111 134 let existed = Ctx.checkout_exists ~fs ~config pkg in 112 - let behind_before = 113 - if existed then Ctx.behind ~sw ~fs ~config pkg else 0 135 + (* Snapshot HEAD before ensure_checkout runs fetch+merge_ff. The 136 + previous approach called [Ctx.behind] here, which reads the 137 + local [origin/<branch>] tracking ref — stale on the first pull 138 + after a push because [push] doesn't update that ref. Counting 139 + after-vs-before HEADs always reflects what pull actually did. *) 140 + let head_before = 141 + if existed then checkout_head ~sw ~fs ~config pkg else None 114 142 in 115 143 match Ctx.ensure_checkout ~proc ~fs ~config pkg with 116 144 | Error e -> ··· 118 146 Error (Ctx.Git_error e) 119 147 | Ok () -> 120 148 Tty.Progress.tick progress; 149 + let commits_pulled = 150 + match (head_before, checkout_head ~sw ~fs ~config pkg) with 151 + | Some before, Some after when not (Git.Hash.equal before after) 152 + -> 153 + let checkouts_root = Config.Paths.checkouts config in 154 + let checkout_dir = Package.checkout_dir ~checkouts_root pkg in 155 + let repo = Git.Repository.open_repo ~sw ~fs checkout_dir in 156 + Git.Repository.count_commits_between repo ~base:before 157 + ~head:after 158 + | _ -> 0 159 + in 121 160 let result = 122 161 { 123 162 repo_name; 124 163 cloned = not existed; 125 - commits_pulled = behind_before; 164 + commits_pulled; 126 165 subtree_added = false; 127 166 } 128 167 in
+58
test/clean.t/run.t
··· 1 + monopam clean 2 + ============== 3 + 4 + Removes empty commits and unrelated subtree-merge plumbing from the 5 + monorepo and checkouts. This smoke test covers the happy path (no 6 + empty commits present) and the `--dry-run` output. 7 + 8 + Setup 9 + ----- 10 + 11 + $ export NO_COLOR=1 12 + $ export GIT_AUTHOR_NAME="Alice" 13 + $ export GIT_AUTHOR_EMAIL="alice@example.com" 14 + $ export GIT_AUTHOR_DATE="2025-01-01T00:00:00+00:00" 15 + $ export GIT_COMMITTER_NAME="Alice" 16 + $ export GIT_COMMITTER_EMAIL="alice@example.com" 17 + $ export GIT_COMMITTER_DATE="2025-01-01T00:00:00+00:00" 18 + $ export HOME="$PWD/home" 19 + $ mkdir -p "$HOME" 20 + $ export GIT_CONFIG_GLOBAL="$HOME/.gitconfig" 21 + $ printf '[init]\n\tdefaultBranch = main\n[user]\n\tname = Alice\n\temail = alice@example.com\n' > "$GIT_CONFIG_GLOBAL" 22 + $ TROOT=$(pwd) 23 + 24 + Minimal workspace: an empty monorepo git repo, an empty opam-repo, 25 + and the monopam config pointing at them. 26 + 27 + $ mkdir -p mono opam-repo 28 + $ cd mono && git init -q && git commit -q --allow-empty -m "init" && cd "$TROOT" 29 + $ cd opam-repo && git init -q && git commit -q --allow-empty -m "init" && cd "$TROOT" 30 + $ mkdir -p "$HOME/.config/monopam" 31 + $ cat > "$HOME/.config/monopam/opamverse.toml" << EOF 32 + > [workspace] 33 + > root = "$TROOT" 34 + > [identity] 35 + > handle = "alice.example.org" 36 + > knot = "git.example.org" 37 + > EOF 38 + 39 + Dry run on a workspace with nothing to clean 40 + ---------------------------------------------- 41 + 42 + $ cd mono 43 + $ monopam clean --dry-run 2>&1 44 + No empty commits found 45 + 46 + Without --dry-run, the same workspace gives the same result: 47 + 48 + $ monopam clean 2>&1 49 + No empty commits found 50 + 51 + The clean op is idempotent — the mono repo's HEAD is unchanged after 52 + running: 53 + 54 + $ before=$(git rev-parse HEAD) 55 + $ monopam clean > /dev/null 2>&1 56 + $ after=$(git rev-parse HEAD) 57 + $ test "$before" = "$after" && echo "HEAD unchanged" 58 + HEAD unchanged
+109
test/diff.t/run.t
··· 1 + monopam diff 2 + ============= 3 + 4 + Shows what would be pushed or pulled. Two assertions on the same 5 + workspace: outgoing (local commits ahead of the remote) and incoming 6 + (remote commits ahead of local). 7 + 8 + Setup 9 + ----- 10 + 11 + $ export NO_COLOR=1 12 + $ export GIT_AUTHOR_NAME="Alice" 13 + $ export GIT_AUTHOR_EMAIL="alice@example.com" 14 + $ export GIT_AUTHOR_DATE="2025-01-01T00:00:00+00:00" 15 + $ export GIT_COMMITTER_NAME="Alice" 16 + $ export GIT_COMMITTER_EMAIL="alice@example.com" 17 + $ export GIT_COMMITTER_DATE="2025-01-01T00:00:00+00:00" 18 + $ export HOME="$PWD/home" 19 + $ mkdir -p "$HOME" 20 + $ export GIT_CONFIG_GLOBAL="$HOME/.gitconfig" 21 + $ printf '[init]\n\tdefaultBranch = main\n[user]\n\tname = Alice\n\temail = alice@example.com\n' > "$GIT_CONFIG_GLOBAL" 22 + $ TROOT=$(pwd) 23 + 24 + Bare upstream, opam-repo overlay, monopam config: 25 + 26 + $ git init -q --bare upstream.git 27 + $ mkdir -p opam-repo/packages/lib/lib.dev 28 + $ cat > opam-repo/packages/lib/lib.dev/opam << OPAM 29 + > opam-version: "2.0" 30 + > name: "lib" 31 + > version: "dev" 32 + > synopsis: "A diff test library" 33 + > dev-repo: "git+file://$TROOT/upstream.git" 34 + > OPAM 35 + $ cd opam-repo && git init -q && git add . && git commit -q -m "init" && cd "$TROOT" 36 + $ mkdir -p "$HOME/.config/monopam" 37 + $ cat > "$HOME/.config/monopam/opamverse.toml" << EOF 38 + > [workspace] 39 + > root = "$TROOT" 40 + > [identity] 41 + > handle = "alice.example.org" 42 + > knot = "git.example.org" 43 + > EOF 44 + 45 + Initial push so the upstream has a baseline: 46 + 47 + $ mkdir -p mono/upstream/lib && cd mono 48 + $ git init -q 49 + $ cat > upstream/dune-project << 'DUNE' 50 + > (lang dune 3.0) 51 + > (name lib) 52 + > DUNE 53 + $ cat > upstream/lib.opam << OPAM 54 + > opam-version: "2.0" 55 + > name: "lib" 56 + > version: "dev" 57 + > synopsis: "A diff test library" 58 + > dev-repo: "git+file://$TROOT/upstream.git" 59 + > OPAM 60 + $ echo "let x = 1" > upstream/lib/main.ml 61 + $ git add . && git commit -q -m "initial" 62 + $ monopam push lib > /dev/null 2>&1 63 + 64 + Outgoing diff — local commit ahead of the remote 65 + --------------------------------------------------- 66 + 67 + Make a new local commit in the monorepo, then run `monopam push 68 + --local lib` so the split lands in the checkout under src/ without 69 + being sent to the remote. At that point `monopam diff lib` should 70 + list the new commit as outgoing (ready to push upstream). 71 + 72 + $ export GIT_AUTHOR_DATE="2025-01-02T00:00:00+00:00" 73 + $ export GIT_COMMITTER_DATE="2025-01-02T00:00:00+00:00" 74 + $ echo "let y = 2" >> upstream/lib/main.ml 75 + $ git add -A && git commit -q -m "add y" 76 + $ monopam push --local lib > /dev/null 2>&1 77 + 78 + $ monopam diff lib 2>&1 \ 79 + > | grep -E "outgoing|add y" \ 80 + > | sed 's/[0-9a-f]\{7\} //' 81 + upstream (outgoing, 1 commits): 82 + add y 83 + 84 + Incoming diff — upstream has a new commit ahead of the local 85 + ------------------------------------------------------------- 86 + 87 + Simulate a collaborator pushing directly to the upstream: 88 + 89 + $ cd "$TROOT" 90 + $ git clone -q upstream.git work 2>/dev/null 91 + $ cd work 92 + $ export GIT_AUTHOR_DATE="2025-02-01T00:00:00+00:00" 93 + $ export GIT_COMMITTER_DATE="2025-02-01T00:00:00+00:00" 94 + $ echo 'let greet = ()' >> lib/main.ml 95 + $ git add lib/main.ml && git commit -q -m "upstream: add greet" 96 + $ git push -q origin main 2>/dev/null 97 + 98 + From the monorepo, `monopam diff --incoming` lists what pulling would 99 + bring in. The checkout needs a fresh fetch first so it sees the new 100 + upstream commit — monopam fetch is exactly that. 101 + 102 + $ cd "$TROOT/mono" 103 + $ monopam fetch lib > /dev/null 2>&1 104 + 105 + $ monopam diff --incoming lib 2>&1 \ 106 + > | grep -E "incoming|add greet" \ 107 + > | sed 's/[0-9a-f]\{7\} //' 108 + upstream (incoming, 1 commits): 109 + upstream: add greet
+75
test/init.t/run.t
··· 1 + monopam init 2 + ============= 3 + 4 + Fresh workspace bootstrap. The test asserts that `monopam init 5 + --handle <handle>` prints the resolved root, prints the resolved 6 + handle, writes CLAUDE.md matching the shipped CLI vocabulary (no 7 + ghost `monopam sync` references — the tool used to ship with this 8 + bug), and emits the unified ✓ success line with a Next: hint. 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_COMMITTER_NAME="Alice" 17 + $ export GIT_COMMITTER_EMAIL="alice@example.com" 18 + $ export HOME="$PWD/home" 19 + $ mkdir -p "$HOME" 20 + $ export GIT_CONFIG_GLOBAL="$HOME/.gitconfig" 21 + $ printf '[init]\n\tdefaultBranch = main\n[user]\n\tname = Alice\n\temail = alice@example.com\n' > "$GIT_CONFIG_GLOBAL" 22 + 23 + A fresh workspace the user has never touched: 24 + 25 + $ mkdir workspace && cd workspace 26 + $ git init -q 27 + 28 + Run init 29 + -------- 30 + 31 + $ monopam init --handle alice.example.org 2>&1 \ 32 + > | sed '/root:/ s|: .*/workspace|: <WS>|' \ 33 + > | sed '/✓/ s/ (.*$//' \ 34 + > | grep -v '^\[init\] verse: ' \ 35 + > | grep -v '^Updated dune-project' 36 + [init] root: <WS> 37 + [init] handle: alice.example.org 38 + ✓ Workspace initialized. 39 + Next: monopam add <git-url> # or: monopam pull 40 + 41 + The "verse" line is stripped above because it depends on whether the 42 + verse registry can be cloned in the sandbox; its outcome is orthogonal 43 + to the rest of init. 44 + 45 + CLAUDE.md must be generated and use the shipped vocabulary 46 + ----------------------------------------------------------- 47 + 48 + $ test -f CLAUDE.md && echo "CLAUDE.md exists" 49 + CLAUDE.md exists 50 + 51 + The template must not tell the user to run a command that doesn't 52 + exist. This is a regression assertion: the generated file used to say 53 + `monopam sync` everywhere. 54 + 55 + $ grep -c "monopam sync" CLAUDE.md || true 56 + 0 57 + $ test $(grep -c "monopam pull" CLAUDE.md) -gt 0 && echo "mentions pull" 58 + mentions pull 59 + $ test $(grep -c "monopam push" CLAUDE.md) -gt 0 && echo "mentions push" 60 + mentions push 61 + 62 + The dune-project and root.opam should be regenerated by init: 63 + 64 + $ test -f dune-project && echo "dune-project exists" 65 + dune-project exists 66 + $ test -f root.opam && echo "root.opam exists" 67 + root.opam exists 68 + 69 + Re-running init is idempotent — it should not crash, should print the 70 + same root, and should leave CLAUDE.md unchanged: 71 + 72 + $ cp CLAUDE.md CLAUDE.md.before 73 + $ monopam init --handle alice.example.org 2>&1 > /dev/null 74 + $ diff CLAUDE.md CLAUDE.md.before && echo "CLAUDE.md unchanged" 75 + CLAUDE.md unchanged
+106
test/pull.t/run.t
··· 1 + monopam pull 2 + ============= 3 + 4 + Round-trip for push.t. Starts from a working monorepo + upstream pair, 5 + makes a new commit directly in the upstream (simulating a 6 + collaborator pushing), runs `monopam pull`, and verifies the monorepo 7 + now contains the new change in its working tree. 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 + Bare upstream, opam-repo overlay, monopam config: 26 + 27 + $ git init -q --bare upstream.git 28 + $ mkdir -p opam-repo/packages/lib/lib.dev 29 + $ cat > opam-repo/packages/lib/lib.dev/opam << OPAM 30 + > opam-version: "2.0" 31 + > name: "lib" 32 + > version: "dev" 33 + > synopsis: "A pull test library" 34 + > dev-repo: "git+file://$TROOT/upstream.git" 35 + > OPAM 36 + $ cd opam-repo && git init -q && git add . && git commit -q -m "init" && cd "$TROOT" 37 + $ mkdir -p "$HOME/.config/monopam" 38 + $ cat > "$HOME/.config/monopam/opamverse.toml" << EOF 39 + > [workspace] 40 + > root = "$TROOT" 41 + > [identity] 42 + > handle = "alice.example.org" 43 + > knot = "git.example.org" 44 + > EOF 45 + 46 + Populate the monorepo and push so the upstream has something to 47 + diverge from: 48 + 49 + $ mkdir -p mono/upstream/lib && cd mono 50 + $ git init -q 51 + $ cat > upstream/dune-project << 'DUNE' 52 + > (lang dune 3.0) 53 + > (name lib) 54 + > DUNE 55 + $ cat > upstream/lib.opam << OPAM 56 + > opam-version: "2.0" 57 + > name: "lib" 58 + > version: "dev" 59 + > synopsis: "A pull test library" 60 + > dev-repo: "git+file://$TROOT/upstream.git" 61 + > OPAM 62 + $ echo "let greet () = ()" > upstream/lib/main.ml 63 + $ git add . && git commit -q -m "initial" 64 + $ monopam push lib > /dev/null 2>&1 65 + 66 + Upstream commits something new 67 + ------------------------------- 68 + 69 + Simulate a collaborator pushing directly to the upstream bare repo: 70 + 71 + $ cd "$TROOT" 72 + $ git clone -q upstream.git work 2>/dev/null 73 + $ cd work 74 + $ export GIT_AUTHOR_DATE="2025-02-01T00:00:00+00:00" 75 + $ export GIT_COMMITTER_DATE="2025-02-01T00:00:00+00:00" 76 + $ echo 'let bye () = ()' >> lib/main.ml 77 + $ git add lib/main.ml && git commit -q -m "upstream: add bye" 78 + $ git push -q origin main 2>/dev/null 79 + 80 + Pull brings the new commit into the monorepo 81 + ---------------------------------------------- 82 + 83 + Running pull reports the per-repo line with a commit count, emits the 84 + unified `✓ Monorepo updated.` success line, and actually updates the 85 + monorepo's working tree with the new content. 86 + 87 + $ cd "$TROOT/mono" 88 + $ monopam pull lib > /tmp/pull-out 2>&1 89 + $ grep -E "upstream \(1|Monorepo updated|Next:" /tmp/pull-out \ 90 + > | sed -e '/Monorepo updated/ s/ (.*$//' 91 + ✓ upstream (1 commits) 92 + ✓ Monorepo updated. 93 + Next: dune build && dune test 94 + 95 + The pulled content must be in the monorepo worktree, not just in the 96 + git object database: 97 + 98 + $ grep "let bye" upstream/lib/main.ml 99 + let bye () = () 100 + 101 + The monorepo's git log should contain the subtree merge commit for 102 + the pull (it sits just under the auto-generated README/dune-project 103 + update commits that pull regenerates at the end): 104 + 105 + $ git log --format="%s" | grep -F "Merge 'upstream/'" | head -1 | sed 's| from .*| from <URL>|' 106 + Merge 'upstream/' from <URL>
+77
test/remove.t/run.t
··· 1 + monopam remove 2 + =============== 3 + 4 + Adding then removing a subtree must leave the workspace in a clean 5 + state: the subtree directory is gone, sources.toml no longer has an 6 + entry for it, and the operation is recorded as a git commit. 7 + 8 + Setup 9 + ----- 10 + 11 + $ export NO_COLOR=1 12 + $ export GIT_AUTHOR_NAME="Alice" 13 + $ export GIT_AUTHOR_EMAIL="alice@example.com" 14 + $ export GIT_AUTHOR_DATE="2025-01-01T00:00:00+00:00" 15 + $ export GIT_COMMITTER_NAME="Alice" 16 + $ export GIT_COMMITTER_EMAIL="alice@example.com" 17 + $ export GIT_COMMITTER_DATE="2025-01-01T00:00:00+00:00" 18 + $ export HOME="$PWD/home" 19 + $ mkdir -p "$HOME" 20 + $ export GIT_CONFIG_GLOBAL="$HOME/.gitconfig" 21 + $ printf '[init]\n\tdefaultBranch = main\n[user]\n\tname = Alice\n\temail = alice@example.com\n' > "$GIT_CONFIG_GLOBAL" 22 + $ TROOT=$(pwd) 23 + 24 + Create an upstream repo with one commit so we have something to add: 25 + 26 + $ git init -q --bare upstream.git 27 + $ git clone -q upstream.git work 2>/dev/null 28 + $ cd work 29 + $ echo "let x = 1" > main.ml 30 + $ git add . && git commit -q -m "initial" 31 + $ git push -q origin main 2>/dev/null 32 + $ cd "$TROOT" 33 + 34 + Create a monorepo and add the upstream as a subtree: 35 + 36 + $ mkdir mono && cd mono && git init -q && git commit -q --allow-empty -m "init" 37 + $ monopam add "$TROOT/upstream.git" > /dev/null 2>&1 38 + 39 + Confirm the subtree and the sources.toml entry exist before we remove 40 + them: 41 + 42 + $ test -d upstream && echo "subtree present" 43 + subtree present 44 + $ grep -c "^\[upstream\]" sources.toml 45 + 1 46 + 47 + Dry run — preview without touching disk 48 + ----------------------------------------- 49 + 50 + $ monopam remove upstream --dry-run 2>&1 51 + Would remove: upstream 52 + 53 + The subtree and sources.toml entry are still intact: 54 + 55 + $ test -d upstream && echo "subtree still present" 56 + subtree still present 57 + $ grep -c "^\[upstream\]" sources.toml 58 + 1 59 + 60 + Real removal 61 + ------------- 62 + 63 + $ monopam remove upstream 2>&1 | grep -E "^Removed|^Updated" 64 + Updated sources.toml 65 + Removed upstream 66 + 67 + The subtree directory is gone and the sources.toml entry has been 68 + deleted. The only untracked files are the pre-existing dune-project 69 + and root.opam regenerated by the earlier add: 70 + 71 + $ test -d upstream || echo "subtree removed" 72 + subtree removed 73 + $ grep -c "^\[upstream\]" sources.toml || true 74 + 0 75 + $ git status --porcelain | sort 76 + ?? dune-project 77 + ?? root.opam
+89
test/status.t/run.t
··· 1 + monopam status 2 + =============== 3 + 4 + Status on a real workspace. After a successful push, `monopam status` 5 + should report the workspace as fully in sync: the count of totals 6 + equals the count of synced, no actionable items, no dirty packages. 7 + 8 + Setup 9 + ----- 10 + 11 + $ export NO_COLOR=1 12 + $ export GIT_AUTHOR_NAME="Alice" 13 + $ export GIT_AUTHOR_EMAIL="alice@example.com" 14 + $ export GIT_AUTHOR_DATE="2025-01-01T00:00:00+00:00" 15 + $ export GIT_COMMITTER_NAME="Alice" 16 + $ export GIT_COMMITTER_EMAIL="alice@example.com" 17 + $ export GIT_COMMITTER_DATE="2025-01-01T00:00:00+00:00" 18 + $ export HOME="$PWD/home" 19 + $ mkdir -p "$HOME" 20 + $ export GIT_CONFIG_GLOBAL="$HOME/.gitconfig" 21 + $ printf '[init]\n\tdefaultBranch = main\n[user]\n\tname = Alice\n\temail = alice@example.com\n' > "$GIT_CONFIG_GLOBAL" 22 + $ TROOT=$(pwd) 23 + 24 + Create an empty upstream bare repo. Using an empty upstream means the 25 + first push establishes the chain, so the workspace ends in a fully 26 + synced state. 27 + 28 + $ git init -q --bare upstream.git 29 + 30 + Create the opam-repo overlay registering one package `lib`: 31 + 32 + $ mkdir -p opam-repo/packages/lib/lib.dev 33 + $ cat > opam-repo/packages/lib/lib.dev/opam << OPAM 34 + > opam-version: "2.0" 35 + > name: "lib" 36 + > version: "dev" 37 + > synopsis: "A status test library" 38 + > dev-repo: "git+file://$TROOT/upstream.git" 39 + > OPAM 40 + $ cd opam-repo && git init -q && git add . && git commit -q -m "init" && cd "$TROOT" 41 + 42 + Configure monopam: 43 + 44 + $ mkdir -p "$HOME/.config/monopam" 45 + $ cat > "$HOME/.config/monopam/opamverse.toml" << EOF 46 + > [workspace] 47 + > root = "$TROOT" 48 + > [identity] 49 + > handle = "alice.example.org" 50 + > knot = "git.example.org" 51 + > EOF 52 + 53 + Create the monorepo with the package content, commit, and push so the 54 + checkout and upstream are in a known-good state: 55 + 56 + $ mkdir -p mono/upstream/lib && cd mono 57 + $ git init -q 58 + $ cat > upstream/dune-project << 'DUNE' 59 + > (lang dune 3.0) 60 + > (name lib) 61 + > DUNE 62 + $ cat > upstream/lib.opam << OPAM 63 + > opam-version: "2.0" 64 + > name: "lib" 65 + > version: "dev" 66 + > synopsis: "A status test library" 67 + > dev-repo: "git+file://$TROOT/upstream.git" 68 + > OPAM 69 + $ echo "let x = 1" > upstream/lib/main.ml 70 + $ git add . && git commit -q -m "initial" 71 + $ monopam push lib > /dev/null 2>&1 72 + 73 + Fully synced workspace 74 + ----------------------- 75 + 76 + $ monopam status 2>&1 | grep -E "Packages:|local:|remote:" | head -5 77 + Packages: 1 total, all synced 78 + 79 + After a new local commit, status should flag the repo as needing a push. 80 + The exact format depends on the workspace's local/remote state; the 81 + test asserts the summary line changes and an actionable row appears. 82 + 83 + $ export GIT_AUTHOR_DATE="2025-01-02T00:00:00+00:00" 84 + $ export GIT_COMMITTER_DATE="2025-01-02T00:00:00+00:00" 85 + $ echo "let y = 2" >> upstream/lib/main.ml 86 + $ git add -A && git commit -q -m "add y" 87 + $ monopam status 2>&1 | grep -E "Packages:|lib" | head -3 88 + Packages: 1 total, 0 synced, 1 local sync 89 + lib local:sync