···137137 | [] -> all_pkgs
138138 | names ->
139139 List.filter
140140- (fun p ->
141141- List.exists
142142- (fun name -> Pkg.name p = name || Pkg.subtree p = name)
143143- names)
140140+ (fun p -> List.exists (fun n -> Pkg.matches_name n p) names)
144141 all_pkgs
145142 in
146143 let total = List.length pkgs in
···194191195192(** {1 CWD-based Export} *)
196193197197-(** Discover packages from a standalone project directory. Similar to
198198- Pkg.discover but works without config, scanning for dune-project and opam
199199- files in the source directory. *)
194194+(** Derive dev_repo and url_src from a dune-project and sources registry. *)
195195+let derive_urls ~sources ~subtree dune_proj =
196196+ match
197197+ (Dune_project.dev_repo_url dune_proj, Dune_project.url_with_branch dune_proj)
198198+ with
199199+ | Ok dr, Ok us -> (dr, us)
200200+ | _ -> (
201201+ match Sources_registry.derive_url sources ~subtree with
202202+ | Some url -> (url, url ^ "#main")
203203+ | None -> ("", ""))
204204+205205+(** Load opam packages from a directory given metadata. *)
206206+let load_opam_pkgs dir_eio ~subtree ~dev_repo ~url_src =
207207+ let opam_files =
208208+ try
209209+ Eio.Path.read_dir dir_eio
210210+ |> List.filter (fun name -> Filename.check_suffix name ".opam")
211211+ with Eio.Io _ -> []
212212+ in
213213+ List.filter_map
214214+ (fun opam_file ->
215215+ let pkg_name = Filename.chop_suffix opam_file ".opam" in
216216+ let opam_path = Eio.Path.(dir_eio / opam_file) in
217217+ try
218218+ let raw_content = Eio.Path.load opam_path in
219219+ let opam_content =
220220+ Opam_transform.transform ~content:raw_content ~dev_repo ~url_src
221221+ in
222222+ Some (Pkg.v ~pkg_name ~subtree ~dev_repo ~url_src ~opam_content)
223223+ with Eio.Io _ -> None)
224224+ opam_files
225225+226226+(** Scan subdirectories for packages (each subdir with its own dune-project). *)
227227+let scan_subdirectories ~source_eio ~sources =
228228+ let subdirs =
229229+ try
230230+ Eio.Path.read_dir source_eio
231231+ |> List.filter (fun name ->
232232+ let child = Eio.Path.(source_eio / name) in
233233+ match Eio.Path.kind ~follow:false child with
234234+ | `Directory -> true
235235+ | _ -> false)
236236+ with Eio.Io _ -> []
237237+ in
238238+ List.fold_left
239239+ (fun acc subtree ->
240240+ let subtree_path = Eio.Path.(source_eio / subtree) in
241241+ let dune_path = Eio.Path.(subtree_path / "dune-project") in
242242+ match Eio.Path.kind ~follow:false dune_path with
243243+ | `Regular_file -> (
244244+ let content =
245245+ try Some (Eio.Path.load dune_path) with Eio.Io _ -> None
246246+ in
247247+ match content with
248248+ | None -> acc
249249+ | Some content -> (
250250+ match Dune_project.parse content with
251251+ | Error _ -> acc
252252+ | Ok dune_proj ->
253253+ let dev_repo, url_src =
254254+ derive_urls ~sources ~subtree dune_proj
255255+ in
256256+ let pkgs =
257257+ load_opam_pkgs subtree_path ~subtree ~dev_repo ~url_src
258258+ in
259259+ pkgs @ acc))
260260+ | _ -> acc
261261+ | exception Eio.Io _ -> acc)
262262+ [] subdirs
263263+ |> List.rev
264264+265265+(** Discover packages from a project directory. Scans both root-level opam files
266266+ (if a dune-project exists) and subdirectory packages (for workspace roots).
267267+*)
200268let discover_from_cwd ~fs ~source =
201269 let source_eio = Eio.Path.(fs / Fpath.to_string source) in
202270 let sources = load_sources ~fs ~dir:source in
203203- (* First check if source itself is a package (has dune-project) *)
271271+ let sub_pkgs = scan_subdirectories ~source_eio ~sources in
204272 let dune_project_path = Eio.Path.(source_eio / "dune-project") in
205273 match Eio.Path.kind ~follow:false dune_project_path with
206274 | `Regular_file -> (
207207- (* Single project - discover packages from this directory *)
208275 let content =
209276 try Some (Eio.Path.load dune_project_path) with Eio.Io _ -> None
210277 in
211278 match content with
212212- | None -> Error (`Config_error "Cannot read dune-project")
279279+ | None -> Ok sub_pkgs
213280 | Some content -> (
214281 match Dune_project.parse content with
215215- | Error msg -> Error (`Config_error msg)
282282+ | Error _ -> Ok sub_pkgs
216283 | Ok dune_proj ->
217284 let subtree = Fpath.basename source in
218218- let dev_repo, url_src =
219219- match
220220- ( Dune_project.dev_repo_url dune_proj,
221221- Dune_project.url_with_branch dune_proj )
222222- with
223223- | Ok dev_repo, Ok url_src -> (dev_repo, url_src)
224224- | _ -> (
225225- match Sources_registry.derive_url sources ~subtree with
226226- | Some url -> (url, url ^ "#main")
227227- | None -> ("", ""))
228228- in
229229- let opam_files =
230230- try
231231- Eio.Path.read_dir source_eio
232232- |> List.filter (fun name ->
233233- Filename.check_suffix name ".opam")
234234- with Eio.Io _ -> []
285285+ let dev_repo, url_src = derive_urls ~sources ~subtree dune_proj in
286286+ let root_pkgs =
287287+ load_opam_pkgs source_eio ~subtree ~dev_repo ~url_src
235288 in
236236- let pkgs =
237237- List.filter_map
238238- (fun opam_file ->
239239- let pkg_name = Filename.chop_suffix opam_file ".opam" in
240240- let opam_path = Eio.Path.(source_eio / opam_file) in
241241- try
242242- let raw_content = Eio.Path.load opam_path in
243243- let opam_content =
244244- Opam_transform.transform ~content:raw_content ~dev_repo
245245- ~url_src
246246- in
247247- Some
248248- (Pkg.make ~pkg_name ~subtree ~dev_repo ~url_src
249249- ~opam_content)
250250- with Eio.Io _ -> None)
251251- opam_files
252252- in
253253- Ok pkgs))
254254- | _ ->
255255- (* Directory of subtrees - scan subdirectories like monorepo *)
256256- let subdirs =
257257- try
258258- Eio.Path.read_dir source_eio
259259- |> List.filter (fun name ->
260260- let child = Eio.Path.(source_eio / name) in
261261- match Eio.Path.kind ~follow:false child with
262262- | `Directory -> true
263263- | _ -> false)
264264- with Eio.Io _ -> []
265265- in
266266- let packages =
267267- List.fold_left
268268- (fun acc subtree ->
269269- let subtree_path = Eio.Path.(source_eio / subtree) in
270270- let dune_path = Eio.Path.(subtree_path / "dune-project") in
271271- match Eio.Path.kind ~follow:false dune_path with
272272- | `Regular_file -> (
273273- let content =
274274- try Some (Eio.Path.load dune_path) with Eio.Io _ -> None
275275- in
276276- match content with
277277- | None -> acc
278278- | Some content -> (
279279- match Dune_project.parse content with
280280- | Error _ -> acc
281281- | Ok dune_proj ->
282282- let dev_repo, url_src =
283283- match
284284- ( Dune_project.dev_repo_url dune_proj,
285285- Dune_project.url_with_branch dune_proj )
286286- with
287287- | Ok dr, Ok us -> (dr, us)
288288- | _ -> (
289289- match
290290- Sources_registry.derive_url sources ~subtree
291291- with
292292- | Some url -> (url, url ^ "#main")
293293- | None -> ("", ""))
294294- in
295295- let opam_files =
296296- try
297297- Eio.Path.read_dir subtree_path
298298- |> List.filter (fun name ->
299299- Filename.check_suffix name ".opam")
300300- with Eio.Io _ -> []
301301- in
302302- let pkgs =
303303- List.filter_map
304304- (fun opam_file ->
305305- let pkg_name =
306306- Filename.chop_suffix opam_file ".opam"
307307- in
308308- let opam_path =
309309- Eio.Path.(subtree_path / opam_file)
310310- in
311311- try
312312- let raw_content = Eio.Path.load opam_path in
313313- let opam_content =
314314- Opam_transform.transform ~content:raw_content
315315- ~dev_repo ~url_src
316316- in
317317- Some
318318- (Pkg.make ~pkg_name ~subtree ~dev_repo
319319- ~url_src ~opam_content)
320320- with Eio.Io _ -> None)
321321- opam_files
322322- in
323323- pkgs @ acc))
324324- | _ -> acc
325325- | exception Eio.Io _ -> acc)
326326- [] subdirs
327327- in
328328- Ok (List.rev packages)
289289+ Ok (root_pkgs @ sub_pkgs)))
290290+ | _ -> Ok sub_pkgs
291291+ | exception Eio.Io _ -> Ok sub_pkgs
329292330293(** List packages in a standalone opam-repo. *)
331294let list_opam_repo_packages_at ~fs opam_repo =
···378341 `Synced pkg_name
379342 end
380343344344+(** Partition sync results into synced and unchanged lists. *)
345345+let partition_sync_results results =
346346+ List.fold_left
347347+ (fun (s, u) r ->
348348+ match r with `Synced n -> (n :: s, u) | `Unchanged n -> (s, n :: u))
349349+ ([], []) results
350350+351351+(** Commit changes in an opam-repo if needed. *)
352352+let commit_if_needed ~fs ~target ~no_commit ~dry_run result =
353353+ if
354354+ (not no_commit) && (not dry_run)
355355+ && (result.synced <> [] || result.orphaned <> [])
356356+ then begin
357357+ let repo = Git.Repository.open_repo ~fs target in
358358+ let msg = commit_message result in
359359+ match Git_cli.global_git_user () with
360360+ | Some user -> (
361361+ match
362362+ Git.Repository.commit_index repo ~author:user ~committer:user
363363+ ~message:msg ()
364364+ with
365365+ | Ok _ -> ()
366366+ | Error (`Msg e) -> Log.warn (fun m -> m "Failed to commit: %s" e))
367367+ | None -> Log.warn (fun m -> m "No git user config found, skipping commit")
368368+ end
369369+381370let run_from_cwd ~fs ~proc:_ ~source ~target ?(packages = [])
382371 ?(no_commit = false) ?(dry_run = false) () =
383372 match discover_from_cwd ~fs ~source with
···388377 | [] -> all_pkgs
389378 | names ->
390379 List.filter
391391- (fun p ->
392392- List.exists
393393- (fun name -> Pkg.name p = name || Pkg.subtree p = name)
394394- names)
380380+ (fun p -> List.exists (fun n -> Pkg.matches_name n p) names)
395381 all_pkgs
396382 in
397383 if pkgs = [] then begin
···410396 pkgs
411397 in
412398 Tty.Progress.finish progress;
413413- let synced, unchanged =
414414- List.fold_left
415415- (fun (s, u) r ->
416416- match r with
417417- | `Synced n -> (n :: s, u)
418418- | `Unchanged n -> (s, n :: u))
419419- ([], []) sync_results
420420- in
399399+ let synced, unchanged = partition_sync_results sync_results in
421400 let generated_names =
422401 List.map Pkg.name pkgs |> List.sort_uniq String.compare
423402 in
424424- (* Only delete orphaned when syncing all packages and not dry-run *)
425403 let deleted =
426404 if packages = [] && not dry_run then begin
427405 let existing = list_opam_repo_packages_at ~fs target in
···447425 orphaned = deleted;
448426 }
449427 in
450450- if
451451- (not no_commit) && (not dry_run)
452452- && (result.synced <> [] || result.orphaned <> [])
453453- then begin
454454- let repo = Git.Repository.open_repo ~fs target in
455455- let msg = commit_message result in
456456- match Git_cli.global_git_user () with
457457- | Some user -> (
458458- match
459459- Git.Repository.commit_index repo ~author:user ~committer:user
460460- ~message:msg ()
461461- with
462462- | Ok _ -> ()
463463- | Error (`Msg e) -> Log.warn (fun m -> m "Failed to commit: %s" e)
464464- )
465465- | None ->
466466- Log.warn (fun m -> m "No git user config found, skipping commit")
467467- end;
428428+ commit_if_needed ~fs ~target ~no_commit ~dry_run result;
468429 Ok result
469430 end
+1
lib/package.ml
···27272828let checkout_dir ~checkouts_root t = Fpath.(checkouts_root / repo_name t)
2929let subtree_prefix t = repo_name t
3030+let matches_name name t = t.name = name || repo_name t = name
3031let compare a b = String.compare a.name b.name
3132let equal a b = String.equal a.name b.name
3233let same_repo a b = Uri.equal a.dev_repo b.dev_repo
+6
lib/package.mli
···7474 This is the repository name (same as [repo_name t]), so multiple packages
7575 from the same repository share the same subtree directory. *)
76767777+(** {1 Matching} *)
7878+7979+val matches_name : string -> t -> bool
8080+(** [matches_name name t] returns true if [name] matches either the opam package
8181+ name or the repository name (subtree prefix). *)
8282+7783(** {1 Comparison} *)
78847985val compare : t -> t -> int
+2-1
lib/pkg.ml
···1212 opam_content : string;
1313}
14141515-let make ~pkg_name ~subtree ~dev_repo ~url_src ~opam_content =
1515+let v ~pkg_name ~subtree ~dev_repo ~url_src ~opam_content =
1616 { pkg_name; subtree; dev_repo; url_src; opam_content }
17171818let pp ppf t = Fmt.pf ppf "@[<v>%s (subtree: %s)@]" t.pkg_name t.subtree
1919let name t = t.pkg_name
2020let subtree t = t.subtree
2121+let matches_name name t = t.pkg_name = name || t.subtree = name
2122let dev_repo t = t.dev_repo
2223let url_src t = t.url_src
2324let opam_content t = t.opam_content
+5-1
lib/pkg.mli
···33type t
44(** Package metadata from a monorepo subtree. *)
5566-val make :
66+val v :
77 pkg_name:string ->
88 subtree:string ->
99 dev_repo:string ->
···20202121val subtree : t -> string
2222(** Subtree directory name within the monorepo. *)
2323+2424+val matches_name : string -> t -> bool
2525+(** [matches_name name t] returns true if [name] matches either the opam package
2626+ name or the subtree directory name. *)
23272428val dev_repo : t -> string
2529(** Development repository URL. *)
+4-1
lib/pull.ml
···120120 match packages with
121121 | [] -> all_pkgs
122122 | names ->
123123- List.filter (fun p -> List.mem (Package.name p) names) all_pkgs
123123+ List.filter
124124+ (fun p ->
125125+ List.exists (fun n -> Package.matches_name n p) names)
126126+ all_pkgs
124127 in
125128 if pkgs = [] && packages <> [] then
126129 Error (Ctx.Package_not_found (List.hd packages))
+3-1
lib/push.ml
···138138 match packages with
139139 | [] -> all_pkgs
140140 | names ->
141141- List.filter (fun p -> List.mem (Package.name p) names) all_pkgs
141141+ List.filter
142142+ (fun p -> List.exists (fun n -> Package.matches_name n p) names)
143143+ all_pkgs
142144 in
143145 if pkgs = [] && packages <> [] then
144146 Error (Ctx.Package_not_found (List.hd packages))
+149
test/publish.t
···11+Publish command tests
22+=====================
33+44+Setup: configure git and disable colors
55+66+ $ export NO_COLOR=1
77+ $ export GIT_AUTHOR_NAME="Test User"
88+ $ export GIT_AUTHOR_EMAIL="test@example.com"
99+ $ export GIT_AUTHOR_DATE="2025-01-01T00:00:00+00:00"
1010+ $ export GIT_COMMITTER_NAME="Test User"
1111+ $ export GIT_COMMITTER_EMAIL="test@example.com"
1212+ $ export GIT_COMMITTER_DATE="2025-01-01T00:00:00+00:00"
1313+ $ export HOME="$PWD/home"
1414+ $ mkdir -p "$HOME"
1515+ $ TROOT=$(pwd)
1616+1717+Helper that strips trailing whitespace:
1818+1919+ $ publish () { monopam publish "$@" 2>&1 | grep -v '^ *$'; }
2020+2121+Publish from a single project
2222+------------------------------
2323+2424+Create a project with dune-project and opam file:
2525+2626+ $ mkdir -p project
2727+ $ cd project
2828+ $ git init -q
2929+3030+ $ cat > dune-project << 'EOF'
3131+ > (lang dune 3.21)
3232+ > (name mylib)
3333+ > (source (github user/mylib))
3434+ > (package (name mylib) (synopsis "A library"))
3535+ > EOF
3636+3737+ $ cat > mylib.opam << 'EOF'
3838+ > opam-version: "2.0"
3939+ > name: "mylib"
4040+ > version: "dev"
4141+ > synopsis: "A library"
4242+ > EOF
4343+4444+ $ git add . && git commit -q -m "initial"
4545+4646+Create target opam-repo:
4747+4848+ $ mkdir -p "$TROOT/opam-repo/packages"
4949+ $ cd "$TROOT/opam-repo" && git init -q && git commit -q --allow-empty -m "init" && cd "$TROOT/project"
5050+5151+Dry-run publish discovers the package:
5252+5353+ $ publish --dry-run --opam-repo "$TROOT/opam-repo" --no-checkouts
5454+ Dry run: publishing from $TESTCASE_ROOT/project to $TESTCASE_ROOT/opam-repo
5555+ Synced 1 package:
5656+ mylib
5757+5858+ $ cd "$TROOT"
5959+6060+Publish from a workspace root with subtrees
6161+---------------------------------------------
6262+6363+Create a monorepo-like workspace with a root dune-project and subtrees.
6464+This tests that `discover_from_cwd` finds packages in subdirectories even
6565+when the root has its own dune-project (workspace root pattern):
6666+6767+ $ mkdir -p workspace/my-lib workspace/my-tool
6868+ $ cd workspace
6969+ $ git init -q
7070+7171+Root dune-project (workspace root, like a monorepo):
7272+7373+ $ cat > dune-project << 'EOF'
7474+ > (lang dune 3.21)
7575+ > (name root)
7676+ > (package (name root) (allow_empty))
7777+ > EOF
7878+7979+ $ cat > root.opam << 'EOF'
8080+ > opam-version: "2.0"
8181+ > name: "root"
8282+ > EOF
8383+8484+Subtree 1: my-lib (pkg name "mylib" differs from dir name "my-lib")
8585+8686+ $ cat > my-lib/dune-project << 'EOF'
8787+ > (lang dune 3.21)
8888+ > (name mylib)
8989+ > (source (github user/my-lib))
9090+ > (package (name mylib) (synopsis "A library"))
9191+ > EOF
9292+9393+ $ cat > my-lib/mylib.opam << 'EOF'
9494+ > opam-version: "2.0"
9595+ > name: "mylib"
9696+ > version: "dev"
9797+ > synopsis: "A library"
9898+ > EOF
9999+100100+Subtree 2: my-tool
101101+102102+ $ cat > my-tool/dune-project << 'EOF'
103103+ > (lang dune 3.21)
104104+ > (name mytool)
105105+ > (source (github user/my-tool))
106106+ > (package (name mytool) (synopsis "A tool"))
107107+ > EOF
108108+109109+ $ cat > my-tool/mytool.opam << 'EOF'
110110+ > opam-version: "2.0"
111111+ > name: "mytool"
112112+ > version: "dev"
113113+ > synopsis: "A tool"
114114+ > EOF
115115+116116+ $ git add . && git commit -q -m "initial"
117117+118118+Create target opam-repo:
119119+120120+ $ mkdir -p "$TROOT/ws-opam-repo/packages"
121121+ $ cd "$TROOT/ws-opam-repo" && git init -q && git commit -q --allow-empty -m "init" && cd "$TROOT/workspace"
122122+123123+Publish all discovers both root and subtree packages:
124124+125125+ $ publish --dry-run --opam-repo "$TROOT/ws-opam-repo" --no-checkouts
126126+ Dry run: publishing from $TESTCASE_ROOT/workspace to $TESTCASE_ROOT/ws-opam-repo
127127+ Synced 3 packages:
128128+ root
129129+ mylib
130130+ mytool
131131+132132+133133+Publish by subtree name (the bug fix: "my-lib" matches subtree, not pkg name):
134134+135135+ $ publish my-lib --dry-run --opam-repo "$TROOT/ws-opam-repo" --no-checkouts
136136+ Dry run: publishing from $TESTCASE_ROOT/workspace to $TESTCASE_ROOT/ws-opam-repo
137137+ Synced 1 package:
138138+ mylib
139139+140140+141141+Publish by package name also works:
142142+143143+ $ publish mylib --dry-run --opam-repo "$TROOT/ws-opam-repo" --no-checkouts
144144+ Dry run: publishing from $TESTCASE_ROOT/workspace to $TESTCASE_ROOT/ws-opam-repo
145145+ Synced 1 package:
146146+ mylib
147147+148148+149149+ $ cd "$TROOT"