Monorepo management for opam overlays
0
fork

Configure Feed

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

monopam: bootstrap UX overhaul — clone command, auto git-init, ref pinning

Onboarding was rough: hand-write sources.toml, git init, hope init
commits the imports (it didn't, due to a stale-index bug in
Root.regenerate's commit_index path that silently dropped subtrees
from HEAD). This adds `monopam clone <url> --handle <you>`, makes
init auto git-init, and fixes Deps.run to pin resolved SHAs into
sources.toml + actually commit imported subtrees. Also drops the
legacy `monopam add <mono.lock>` path and the Mono_lock module.

Clone specifics:

- Auto-detects the remote's default branch via ls-remote --symref so
master/trunk repos don't need --branch.
- Renames cloned origin → upstream so a bare git push won't publish
to the source repo; prints a hint about adding own origin.
- Strips the cloned sources.toml's origin field so a later
`monopam init --handle <missing>` can't silently re-derive the
original author's identity for the new owner.
- Falls through to bootstrap on existing target dir (idempotent
re-run after interrupted clone).

Init specifics:

- ensure_git_repo creates the dir + git init + empty initial commit
so subtree.add has a HEAD to graft on.
- Smart Next: hint based on Deps.stats: `dune build && dune test`
after a real bootstrap, the old add/pull hint when nothing was
imported.

Output hygiene:

- Verse and Deps log lines lost the hardcoded `[init]` prefix; the
calling command tags its own lines via a ~prefix arg on
Init.workspace_setup. No more `[init] subtree foo:` leaking out
of `monopam clone`.
- Init and clone both print `[<cmd>] target: <path>` (was inconsistent
`root:` vs `target:`).
- Concise git-clone errors: extract git's actual `fatal:` line
instead of pp_error's wrapping `Command failed: ... (exit 128)
stdout: ... stderr: ...`.

Help and docs:

- Top-level QUICK START leads with `monopam clone` for onboarding,
then `monopam init` for from-scratch.
- TYPICAL SESSION uses `git add <paths>` instead of `git add -A`,
matching project doctrine.
- Generated CLAUDE.md leads with an imperative pointer to
.claude/CLAUDE.md (project doctrine), replacing a passive note
that contradicted itself about committed-vs-gitignored.

Tests: each scenario gets its own cram file, no mixed concerns.

bootstrap.t — hand-written sources.toml → init bootstraps
clone.t — thin remote (sources.toml only) onboarding
clone_fat.t — fat remote (subtrees pre-committed)
clone_again.t — re-running clone is idempotent
clone_master_branch.t — auto-detect non-main default branch
init.t — empty workspace (existing test, updated for
target: + auto git-init)

+1038 -498
+34 -35
bin/cmd_add.ml
··· 1 1 open Cmdliner 2 2 3 - (** Parse the positional argument. A [.lock] suffix always means lock file; a 4 - URL-ish string is a direct Git_url; anything else is treated as an opam 5 - package name to be resolved against the local overlay or [opam show]. *) 3 + (** Parse the positional argument. A URL-ish string is a direct Git_url; 4 + anything else is treated as an opam package name to be resolved against the 5 + local overlay or [opam show]. *) 6 6 type parsed = Parsed of Monopam.Import.source | Opam_name of string 7 7 8 8 let parse_source ~path source = 9 - if String.ends_with ~suffix:".lock" source then 10 - Parsed (Monopam.Import.Lock_file (Fpath.v source)) 9 + (* Split opam URL syntax: URL#ref *) 10 + let split_at_hash s = 11 + match String.rindex_opt s '#' with 12 + | Some i -> 13 + let u = String.sub s 0 i in 14 + let r = String.sub s (i + 1) (String.length s - i - 1) in 15 + (u, Some r) 16 + | None -> (s, None) 17 + in 18 + if Monopam.Import.looks_like_url source then 19 + let url, ref_ = split_at_hash source in 20 + Parsed (Monopam.Import.Git_url { url; branch = None; ref_; path }) 11 21 else 12 - (* Split opam URL syntax: URL#ref *) 13 - let split_at_hash s = 14 - match String.rindex_opt s '#' with 15 - | Some i -> 16 - let u = String.sub s 0 i in 17 - let r = String.sub s (i + 1) (String.length s - i - 1) in 18 - (u, Some r) 19 - | None -> (s, None) 20 - in 21 - if Monopam.Import.looks_like_url source then 22 - let url, ref_ = split_at_hash source in 23 - Parsed (Monopam.Import.Git_url { url; branch = None; ref_; path }) 24 - else 25 - (* Plain opam package name. Defer resolution to the main runner 26 - where we have access to [fs] and a process manager. *) 27 - let name, _ref = split_at_hash source in 28 - Opam_name name 22 + (* Plain opam package name. Defer resolution to the main runner 23 + where we have access to [fs] and a process manager. *) 24 + let name, _ref = split_at_hash source in 25 + Opam_name name 29 26 30 27 (** Best-effort: find the opam-repo directory associated with the current 31 28 workspace, so we can resolve package names against the local overlay before ··· 103 100 `S "SOURCE FORMATS"; 104 101 `P "The SOURCE argument can be:"; 105 102 `I ("Git URL", "A git repository URL (opam syntax: URL#ref)"); 106 - `I ("Lock file", "A mono.lock file path (adds all entries, legacy)"); 103 + `I 104 + ( "Opam package name", 105 + "Resolved via the local opam-repo overlay or $(b,opam show)" ); 107 106 `S "URL PARSING (opam syntax)"; 108 107 `P "Git URLs support the opam URL syntax with optional ref:"; 109 108 `I ("- ", "https://github.com/mirage/eio.git → ref=main"); 110 109 `I ("- ", "https://github.com/ocaml/dune.git#v3.17.0 → ref=v3.17.0"); 111 110 `I ("- ", "git+https://github.com/foo/bar#main → ref=main"); 112 - `S "LOCK FILE"; 111 + `S "SOURCES REGISTRY"; 113 112 `P 114 113 "When you add a package, monopam creates or updates sources.toml in the \ 115 - current directory. This file records:"; 116 - `I ("- ", "The source URL of each added package"); 117 - `I ("- ", "The exact commit SHA that was added"); 118 - `P 119 - "You can use a lock file as SOURCE to re-add all packages, ensuring \ 120 - reproducible builds."; 114 + current directory. Each entry records the source URL, branch/ref, and \ 115 + the exact commit SHA that was imported. Bootstrap a fresh workspace \ 116 + from a populated sources.toml with $(b,monopam init), or clone an \ 117 + existing workspace and bootstrap in one step with $(b,monopam clone)."; 121 118 `S Manpage.s_examples; 122 119 `Pre "# From git URL (opam syntax: URL#ref)"; 123 120 `Pre "monopam add https://github.com/mirage/eio.git"; ··· 125 122 `Pre "monopam add https://github.com/ocaml/dune.git#v3.17.0"; 126 123 `Pre "monopam add https://github.com/ocaml/dune.git#main"; 127 124 `Pre ""; 128 - `Pre "# From lock file (adds all entries)"; 129 - `Pre "monopam add mono.lock"; 125 + `Pre "# From opam package name (resolved via overlay or `opam show`)"; 126 + `Pre "monopam add eio"; 130 127 `S Manpage.s_see_also; 131 - `P "$(b,monopam remove)(1), $(b,monopam publish)(1)"; 128 + `P 129 + "$(b,monopam init)(1), $(b,monopam clone)(1), $(b,monopam remove)(1), \ 130 + $(b,monopam publish)(1)"; 132 131 ] 133 132 134 133 let cmd = 135 - let doc = "Add a package from a git URL or lock file" in 134 + let doc = "Add a package from a git URL or opam name" in 136 135 let info = Cmd.info "add" ~doc ~man in 137 136 let source_arg = 138 - let doc = "Git URL (opam syntax: URL#ref) or path to mono.lock file." in 137 + let doc = "Git URL (opam syntax: URL#ref) or opam package name." in 139 138 Arg.(required & pos 0 (some string) None & info [] ~docv:"SOURCE" ~doc) 140 139 in 141 140 let dir_arg =
+225
bin/cmd_clone.ml
··· 1 + open Cmdliner 2 + 3 + let url_arg = 4 + let doc = "Git URL of an existing monopam workspace to clone." in 5 + Arg.(required & pos 0 (some string) None & info [] ~docv:"URL" ~doc) 6 + 7 + let dir_arg = 8 + let doc = 9 + "Local directory to clone into. Defaults to the repository name derived \ 10 + from the URL." 11 + in 12 + Arg.(value & pos 1 (some string) None & info [] ~docv:"DIR" ~doc) 13 + 14 + let handle_arg = 15 + let doc = 16 + "Your handle (e.g., alice.bsky.social). Required: clone always sets \ 17 + identity from this flag, never inherits the cloned workspace's origin \ 18 + field — that field belongs to the original author." 19 + in 20 + Arg.( 21 + required & opt (some string) None & info [ "handle" ] ~docv:"HANDLE" ~doc) 22 + 23 + let branch_arg = 24 + let doc = 25 + "Branch to clone. Default: detect the remote's HEAD branch (handles repos \ 26 + that default to master, trunk, etc.) and fall back to main if detection \ 27 + fails." 28 + in 29 + Arg.( 30 + value & opt (some string) None & info [ "branch"; "b" ] ~docv:"BRANCH" ~doc) 31 + 32 + let dry_run_arg = 33 + let doc = "Show what would be done without making changes." in 34 + Arg.(value & flag & info [ "dry-run" ] ~doc) 35 + 36 + let dir_exists ~fs path = 37 + let eio_path = Eio.Path.(fs / Fpath.to_string path) in 38 + match Eio.Path.kind ~follow:true eio_path with 39 + | `Directory -> true 40 + | _ | (exception _) -> false 41 + 42 + (** Concise error message for [git clone] failures. The default 43 + {!Monopam.Git_cli.pp_error} prints the full command line, exit code, stdout, 44 + and stderr — fine for trace-level diagnostics, noisy for the canonical 45 + "branch not found" or "auth failed" cases. Prefer the [fatal:] line (git's 46 + actual error message) over generic preamble like "Cloning into '...'". *) 47 + let concise_git_error (e : Monopam.Git_cli.error) = 48 + match e with 49 + | Command_failed (_, r) -> ( 50 + let lines = 51 + String.split_on_char '\n' r.stderr 52 + |> List.filter_map (fun s -> 53 + let t = String.trim s in 54 + if t = "" then None else Some t) 55 + in 56 + let fatal = List.find_opt (String.starts_with ~prefix:"fatal: ") lines in 57 + match fatal with 58 + | Some line -> line 59 + | None -> ( 60 + match lines with 61 + | last :: _ -> List.fold_left (fun _ x -> x) last lines 62 + | [] -> Fmt.str "git exited %d with no stderr" r.exit_code)) 63 + | other -> Fmt.str "%a" Monopam.Git_cli.pp_error other 64 + 65 + (** Rename the cloned git remote from [origin] to [upstream]. After [git clone], 66 + git points [origin] at the URL we cloned from — i.e. the source repository. 67 + A bare [git push] would then publish the new owner's commits back to the 68 + source repo (or fail with an auth error). Renaming makes the relationship 69 + explicit: [upstream] is where we fetch updates from; the new owner sets 70 + their own [origin] to publish to. *) 71 + let rename_origin_to_upstream ~sw ~fs ~target = 72 + let repo = Git.Repository.open_repo ~sw ~fs target in 73 + match Git.Repository.remote_url repo "origin" with 74 + | None -> () 75 + | Some url -> ( 76 + match Git.Repository.remove_remote repo "origin" with 77 + | Error (`Msg e) -> Fmt.pr "[clone] could not remove origin: %s@." e 78 + | Ok () -> ( 79 + match Git.Repository.add_remote repo ~name:"upstream" ~url () with 80 + | Error (`Msg e) -> Fmt.pr "[clone] could not add upstream: %s@." e 81 + | Ok () -> Fmt.pr "[clone] renamed git remote origin -> upstream@.")) 82 + 83 + (** Strip the original author's [origin] field from the cloned [sources.toml]. 84 + The field would otherwise be silently picked up by a later [monopam init] 85 + (no --handle) and applied as the new owner's identity. *) 86 + let strip_cloned_origin ~fs ~target = 87 + let sources_path = Fpath.(target / "sources.toml") in 88 + match Monopam.Sources_registry.load ~fs sources_path with 89 + | Error _ -> () 90 + | Ok sources -> ( 91 + match Monopam.Sources_registry.origin sources with 92 + | None -> () 93 + | Some _ -> ( 94 + let stripped = Monopam.Sources_registry.without_origin sources in 95 + match Monopam.Sources_registry.save ~fs sources_path stripped with 96 + | Ok () -> () 97 + | Error e -> Fmt.pr "[clone] strip origin: %s@." e)) 98 + 99 + let run url dir handle branch dry_run () = 100 + let t0 = Unix.gettimeofday () in 101 + Eio_main.run @@ fun env -> 102 + let fs = Eio.Stdenv.fs env in 103 + let proc = Eio.Stdenv.process_mgr env in 104 + Eio.Switch.run @@ fun sw -> 105 + let target = 106 + match dir with 107 + | Some d -> Fpath.v d 108 + | None -> Fpath.v (Monopam.Import.repo_name_from_url url) 109 + in 110 + let abs_target = 111 + if Fpath.is_abs target then target 112 + else Fpath.(v (Sys.getcwd ()) // target |> normalize) 113 + in 114 + Fmt.pr "[clone] target: %a@." Fpath.pp abs_target; 115 + Fmt.pr "[clone] url: %s@." url; 116 + (* Strip the opam-style [git+] prefix before invoking [git clone]: git 117 + itself doesn't understand that scheme prefix. *) 118 + let clone_url = 119 + if String.starts_with ~prefix:"git+" url then 120 + String.sub url 4 (String.length url - 4) 121 + else url 122 + in 123 + let already_cloned = dir_exists ~fs abs_target in 124 + let clone_step = 125 + if already_cloned then begin 126 + Fmt.pr "[clone] target exists; running bootstrap on existing checkout@."; 127 + Ok () 128 + end 129 + else 130 + let resolved_branch = 131 + match branch with 132 + | Some b -> b 133 + | None -> ( 134 + match Monopam.Git_cli.default_branch ~proc ~fs ~url:clone_url with 135 + | Some b -> 136 + Fmt.pr "[clone] branch: %s (auto-detected)@." b; 137 + b 138 + | None -> "main") 139 + in 140 + match 141 + Monopam.Git_cli.clone ~proc ~fs ~url:clone_url ~branch:resolved_branch 142 + abs_target 143 + with 144 + | Ok () -> Ok () 145 + | Error e -> Error (Fmt.str "git clone failed: %s" (concise_git_error e)) 146 + in 147 + match clone_step with 148 + | Error msg -> 149 + let hint = 150 + "Check that the URL is a git repository you can reach. Use --branch to \ 151 + clone a non-default branch." 152 + in 153 + Common.fail_ctx (Monopam.Ctx.err ~hint msg) 154 + | Ok () -> 155 + Fmt.pr "[clone] handle: %s@." handle; 156 + if not already_cloned then 157 + rename_origin_to_upstream ~sw ~fs ~target:abs_target; 158 + strip_cloned_origin ~fs ~target:abs_target; 159 + let stats = 160 + Monopam.Init.workspace_setup ~sw ~proc ~fs ~target:abs_target ~handle 161 + ~prefix:"[clone]" ~dry_run () 162 + in 163 + let elapsed = Unix.gettimeofday () -. t0 in 164 + let next_step = 165 + Fmt.str "cd %a && dune build && dune test" Fpath.pp target 166 + in 167 + let banner = 168 + if already_cloned then "Workspace re-bootstrapped." 169 + else if stats.imported > 0 then "Workspace cloned and bootstrapped." 170 + else "Workspace cloned." 171 + in 172 + Common.print_success ~elapsed ~next_step banner; 173 + if not already_cloned then 174 + Fmt.pr 175 + " Hint: 'origin' was renamed to 'upstream' (the source \ 176 + repo).@. Add your own remote when ready: git remote add \ 177 + origin <your-url>@."; 178 + `Ok () 179 + 180 + let man = 181 + [ 182 + `S Manpage.s_description; 183 + `P 184 + "Clone an existing monopam workspace and bootstrap it. Equivalent to \ 185 + $(b,git clone) followed by $(b,monopam init): the cloned repository's \ 186 + $(i,sources.toml) drives import of any subtrees that aren't already \ 187 + committed in the cloned history."; 188 + `P 189 + "Clone always uses $(b,--handle) for identity — it does not inherit the \ 190 + cloned $(i,sources.toml)'s $(b,origin) field, which records the \ 191 + original author and would otherwise leak into the new owner's \ 192 + workspace. The original $(b,origin) is stripped from the local \ 193 + $(i,sources.toml) on clone."; 194 + `P 195 + "Re-running $(b,monopam clone) on an existing target directory falls \ 196 + through to the bootstrap step on the existing checkout — useful for \ 197 + refreshing a workspace whose subtree directories were partially \ 198 + removed, or for resuming an interrupted clone."; 199 + `P 200 + "Clone renames the git remote from $(b,origin) to $(b,upstream), so a \ 201 + bare $(b,git push) won't accidentally publish your commits to the \ 202 + source repository. Add your own remote when ready: $(b,git remote add \ 203 + origin <your-url>)."; 204 + `S Manpage.s_examples; 205 + `Pre "# Clone a teammate's monorepo and bootstrap"; 206 + `Pre 207 + "monopam clone https://tangled.org/alice.bsky.social/mono.git --handle \ 208 + bob.bsky.social"; 209 + `Pre ""; 210 + `Pre "# Clone into a specific directory"; 211 + `Pre 212 + "monopam clone https://tangled.org/alice.bsky.social/mono.git mono \ 213 + --handle bob.bsky.social"; 214 + `S Manpage.s_see_also; 215 + `P "$(b,monopam init)(1), $(b,monopam pull)(1)"; 216 + ] 217 + 218 + let cmd = 219 + let doc = "Clone a monopam workspace and bootstrap subtrees" in 220 + let info = Cmd.info "clone" ~doc ~man in 221 + Cmd.v info 222 + Term.( 223 + ret 224 + (const run $ url_arg $ dir_arg $ handle_arg $ branch_arg $ dry_run_arg 225 + $ Common.logging_term))
+19 -28
bin/cmd_init.ml
··· 64 64 let target = 65 65 match root with Some r -> r | None -> Fpath.v (Sys.getcwd ()) 66 66 in 67 - Fmt.pr "[init] root: %a@." Fpath.pp target; 67 + Fmt.pr "[init] target: %a@." Fpath.pp target; 68 68 (* Step 1: Resolve identity *) 69 69 match resolve_handle ~fs handle target with 70 70 | Error msg -> ··· 75 75 Common.fail_ctx (Monopam.Ctx.err ~hint msg) 76 76 | Ok resolved_handle -> 77 77 Fmt.pr "[init] handle: %s@." resolved_handle; 78 - (* Step 2: Verse workspace setup (idempotent) *) 79 - (match 80 - Monopam.Verse.init ~sw ~proc ~fs ~root:target ~handle:resolved_handle 81 - () 82 - with 83 - | Ok () -> () 84 - | Error e -> 85 - Fmt.pr "[init] verse: skipped (%a)@." Monopam.Verse.pp_error e); 86 - (* Step 3: Bootstrap subtrees from sources.toml *) 87 - (match Monopam.Deps.run ~sw ~proc ~fs ~target ~dry_run () with 88 - | Ok () -> () 89 - | Error e -> Fmt.pr "[init] bootstrap: %s@." e); 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 files (dune-project, README, llms, CLAUDE) *) 96 - let (_ : string list) = 97 - Monopam.Root.regenerate ~sw ~fs ~monorepo:target () 78 + let stats = 79 + Monopam.Init.workspace_setup ~sw ~proc ~fs ~target 80 + ~handle:resolved_handle ~prefix:"[init]" ~dry_run () 98 81 in 99 82 let elapsed = Unix.gettimeofday () -. t0 in 100 - Common.print_success ~elapsed 101 - ~next_step:"monopam add <git-url> # or: monopam pull" 102 - "Workspace initialized."; 83 + let next_step = 84 + if stats.imported > 0 then "dune build && dune test" 85 + else "monopam add <git-url> # or: monopam pull" 86 + in 87 + Common.print_success ~elapsed ~next_step "Workspace initialized."; 103 88 `Ok () 104 89 105 90 let man = ··· 114 99 "Uses --handle if provided, otherwise derives handle from sources.toml \ 115 100 origin URL." ); 116 101 `I 117 - ( "2. Verse workspace setup", 102 + ( "2. Initialize git repository", 103 + "Creates the target directory if missing and runs $(b,git init) plus \ 104 + an initial empty commit, so users can `mkdir mono && monopam init` \ 105 + without ceremony." ); 106 + `I 107 + ( "3. Verse workspace setup", 118 108 "Creates verse config, clones registry and repos. Skipped if already \ 119 109 configured." ); 120 110 `I 121 - ( "3. Bootstrap subtrees", 111 + ( "4. Bootstrap subtrees", 122 112 "Imports any subtrees listed in sources.toml whose directories don't \ 123 - exist on disk." ); 113 + exist on disk. The resolved commit SHAs are pinned back into \ 114 + sources.toml for reproducibility." ); 124 115 `I 125 - ( "4. Regenerate root metadata", 116 + ( "5. Regenerate root metadata", 126 117 "Scans all subtrees for .opam files and regenerates dune-project with \ 127 118 external dependencies." ); 128 119 `S Manpage.s_examples;
+13 -3
bin/main.ml
··· 17 17 configured remotes — monopam never pushes to someone else's canonical \ 18 18 repo."; 19 19 `S "QUICK START"; 20 - `P "Initialize a new workspace:"; 20 + `P "Onboard onto a teammate's monorepo:"; 21 + `Pre 22 + "monopam clone https://example.org/teammate/mono.git --handle \ 23 + yourname.bsky.social"; 24 + `P "Or initialize a fresh workspace from scratch:"; 21 25 `Pre "monopam init --handle yourname.bsky.social"; 22 26 `P "Check the status of your packages:"; 23 27 `Pre "monopam status"; ··· 27 31 `Pre "monopam push"; 28 32 `S "CORE WORKFLOW"; 29 33 `P "Commands match the git mental model:"; 34 + `I 35 + ( "$(b,monopam clone)", 36 + "Clone a teammate's workspace and bootstrap subtrees" ); 37 + `I ("$(b,monopam init)", "Initialize or refresh a workspace"); 30 38 `I ("$(b,monopam add)", "Add a package (subtree) from a git URL or name"); 31 39 `I ("$(b,monopam remove)", "Remove a package from the project"); 32 40 `I ("$(b,monopam pull)", "Fetch and merge upstream changes into mono/"); ··· 45 53 vim mono/eio/lib/core.ml\n\n\ 46 54 # Build and test\n\ 47 55 dune build && dune test\n\n\ 48 - # Commit\n\ 49 - git add -A && git commit -m \"Add feature\"\n\n\ 56 + # Commit (stage explicit paths, not `git add -A` —\n\ 57 + \ # other sessions may have staged unrelated work)\n\ 58 + git add eio/lib/core.ml && git commit -m \"Add feature\"\n\n\ 50 59 # Push to your remotes\n\ 51 60 monopam push"; 52 61 `S "VERSE COLLABORATION"; ··· 69 78 Cmd_status.cmd; 70 79 Cmd_diff.cmd; 71 80 Cmd_init.cmd; 81 + Cmd_clone.cmd; 72 82 Cmd_clean.cmd; 73 83 Cmd_add.cmd; 74 84 Cmd_remove.cmd;
+48 -11
lib/deps.ml
··· 4 4 5 5 module Log = (val Logs.src_log src : Logs.LOG) 6 6 7 + type stats = { imported : int; skipped : int } 8 + 7 9 let dir_exists ~fs path = 8 10 let eio_path = Eio.Path.(fs / Fpath.to_string path) in 9 11 match Eio.Path.kind ~follow:true eio_path with 10 12 | `Directory -> true 11 13 | _ -> false 12 14 | exception _ -> false 15 + 16 + type outcome = Skipped | Imported of { commit : string } 13 17 14 18 let import_entry ~sw ~proc ~fs ~target ~dry_run name 15 19 (entry : Sources_registry.entry) = 16 20 let prefix_path = Fpath.(target / name) in 17 21 if dir_exists ~fs prefix_path then begin 18 - Log.app (fun m -> m "[init] subtree %s: already exists, skipping" name); 19 - Ok () 22 + Log.app (fun m -> m "subtree %s: already exists, skipping" name); 23 + Ok Skipped 20 24 end 21 25 else begin 22 26 let url = entry.source in 23 27 let branch = entry.branch in 24 28 let ref_ = entry.ref_ in 25 29 let path = entry.path in 26 - Log.app (fun m -> m "[init] subtree %s: importing from %s" name url); 30 + Log.app (fun m -> m "subtree %s: importing from %s" name url); 27 31 match 28 32 Import.git_url ~sw ~proc ~fs ~target ~url ~branch ~ref_ ?path 29 33 ~name:(Some name) ~dry_run () 30 34 with 31 - | Ok _result -> Ok () 35 + | Ok result -> Ok (Imported { commit = result.commit }) 32 36 | Error e -> 33 37 Log.warn (fun m -> m "Failed to import %s: %s" name e); 34 38 Error e 35 39 end 36 40 41 + let pin_ref sources name commit = 42 + match List.assoc_opt name (Sources_registry.to_list sources) with 43 + | None -> sources 44 + | Some entry -> 45 + let entry = { entry with Sources_registry.ref_ = Some commit } in 46 + Sources_registry.add sources ~subtree:name entry 47 + 37 48 let run ~sw ~proc ~fs ~target ~dry_run () = 38 49 let sources_path = Fpath.(target / "sources.toml") in 39 50 let sources = ··· 42 53 | Error _ -> Sources_registry.empty 43 54 in 44 55 let entries = Sources_registry.to_list sources in 45 - (* Step 3: Import missing subtrees *) 56 + let outcomes = 57 + List.map 58 + (fun (name, entry) -> 59 + (name, import_entry ~sw ~proc ~fs ~target ~dry_run name entry)) 60 + entries 61 + in 46 62 let errors = 47 63 List.filter_map 48 - (fun (name, entry) -> 49 - match import_entry ~sw ~proc ~fs ~target ~dry_run name entry with 50 - | Ok () -> None 51 - | Error e -> Some (Fmt.str "%s: %s" name e)) 52 - entries 64 + (fun (name, r) -> 65 + match r with Ok _ -> None | Error e -> Some (Fmt.str "%s: %s" name e)) 66 + outcomes 67 + in 68 + let imported, skipped = 69 + List.fold_left 70 + (fun (i, s) (_, r) -> 71 + match r with 72 + | Ok (Imported _) -> (i + 1, s) 73 + | Ok Skipped -> (i, s + 1) 74 + | Error _ -> (i, s)) 75 + (0, 0) outcomes 53 76 in 54 - if errors = [] then Ok () else Error (String.concat "\n" errors) 77 + if (not dry_run) && imported > 0 then begin 78 + let pinned = 79 + List.fold_left 80 + (fun acc (name, r) -> 81 + match r with 82 + | Ok (Imported { commit }) -> pin_ref acc name commit 83 + | _ -> acc) 84 + sources outcomes 85 + in 86 + match Sources_registry.save ~fs sources_path pinned with 87 + | Ok () -> () 88 + | Error e -> Log.warn (fun m -> m "Failed to update sources.toml: %s" e) 89 + end; 90 + if errors = [] then Ok { imported; skipped } 91 + else Error (String.concat "\n" errors)
+12 -6
lib/deps.mli
··· 3 3 Loads [sources.toml], imports any subtrees whose directories are missing on 4 4 disk, then regenerates [dune-project] with external dependencies. *) 5 5 6 + type stats = { 7 + imported : int; (** Subtrees newly imported in this run. *) 8 + skipped : int; (** Subtrees whose directory already existed. *) 9 + } 10 + 6 11 val run : 7 12 sw:Eio.Switch.t -> 8 13 proc:_ Eio.Process.mgr -> ··· 10 15 target:Fpath.t -> 11 16 dry_run:bool -> 12 17 unit -> 13 - (unit, string) result 18 + (stats, string) result 14 19 (** [run ~sw ~proc ~fs ~target ~dry_run ()] brings the monorepo to a consistent 15 - state: 20 + state by importing every subtree listed in [sources.toml] whose directory 21 + does not yet exist on disk. After each successful import, the resolved 22 + commit SHA is written back to [sources.toml] so subsequent bootstraps from 23 + the same file are reproducible. 16 24 17 - 1. Loads [sources.toml] from [target]. If absent, does nothing. 2. For each 18 - entry whose subtree directory does not exist, imports it via 19 - {!Import.git_url}. 3. Regenerates [dune-project] with external dependencies 20 - from all subtrees. 25 + Returns a count of how many subtrees were imported vs skipped, so callers 26 + can phrase their next-step hint appropriately. 21 27 22 28 Idempotent — existing subtrees are skipped. *)
+15
lib/git_cli.ml
··· 79 79 if result.exit_code = 0 then Ok result.stdout 80 80 else Error (Command_failed (String.concat " " ("git" :: args), result)) 81 81 82 + let default_branch ~proc ~fs ~url = 83 + let cwd = Eio.Path.(fs / "/") in 84 + match run_git_ok ~proc ~cwd [ "ls-remote"; "--symref"; url; "HEAD" ] with 85 + | Error _ -> None 86 + | Ok stdout -> ( 87 + (* First line is "ref: refs/heads/<branch>\tHEAD" when HEAD is a 88 + symbolic ref; absent when HEAD is detached. *) 89 + match String.split_on_char '\n' stdout with 90 + | first :: _ when String.starts_with ~prefix:"ref: refs/heads/" first -> ( 91 + let after = String.sub first 16 (String.length first - 16) in 92 + match String.index_opt after '\t' with 93 + | Some i -> Some (String.sub after 0 i) 94 + | None -> None) 95 + | _ -> None) 96 + 82 97 (** Read user info from global git config (~/.gitconfig). *) 83 98 let global_git_user ~fs () = 84 99 let path = Eio.Path.(Xdg_eio.home_dir fs / ".gitconfig") in
+12
lib/git_cli.mli
··· 48 48 @param branch Branch to checkout (or create for empty remotes). 49 49 @param target Destination directory. *) 50 50 51 + val default_branch : 52 + proc:_ Eio.Process.mgr -> 53 + fs:Eio.Fs.dir_ty Eio.Path.t -> 54 + url:string -> 55 + string option 56 + (** [default_branch ~proc ~fs ~url] queries the remote's symbolic HEAD via 57 + [git ls-remote --symref URL HEAD] and returns the branch name (e.g. 58 + ["main"], ["master"], ["trunk"]). Returns [None] if the remote is 59 + unreachable or HEAD is detached. Used by [monopam clone] to avoid the 60 + [--branch main] hardcode that would fail on repos that default to [master] 61 + or other names. *) 62 + 51 63 val ensure_receive_config : 52 64 proc:_ Eio.Process.mgr -> 53 65 fs:Eio.Fs.dir_ty Eio.Path.t ->
-43
lib/import.ml
··· 29 29 ref_ : string option; 30 30 path : string option; 31 31 } 32 - | Lock_file of Fpath.t 33 32 34 33 type result = { 35 34 name : string; ··· 288 287 do_fetch_and_add ~sw ~proc ~fs ~target ~name ~url ~fetch_url ~path 289 288 ~ref_to_use 290 289 291 - (** Import all entries from a lock file *) 292 - let from_lock ~sw ~proc ~fs ~target ~lock_path ~dry_run = 293 - let lock_dir = Fpath.parent lock_path in 294 - match Mono_lock.load ~fs lock_dir with 295 - | Error e -> Error e 296 - | Ok lock -> 297 - let imports = Mono_lock.to_list lock in 298 - if imports = [] then begin 299 - Log.info (fun m -> m "No imports found in %a" Fpath.pp lock_path); 300 - Ok [] 301 - end 302 - else begin 303 - let results = 304 - List.map 305 - (fun (name, entry) -> 306 - let result = 307 - git_url ~sw ~proc ~fs ~target ~url:entry.Mono_lock.url 308 - ~branch:None ~ref_:(Some entry.Mono_lock.ref_) 309 - ~name:(Some name) ~dry_run () 310 - in 311 - (name, result)) 312 - imports 313 - in 314 - let successes = 315 - List.filter_map 316 - (fun (_, r) -> match r with Ok res -> Some res | Error _ -> None) 317 - results 318 - in 319 - let failures = 320 - List.filter_map 321 - (fun (name, r) -> 322 - match r with Ok _ -> None | Error e -> Some (name, e)) 323 - results 324 - in 325 - List.iter 326 - (fun (name, e) -> 327 - Log.warn (fun m -> m "Failed to import %s: %s" name e)) 328 - failures; 329 - Ok successes 330 - end 331 - 332 290 let stage_and_commit_sources ~sw ~fs ~target ~name = 333 291 let git_repo = Git.Repository.open_repo ~sw ~fs target in 334 292 (* After [Git.Subtree.add] the HEAD commit contains the newly-added ··· 375 333 (** Main import function *) 376 334 let run ~sw ~proc ~fs ~target ~source ~name ~dry_run () = 377 335 match source with 378 - | Lock_file path -> from_lock ~sw ~proc ~fs ~target ~lock_path:path ~dry_run 379 336 | Git_url { url; branch; ref_; path } -> ( 380 337 match 381 338 git_url ~sw ~proc ~fs ~target ~url ~branch ~ref_ ?path ~name ~dry_run ()
+1 -2
lib/import.mli
··· 15 15 subdirectory [p] is materialized as a local subtree. See 16 16 {!Sources_registry.entry.path}. *) 17 17 } 18 - | Lock_file of Fpath.t 19 18 20 19 type result = { 21 20 name : string; (** Directory name of the imported subtree *) ··· 68 67 (** [run ~sw ~proc ~fs ~target ~source ~name ~dry_run ()] imports a git 69 68 repository as a subtree into [target]. 70 69 71 - - [source] specifies either a Git URL or a lock file to import from 70 + - [source] specifies the Git URL (with optional branch/ref/path) to import 72 71 - [name] overrides the default subtree directory name 73 72 - [dry_run] shows what would be imported without making changes 74 73
+64
lib/init.ml
··· 93 93 ignore (Eio.Process.await child)) 94 94 end 95 95 96 + let git_user ~fs () = 97 + match Git_cli.global_git_user ~fs () with 98 + | Some u -> u 99 + | None -> 100 + Git.User.v ~name:"monopam" ~email:"monopam@localhost" 101 + ~date:(Int64.of_float (Unix.time ())) 102 + () 103 + 104 + (** Commit anything left staged after [Root.regenerate] returned no changes. 105 + [bootstrap_files] writes CLAUDE.md/.gitignore unstaged, [add_all] stages 106 + them, but if no root files needed regenerating, [Root.regenerate] never 107 + commits — leaving the workspace with a dirty index. Best-effort: ignore 108 + errors (most commonly "nothing to commit"). *) 109 + let commit_pending ~fs ~repo = 110 + let user = git_user ~fs () in 111 + match 112 + Git.Repository.commit_index repo ~author:user ~committer:user 113 + ~message:"Bootstrap workspace files" () 114 + with 115 + | Ok _ | Error _ -> () 116 + 117 + let ensure_git_repo ~sw ~fs ~target ~prefix = 118 + let target_eio = Eio.Path.(fs / Fpath.to_string target) in 119 + let exists = 120 + match Eio.Path.kind ~follow:true target_eio with 121 + | `Directory -> true 122 + | _ -> false 123 + | exception Eio.Io _ -> false 124 + in 125 + if not exists then Ctx.mkdirs target_eio; 126 + if not (Git.Repository.is_repo ~fs target) then begin 127 + Fmt.pr "%s git init %a@." prefix Fpath.pp target; 128 + let repo = Git.Repository.init ~sw ~fs target in 129 + (* [Git.Subtree.add] needs HEAD to exist — make an empty initial 130 + commit so subsequent imports can graft on top. *) 131 + let user = git_user ~fs () in 132 + match 133 + Git.Repository.commit_index repo ~author:user ~committer:user 134 + ~message:"Initial commit" () 135 + with 136 + | Ok _ | Error _ -> () 137 + end 138 + 139 + let workspace_setup ~sw ~proc ~fs ~target ~handle ~prefix ~dry_run () = 140 + ensure_git_repo ~sw ~fs ~target ~prefix; 141 + (match Verse.init ~sw ~proc ~fs ~root:target ~handle () with 142 + | Ok () -> () 143 + | Error e -> Fmt.pr "%s verse: skipped (%a)@." prefix Verse.pp_error e); 144 + let stats = 145 + match Deps.run ~sw ~proc ~fs ~target ~dry_run () with 146 + | Ok s -> s 147 + | Error e -> 148 + Fmt.pr "%s bootstrap: %s@." prefix e; 149 + { Deps.imported = 0; skipped = 0 } 150 + in 151 + bootstrap_files ~fs ~target; 152 + let repo = Git.Repository.open_repo ~sw ~fs target in 153 + (match Git.Repository.add_all repo with 154 + | Ok () -> () 155 + | Error (`Msg e) -> Fmt.pr "%s stage workspace: %s@." prefix e); 156 + let (_ : string list) = Root.regenerate ~sw ~fs ~monorepo:target () in 157 + commit_pending ~fs ~repo; 158 + stats 159 + 96 160 let ensure ~sw ~proc ~fs ~config = 97 161 let monorepo = Config.Paths.monorepo config in 98 162 let monorepo_eio = Eio.Path.(fs / Fpath.to_string monorepo) in
+26
lib/init.mli
··· 17 17 they don't already exist. Safe to call from [monopam init] before any config 18 18 exists, and idempotent — an existing CLAUDE.md with the current shipped 19 19 content is left alone, and a stale one is overwritten. *) 20 + 21 + val workspace_setup : 22 + sw:Eio.Switch.t -> 23 + proc:_ Eio.Process.mgr -> 24 + fs:Eio.Fs.dir_ty Eio.Path.t -> 25 + target:Fpath.t -> 26 + handle:string -> 27 + prefix:string -> 28 + dry_run:bool -> 29 + unit -> 30 + Deps.stats 31 + (** [workspace_setup ~sw ~proc ~fs ~target ~handle ~prefix ~dry_run ()] runs the 32 + post-identity steps shared by [monopam init] and [monopam clone]: 33 + {{!Verse.init} verse setup}, 34 + {{!Deps.run} subtree bootstrap from [sources.toml]}, {!bootstrap_files}, an 35 + index refresh from the working tree (so [Git.Subtree.add]'s plumbing-only 36 + commits are not silently dropped by the next [commit_index]), and 37 + {{!Root.regenerate} root-file regeneration}. 38 + 39 + [prefix] tags the diagnostic lines emitted by this run (e.g. ["[init]"] or 40 + ["[clone]"]) so the calling command's identity stays consistent in the 41 + output instead of leaking the implementation. 42 + 43 + Returns the {!Deps.stats} from the bootstrap step so callers can phrase 44 + their next-step hint based on how much actually happened. Idempotent. Errors 45 + are printed but not raised. *)
-103
lib/mono_lock.ml
··· 1 - (** Lock file for tracking imported packages. 2 - 3 - Simple line-based format using opam URL syntax: 4 - {v 5 - # mono.lock 6 - ocaml-eio git+https://github.com/mirage/eio.git#main 7 - ocaml-dune git+https://github.com/ocaml/dune.git#v3.17.0 8 - ocaml-logs git+https://github.com/dbuenzli/logs.git#abc123def456 9 - v} 10 - 11 - Format: [<name> <url>#<branch|tag|commit>] Lines starting with # are 12 - comments. *) 13 - 14 - type entry = { 15 - url : string; (** Git URL without fragment *) 16 - ref_ : string; (** Branch, tag, or commit SHA *) 17 - } 18 - 19 - type t = (string * entry) list 20 - 21 - (** {1 Errors} *) 22 - 23 - let err_load exn = 24 - Error (Fmt.str "Error loading mono.lock: %s" (Printexc.to_string exn)) 25 - 26 - (** {1 Operations} *) 27 - 28 - let empty = [] 29 - let find t ~name = List.assoc_opt name t 30 - let add t ~name entry = (name, entry) :: List.remove_assoc name t 31 - let remove t ~name = List.remove_assoc name t 32 - let to_list t = t 33 - let names t = List.map fst t 34 - 35 - (** {1 Parsing} *) 36 - 37 - let parse_url_with_ref url_str = 38 - let uri = Uri.of_string url_str in 39 - let ref_ = Uri.fragment uri in 40 - let url = Uri.with_fragment uri None |> Uri.to_string in 41 - (url, ref_) 42 - 43 - let parse_line line = 44 - let line = String.trim line in 45 - if line = "" || (String.length line > 0 && line.[0] = '#') then None 46 - else 47 - match String.index_opt line ' ' with 48 - | None -> None 49 - | Some i -> ( 50 - let name = String.sub line 0 i in 51 - let url_with_ref = 52 - String.trim (String.sub line (i + 1) (String.length line - i - 1)) 53 - in 54 - let url, ref_ = parse_url_with_ref url_with_ref in 55 - match ref_ with 56 - | Some r -> Some (name, { url; ref_ = r }) 57 - | None -> Some (name, { url; ref_ = "main" })) 58 - (* Default to main *) 59 - 60 - let of_string s = String.split_on_char '\n' s |> List.filter_map parse_line 61 - 62 - (** {1 Serializing} *) 63 - 64 - let entry_to_string name e = Fmt.str "%s %s#%s" name e.url e.ref_ 65 - 66 - let to_string t = 67 - if t = [] then "" 68 - else 69 - let lines = List.map (fun (name, e) -> entry_to_string name e) t in 70 - String.concat "\n" lines ^ "\n" 71 - 72 - (** {1 File Operations} *) 73 - 74 - let lock_filename = "mono.lock" 75 - 76 - let load ~fs dir = 77 - let path = Fpath.(dir / lock_filename) in 78 - let path_str = Fpath.to_string path in 79 - let eio_path = Eio.Path.(fs / path_str) in 80 - match Eio.Path.kind ~follow:true eio_path with 81 - | `Regular_file -> ( 82 - try 83 - let content = Eio.Path.load eio_path in 84 - Ok (of_string content) 85 - with exn -> err_load exn) 86 - | _ -> Ok empty 87 - | exception _ -> Ok empty 88 - 89 - let save ~fs dir t = 90 - let path = Fpath.(dir / lock_filename) in 91 - let eio_path = Eio.Path.(fs / Fpath.to_string path) in 92 - try 93 - Eio.Path.save ~create:(`Or_truncate 0o644) eio_path (to_string t); 94 - Ok () 95 - with exn -> Error (Printexc.to_string exn) 96 - 97 - (** {1 Pretty Printing} *) 98 - 99 - let pp_entry ppf e = Fmt.pf ppf "%s#%s" e.url e.ref_ 100 - 101 - let pp ppf t = 102 - if t = [] then Fmt.pf ppf "(empty)" 103 - else List.iter (fun (name, e) -> Fmt.pf ppf "%s %a@," name pp_entry e) t
-59
lib/mono_lock.mli
··· 1 - (** Lock file for tracking imported packages. 2 - 3 - Simple line-based format using opam URL syntax: 4 - {v 5 - # mono.lock 6 - ocaml-eio git+https://github.com/mirage/eio.git#main 7 - ocaml-dune git+https://github.com/ocaml/dune.git#v3.17.0 8 - ocaml-logs git+https://github.com/dbuenzli/logs.git#abc123def456 9 - v} 10 - 11 - Format: [<name> <url>#<branch|tag|commit>] Lines starting with # are 12 - comments. *) 13 - 14 - type entry = { 15 - url : string; (** Git URL without fragment *) 16 - ref_ : string; (** Branch, tag, or commit SHA *) 17 - } 18 - 19 - type t 20 - (** Lock file contents, indexed by package/subtree name. *) 21 - 22 - val empty : t 23 - (** [empty] is an empty lock file. *) 24 - 25 - val find : t -> name:string -> entry option 26 - (** [find t ~name] looks up the entry for [name]. *) 27 - 28 - val add : t -> name:string -> entry -> t 29 - (** [add t ~name entry] adds or replaces the entry for [name]. *) 30 - 31 - val remove : t -> name:string -> t 32 - (** [remove t ~name] removes the entry for [name]. *) 33 - 34 - val to_list : t -> (string * entry) list 35 - (** [to_list t] returns all entries as [(name, entry)] pairs. *) 36 - 37 - val names : t -> string list 38 - (** [names t] returns the list of package names. *) 39 - 40 - val lock_filename : string 41 - (** [lock_filename] is the default filename: ["mono.lock"]. *) 42 - 43 - val load : fs:Eio.Fs.dir_ty Eio.Path.t -> Fpath.t -> (t, string) result 44 - (** [load ~fs path] reads a lock file from [path]. *) 45 - 46 - val save : fs:Eio.Fs.dir_ty Eio.Path.t -> Fpath.t -> t -> (unit, string) result 47 - (** [save ~fs path t] writes the lock file [t] to [path]. *) 48 - 49 - val of_string : string -> t 50 - (** [of_string s] parses a lock file from its string representation. *) 51 - 52 - val to_string : t -> string 53 - (** [to_string t] serializes the lock file to a string. *) 54 - 55 - val pp_entry : entry Fmt.t 56 - (** [pp_entry] pretty-prints a lock file entry. *) 57 - 58 - val pp : t Fmt.t 59 - (** [pp] pretty-prints the lock file. *)
-1
lib/monopam.ml
··· 26 26 module Opam_sync = Opam_sync 27 27 module Pkg = Pkg 28 28 module Progress = Sync_progress 29 - module Mono_lock = Mono_lock 30 29 module Import = Import 31 30 module Deps = Deps 32 31 module Lint = Lint
-1
lib/monopam.mli
··· 48 48 module Opam_sync = Opam_sync 49 49 module Pkg = Pkg 50 50 module Progress = Sync_progress 51 - module Mono_lock = Mono_lock 52 51 module Import = Import 53 52 module Deps = Deps 54 53 module Lint = Lint
+6 -3
lib/root.ml
··· 31 31 from a separate upstream repository. You edit everything in one tree, 32 32 build with dune, then `pull` and `push` to move changes in and out. 33 33 34 - > **Note:** Project-specific doctrine lives in `.claude/CLAUDE.md` 35 - > (committed, team-shared). User-local overrides belong in a 36 - > gitignored `.claude/CLAUDE.md` that shadows the committed one. 34 + > **READ FIRST — project doctrine: [`.claude/CLAUDE.md`](.claude/CLAUDE.md)** 35 + > 36 + > Coding conventions, library usage patterns, testing discipline, and 37 + > any project-specific rules live there and override the generic 38 + > guidance in this file. Read it before making code changes; what's 39 + > below is just the monopam command surface. 37 40 38 41 ## Quick Reference 39 42
+1
lib/sources_registry.ml
··· 33 33 (* New accessors *) 34 34 let origin t = t.origin 35 35 let with_origin t base = { t with origin = Some base } 36 + let without_origin t = { t with origin = None } 36 37 37 38 (* Backward-compat aliases *) 38 39 let default_url_base t = t.origin
+6
lib/sources_registry.mli
··· 56 56 val with_origin : t -> string -> t 57 57 (** [with_origin t base] sets the origin URL base. *) 58 58 59 + val without_origin : t -> t 60 + (** [without_origin t] removes the origin URL base. Used by [monopam clone] to 61 + strip the original author's origin from the cloned workspace so that a later 62 + [monopam init] without [--handle] does not silently re-derive their identity 63 + for the new owner. *) 64 + 59 65 val default_url_base : t -> string option 60 66 (** @deprecated Use {!origin} instead. *) 61 67
+2 -3
lib/verse.ml
··· 182 182 let config_file = Verse_config.file () in 183 183 Log.info (fun m -> m "Config file: %a" Fpath.pp config_file); 184 184 if is_file ~fs config_file then begin 185 - Log.app (fun m -> m "[init] verse: already configured, skipping"); 185 + Log.app (fun m -> m "verse: already configured, skipping"); 186 186 Ok () 187 187 end 188 188 else ··· 199 199 match Verse_registry.member registry ~handle with 200 200 | None -> ( 201 201 Log.app (fun m -> 202 - m "[init] verse: handle %s not found in registry, skipping" 203 - handle); 202 + m "verse: handle %s not found in registry, skipping" handle); 204 203 (* Save config anyway so init is not re-attempted *) 205 204 match Verse_config.save ~fs config with 206 205 | Error msg ->
+124
test/cram/bootstrap.t/run.t
··· 1 + Scenario: bootstrap from a hand-written sources.toml 2 + ===================================================== 3 + 4 + A user has a list of packages they want to consume — typically copied 5 + from a teammate or written by hand from `llms.txt` — and wants the 6 + matching working monorepo. Workflow: drop a `sources.toml` into a 7 + fresh directory, run `monopam init --handle <you>`. Monopam takes 8 + care of `git init`, imports every listed subtree, commits everything 9 + in one go, and pins the resolved commit SHAs back into `sources.toml` 10 + so the file is reproducible. Re-running `init` is a no-op. 11 + 12 + This test pins that whole flow against a fixture with two upstreams. 13 + 14 + Setup 15 + ----- 16 + 17 + $ export NO_COLOR=1 18 + $ export GIT_AUTHOR_NAME="Alice" 19 + $ export GIT_AUTHOR_EMAIL="alice@example.com" 20 + $ export GIT_AUTHOR_DATE="2025-01-01T00:00:00+00:00" 21 + $ export GIT_COMMITTER_NAME="Alice" 22 + $ export GIT_COMMITTER_EMAIL="alice@example.com" 23 + $ export GIT_COMMITTER_DATE="2025-01-01T00:00:00+00:00" 24 + $ export HOME="$PWD/home" 25 + $ mkdir -p "$HOME" 26 + $ export GIT_CONFIG_GLOBAL="$HOME/.gitconfig" 27 + $ printf '[init]\n\tdefaultBranch = main\n[user]\n\tname = Alice\n\temail = alice@example.com\n' > "$GIT_CONFIG_GLOBAL" 28 + $ TROOT=$(pwd) 29 + 30 + $ for pkg in lib-a lib-b; do 31 + > git init -q --bare "$pkg.git" 32 + > git clone -q "$pkg.git" "$pkg-work" 2>/dev/null 33 + > cd "$pkg-work" 34 + > echo "let name = \"$pkg\"" > "$pkg.ml" 35 + > cat > "$pkg.opam" << OPAM 36 + > opam-version: "2.0" 37 + > name: "$pkg" 38 + > version: "dev" 39 + > synopsis: "Bootstrap fixture: $pkg" 40 + > dev-repo: "git+file://$TROOT/$pkg.git" 41 + > OPAM 42 + > git add . && git commit -q -m "initial $pkg" 43 + > git push -q origin main 2>/dev/null 44 + > cd "$TROOT" 45 + > done 46 + 47 + A fresh workspace, with a hand-written sources.toml that has no 48 + ref pinning yet — typical of a freshly authored manifest: 49 + 50 + $ mkdir mono && cd mono 51 + $ cat > sources.toml << EOF 52 + > [lib-a] 53 + > source = "git+file://$TROOT/lib-a.git" 54 + > [lib-b] 55 + > source = "git+file://$TROOT/lib-b.git" 56 + > EOF 57 + $ grep -c '^ref=' sources.toml || true 58 + 0 59 + 60 + One command imports both subtrees 61 + --------------------------------- 62 + 63 + The directory has no .git yet. Monopam init runs `git init` for the 64 + user, imports every entry in sources.toml, and commits the lot. 65 + 66 + $ monopam init --handle alice.example.org 2>&1 \ 67 + > | sed '/target:/ s|: .*/mono|: <WS>|' \ 68 + > | sed '/git init/ s|/.*/mono|<WS>|' \ 69 + > | sed 's/ at [0-9a-f]*$/ at <SHA>/' \ 70 + > | sed 's|importing from .*/\([a-z-]*\)\.git|importing from <URL>/\1.git|' \ 71 + > | sed '/✓/ s/ (.*$//' \ 72 + > | grep -v '^verse: ' \ 73 + > | grep -v 'Registry clone failed' \ 74 + > | grep -v 'Cloning into' \ 75 + > | grep -v 'fatal: unable to access' \ 76 + > | grep -v '^stdout:' \ 77 + > | grep -v '^stderr:' 78 + [init] target: <WS> 79 + [init] handle: alice.example.org 80 + [init] git init <WS> 81 + subtree lib-a: importing from <URL>/lib-a.git 82 + Imported lib-a at <SHA> 83 + subtree lib-b: importing from <URL>/lib-b.git 84 + Imported lib-b at <SHA> 85 + Regenerated root files: dune-project, README.md, llms.txt 86 + ✓ Workspace initialized. 87 + Next: dune build && dune test 88 + 89 + Working tree contains both subtrees with their upstream content: 90 + 91 + $ cat lib-a/lib-a.ml 92 + let name = "lib-a" 93 + $ cat lib-b/lib-b.ml 94 + let name = "lib-b" 95 + 96 + HEAD contains every file the workspace needs — subtrees, root meta, 97 + CLAUDE.md, .gitignore, sources.toml. `git status` is clean: nothing 98 + left for the user to stage. 99 + 100 + $ git ls-tree HEAD | awk '{print $2, $4}' | sort 101 + blob .gitignore 102 + blob CLAUDE.md 103 + blob README.md 104 + blob dune-project 105 + blob llms.txt 106 + blob sources.toml 107 + tree lib-a 108 + tree lib-b 109 + $ git status --porcelain 110 + 111 + Refs are now pinned in sources.toml — the manifest the user 112 + hand-wrote is reproducible from this point on: 113 + 114 + $ grep -oE 'ref="[0-9a-f]{40}"' sources.toml | wc -l | tr -d ' ' 115 + 2 116 + 117 + Re-running init on a populated workspace is a no-op 118 + --------------------------------------------------- 119 + 120 + $ monopam init --handle alice.example.org 2>&1 \ 121 + > | grep -E '(already exists|importing from)' \ 122 + > | sort 123 + subtree lib-a: already exists, skipping 124 + subtree lib-b: already exists, skipping
+134
test/cram/clone.t/run.t
··· 1 + Scenario: clone a teammate's monorepo (thin remote, sources.toml only) 2 + ======================================================================= 3 + 4 + Bob is onboarding onto Alice's monorepo. Alice published a "thin" 5 + remote: a git repo that ships only `sources.toml` and root metadata, 6 + not committed subtrees. Bob runs `monopam clone <alice-url> --handle 7 + bob` once and gets a fully-bootstrapped workspace. 8 + 9 + Two things this test pins beyond plain bootstrap. First, `--handle` 10 + is required and is the only source of identity — the `origin` field 11 + in Alice's sources.toml records her handle and must not silently leak 12 + into Bob's workspace. Clone strips it. Second, the bootstrap step 13 + pins resolved commit SHAs into Bob's local sources.toml, just like 14 + running `monopam init` does. 15 + 16 + Setup 17 + ----- 18 + 19 + $ export NO_COLOR=1 20 + $ export GIT_AUTHOR_NAME="Alice" 21 + $ export GIT_AUTHOR_EMAIL="alice@example.com" 22 + $ export GIT_AUTHOR_DATE="2025-01-01T00:00:00+00:00" 23 + $ export GIT_COMMITTER_NAME="Alice" 24 + $ export GIT_COMMITTER_EMAIL="alice@example.com" 25 + $ export GIT_COMMITTER_DATE="2025-01-01T00:00:00+00:00" 26 + $ export HOME="$PWD/home" 27 + $ mkdir -p "$HOME" 28 + $ export GIT_CONFIG_GLOBAL="$HOME/.gitconfig" 29 + $ printf '[init]\n\tdefaultBranch = main\n[user]\n\tname = Alice\n\temail = alice@example.com\n' > "$GIT_CONFIG_GLOBAL" 30 + $ TROOT=$(pwd) 31 + 32 + Two upstream libraries: 33 + 34 + $ for pkg in lib-a lib-b; do 35 + > git init -q --bare "$pkg.git" 36 + > git clone -q "$pkg.git" "$pkg-work" 2>/dev/null 37 + > cd "$pkg-work" 38 + > echo "let name = \"$pkg\"" > "$pkg.ml" 39 + > cat > "$pkg.opam" << OPAM 40 + > opam-version: "2.0" 41 + > name: "$pkg" 42 + > version: "dev" 43 + > synopsis: "Clone fixture: $pkg" 44 + > dev-repo: "git+file://$TROOT/$pkg.git" 45 + > OPAM 46 + > git add . && git commit -q -m "initial $pkg" 47 + > git push -q origin main 2>/dev/null 48 + > cd "$TROOT" 49 + > done 50 + 51 + Alice's thin remote — sources.toml carrying her origin field: 52 + 53 + $ git init -q --bare alice-mono.git 54 + $ git clone -q alice-mono.git alice-mono-work 2>/dev/null 55 + $ cd alice-mono-work 56 + $ cat > sources.toml << EOF 57 + > origin = "git+https://example.org/alice.example.org" 58 + > [lib-a] 59 + > source = "git+file://$TROOT/lib-a.git" 60 + > [lib-b] 61 + > source = "git+file://$TROOT/lib-b.git" 62 + > EOF 63 + $ git add sources.toml && git commit -q -m "list packages" 64 + $ git push -q origin main 2>/dev/null 65 + $ cd "$TROOT" 66 + 67 + Bob clones Alice's mono with his own handle 68 + ------------------------------------------- 69 + 70 + $ monopam clone "$TROOT/alice-mono.git" mono --handle bob.example.org 2>&1 \ 71 + > | sed "s|$TROOT|<TROOT>|g" \ 72 + > | sed '/target:/ s|: .*/mono|: <WS>|' \ 73 + > | sed 's/ at [0-9a-f]*$/ at <SHA>/' \ 74 + > | sed 's|importing from .*/\([a-z-]*\)\.git|importing from <URL>/\1.git|' \ 75 + > | sed '/✓/ s/ (.*$//' \ 76 + > | grep -v '^verse: ' \ 77 + > | grep -v 'Registry clone failed' \ 78 + > | grep -v 'Cloning into' \ 79 + > | grep -v 'fatal: unable to access' \ 80 + > | grep -v '^stdout:' \ 81 + > | grep -v '^stderr:' 82 + [clone] target: <WS> 83 + [clone] url: <TROOT>/alice-mono.git 84 + [clone] branch: main (auto-detected) 85 + [clone] handle: bob.example.org 86 + [clone] renamed git remote origin -> upstream 87 + subtree lib-a: importing from <URL>/lib-a.git 88 + Imported lib-a at <SHA> 89 + subtree lib-b: importing from <URL>/lib-b.git 90 + Imported lib-b at <SHA> 91 + Regenerated root files: dune-project, README.md, llms.txt 92 + ✓ Workspace cloned and bootstrapped. 93 + Next: cd mono && dune build && dune test 94 + Hint: 'origin' was renamed to 'upstream' (the source repo). 95 + Add your own remote when ready: git remote add origin <your-url> 96 + 97 + Identity hygiene: Alice's origin must not survive into Bob's local 98 + sources.toml. A future `monopam init --handle <missing>` in Bob's 99 + workspace must not silently re-derive Alice's identity. 100 + 101 + $ cd mono 102 + $ grep -c '^origin=' sources.toml || true 103 + 0 104 + 105 + Refs are pinned. Bob's workspace is reproducible from this 106 + sources.toml going forward. 107 + 108 + $ grep -oE 'ref="[0-9a-f]{40}"' sources.toml | wc -l | tr -d ' ' 109 + 2 110 + 111 + Working tree and HEAD are consistent — clean status, both subtrees 112 + materialized, all root files committed. 113 + 114 + $ cat lib-a/lib-a.ml 115 + let name = "lib-a" 116 + $ git ls-tree HEAD | awk '{print $2, $4}' | sort 117 + blob .gitignore 118 + blob CLAUDE.md 119 + blob README.md 120 + blob dune-project 121 + blob llms.txt 122 + blob sources.toml 123 + tree lib-a 124 + tree lib-b 125 + $ git status --porcelain 126 + 127 + The git remote was renamed: `upstream` points at Alice's repo, 128 + `origin` is unset so a bare `git push` won't accidentally publish 129 + Bob's commits to Alice. Bob configures his own `origin` separately. 130 + 131 + $ git remote 132 + upstream 133 + $ git remote get-url upstream | sed "s|$TROOT|<TROOT>|g" 134 + <TROOT>/alice-mono.git
+86
test/cram/clone_again.t/run.t
··· 1 + Scenario: re-running monopam clone is idempotent 2 + ================================================= 3 + 4 + A user re-runs `monopam clone <url>` on a directory that already 5 + exists — typically because their first clone was interrupted, or 6 + they want to refresh the bootstrap step after manually deleting a 7 + subtree directory. Clone must not fail with "directory exists" from 8 + plain git clone; it falls through to the bootstrap step on the 9 + existing checkout. 10 + 11 + Setup 12 + ----- 13 + 14 + $ export NO_COLOR=1 15 + $ export GIT_AUTHOR_NAME="Alice" 16 + $ export GIT_AUTHOR_EMAIL="alice@example.com" 17 + $ export GIT_AUTHOR_DATE="2025-01-01T00:00:00+00:00" 18 + $ export GIT_COMMITTER_NAME="Alice" 19 + $ export GIT_COMMITTER_EMAIL="alice@example.com" 20 + $ export GIT_COMMITTER_DATE="2025-01-01T00:00:00+00:00" 21 + $ export HOME="$PWD/home" 22 + $ mkdir -p "$HOME" 23 + $ export GIT_CONFIG_GLOBAL="$HOME/.gitconfig" 24 + $ printf '[init]\n\tdefaultBranch = main\n[user]\n\tname = Alice\n\temail = alice@example.com\n' > "$GIT_CONFIG_GLOBAL" 25 + $ TROOT=$(pwd) 26 + 27 + One upstream library and a thin remote: 28 + 29 + $ git init -q --bare lib.git 30 + $ git clone -q lib.git lib-work 2>/dev/null 31 + $ cd lib-work 32 + $ echo "let v = 1" > lib.ml 33 + $ cat > lib.opam << OPAM 34 + > opam-version: "2.0" 35 + > name: "lib" 36 + > version: "dev" 37 + > synopsis: "Idempotent-clone fixture" 38 + > dev-repo: "git+file://$TROOT/lib.git" 39 + > OPAM 40 + $ git add . && git commit -q -m "initial lib" 41 + $ git push -q origin main 2>/dev/null 42 + $ cd "$TROOT" 43 + 44 + $ git init -q --bare alice-mono.git 45 + $ git clone -q alice-mono.git alice-mono-work 2>/dev/null 46 + $ cd alice-mono-work 47 + $ cat > sources.toml << EOF 48 + > [lib] 49 + > source = "git+file://$TROOT/lib.git" 50 + > EOF 51 + $ git add sources.toml && git commit -q -m "list packages" 52 + $ git push -q origin main 2>/dev/null 53 + $ cd "$TROOT" 54 + 55 + First clone — succeeds: 56 + 57 + $ monopam clone "$TROOT/alice-mono.git" mono --handle bob.example.org \ 58 + > > /dev/null 2>&1 59 + $ test -d mono/lib && echo "lib materialized" 60 + lib materialized 61 + 62 + Second clone — falls through to bootstrap on existing dir 63 + --------------------------------------------------------- 64 + 65 + The target directory already exists; plain `git clone` would error 66 + with "destination exists". Monopam recognizes the existing checkout 67 + and runs the bootstrap step on it. Output reports the fall-through 68 + and lib is already present, so the bootstrap is a no-op. 69 + 70 + $ monopam clone "$TROOT/alice-mono.git" mono --handle bob.example.org 2>&1 \ 71 + > | sed "s|$TROOT|<TROOT>|g" \ 72 + > | sed '/target:/ s|: .*/mono|: <WS>|' \ 73 + > | sed '/✓/ s/ (.*$//' \ 74 + > | grep -v '^verse: ' \ 75 + > | grep -v 'Registry clone failed' \ 76 + > | grep -v 'Cloning into' \ 77 + > | grep -v 'fatal: unable to access' \ 78 + > | grep -v '^stdout:' \ 79 + > | grep -v '^stderr:' 80 + [clone] target: <WS> 81 + [clone] url: <TROOT>/alice-mono.git 82 + [clone] target exists; running bootstrap on existing checkout 83 + [clone] handle: bob.example.org 84 + subtree lib: already exists, skipping 85 + ✓ Workspace re-bootstrapped. 86 + Next: cd mono && dune build && dune test
+90
test/cram/clone_fat.t/run.t
··· 1 + Scenario: clone a "fat" monorepo whose subtrees are already committed 2 + ====================================================================== 3 + 4 + Alice publishes her monorepo with every subtree's history committed 5 + in HEAD — the canonical case after she's been working in the 6 + monorepo. Bob runs `monopam clone <alice-url>` and the git clone 7 + itself delivers the entire working tree; the bootstrap step has 8 + nothing to import. Clone must still complete cleanly without trying 9 + to re-import or producing spurious "missing dir" output. 10 + 11 + Setup 12 + ----- 13 + 14 + $ export NO_COLOR=1 15 + $ export GIT_AUTHOR_NAME="Alice" 16 + $ export GIT_AUTHOR_EMAIL="alice@example.com" 17 + $ export GIT_AUTHOR_DATE="2025-01-01T00:00:00+00:00" 18 + $ export GIT_COMMITTER_NAME="Alice" 19 + $ export GIT_COMMITTER_EMAIL="alice@example.com" 20 + $ export GIT_COMMITTER_DATE="2025-01-01T00:00:00+00:00" 21 + $ export HOME="$PWD/home" 22 + $ mkdir -p "$HOME" 23 + $ export GIT_CONFIG_GLOBAL="$HOME/.gitconfig" 24 + $ printf '[init]\n\tdefaultBranch = main\n[user]\n\tname = Alice\n\temail = alice@example.com\n' > "$GIT_CONFIG_GLOBAL" 25 + $ TROOT=$(pwd) 26 + 27 + One upstream library: 28 + 29 + $ git init -q --bare lib.git 30 + $ git clone -q lib.git lib-work 2>/dev/null 31 + $ cd lib-work 32 + $ echo "let v = 1" > lib.ml 33 + $ cat > lib.opam << OPAM 34 + > opam-version: "2.0" 35 + > name: "lib" 36 + > version: "dev" 37 + > synopsis: "Fat-clone fixture" 38 + > dev-repo: "git+file://$TROOT/lib.git" 39 + > OPAM 40 + $ git add . && git commit -q -m "initial lib" 41 + $ git push -q origin main 2>/dev/null 42 + $ cd "$TROOT" 43 + 44 + Alice builds her monorepo by adding the subtree, so the full lib/ 45 + tree is committed in HEAD. The published bare repo carries 46 + everything. 47 + 48 + $ git init -q --bare alice-mono.git 49 + $ mkdir alice-mono-work && cd alice-mono-work && git init -q 50 + $ git remote add origin "$TROOT/alice-mono.git" 51 + $ git commit -q --allow-empty -m "init monorepo" 52 + $ monopam add "$TROOT/lib.git" 2>&1 > /dev/null 53 + $ git push -q origin main 54 + $ cd "$TROOT" 55 + 56 + Bob clones the fat remote 57 + ------------------------- 58 + 59 + The git clone itself delivers the working tree. The bootstrap step 60 + finds no missing directories and skips them. No "subtree X: 61 + importing" lines should appear; clone reports success without an 62 + import count. 63 + 64 + $ monopam clone "$TROOT/alice-mono.git" mono --handle bob.example.org 2>&1 \ 65 + > | sed "s|$TROOT|<TROOT>|g" \ 66 + > | sed '/target:/ s|: .*/mono|: <WS>|' \ 67 + > | sed '/✓/ s/ (.*$//' \ 68 + > | grep -v '^verse: ' \ 69 + > | grep -v 'Registry clone failed' \ 70 + > | grep -v 'Cloning into' \ 71 + > | grep -v 'fatal: unable to access' \ 72 + > | grep -v '^stdout:' \ 73 + > | grep -v '^stderr:' 74 + [clone] target: <WS> 75 + [clone] url: <TROOT>/alice-mono.git 76 + [clone] branch: main (auto-detected) 77 + [clone] handle: bob.example.org 78 + [clone] renamed git remote origin -> upstream 79 + subtree lib: already exists, skipping 80 + ✓ Workspace cloned. 81 + Next: cd mono && dune build && dune test 82 + Hint: 'origin' was renamed to 'upstream' (the source repo). 83 + Add your own remote when ready: git remote add origin <your-url> 84 + 85 + The lib subtree was delivered by git clone, not re-imported: 86 + 87 + $ cd mono 88 + $ cat lib/lib.ml 89 + let v = 1 90 + $ git status --porcelain
+94
test/cram/clone_master_branch.t/run.t
··· 1 + Scenario: clone a remote whose default branch is master (not main) 2 + ==================================================================== 3 + 4 + The user clones a teammate's monorepo whose default branch is the 5 + older `master` (or `trunk`, `develop`, etc.). Without auto-detection, 6 + `monopam clone` would hardcode `--branch main` and fail with a cryptic 7 + "Remote branch main not found" from git. The fix is to query 8 + `git ls-remote --symref HEAD` first and pass the actual default branch 9 + to git clone. 10 + 11 + Setup 12 + ----- 13 + 14 + $ export NO_COLOR=1 15 + $ export GIT_AUTHOR_NAME="Alice" 16 + $ export GIT_AUTHOR_EMAIL="alice@example.com" 17 + $ export GIT_AUTHOR_DATE="2025-01-01T00:00:00+00:00" 18 + $ export GIT_COMMITTER_NAME="Alice" 19 + $ export GIT_COMMITTER_EMAIL="alice@example.com" 20 + $ export GIT_COMMITTER_DATE="2025-01-01T00:00:00+00:00" 21 + $ export HOME="$PWD/home" 22 + $ mkdir -p "$HOME" 23 + $ export GIT_CONFIG_GLOBAL="$HOME/.gitconfig" 24 + $ printf '[init]\n\tdefaultBranch = master\n[user]\n\tname = Alice\n\temail = alice@example.com\n' > "$GIT_CONFIG_GLOBAL" 25 + $ TROOT=$(pwd) 26 + 27 + A bare upstream library, on `master`: 28 + 29 + $ git init -q -b master --bare lib.git 30 + $ git clone -q lib.git lib-work 2>/dev/null 31 + $ cd lib-work 32 + $ echo "let v = 1" > lib.ml 33 + $ cat > lib.opam << OPAM 34 + > opam-version: "2.0" 35 + > name: "lib" 36 + > version: "dev" 37 + > synopsis: "master-branch fixture" 38 + > dev-repo: "git+file://$TROOT/lib.git" 39 + > OPAM 40 + $ git add . && git commit -q -m "initial lib" 41 + $ git push -q origin master 2>/dev/null 42 + $ cd "$TROOT" 43 + 44 + Alice's thin remote — also on `master`: 45 + 46 + $ git init -q -b master --bare alice-mono.git 47 + $ git clone -q alice-mono.git alice-mono-work 2>/dev/null 48 + $ cd alice-mono-work 49 + $ cat > sources.toml << EOF 50 + > [lib] 51 + > source = "git+file://$TROOT/lib.git" 52 + > EOF 53 + $ git add sources.toml && git commit -q -m "list packages" 54 + $ git push -q origin master 2>/dev/null 55 + $ cd "$TROOT" 56 + 57 + Clone with no --branch flag 58 + --------------------------- 59 + 60 + `monopam clone` queries the remote's HEAD via `git ls-remote --symref` 61 + and discovers `master`. It does NOT fall back to `main` (which would 62 + fail) and does NOT require the user to pass `--branch master`. 63 + 64 + $ monopam clone "$TROOT/alice-mono.git" mono --handle bob.example.org 2>&1 \ 65 + > | sed "s|$TROOT|<TROOT>|g" \ 66 + > | sed '/target:/ s|: .*/mono|: <WS>|' \ 67 + > | sed 's/ at [0-9a-f]*$/ at <SHA>/' \ 68 + > | sed 's|importing from .*/\([a-z-]*\)\.git|importing from <URL>/\1.git|' \ 69 + > | sed '/✓/ s/ (.*$//' \ 70 + > | grep -v '^verse: ' \ 71 + > | grep -v 'Registry clone failed' \ 72 + > | grep -v 'Cloning into' \ 73 + > | grep -v 'fatal: unable to access' \ 74 + > | grep -v '^stdout:' \ 75 + > | grep -v '^stderr:' 76 + [clone] target: <WS> 77 + [clone] url: <TROOT>/alice-mono.git 78 + [clone] branch: master (auto-detected) 79 + [clone] handle: bob.example.org 80 + [clone] renamed git remote origin -> upstream 81 + subtree lib: importing from <URL>/lib.git 82 + Imported lib at <SHA> 83 + Regenerated root files: dune-project, README.md, llms.txt 84 + ✓ Workspace cloned and bootstrapped. 85 + Next: cd mono && dune build && dune test 86 + Hint: 'origin' was renamed to 'upstream' (the source repo). 87 + Add your own remote when ready: git remote add origin <your-url> 88 + 89 + Working tree is consistent — lib materialized, status clean: 90 + 91 + $ cd mono 92 + $ cat lib/lib.ml 93 + let v = 1 94 + $ git status --porcelain
+7 -5
test/cram/init.t/run.t
··· 20 20 $ export GIT_CONFIG_GLOBAL="$HOME/.gitconfig" 21 21 $ printf '[init]\n\tdefaultBranch = main\n[user]\n\tname = Alice\n\temail = alice@example.com\n' > "$GIT_CONFIG_GLOBAL" 22 22 23 - A fresh workspace the user has never touched: 23 + A fresh workspace the user has never touched. They don't even need 24 + to `git init` first — monopam init does that for them: 24 25 25 26 $ mkdir workspace && cd workspace 26 - $ git init -q 27 27 28 28 Run init 29 29 -------- 30 30 31 31 $ monopam init --handle alice.example.org 2>&1 \ 32 - > | sed '/root:/ s|: .*/workspace|: <WS>|' \ 32 + > | sed '/target:/ s|: .*/workspace|: <WS>|' \ 33 + > | sed '/git init/ s|/.*/workspace|<WS>|' \ 33 34 > | sed '/✓/ s/ (.*$//' \ 34 - > | grep -v '^\[init\] verse: ' \ 35 + > | grep -v '^verse: ' \ 35 36 > | grep -v '^Updated dune-project' \ 36 37 > | grep -v 'Registry clone failed' \ 37 38 > | grep -v 'Cloning into' \ 38 39 > | grep -v 'fatal: unable to access' \ 39 40 > | grep -v '^stdout:' \ 40 41 > | grep -v '^stderr:' 41 - [init] root: <WS> 42 + [init] target: <WS> 42 43 [init] handle: alice.example.org 44 + [init] git init <WS> 43 45 Regenerated root files: dune-project, README.md, llms.txt 44 46 ✓ Workspace initialized. 45 47 Next: monopam add <git-url> # or: monopam pull
-1
test/test.ml
··· 22 22 Test_git_cli.suite; 23 23 Test_import.suite; 24 24 Test_init.suite; 25 - Test_mono_lock.suite; 26 25 Test_monopam.suite; 27 26 Test_opam_repo.suite; 28 27 Test_opam_sync.suite;
+19 -6
test/test_deps.ml
··· 35 35 let result_testable = 36 36 Alcotest.testable 37 37 (fun ppf -> function 38 - | Ok () -> Fmt.string ppf "Ok ()" | Error e -> Fmt.pf ppf "Error %S" e) 38 + | Ok { Monopam.Deps.imported; skipped } -> 39 + Fmt.pf ppf "Ok { imported=%d; skipped=%d }" imported skipped 40 + | Error e -> Fmt.pf ppf "Error %S" e) 39 41 ( = ) 42 + 43 + let stats ?(imported = 0) ?(skipped = 0) () = { Monopam.Deps.imported; skipped } 40 44 41 45 let string_contains ~sub s = 42 46 let sub_len = String.length sub in ··· 82 86 let test_run_no_sources () = 83 87 with_tmp_env @@ fun ~sw ~fs ~proc ~target ~tmp:_ -> 84 88 let result = Monopam.Deps.run ~sw ~proc ~fs ~target ~dry_run:true () in 85 - Alcotest.(check result_testable) "no sources → Ok" (Ok ()) result 89 + Alcotest.(check result_testable) 90 + "no sources → Ok empty" 91 + (Ok (stats ())) 92 + result 86 93 87 94 (* Test: run with entries whose dirs already exist skips them *) 88 95 let test_run_existing_dirs_skipped () = ··· 95 102 Sys.mkdir (Filename.concat tmp "foo") 0o755; 96 103 Sys.mkdir (Filename.concat tmp "bar") 0o755; 97 104 let result = Monopam.Deps.run ~sw ~proc ~fs ~target ~dry_run:true () in 98 - Alcotest.(check result_testable) "existing dirs → Ok" (Ok ()) result 105 + Alcotest.(check result_testable) 106 + "existing dirs → Ok skipped" 107 + (Ok (stats ~skipped:2 ())) 108 + result 99 109 100 110 (* Test: dry run with missing dirs succeeds (Import.git_url returns Ok in 101 111 dry_run mode without actually fetching) *) ··· 107 117 ("pkg-b", entry "git+https://example.com/pkg-b"); 108 118 ]; 109 119 let result = Monopam.Deps.run ~sw ~proc ~fs ~target ~dry_run:true () in 110 - Alcotest.(check result_testable) "dry_run succeeds" (Ok ()) result 120 + Alcotest.(check result_testable) 121 + "dry_run succeeds" 122 + (Ok (stats ~imported:2 ())) 123 + result 111 124 112 125 (* Test: dry run does not create subdirectories *) 113 126 let test_dry_run_no_effects () = ··· 142 155 Alcotest.(check bool) 143 156 "error mentions package name" true 144 157 (string_contains ~sub:"bogus-pkg" msg) 145 - | Ok () -> Alcotest.fail "expected Error for bogus URL" 158 + | Ok _ -> Alcotest.fail "expected Error for bogus URL" 146 159 147 160 (* Test: mixed existing/missing dirs — existing skipped, missing fails *) 148 161 let test_run_partial_skip () = ··· 173 186 Alcotest.(check bool) 174 187 "error does not mention existing package" false 175 188 (string_contains ~sub:"existing-pkg" msg) 176 - | Ok () -> Alcotest.fail "expected Error for missing package" 189 + | Ok _ -> Alcotest.fail "expected Error for missing package" 177 190 178 191 let suite = 179 192 ( "deps",
-185
test/test_mono_lock.ml
··· 1 - (* Tests for mono_lock module *) 2 - 3 - module ML = Monopam.Mono_lock 4 - 5 - (* {1 empty tests} *) 6 - 7 - let test_empty () = 8 - let t = ML.empty in 9 - Alcotest.(check (list string)) "empty names" [] (ML.names t); 10 - Alcotest.(check (list (pair string pass))) "empty list" [] (ML.to_list t) 11 - 12 - (* {1 add / find / remove tests} *) 13 - 14 - let test_add_and_find () = 15 - let entry : ML.entry = 16 - { url = "https://github.com/mirage/eio.git"; ref_ = "main" } 17 - in 18 - let t = ML.add ML.empty ~name:"eio" entry in 19 - match ML.find t ~name:"eio" with 20 - | Some e -> 21 - Alcotest.(check string) "url" "https://github.com/mirage/eio.git" e.url; 22 - Alcotest.(check string) "ref" "main" e.ref_ 23 - | None -> Alcotest.fail "expected to find eio" 24 - 25 - let test_find_missing () = 26 - Alcotest.(check (option pass)) "missing" None (ML.find ML.empty ~name:"eio") 27 - 28 - let test_add_replaces () = 29 - let e1 : ML.entry = { url = "https://example.com/a.git"; ref_ = "v1" } in 30 - let e2 : ML.entry = { url = "https://example.com/a.git"; ref_ = "v2" } in 31 - let t = ML.add ML.empty ~name:"a" e1 in 32 - let t = ML.add t ~name:"a" e2 in 33 - match ML.find t ~name:"a" with 34 - | Some e -> Alcotest.(check string) "updated ref" "v2" e.ref_ 35 - | None -> Alcotest.fail "expected to find a" 36 - 37 - let test_remove () = 38 - let entry : ML.entry = { url = "https://example.com/a.git"; ref_ = "main" } in 39 - let t = ML.add ML.empty ~name:"a" entry in 40 - let t = ML.remove t ~name:"a" in 41 - Alcotest.(check (option pass)) "removed" None (ML.find t ~name:"a") 42 - 43 - let test_remove_nonexistent () = 44 - let t = ML.remove ML.empty ~name:"nonexistent" in 45 - Alcotest.(check (list string)) "still empty" [] (ML.names t) 46 - 47 - (* {1 names and to_list tests} *) 48 - 49 - let test_names () = 50 - let mk name ref_ : ML.entry = 51 - { url = "https://example.com/" ^ name ^ ".git"; ref_ } 52 - in 53 - let t = ML.add ML.empty ~name:"b" (mk "b" "main") in 54 - let t = ML.add t ~name:"a" (mk "a" "main") in 55 - let names = ML.names t in 56 - Alcotest.(check int) "two names" 2 (List.length names); 57 - Alcotest.(check bool) "has a" true (List.mem "a" names); 58 - Alcotest.(check bool) "has b" true (List.mem "b" names) 59 - 60 - let test_to_list () = 61 - let e : ML.entry = { url = "https://example.com/a.git"; ref_ = "main" } in 62 - let t = ML.add ML.empty ~name:"a" e in 63 - let l = ML.to_list t in 64 - Alcotest.(check int) "one entry" 1 (List.length l); 65 - let name, _ = List.hd l in 66 - Alcotest.(check string) "name" "a" name 67 - 68 - (* {1 of_string / to_string roundtrip tests} *) 69 - 70 - let test_of_string_basic () = 71 - let s = "eio https://github.com/mirage/eio.git#main\n" in 72 - let t = ML.of_string s in 73 - match ML.find t ~name:"eio" with 74 - | Some e -> 75 - Alcotest.(check string) "url" "https://github.com/mirage/eio.git" e.url; 76 - Alcotest.(check string) "ref" "main" e.ref_ 77 - | None -> Alcotest.fail "expected eio" 78 - 79 - let test_of_string_multiple () = 80 - let s = 81 - "eio https://github.com/mirage/eio.git#main\n\ 82 - dune https://github.com/ocaml/dune.git#v3.17\n" 83 - in 84 - let t = ML.of_string s in 85 - Alcotest.(check int) "two entries" 2 (List.length (ML.to_list t)) 86 - 87 - let test_of_string_comments () = 88 - let s = 89 - "# comment\neio https://github.com/mirage/eio.git#main\n# another\n" 90 - in 91 - let t = ML.of_string s in 92 - Alcotest.(check int) "one entry" 1 (List.length (ML.to_list t)) 93 - 94 - let test_of_string_empty_lines () = 95 - let s = "\n\neio https://github.com/mirage/eio.git#main\n\n" in 96 - let t = ML.of_string s in 97 - Alcotest.(check int) "one entry" 1 (List.length (ML.to_list t)) 98 - 99 - let test_of_string_no_ref () = 100 - let s = "eio https://github.com/mirage/eio.git\n" in 101 - let t = ML.of_string s in 102 - match ML.find t ~name:"eio" with 103 - | Some e -> Alcotest.(check string) "default ref" "main" e.ref_ 104 - | None -> Alcotest.fail "expected eio" 105 - 106 - let test_roundtrip () = 107 - let e1 : ML.entry = 108 - { url = "https://github.com/mirage/eio.git"; ref_ = "main" } 109 - in 110 - let e2 : ML.entry = 111 - { url = "https://github.com/ocaml/dune.git"; ref_ = "v3.17" } 112 - in 113 - let t = ML.add (ML.add ML.empty ~name:"eio" e1) ~name:"dune" e2 in 114 - let s = ML.to_string t in 115 - let t' = ML.of_string s in 116 - Alcotest.(check int) 117 - "same count" 118 - (List.length (ML.to_list t)) 119 - (List.length (ML.to_list t')); 120 - (match ML.find t' ~name:"eio" with 121 - | Some e -> 122 - Alcotest.(check string) "eio url" e1.url e.url; 123 - Alcotest.(check string) "eio ref" e1.ref_ e.ref_ 124 - | None -> Alcotest.fail "expected eio"); 125 - match ML.find t' ~name:"dune" with 126 - | Some e -> 127 - Alcotest.(check string) "dune url" e2.url e.url; 128 - Alcotest.(check string) "dune ref" e2.ref_ e.ref_ 129 - | None -> Alcotest.fail "expected dune" 130 - 131 - let test_to_string_empty () = 132 - Alcotest.(check string) "empty" "" (ML.to_string ML.empty) 133 - 134 - (* {1 pp tests} *) 135 - 136 - let test_pp_empty () = 137 - let s = Fmt.str "%a" ML.pp ML.empty in 138 - Alcotest.(check string) "empty" "(empty)" s 139 - 140 - let test_pp_entry () = 141 - let e : ML.entry = { url = "https://example.com/a.git"; ref_ = "main" } in 142 - let s = Fmt.str "%a" ML.pp_entry e in 143 - Alcotest.(check bool) "non-empty" true (String.length s > 0) 144 - 145 - let test_pp_nonempty () = 146 - let e : ML.entry = { url = "https://example.com/a.git"; ref_ = "main" } in 147 - let t = ML.add ML.empty ~name:"a" e in 148 - let s = Fmt.str "%a" ML.pp t in 149 - Alcotest.(check bool) "non-empty" true (String.length s > 0) 150 - 151 - (* {1 lock_filename test} *) 152 - 153 - let test_lock_filename () = 154 - Alcotest.(check string) "filename" "mono.lock" ML.lock_filename 155 - 156 - let suite = 157 - ( "mono_lock", 158 - [ 159 - (* empty *) 160 - Alcotest.test_case "empty" `Quick test_empty; 161 - (* add / find / remove *) 162 - Alcotest.test_case "add and find" `Quick test_add_and_find; 163 - Alcotest.test_case "find missing" `Quick test_find_missing; 164 - Alcotest.test_case "add replaces" `Quick test_add_replaces; 165 - Alcotest.test_case "remove" `Quick test_remove; 166 - Alcotest.test_case "remove nonexistent" `Quick test_remove_nonexistent; 167 - (* names / to_list *) 168 - Alcotest.test_case "names" `Quick test_names; 169 - Alcotest.test_case "to_list" `Quick test_to_list; 170 - (* of_string / to_string *) 171 - Alcotest.test_case "of_string basic" `Quick test_of_string_basic; 172 - Alcotest.test_case "of_string multiple" `Quick test_of_string_multiple; 173 - Alcotest.test_case "of_string comments" `Quick test_of_string_comments; 174 - Alcotest.test_case "of_string empty lines" `Quick 175 - test_of_string_empty_lines; 176 - Alcotest.test_case "of_string no ref" `Quick test_of_string_no_ref; 177 - Alcotest.test_case "roundtrip" `Quick test_roundtrip; 178 - Alcotest.test_case "to_string empty" `Quick test_to_string_empty; 179 - (* pp *) 180 - Alcotest.test_case "pp empty" `Quick test_pp_empty; 181 - Alcotest.test_case "pp entry" `Quick test_pp_entry; 182 - Alcotest.test_case "pp nonempty" `Quick test_pp_nonempty; 183 - (* lock_filename *) 184 - Alcotest.test_case "lock filename" `Quick test_lock_filename; 185 - ] )
-2
test/test_mono_lock.mli
··· 1 - val suite : string * unit Alcotest.test_case list 2 - (** Test suite. *)
-1
test/test_monopam.ml
··· 5 5 let test_modules_accessible () = 6 6 (* Verify key module re-exports are accessible by touching a value *) 7 7 ignore (Monopam.Config.default_branch : string); 8 - ignore (Monopam.Mono_lock.lock_filename : string); 9 8 ignore (Monopam.Verse_registry.default_url : string); 10 9 () 11 10