From 655fdccd76aacd001268b35cb97e0f1a89223013 Mon Sep 17 00:00:00 2001 From: Mathieu Barbin Date: Mon, 17 Aug 2026 21:55:45 +0200 Subject: [PATCH 01/26] Add dune files --- .gitattributes | 6 ++ .headache.config | 14 +++++ .ocamlformat | 4 ++ COPYING.HEADER | 3 + LICENSE | 21 +++++++ Makefile | 34 +++++++++++ central-dev.opam | 37 +++++++++++ central-tests.opam | 36 +++++++++++ central.opam | 51 ++++++++++++++++ dune-project | 112 ++++++++++++++++++++++++++++++++++ dune-workspace.5.3 | 13 ++++ dune-workspace.5.4 | 17 ++++++ dune-workspace.5.5 | 17 ++++++ headache.sh | 78 +++++++++++++++++++++++ src/bin/dune | 9 +++ src/central/dune | 21 +++++++ src/cli/dune | 51 ++++++++++++++++ src/gitrepo-file-parser/dune | 26 ++++++++ src/gitrepo/dune | 19 ++++++ src/merge3/dune | 9 +++ src/myers/dune | 10 +++ src/parsing-utils/dune | 17 ++++++ src/stdlib/dune | 10 +++ src/test-harness/dune | 19 ++++++ src/test-helpers/dune | 33 ++++++++++ test/expect/dune | 108 ++++++++++++++++++++++++++++++++ test/gitrepo-file-parser/dune | 33 ++++++++++ test/gitrepo/dune | 21 +++++++ 28 files changed, 829 insertions(+) create mode 100644 .gitattributes create mode 100644 .headache.config create mode 100644 .ocamlformat create mode 100644 COPYING.HEADER create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 central-dev.opam create mode 100644 central-tests.opam create mode 100644 central.opam create mode 100644 dune-project create mode 100644 dune-workspace.5.3 create mode 100644 dune-workspace.5.4 create mode 100644 dune-workspace.5.5 create mode 100755 headache.sh create mode 100644 src/bin/dune create mode 100644 src/central/dune create mode 100644 src/cli/dune create mode 100644 src/gitrepo-file-parser/dune create mode 100644 src/gitrepo/dune create mode 100644 src/merge3/dune create mode 100644 src/myers/dune create mode 100644 src/parsing-utils/dune create mode 100644 src/stdlib/dune create mode 100644 src/test-harness/dune create mode 100644 src/test-helpers/dune create mode 100644 test/expect/dune create mode 100644 test/gitrepo-file-parser/dune create mode 100644 test/gitrepo/dune diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..335f7e2 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +# Tell github that .ml and .mli files are OCaml +*.ml linguist-language=OCaml +*.mli linguist-language=OCaml + +# Disable syntax detection for cram tests +*.t linguist-language=Text diff --git a/.headache.config b/.headache.config new file mode 100644 index 0000000..8d9fbcc --- /dev/null +++ b/.headache.config @@ -0,0 +1,14 @@ +# Objective Caml source + ".*\\.ml[l]?" -> frame open:"(*" line:"*" close:"*)" +# We add '_' in mli to comply with [ppx_js_style -check-doc-comments]. +| ".*\\.mli" -> frame open:"(*_" line:"*" close:"*)" +| ".*\\.fml[i]?" -> frame open:"(*" line:"*" close:"*)" +| ".*\\.mly" -> frame open:"/*" line:"*" close:"*/" +# C source +| ".*\\.[chy]" -> frame open:"/*" line:"*" close:"*/" +# Latex +| ".*\\.tex" -> frame open:"%" line:"%" close:"%" +# Misc +| ".*Makefile.*" -> frame open:"#" line:"#" close:"#" +| ".*README.*" -> frame open:"*" line:"*" close:"*" +| ".*LICENSE.*" -> frame open:"*" line:"*" close:"*" diff --git a/.ocamlformat b/.ocamlformat new file mode 100644 index 0000000..1537c98 --- /dev/null +++ b/.ocamlformat @@ -0,0 +1,4 @@ +version=0.29.0 +ocaml-version=5.3 +profile=janestreet +parse-docstrings=true diff --git a/COPYING.HEADER b/COPYING.HEADER new file mode 100644 index 0000000..a3b5ac5 --- /dev/null +++ b/COPYING.HEADER @@ -0,0 +1,3 @@ +central - Manage history between sub-repos and their monorepo +SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin +SPDX-License-Identifier: MIT diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0b87c65 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024-2026 Mathieu Barbin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..f37bffd --- /dev/null +++ b/Makefile @@ -0,0 +1,34 @@ +.PHONY: all +all: build + +.PHONY: build +build: + opam exec -- dune build + +.PHONY: test +test: + opam exec -- dune runtest + +.PHONY: fmt +fmt: + opam exec -- dune build @fmt --auto-promote + +.PHONY: lint +lint: + opam lint + opam exec -- opam-dune-lint + +.PHONY: deps +deps: + opam install . --deps-only --with-doc --with-test --with-dev-setup + +.PHONY: doc +doc: + opam exec -- dune build @doc + +.PHONY: clean +clean: + opam exec -- dune clean + +.PHONY: check-all +check-all: deps all test doc clean lint fmt diff --git a/central-dev.opam b/central-dev.opam new file mode 100644 index 0000000..bd184aa --- /dev/null +++ b/central-dev.opam @@ -0,0 +1,37 @@ +# This file is generated by dune, edit dune-project instead +opam-version: "2.0" +synopsis: "Package to regroup dev targets, documentation, and more" +maintainer: ["Mathieu Barbin "] +authors: ["Mathieu Barbin"] +license: "MIT" +homepage: "https://github.com/mbarbin/central-cli" +doc: "https://mbarbin.github.io/central-cli/" +bug-reports: "https://github.com/mbarbin/central-cli/issues" +depends: [ + "dune" {>= "3.20"} + "ocaml" {>= "5.3"} + "ocamlformat" {= "0.29.0"} + "bisect_ppx" {>= "2.8.3"} + "central" {= version} + "central-tests" {= version} + "mdexp" {>= "0.0.20260814"} + "ppx_expect" {>= "v0.17"} + "ppx_js_style" {>= "v0.17"} + "odoc" {>= "3.0.0"} +] +build: [ + ["dune" "subst"] {dev} + [ + "dune" + "build" + "-p" + name + "-j" + jobs + "@install" + "@runtest" {with-test} + "@doc" {with-doc} + ] +] +dev-repo: "git+https://github.com/mbarbin/central-cli.git" +x-maintenance-intent: ["(latest)"] diff --git a/central-tests.opam b/central-tests.opam new file mode 100644 index 0000000..dd412db --- /dev/null +++ b/central-tests.opam @@ -0,0 +1,36 @@ +# This file is generated by dune, edit dune-project instead +opam-version: "2.0" +synopsis: "Tests and end-to-end examples for central" +maintainer: ["Mathieu Barbin "] +authors: ["Mathieu Barbin"] +license: "MIT" +homepage: "https://github.com/mbarbin/central-cli" +doc: "https://mbarbin.github.io/central-cli/" +bug-reports: "https://github.com/mbarbin/central-cli/issues" +depends: [ + "dune" {>= "3.20"} + "ocaml" {>= "5.3"} + "central" {= version} + "mdexp" {>= "0.0.20260814"} + "pplumbing-err" {>= "0.0.16"} + "ppx_expect" {>= "v0.17"} + "volgo" {>= "0.0.22"} + "volgo-git-unix" {>= "0.0.22"} + "odoc" {with-doc} +] +build: [ + ["dune" "subst"] {dev} + [ + "dune" + "build" + "-p" + name + "-j" + jobs + "@install" + "@runtest" {with-test} + "@doc" {with-doc} + ] +] +dev-repo: "git+https://github.com/mbarbin/central-cli.git" +x-maintenance-intent: ["(latest)"] diff --git a/central.opam b/central.opam new file mode 100644 index 0000000..75988b4 --- /dev/null +++ b/central.opam @@ -0,0 +1,51 @@ +# This file is generated by dune, edit dune-project instead +opam-version: "2.0" +synopsis: "Manage history between sub-repos and their monorepo" +maintainer: ["Mathieu Barbin "] +authors: ["Mathieu Barbin"] +license: "MIT" +homepage: "https://github.com/mbarbin/central-cli" +doc: "https://mbarbin.github.io/central-cli/" +bug-reports: "https://github.com/mbarbin/central-cli/issues" +depends: [ + "dune" {>= "3.20"} + "ocaml" {>= "5.3"} + "cmdlang" {>= "0.0.11"} + "cmdlang-cmdliner-err-runner" {>= "0.0.17"} + "dune-build-info" {>= "3.20"} + "dyn" {>= "3.20"} + "file-rewriter" {>= "0.0.3"} + "fpath" {>= "0.7.3"} + "fpath-sexp0" {>= "0.4.0"} + "loc" {>= "0.2.2"} + "menhir" {>= "20220210"} + "pp" {>= "2.0.0"} + "pplumbing-err" {>= "0.0.16"} + "pplumbing-log" {>= "0.0.17"} + "pplumbing-log-cli" {>= "0.0.17"} + "pplumbing-pp-tty" {>= "0.0.17"} + "print-table" {>= "0.1.3"} + "sexplib0" {>= "v0.17"} + "spawn" {>= "v0.17"} + "volgo" {>= "0.0.22"} + "volgo-git-unix" {>= "0.0.22"} + "xdg" {>= "3.20"} + "yojson" {>= "3.0.0"} + "odoc" {with-doc} +] +build: [ + ["dune" "subst"] {dev} + [ + "dune" + "build" + "-p" + name + "-j" + jobs + "@install" + "@runtest" {with-test} + "@doc" {with-doc} + ] +] +dev-repo: "git+https://github.com/mbarbin/central-cli.git" +x-maintenance-intent: ["(latest)"] diff --git a/dune-project b/dune-project new file mode 100644 index 0000000..8661dfc --- /dev/null +++ b/dune-project @@ -0,0 +1,112 @@ +(lang dune 3.20) + +(name central) + +(generate_opam_files) + +(license MIT) + +(authors "Mathieu Barbin") + +(maintainers "Mathieu Barbin ") + +(source + (github mbarbin/central-cli)) + +(documentation "https://mbarbin.github.io/central-cli/") + +(using menhir 3.0) + +(implicit_transitive_deps false) + +(package + (name central) + (synopsis "Manage history between sub-repos and their monorepo") + (depends + (ocaml + (>= 5.3)) + (cmdlang + (>= 0.0.11)) + (cmdlang-cmdliner-err-runner + (>= 0.0.17)) + (dune-build-info + (>= 3.20)) + (dyn + (>= 3.20)) + (file-rewriter + (>= 0.0.3)) + (fpath + (>= 0.7.3)) + (fpath-sexp0 + (>= 0.4.0)) + (loc + (>= 0.2.2)) + (menhir + (>= 20220210)) + (pp + (>= 2.0.0)) + (pplumbing-err + (>= 0.0.16)) + (pplumbing-log + (>= 0.0.17)) + (pplumbing-log-cli + (>= 0.0.17)) + (pplumbing-pp-tty + (>= 0.0.17)) + (print-table + (>= 0.1.3)) + (sexplib0 + (>= v0.17)) + (spawn + (>= v0.17)) + (volgo + (>= 0.0.22)) + (volgo-git-unix + (>= 0.0.22)) + (xdg + (>= 3.20)) + (yojson + (>= 3.0.0)))) + +(package + (name central-tests) + (synopsis "Tests and end-to-end examples for central") + (depends + (ocaml + (>= 5.3)) + (central + (= :version)) + (mdexp + (>= 0.0.20260814)) + (pplumbing-err + (>= 0.0.16)) + (ppx_expect + (>= v0.17)) + (volgo + (>= 0.0.22)) + (volgo-git-unix + (>= 0.0.22)))) + +(package + (name central-dev) + (synopsis "Package to regroup dev targets, documentation, and more") + (allow_empty) + (depends + (ocaml + (>= 5.3)) + (ocamlformat + (= 0.29.0)) + (bisect_ppx + (>= 2.8.3)) + (central + (= :version)) + (central-tests + (= :version)) + (mdexp + (>= 0.0.20260814)) + (ppx_expect + (>= v0.17)) + (ppx_js_style + (>= v0.17)) + (odoc + (>= 3.0.0)))) diff --git a/dune-workspace.5.3 b/dune-workspace.5.3 new file mode 100644 index 0000000..9f241c8 --- /dev/null +++ b/dune-workspace.5.3 @@ -0,0 +1,13 @@ +(lang dune 3.20) + +(pkg enabled) + +(lock_dir + (repositories overlay upstream mbarbin) + (constraints + (ocaml + (= 5.3.0)))) + +(repository + (name mbarbin) + (url "git+https://github.com/mbarbin/opam-repository.git")) diff --git a/dune-workspace.5.4 b/dune-workspace.5.4 new file mode 100644 index 0000000..499becf --- /dev/null +++ b/dune-workspace.5.4 @@ -0,0 +1,17 @@ +(lang dune 3.20) + +(pkg enabled) + +(lock_dir + (repositories overlay upstream alpha mbarbin) + (constraints + (ocaml + (= 5.4.1)))) + +(repository + (name mbarbin) + (url "git+https://github.com/mbarbin/opam-repository.git")) + +(repository + (name alpha) + (url "git+https://github.com/kit-ty-kate/opam-alpha-repository.git")) diff --git a/dune-workspace.5.5 b/dune-workspace.5.5 new file mode 100644 index 0000000..ac8292f --- /dev/null +++ b/dune-workspace.5.5 @@ -0,0 +1,17 @@ +(lang dune 3.20) + +(pkg enabled) + +(lock_dir + (repositories overlay upstream alpha mbarbin) + (constraints + (ocaml + (= 5.5.0)))) + +(repository + (name mbarbin) + (url "git+https://github.com/mbarbin/opam-repository.git")) + +(repository + (name alpha) + (url "git+https://github.com/kit-ty-kate/opam-alpha-repository.git")) diff --git a/headache.sh b/headache.sh new file mode 100755 index 0000000..0dcfa8d --- /dev/null +++ b/headache.sh @@ -0,0 +1,78 @@ +#!/bin/bash -e +# SPDX-FileCopyrightText: 2025-2026 Mathieu Barbin +# SPDX-License-Identifier: MIT + +# Build exclusion list from all .headache.exclude files found in the tree. +# Paths in each file are relative to the file's location. +# Empty lines and lines starting with '#' are ignored. +EXCLUDES=() +while IFS= read -r exclude_file; do + exclude_dir="$(dirname "$exclude_file")" + while IFS= read -r line; do + [ -z "$line" ] && continue + case "$line" in + \#*) continue ;; + esac + if [ "$exclude_dir" = "." ]; then + EXCLUDES+=("$line") + else + EXCLUDES+=("${exclude_dir}/${line}") + fi + done < "$exclude_file" +done < <(git ls-files '*.headache.exclude') + +# Check if a directory matches any exclusion pattern (recursive). +is_excluded() { + local dir="$1" + for excl in "${EXCLUDES[@]}"; do + if [[ "$dir" == "$excl" ]] || [[ "$dir" == "$excl"/* ]]; then + return 0 + fi + done + return 1 +} + +# Find the nearest COPYING.HEADER by walking up from a directory. +find_header() { + local dir="$1" + local current="$dir" + while [ "$current" != "." ] && [ "$current" != "/" ]; do + if [ -f "${current}/COPYING.HEADER" ]; then + echo "${current}/COPYING.HEADER" + return + fi + current="$(dirname "$current")" + done + # Fall back to root + if [ -f "COPYING.HEADER" ]; then + echo "COPYING.HEADER" + else + echo "No COPYING.HEADER found for ${dir}" >&2 + return 1 + fi +} + +# Discover directories containing .ml or .mli files from tracked git files. +dirs=$(git ls-files '*.ml' '*.mli' | xargs -n1 dirname | sort -u) + +for dir in $dirs; do + if is_excluded "$dir"; then + echo "Skipping excluded directory: ${dir}" + continue + fi + + header=$(find_header "$dir") + echo "Apply headache to directory ${dir} (header: ${header})" + + # Apply headache to .ml files + if ls "${dir}"/*.ml 1> /dev/null 2>&1; then + headache -c .headache.config -h "${header}" "${dir}"/*.ml + fi + + # Apply headache to .mli files + if ls "${dir}"/*.mli 1> /dev/null 2>&1; then + headache -c .headache.config -h "${header}" "${dir}"/*.mli + fi +done + +dune fmt diff --git a/src/bin/dune b/src/bin/dune new file mode 100644 index 0000000..b56a877 --- /dev/null +++ b/src/bin/dune @@ -0,0 +1,9 @@ +(executable + (name main) + (public_name central) + (package central) + (flags :standard -w +a-4-40-41-42-44-45-48-66 -warn-error +a) + (libraries central_cli cmdlang-cmdliner-err-runner dune-build-info) + (instrumentation + (backend bisect_ppx)) + (preprocess no_preprocessing)) diff --git a/src/central/dune b/src/central/dune new file mode 100644 index 0000000..61ac880 --- /dev/null +++ b/src/central/dune @@ -0,0 +1,21 @@ +(library + (package central) + (name central) + (flags + :standard + -w + +a-4-40-41-42-44-45-48-66 + -warn-error + +a + -open + Central_stdlib + -open + Pplumbing_err + -open + Volgo) + (libraries central_stdlib fpath loc pp pplumbing-err volgo xdg yojson) + (instrumentation + (backend bisect_ppx)) + (lint + (pps ppx_js_style -allow-let-operators -check-doc-comments)) + (preprocess no_preprocessing)) diff --git a/src/cli/dune b/src/cli/dune new file mode 100644 index 0000000..023e6d8 --- /dev/null +++ b/src/cli/dune @@ -0,0 +1,51 @@ +(library + (name central_cli) + (public_name central.cli) + (flags + :standard + -w + +a-4-40-41-42-44-45-48-66 + -warn-error + +a + -open + Central_stdlib + -open + Pplumbing_err + -open + Pplumbing_log + -open + Pplumbing_log_cli + -open + Pplumbing_pp_tty + -open + Volgo + -open + Cmdlang + -open + Central + -open + Central_parsing_utils) + (libraries + central + central_parsing_utils + central_stdlib + cmdlang + file-rewriter + fpath + gitrepo_file + gitrepo_file_parser + pp + pplumbing-err + pplumbing-log + pplumbing-log-cli + pplumbing-pp-tty + print-table + spawn + unix + volgo + volgo-git-unix) + (instrumentation + (backend bisect_ppx)) + (lint + (pps ppx_js_style -allow-let-operators -check-doc-comments)) + (preprocess no_preprocessing)) diff --git a/src/gitrepo-file-parser/dune b/src/gitrepo-file-parser/dune new file mode 100644 index 0000000..27db71e --- /dev/null +++ b/src/gitrepo-file-parser/dune @@ -0,0 +1,26 @@ +(ocamllex lexer) + +(menhir + (modules parser)) + +(library + (name gitrepo_file_parser) + (public_name central.gitrepo_file_parser) + (flags + :standard + -w + +a-4-40-41-42-44-45-48-66 + -warn-error + +a + -w + -70 + -open + Volgo + -open + Central_stdlib + -open + Central_parsing_utils) + (libraries central_parsing_utils central_stdlib gitrepo_file volgo) + (instrumentation + (backend bisect_ppx)) + (preprocess no_preprocessing)) diff --git a/src/gitrepo/dune b/src/gitrepo/dune new file mode 100644 index 0000000..f68a4ce --- /dev/null +++ b/src/gitrepo/dune @@ -0,0 +1,19 @@ +(library + (name gitrepo_file) + (public_name central.gitrepo_file) + (flags + :standard + -w + +a-4-40-41-42-44-45-48-66 + -warn-error + +a + -open + Volgo + -open + Central_stdlib) + (libraries central_stdlib volgo) + (instrumentation + (backend bisect_ppx)) + (lint + (pps ppx_js_style -allow-let-operators -check-doc-comments)) + (preprocess no_preprocessing)) diff --git a/src/merge3/dune b/src/merge3/dune new file mode 100644 index 0000000..f73a273 --- /dev/null +++ b/src/merge3/dune @@ -0,0 +1,9 @@ +(library + (name merge3) + (public_name central.merge3) + (flags :standard -w +a-4-40-41-42-44-45-48-66 -warn-error +a) + (instrumentation + (backend bisect_ppx)) + (lint + (pps ppx_js_style -allow-let-operators -check-doc-comments)) + (preprocess no_preprocessing)) diff --git a/src/myers/dune b/src/myers/dune new file mode 100644 index 0000000..f5ad48a --- /dev/null +++ b/src/myers/dune @@ -0,0 +1,10 @@ +(library + (name central_myers) + (public_name central.myers) + (flags :standard -w +a-4-40-41-42-44-45-48-66 -warn-error +a) + (libraries merge3) + (instrumentation + (backend bisect_ppx)) + (lint + (pps ppx_js_style -allow-let-operators -check-doc-comments)) + (preprocess no_preprocessing)) diff --git a/src/parsing-utils/dune b/src/parsing-utils/dune new file mode 100644 index 0000000..1806900 --- /dev/null +++ b/src/parsing-utils/dune @@ -0,0 +1,17 @@ +(library + (name central_parsing_utils) + (public_name central.parsing-utils) + (flags + :standard + -w + +a-4-40-41-42-44-45-48-66 + -warn-error + +a + -open + Pplumbing_err) + (libraries fpath loc pp pplumbing-err) + (instrumentation + (backend bisect_ppx)) + (lint + (pps ppx_js_style -allow-let-operators -check-doc-comments)) + (preprocess no_preprocessing)) diff --git a/src/stdlib/dune b/src/stdlib/dune new file mode 100644 index 0000000..6cb3e33 --- /dev/null +++ b/src/stdlib/dune @@ -0,0 +1,10 @@ +(library + (name central_stdlib) + (public_name central.stdlib) + (flags :standard -w +a-4-40-41-42-44-45-48-66 -warn-error +a) + (libraries central_myers dyn fpath-sexp0 loc pp sexplib0 yojson) + (instrumentation + (backend bisect_ppx)) + (lint + (pps ppx_js_style -allow-let-operators -check-doc-comments)) + (preprocess no_preprocessing)) diff --git a/src/test-harness/dune b/src/test-harness/dune new file mode 100644 index 0000000..86795fb --- /dev/null +++ b/src/test-harness/dune @@ -0,0 +1,19 @@ +(library + (name central_test_harness) + (public_name central.test_harness) + (flags + :standard + -w + +a-4-40-41-42-44-45-48-66 + -warn-error + +a + -open + Central_stdlib + -open + Volgo) + (libraries central_stdlib pp unix volgo) + (instrumentation + (backend bisect_ppx)) + (lint + (pps ppx_js_style -allow-let-operators -check-doc-comments)) + (preprocess no_preprocessing)) diff --git a/src/test-helpers/dune b/src/test-helpers/dune new file mode 100644 index 0000000..260e5f3 --- /dev/null +++ b/src/test-helpers/dune @@ -0,0 +1,33 @@ +(library + (name central_test_helpers) + (public_name central.test_helpers) + (flags + :standard + -w + +a-4-40-41-42-44-45-48-66 + -warn-error + +a + -open + Central_stdlib + -open + Pplumbing_err + -open + Pplumbing_pp_tty + -open + Volgo + -open + Central) + (libraries + central + central_stdlib + gitrepo_file + pp + pplumbing-err + pplumbing-pp-tty + unix + volgo) + (instrumentation + (backend bisect_ppx)) + (lint + (pps ppx_js_style -allow-let-operators -check-doc-comments)) + (preprocess no_preprocessing)) diff --git a/test/expect/dune b/test/expect/dune new file mode 100644 index 0000000..c635774 --- /dev/null +++ b/test/expect/dune @@ -0,0 +1,108 @@ +(library + (name central_test) + (package central-tests) + (inline_tests + (deps central.exe)) + (flags + :standard + -w + +a-4-40-41-42-44-45-48-66 + -warn-error + +a + -open + Central_stdlib + -open + Pplumbing_err + -open + Volgo + -open + Central) + (libraries + central + central_stdlib + central_test_harness + central_test_helpers + pplumbing-err + volgo + volgo-git-unix) + (instrumentation + (backend bisect_ppx)) + (lint + (pps ppx_js_style -allow-let-operators -check-doc-comments)) + (preprocess + (pps ppx_expect))) + +(rule + (copy %{bin:central} central.exe)) + +(rule + (target export.md) + (alias runtest) + (mode promote) + (action + (with-stdout-to + %{target} + (run %{bin:mdexp} pp %{dep:export.ml})))) + +(rule + (target import.md) + (alias runtest) + (mode promote) + (action + (with-stdout-to + %{target} + (run %{bin:mdexp} pp %{dep:import.ml})))) + +(rule + (target push.md) + (alias runtest) + (mode promote) + (action + (with-stdout-to + %{target} + (run %{bin:mdexp} pp %{dep:push.ml})))) + +(rule + (target stitch.md) + (alias runtest) + (mode promote) + (action + (with-stdout-to + %{target} + (run %{bin:mdexp} pp %{dep:stitch.ml})))) + +(rule + (target advance.md) + (alias runtest) + (mode promote) + (action + (with-stdout-to + %{target} + (run %{bin:mdexp} pp %{dep:advance.ml})))) + +(rule + (target workflow.md) + (alias runtest) + (mode promote) + (action + (with-stdout-to + %{target} + (run %{bin:mdexp} pp %{dep:workflow.ml})))) + +(rule + (target config.md) + (alias runtest) + (mode promote) + (action + (with-stdout-to + %{target} + (run %{bin:mdexp} pp %{dep:config.ml})))) + +(rule + (target todo.md) + (alias runtest) + (mode promote) + (action + (with-stdout-to + %{target} + (run %{bin:mdexp} pp %{dep:todo.ml})))) diff --git a/test/gitrepo-file-parser/dune b/test/gitrepo-file-parser/dune new file mode 100644 index 0000000..bf50f75 --- /dev/null +++ b/test/gitrepo-file-parser/dune @@ -0,0 +1,33 @@ +(library + (name gitrepo_file_parser_test) + (package central-tests) + (inline_tests) + (flags + :standard + -w + +a-4-40-41-42-44-45-48-66 + -warn-error + +a + -open + Central_stdlib + -open + Pplumbing_err + -open + Volgo + -open + Central_parsing_utils) + (libraries + central_parsing_utils + central_stdlib + file-rewriter + fpath + gitrepo_file + gitrepo_file_parser + pplumbing-err + volgo) + (instrumentation + (backend bisect_ppx)) + (lint + (pps ppx_js_style -allow-let-operators -check-doc-comments)) + (preprocess + (pps ppx_expect))) diff --git a/test/gitrepo/dune b/test/gitrepo/dune new file mode 100644 index 0000000..34228fb --- /dev/null +++ b/test/gitrepo/dune @@ -0,0 +1,21 @@ +(library + (name gitrepo_file_test) + (package central-tests) + (inline_tests) + (flags + :standard + -w + +a-4-40-41-42-44-45-48-66 + -warn-error + +a + -open + Central_stdlib + -open + Volgo) + (libraries central_stdlib gitrepo_file volgo) + (instrumentation + (backend bisect_ppx)) + (lint + (pps ppx_js_style -allow-let-operators -check-doc-comments)) + (preprocess + (pps ppx_expect))) From 44de8a6f30458726f077b82661c2009cbc1930ea Mon Sep 17 00:00:00 2001 From: Mathieu Barbin Date: Mon, 17 Aug 2026 22:01:09 +0200 Subject: [PATCH 02/26] Add missing deps --- central-tests.opam | 2 ++ dune-project | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/central-tests.opam b/central-tests.opam index dd412db..d3e629c 100644 --- a/central-tests.opam +++ b/central-tests.opam @@ -11,6 +11,8 @@ depends: [ "dune" {>= "3.20"} "ocaml" {>= "5.3"} "central" {= version} + "file-rewriter" {>= "0.0.3"} + "fpath" {>= "0.7.3"} "mdexp" {>= "0.0.20260814"} "pplumbing-err" {>= "0.0.16"} "ppx_expect" {>= "v0.17"} diff --git a/dune-project b/dune-project index 8661dfc..803d4ed 100644 --- a/dune-project +++ b/dune-project @@ -76,6 +76,10 @@ (>= 5.3)) (central (= :version)) + (file-rewriter + (>= 0.0.3)) + (fpath + (>= 0.7.3)) (mdexp (>= 0.0.20260814)) (pplumbing-err From c23f8b92cdef3f03db420a3270cf5c3ed187c875 Mon Sep 17 00:00:00 2001 From: Mathieu Barbin Date: Mon, 17 Aug 2026 22:08:29 +0200 Subject: [PATCH 03/26] Use bin-available scheme --- doc/book/introduction-to-central-cli/dune | 95 ++++++++++++++++++++++ test/expect/dune | 98 +++++++++++++++++------ 2 files changed, 168 insertions(+), 25 deletions(-) create mode 100644 doc/book/introduction-to-central-cli/dune diff --git a/doc/book/introduction-to-central-cli/dune b/doc/book/introduction-to-central-cli/dune new file mode 100644 index 0000000..b8b82e8 --- /dev/null +++ b/doc/book/introduction-to-central-cli/dune @@ -0,0 +1,95 @@ +(data_only_dirs shared-theme) + +(library + (name central_introduction) + (package central-tests) + (inline_tests + (deps central.exe)) + (flags + :standard + -w + +a-4-40-41-42-44-45-48-66 + -warn-error + +a + -open + Central_stdlib + -open + Volgo + -open + Central) + (libraries + central + central_stdlib + central_test_harness + central_test_helpers + volgo + volgo-git-unix) + (instrumentation + (backend bisect_ppx)) + (lint + (pps ppx_js_style -allow-let-operators -check-doc-comments)) + (preprocess + (pps ppx_expect))) + +(rule + (copy %{bin:central} central.exe)) + +(rule + (enabled_if %{bin-available:mdexp}) + (target export.md.gen) + (deps export.ml) + (action + (with-stdout-to + %{target} + (run mdexp pp %{deps})))) + +(rule + (enabled_if %{bin-available:mdexp}) + (alias runtest) + (action + (diff export.md export.md.gen))) + +(rule + (enabled_if %{bin-available:mdexp}) + (target import.md.gen) + (deps import.ml) + (action + (with-stdout-to + %{target} + (run mdexp pp %{deps})))) + +(rule + (enabled_if %{bin-available:mdexp}) + (alias runtest) + (action + (diff import.md import.md.gen))) + +(rule + (enabled_if %{bin-available:mdexp}) + (target stitch.md.gen) + (deps stitch.ml) + (action + (with-stdout-to + %{target} + (run mdexp pp %{deps})))) + +(rule + (enabled_if %{bin-available:mdexp}) + (alias runtest) + (action + (diff stitch.md stitch.md.gen))) + +(rule + (enabled_if %{bin-available:mdexp}) + (target push.md.gen) + (deps push.ml) + (action + (with-stdout-to + %{target} + (run mdexp pp %{deps})))) + +(rule + (enabled_if %{bin-available:mdexp}) + (alias runtest) + (action + (diff push.md push.md.gen))) diff --git a/test/expect/dune b/test/expect/dune index c635774..68e6994 100644 --- a/test/expect/dune +++ b/test/expect/dune @@ -36,73 +36,121 @@ (copy %{bin:central} central.exe)) (rule - (target export.md) - (alias runtest) - (mode promote) + (enabled_if %{bin-available:mdexp}) + (target export.md.gen) + (deps export.ml) (action (with-stdout-to %{target} - (run %{bin:mdexp} pp %{dep:export.ml})))) + (run mdexp pp %{deps})))) (rule - (target import.md) + (enabled_if %{bin-available:mdexp}) (alias runtest) - (mode promote) + (action + (diff export.md export.md.gen))) + +(rule + (enabled_if %{bin-available:mdexp}) + (target import.md.gen) + (deps import.ml) (action (with-stdout-to %{target} - (run %{bin:mdexp} pp %{dep:import.ml})))) + (run mdexp pp %{deps})))) (rule - (target push.md) + (enabled_if %{bin-available:mdexp}) (alias runtest) - (mode promote) + (action + (diff import.md import.md.gen))) + +(rule + (enabled_if %{bin-available:mdexp}) + (target push.md.gen) + (deps push.ml) (action (with-stdout-to %{target} - (run %{bin:mdexp} pp %{dep:push.ml})))) + (run mdexp pp %{deps})))) (rule - (target stitch.md) + (enabled_if %{bin-available:mdexp}) (alias runtest) - (mode promote) + (action + (diff push.md push.md.gen))) + +(rule + (enabled_if %{bin-available:mdexp}) + (target stitch.md.gen) + (deps stitch.ml) (action (with-stdout-to %{target} - (run %{bin:mdexp} pp %{dep:stitch.ml})))) + (run mdexp pp %{deps})))) (rule - (target advance.md) + (enabled_if %{bin-available:mdexp}) (alias runtest) - (mode promote) + (action + (diff stitch.md stitch.md.gen))) + +(rule + (enabled_if %{bin-available:mdexp}) + (target advance.md.gen) + (deps advance.ml) (action (with-stdout-to %{target} - (run %{bin:mdexp} pp %{dep:advance.ml})))) + (run mdexp pp %{deps})))) (rule - (target workflow.md) + (enabled_if %{bin-available:mdexp}) (alias runtest) - (mode promote) + (action + (diff advance.md advance.md.gen))) + +(rule + (enabled_if %{bin-available:mdexp}) + (target workflow.md.gen) + (deps workflow.ml) (action (with-stdout-to %{target} - (run %{bin:mdexp} pp %{dep:workflow.ml})))) + (run mdexp pp %{deps})))) (rule - (target config.md) + (enabled_if %{bin-available:mdexp}) (alias runtest) - (mode promote) + (action + (diff workflow.md workflow.md.gen))) + +(rule + (enabled_if %{bin-available:mdexp}) + (target config.md.gen) + (deps config.ml) (action (with-stdout-to %{target} - (run %{bin:mdexp} pp %{dep:config.ml})))) + (run mdexp pp %{deps})))) (rule - (target todo.md) + (enabled_if %{bin-available:mdexp}) (alias runtest) - (mode promote) + (action + (diff config.md config.md.gen))) + +(rule + (enabled_if %{bin-available:mdexp}) + (target todo.md.gen) + (deps todo.ml) (action (with-stdout-to %{target} - (run %{bin:mdexp} pp %{dep:todo.ml})))) + (run mdexp pp %{deps})))) + +(rule + (enabled_if %{bin-available:mdexp}) + (alias runtest) + (action + (diff todo.md todo.md.gen))) From ee6388898caae14e205030e9ff2c9332b99fc69d Mon Sep 17 00:00:00 2001 From: Mathieu Barbin Date: Mon, 17 Aug 2026 22:12:21 +0200 Subject: [PATCH 04/26] Add dunolint files --- doc/dunolint | 5 ++++ doc/static/dunolint | 5 ++++ dunolint | 57 +++++++++++++++++++++++++++++++++++++++++++++ test/dunolint | 5 ++++ 4 files changed, 72 insertions(+) create mode 100644 doc/dunolint create mode 100644 doc/static/dunolint create mode 100644 dunolint create mode 100644 test/dunolint diff --git a/doc/dunolint b/doc/dunolint new file mode 100644 index 0000000..9116c86 --- /dev/null +++ b/doc/dunolint @@ -0,0 +1,5 @@ +(lang dunolint 1.0) + +; Generated by zola + +(skip_paths public/) diff --git a/doc/static/dunolint b/doc/static/dunolint new file mode 100644 index 0000000..79ce9c7 --- /dev/null +++ b/doc/static/dunolint @@ -0,0 +1,5 @@ +(lang dunolint 1.0) + +; Generated by mdbook + +(skip_paths book/) diff --git a/dunolint b/dunolint new file mode 100644 index 0000000..12ff786 --- /dev/null +++ b/dunolint @@ -0,0 +1,57 @@ +(lang dunolint 1.0) + +;; Everything is instrumented + +(rule + (enforce + (dune + (instrumentation + (backend bisect_ppx))))) + +;; We do not depend on ppx in src/ libraries - every dune file under there +;; must declare [(preprocess no_preprocessing)]. + +(rule + (cond + ((path + (glob src/**)) + (enforce + (dune + (preprocess no_preprocessing)))))) + +;; Every library is linted with ppx_js_style, except menhir/ocamllex-based +;; parser libraries: their generated .mli carries a plain (undocumented) +;; comment we don't control, which [-check-doc-comments] rejects. + +(rule + (cond + ((path + (glob src/gitrepo-file-parser/*)) + return) + (true + (enforce + (dune + (library + (lint + (pps + (and + (pp ppx_js_style) + (flag + (name -allow-let-operators) + (param none) + (applies_to (pp ppx_js_style))) + (flag + (name -check-doc-comments) + (param none) + (applies_to (pp ppx_js_style)))))))))))) + +;; Test libraries are named after their directory, suffixed with [_test]. + +(rule + (cond + ((path + (glob test/**)) + (enforce + (dune + (library + (name (is_suffix _test)))))))) diff --git a/test/dunolint b/test/dunolint new file mode 100644 index 0000000..79ce9c7 --- /dev/null +++ b/test/dunolint @@ -0,0 +1,5 @@ +(lang dunolint 1.0) + +; Generated by mdbook + +(skip_paths book/) From a63e284cb77afbc27cafb9c5835efaa489e7811b Mon Sep 17 00:00:00 2001 From: Mathieu Barbin Date: Mon, 17 Aug 2026 22:14:48 +0200 Subject: [PATCH 05/26] Add gitrepo utils --- src/gitrepo-file-parser/config_field.ml | 55 +++++++ src/gitrepo-file-parser/config_field.mli | 22 +++ .../gitrepo_file_parser.ml | 11 ++ .../gitrepo_file_parser.mli | 9 + src/gitrepo-file-parser/lexer.mli | 7 + src/gitrepo-file-parser/lexer.mll | 32 ++++ src/gitrepo-file-parser/parser.mly | 73 +++++++++ src/gitrepo/gitrepo_file.ml | 90 ++++++++++ src/gitrepo/gitrepo_file.mli | 32 ++++ .../test__gitrepo_file_parser.ml | 155 ++++++++++++++++++ .../test__gitrepo_file_parser.mli | 7 + test/gitrepo/test__gitrepo_file.ml | 33 ++++ test/gitrepo/test__gitrepo_file.mli | 7 + 13 files changed, 533 insertions(+) create mode 100644 src/gitrepo-file-parser/config_field.ml create mode 100644 src/gitrepo-file-parser/config_field.mli create mode 100644 src/gitrepo-file-parser/gitrepo_file_parser.ml create mode 100644 src/gitrepo-file-parser/gitrepo_file_parser.mli create mode 100644 src/gitrepo-file-parser/lexer.mli create mode 100644 src/gitrepo-file-parser/lexer.mll create mode 100644 src/gitrepo-file-parser/parser.mly create mode 100644 src/gitrepo/gitrepo_file.ml create mode 100644 src/gitrepo/gitrepo_file.mli create mode 100644 test/gitrepo-file-parser/test__gitrepo_file_parser.ml create mode 100644 test/gitrepo-file-parser/test__gitrepo_file_parser.mli create mode 100644 test/gitrepo/test__gitrepo_file.ml create mode 100644 test/gitrepo/test__gitrepo_file.mli diff --git a/src/gitrepo-file-parser/config_field.ml b/src/gitrepo-file-parser/config_field.ml new file mode 100644 index 0000000..6936d6d --- /dev/null +++ b/src/gitrepo-file-parser/config_field.ml @@ -0,0 +1,55 @@ +(*********************************************************************************) +(* central - Manage history between sub-repos and their monorepo *) +(* SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(* SPDX-License-Identifier: MIT *) +(*********************************************************************************) + +type t = + | Remote of [ `Repo_root of Vcs.Repo_root.t ] Loc.Txt.t + | Branch of Vcs.Branch_name.t Loc.Txt.t + | Commit of Vcs.Rev.t Loc.Txt.t + | Parent of Vcs.Rev.t Loc.Txt.t + | Method of [ `Merge | `Rebase ] Loc.Txt.t + | Cmdver of string Loc.Txt.t + +let remote ts = + List.find_map ts ~f:(function + | Remote r -> Some r + | _ -> None) + |> Option.get +;; + +let branch ts = + List.find_map ts ~f:(function + | Branch b -> Some b + | _ -> None) + |> Option.get +;; + +let commit ts = + List.find_map ts ~f:(function + | Commit c -> Some c + | _ -> None) + |> Option.get +;; + +let parent ts = + List.find_map ts ~f:(function + | Parent p -> Some p + | _ -> None) + |> Option.get +;; + +let method_ ts = + List.find_map ts ~f:(function + | Method m -> Some m + | _ -> None) + |> Option.get +;; + +let cmdver ts = + List.find_map ts ~f:(function + | Cmdver c -> Some c + | _ -> None) + |> Option.get +;; diff --git a/src/gitrepo-file-parser/config_field.mli b/src/gitrepo-file-parser/config_field.mli new file mode 100644 index 0000000..a51fb2f --- /dev/null +++ b/src/gitrepo-file-parser/config_field.mli @@ -0,0 +1,22 @@ +(*_********************************************************************************) +(*_ central - Manage history between sub-repos and their monorepo *) +(*_ SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(*_ SPDX-License-Identifier: MIT *) +(*_********************************************************************************) + +(** Intermediary type to allow parsing the config fields out of order. *) + +type t = + | Remote of [ `Repo_root of Vcs.Repo_root.t ] Loc.Txt.t + | Branch of Vcs.Branch_name.t Loc.Txt.t + | Commit of Vcs.Rev.t Loc.Txt.t + | Parent of Vcs.Rev.t Loc.Txt.t + | Method of [ `Merge | `Rebase ] Loc.Txt.t + | Cmdver of string Loc.Txt.t + +val remote : t list -> [ `Repo_root of Vcs.Repo_root.t ] Loc.Txt.t +val branch : t list -> Vcs.Branch_name.t Loc.Txt.t +val commit : t list -> Vcs.Rev.t Loc.Txt.t +val parent : t list -> Vcs.Rev.t Loc.Txt.t +val method_ : t list -> [ `Merge | `Rebase ] Loc.Txt.t +val cmdver : t list -> string Loc.Txt.t diff --git a/src/gitrepo-file-parser/gitrepo_file_parser.ml b/src/gitrepo-file-parser/gitrepo_file_parser.ml new file mode 100644 index 0000000..7044f8c --- /dev/null +++ b/src/gitrepo-file-parser/gitrepo_file_parser.ml @@ -0,0 +1,11 @@ +(*********************************************************************************) +(* central - Manage history between sub-repos and their monorepo *) +(* SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(* SPDX-License-Identifier: MIT *) +(*********************************************************************************) + +type t = Gitrepo_file.t +type token = Parser.token + +let lexer = Lexer.read +let parser = Parser.file diff --git a/src/gitrepo-file-parser/gitrepo_file_parser.mli b/src/gitrepo-file-parser/gitrepo_file_parser.mli new file mode 100644 index 0000000..46ff8b1 --- /dev/null +++ b/src/gitrepo-file-parser/gitrepo_file_parser.mli @@ -0,0 +1,9 @@ +(*_********************************************************************************) +(*_ central - Manage history between sub-repos and their monorepo *) +(*_ SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(*_ SPDX-License-Identifier: MIT *) +(*_********************************************************************************) + +type t = Gitrepo_file.t + +include Parsing_utils.S with type t := t diff --git a/src/gitrepo-file-parser/lexer.mli b/src/gitrepo-file-parser/lexer.mli new file mode 100644 index 0000000..ea412b6 --- /dev/null +++ b/src/gitrepo-file-parser/lexer.mli @@ -0,0 +1,7 @@ +(*_********************************************************************************) +(*_ central - Manage history between sub-repos and their monorepo *) +(*_ SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(*_ SPDX-License-Identifier: MIT *) +(*_********************************************************************************) + +val read : Lexing.lexbuf -> Parser.token diff --git a/src/gitrepo-file-parser/lexer.mll b/src/gitrepo-file-parser/lexer.mll new file mode 100644 index 0000000..2aa983f --- /dev/null +++ b/src/gitrepo-file-parser/lexer.mll @@ -0,0 +1,32 @@ +(*********************************************************************************) +(* central-cli - Manage history between sub-repos and their monorepo *) +(* SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(* SPDX-License-Identifier: MIT *) +(*********************************************************************************) + +{ + open Parser +} + +let whitespace = [' ' '\t']+ +let newline = '\n' | "\r\n" + +rule read = parse + | whitespace { read lexbuf } + | newline { Lexing.new_line lexbuf; + read lexbuf } + | ([';'] [^'\n']*) as text { COMMENT text } + | "[subrepo]" { SUBREPO } + | "remote" { REMOTE } + | "branch" { BRANCH } + | "commit" { COMMIT } + | "parent" { PARENT } + | "method" { METHOD } + | "rebase" { REBASE } + | "merge" { MERGE } + | "cmdver" { CMDVER } + | '=' { EQUAL } + | (['A'-'Z' 'a'-'z' '0'-'9' + '_' '-' '\'' '/' '~' '.' + ]+ as lexem) { LEXEM lexem } + | eof { EOF } diff --git a/src/gitrepo-file-parser/parser.mly b/src/gitrepo-file-parser/parser.mly new file mode 100644 index 0000000..cb134c5 --- /dev/null +++ b/src/gitrepo-file-parser/parser.mly @@ -0,0 +1,73 @@ +/*********************************************************************************/ +/* central-cli - Manage history between sub-repos and their monorepo */ +/* SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin */ +/* SPDX-License-Identifier: MIT */ +/*********************************************************************************/ + +%{ +%} + +%token EOF +%token COMMENT +%token LEXEM +%token SUBREPO +%token REMOTE +%token BRANCH +%token COMMIT +%token PARENT +%token METHOD +%token CMDVER +%token REBASE +%token MERGE +%token EQUAL + +%type file + +%start file + +%% + +file: + | header=COMMENT* SUBREPO fields=field+ EOF + { { Gitrepo_file. + header + ; remote = (Config_field.remote fields) + ; branch = (Config_field.branch fields) + ; commit = (Config_field.commit fields) + ; parent = (Config_field.parent fields) + ; method_ = (Config_field.method_ fields) + ; cmdver = (Config_field.cmdver fields) + } + } +; + +field: + | REMOTE EQUAL lexem=LEXEM + { Config_field.Remote + (Loc.Txt.create $loc(lexem) (`Repo_root (Vcs.Repo_root.v lexem))) + } + | BRANCH EQUAL lexem=LEXEM + { Config_field.Branch + (Loc.Txt.create $loc(lexem) (Vcs.Branch_name.v lexem)) + } + | COMMIT EQUAL lexem=LEXEM + { Config_field.Commit + (Loc.Txt.create $loc(lexem) (Vcs.Rev.v lexem)) + } + | PARENT EQUAL lexem=LEXEM + { Config_field.Parent + (Loc.Txt.create $loc(lexem) (Vcs.Rev.v lexem)) + } + | METHOD EQUAL _lexem=REBASE + { Config_field.Method + (Loc.Txt.create $loc(_lexem) `Rebase) + } + | METHOD EQUAL _lexem=MERGE + { Config_field.Method + (Loc.Txt.create $loc(_lexem) `Merge) + } + | CMDVER EQUAL lexem=LEXEM + { Config_field.Cmdver + (Loc.Txt.create $loc(lexem) lexem) + } +; diff --git a/src/gitrepo/gitrepo_file.ml b/src/gitrepo/gitrepo_file.ml new file mode 100644 index 0000000..7f6981e --- /dev/null +++ b/src/gitrepo/gitrepo_file.ml @@ -0,0 +1,90 @@ +(*********************************************************************************) +(* central - Manage history between sub-repos and their monorepo *) +(* SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(* SPDX-License-Identifier: MIT *) +(*********************************************************************************) + +type t = + { header : string list + ; remote : [ `Repo_root of Vcs.Repo_root.t ] Loc.Txt.t + ; branch : Vcs.Branch_name.t Loc.Txt.t + ; commit : Vcs.Rev.t Loc.Txt.t + ; parent : Vcs.Rev.t Loc.Txt.t + ; method_ : [ `Merge | `Rebase ] Loc.Txt.t + ; cmdver : string Loc.Txt.t + } + +let to_dyn { header; remote; branch; commit; parent; method_; cmdver } = + Dyn.Record + [ "header", Dyn.list Dyn.string header + ; ( "remote" + , Loc.Txt.to_dyn + (function + | `Repo_root repo_root -> + Dyn.Variant ("Repo_root", [ Dyn.string (Vcs.Repo_root.to_string repo_root) ])) + remote ) + ; "branch", Loc.Txt.to_dyn (fun b -> Dyn.string (Vcs.Branch_name.to_string b)) branch + ; "commit", Loc.Txt.to_dyn (fun c -> Dyn.string (Vcs.Rev.to_string c)) commit + ; "parent", Loc.Txt.to_dyn (fun p -> Dyn.string (Vcs.Rev.to_string p)) parent + ; ( "method_" + , Loc.Txt.to_dyn + (function + | `Merge -> Dyn.Variant ("Merge", []) + | `Rebase -> Dyn.Variant ("Rebase", [])) + method_ ) + ; "cmdver", Loc.Txt.to_dyn Dyn.string cmdver + ] +;; + +let default_header = + {| +; DO NOT EDIT (unless you know what you are doing) +; +; This subdirectory is a git "subrepo", and this file is maintained by the +; git-subrepo command. See https://github.com/ingydotnet/git-subrepo#readme +; +|} + |> String.trim + |> String.split_on_char ~sep:'\n' +;; + +let create + ?(header = default_header) + ~remote + ~branch + ~commit + ~parent + ?(method_ = `Rebase) + ?(cmdver = "0.4.6") + () + = + let f = Loc.Txt.no_loc in + { header + ; remote = f remote + ; branch = f branch + ; commit = f commit + ; parent = f parent + ; method_ = f method_ + ; cmdver = f cmdver + } +;; + +let write { header; remote; branch; commit; parent; method_; cmdver } = + let fields = + List.map + [ ( "remote" + , match remote.txt with + | `Repo_root repo_root -> Vcs.Repo_root.to_string repo_root ) + ; "branch", Vcs.Branch_name.to_string branch.txt + ; "commit", Vcs.Rev.to_string commit.txt + ; "parent", Vcs.Rev.to_string parent.txt + ; ( "method" + , match method_.txt with + | `Merge -> "merge" + | `Rebase -> "rebase" ) + ; "cmdver", cmdver.txt + ] + ~f:(fun (field, value) -> Printf.sprintf "\t%s = %s" field value) + in + String.concat ~sep:"\n" (List.concat [ header; [ "[subrepo]" ]; fields ]) ^ "\n" +;; diff --git a/src/gitrepo/gitrepo_file.mli b/src/gitrepo/gitrepo_file.mli new file mode 100644 index 0000000..3564fad --- /dev/null +++ b/src/gitrepo/gitrepo_file.mli @@ -0,0 +1,32 @@ +(*_********************************************************************************) +(*_ central - Manage history between sub-repos and their monorepo *) +(*_ SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(*_ SPDX-License-Identifier: MIT *) +(*_********************************************************************************) + +(** Manipulating the files [.gitrepo] created by [git subrepo]. *) + +type t = + { header : string list + ; remote : [ `Repo_root of Vcs.Repo_root.t ] Loc.Txt.t + ; branch : Vcs.Branch_name.t Loc.Txt.t + ; commit : Vcs.Rev.t Loc.Txt.t + ; parent : Vcs.Rev.t Loc.Txt.t + ; method_ : [ `Merge | `Rebase ] Loc.Txt.t + ; cmdver : string Loc.Txt.t + } + +val to_dyn : t -> Dyn.t + +val create + : ?header:string list + -> remote:[ `Repo_root of Vcs.Repo_root.t ] + -> branch:Vcs.Branch_name.t + -> commit:Vcs.Rev.t + -> parent:Vcs.Rev.t + -> ?method_:[ `Merge | `Rebase ] + -> ?cmdver:string + -> unit + -> t + +val write : t -> string diff --git a/test/gitrepo-file-parser/test__gitrepo_file_parser.ml b/test/gitrepo-file-parser/test__gitrepo_file_parser.ml new file mode 100644 index 0000000..72e0ef4 --- /dev/null +++ b/test/gitrepo-file-parser/test__gitrepo_file_parser.ml @@ -0,0 +1,155 @@ +(*********************************************************************************) +(* central - Manage history between sub-repos and their monorepo *) +(* SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(* SPDX-License-Identifier: MIT *) +(*********************************************************************************) + +let parse_string_exn ~path str = + Parsing_utils.parse_lexbuf_exn + (module Gitrepo_file_parser) + ~path + ~lexbuf:(Lexing.from_string str) +;; + +let test ?(strip = true) ?(show_positions = false) str = + let@ () = fun f -> Err.For_test.protect f in + let c = + parse_string_exn + ~path:("test" |> Fpath.v) + (if strip then String.trim str ^ "\n" else str) + in + Ref.set_temporarily Loc.include_sexp_of_locs show_positions ~f:(fun () -> + print_dyn (Gitrepo_file.to_dyn c)) +;; + +let%expect_test "parsing" = + test ~strip:false ~show_positions:true ""; + [%expect + {| + File "test", line 1, characters 0-0: + Error: Syntax error. + [123] |}]; + test ~show_positions:true ""; + [%expect + {| + File "test", line 2, characters 0-0: + Error: Syntax error. + [123] |}]; + test ~strip:false ~show_positions:true "\n"; + [%expect + {| + File "test", line 2, characters 0-0: + Error: Syntax error. + [123] |}]; + test + ~show_positions:true + {| +; DO NOT EDIT (unless you know what you are doing) +; +; This subdirectory is a git "subrepo", and this file is maintained by the +; git-subrepo command. See https://github.com/ingydotnet/git-subrepo#readme +; +[subrepo] + remote = /home/mathieu/dev/micrograd + branch = subrepo + commit = 94b6be7e2baf34c57aac3de0bab8448289de8391 + parent = f9ade60d84dd80a5a0eddf63e67111d257b53dca + method = rebase + cmdver = 0.4.6 +|}; + [%expect + {| + { header = + [ "; DO NOT EDIT (unless you know what you are doing)" + ; ";" + ; "; This subdirectory is a git \"subrepo\", and this file is maintained by the" + ; "; git-subrepo command. See https://github.com/ingydotnet/git-subrepo#readme" + ; ";" + ] + ; remote = + { txt = Repo_root "/home/mathieu/dev/micrograd" + ; loc = { start = "test:7:10"; stop = "test:7:37" } + } + ; branch = + { txt = "subrepo"; loc = { start = "test:8:10"; stop = "test:8:17" } } + ; commit = + { txt = "94b6be7e2baf34c57aac3de0bab8448289de8391" + ; loc = { start = "test:9:10"; stop = "test:9:50" } + } + ; parent = + { txt = "f9ade60d84dd80a5a0eddf63e67111d257b53dca" + ; loc = { start = "test:10:10"; stop = "test:10:50" } + } + ; method_ = + { txt = Rebase; loc = { start = "test:11:10"; stop = "test:11:16" } } + ; cmdver = + { txt = "0.4.6"; loc = { start = "test:12:10"; stop = "test:12:15" } } + } + |}]; + () +;; + +let%expect_test "rewriter" = + let@ () = fun f -> Err.For_test.protect f in + let original_contents = + Gitrepo_file.create + ~remote:(`Repo_root (Vcs.Repo_root.v "/tmp/repo")) + ~branch:Vcs.Branch_name.main + ~commit:(Vcs.Rev.v "94b6be7e2baf34c57aac3de0bab8448289de8391") + ~parent:(Vcs.Rev.v "f9ade60d84dd80a5a0eddf63e67111d257b53dca") + () + |> Gitrepo_file.write + in + test original_contents; + [%expect + {| + { header = + [ "; DO NOT EDIT (unless you know what you are doing)" + ; ";" + ; "; This subdirectory is a git \"subrepo\", and this file is maintained by the" + ; "; git-subrepo command. See https://github.com/ingydotnet/git-subrepo#readme" + ; ";" + ] + ; remote = Repo_root "/tmp/repo" + ; branch = "main" + ; commit = "94b6be7e2baf34c57aac3de0bab8448289de8391" + ; parent = "f9ade60d84dd80a5a0eddf63e67111d257b53dca" + ; method_ = Rebase + ; cmdver = "0.4.6" + } + |}]; + let path = Fpath.v "test" in + let gitrepo_file = + Parsing_utils.parse_lexbuf_exn + (module Gitrepo_file_parser) + ~path + ~lexbuf:(Lexing.from_string original_contents) + in + let file_rewriter = File_rewriter.create ~path ~original_contents in + let () = + File_rewriter.replace + file_rewriter + ~range:(Loc.range gitrepo_file.commit.loc) + ~text:"b258b0cde128083c4f05bcf276bcc1322f1d36a2"; + File_rewriter.replace + file_rewriter + ~range:(Loc.range gitrepo_file.method_.loc) + ~text:"merge" + in + let modified_contents = File_rewriter.contents file_rewriter in + print_string (Myers.diff original_contents modified_contents ~context:3); + [%expect + {| + @@ -6,7 +6,7 @@ + [subrepo] + remote = /tmp/repo + branch = main + -| commit = 94b6be7e2baf34c57aac3de0bab8448289de8391 + +| commit = b258b0cde128083c4f05bcf276bcc1322f1d36a2 + parent = f9ade60d84dd80a5a0eddf63e67111d257b53dca + -| method = rebase + +| method = merge + cmdver = 0.4.6 + |}]; + () +;; diff --git a/test/gitrepo-file-parser/test__gitrepo_file_parser.mli b/test/gitrepo-file-parser/test__gitrepo_file_parser.mli new file mode 100644 index 0000000..8ff46c6 --- /dev/null +++ b/test/gitrepo-file-parser/test__gitrepo_file_parser.mli @@ -0,0 +1,7 @@ +(*_********************************************************************************) +(*_ central - Manage history between sub-repos and their monorepo *) +(*_ SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(*_ SPDX-License-Identifier: MIT *) +(*_********************************************************************************) + +(*_ This signature is deliberately empty. *) diff --git a/test/gitrepo/test__gitrepo_file.ml b/test/gitrepo/test__gitrepo_file.ml new file mode 100644 index 0000000..45a2c4e --- /dev/null +++ b/test/gitrepo/test__gitrepo_file.ml @@ -0,0 +1,33 @@ +(*********************************************************************************) +(* central - Manage history between sub-repos and their monorepo *) +(* SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(* SPDX-License-Identifier: MIT *) +(*********************************************************************************) + +let%expect_test "write" = + let t = + Gitrepo_file.create + ~remote:(`Repo_root (Vcs.Repo_root.v "/tmp/repo")) + ~branch:Vcs.Branch_name.main + ~commit:(Vcs.Rev.v "94b6be7e2baf34c57aac3de0bab8448289de8391") + ~parent:(Vcs.Rev.v "f9ade60d84dd80a5a0eddf63e67111d257b53dca") + () + in + print_string (Gitrepo_file.write t); + [%expect + {| + ; DO NOT EDIT (unless you know what you are doing) + ; + ; This subdirectory is a git "subrepo", and this file is maintained by the + ; git-subrepo command. See https://github.com/ingydotnet/git-subrepo#readme + ; + [subrepo] + remote = /tmp/repo + branch = main + commit = 94b6be7e2baf34c57aac3de0bab8448289de8391 + parent = f9ade60d84dd80a5a0eddf63e67111d257b53dca + method = rebase + cmdver = 0.4.6 + |}]; + () +;; diff --git a/test/gitrepo/test__gitrepo_file.mli b/test/gitrepo/test__gitrepo_file.mli new file mode 100644 index 0000000..8ff46c6 --- /dev/null +++ b/test/gitrepo/test__gitrepo_file.mli @@ -0,0 +1,7 @@ +(*_********************************************************************************) +(*_ central - Manage history between sub-repos and their monorepo *) +(*_ SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(*_ SPDX-License-Identifier: MIT *) +(*_********************************************************************************) + +(*_ This signature is deliberately empty. *) From 8c1d075d4ea0fd94ea15902580f9369d45eb9bef Mon Sep 17 00:00:00 2001 From: Mathieu Barbin Date: Mon, 17 Aug 2026 22:18:16 +0200 Subject: [PATCH 06/26] Add parsing-utils --- src/parsing-utils/COPYING.HEADER | 3 ++ src/parsing-utils/parsing_utils.ml | 68 +++++++++++++++++++++++++++ src/parsing-utils/parsing_utils.mli | 71 +++++++++++++++++++++++++++++ 3 files changed, 142 insertions(+) create mode 100644 src/parsing-utils/COPYING.HEADER create mode 100644 src/parsing-utils/parsing_utils.ml create mode 100644 src/parsing-utils/parsing_utils.mli diff --git a/src/parsing-utils/COPYING.HEADER b/src/parsing-utils/COPYING.HEADER new file mode 100644 index 0000000..2c40403 --- /dev/null +++ b/src/parsing-utils/COPYING.HEADER @@ -0,0 +1,3 @@ +parsing-utils: Making it easier to use generated Parsers/Lexers +SPDX-FileCopyrightText: 2023-2026 Mathieu Barbin +SPDX-License-Identifier: MIT diff --git a/src/parsing-utils/parsing_utils.ml b/src/parsing-utils/parsing_utils.ml new file mode 100644 index 0000000..7efc784 --- /dev/null +++ b/src/parsing-utils/parsing_utils.ml @@ -0,0 +1,68 @@ +(*********************************************************************************) +(* parsing-utils: Making it easier to use generated Parsers/Lexers *) +(* SPDX-FileCopyrightText: 2023-2026 Mathieu Barbin *) +(* SPDX-License-Identifier: MIT *) +(*********************************************************************************) + +module type S = sig + type token + type t + + val lexer : Lexing.lexbuf -> token + val parser : (Lexing.lexbuf -> token) -> Lexing.lexbuf -> t +end + +module Parsing_result = struct + type error = + { loc : Loc.t + ; exn : exn + } + + type 'a t = ('a, error) Result.t + + let with_dot m = + let len = String.length m in + if len > 0 && m.[len - 1] = '.' then m else m ^ "." + ;; + + let ok_exn (t : _ t) = + match t with + | Ok t -> t + | Error { loc; exn } -> + let extra = + match exn with + | Failure m -> [ Pp.text (with_dot m) ] + | _ -> [ Pp.text "Syntax error." ] + in + Err.raise ~loc extra + ;; +end + +let parse_lexbuf (type t) (module S : S with type t = t) ~path ~lexbuf = + Lexing.set_filename lexbuf (path |> Fpath.to_string); + match S.parser S.lexer lexbuf with + | program -> Ok program + | exception exn -> + let loc = Loc.of_lexbuf lexbuf in + Error { Parsing_result.loc; exn } +;; + +let parse_lexbuf_exn (type t) (module S : S with type t = t) ~path ~lexbuf = + parse_lexbuf (module S) ~path ~lexbuf |> Parsing_result.ok_exn +;; + +let parse_file (type t) (module S : S with type t = t) ~path = + match In_channel.open_bin (path |> Fpath.to_string) with + | exception Sys_error (m : string) -> + Error { Parsing_result.loc = Loc.of_file ~path; exn = Failure m } + | ic -> + Fun.protect + ~finally:(fun () -> In_channel.close ic) + (fun () -> + let lexbuf = Lexing.from_channel ic in + parse_lexbuf (module S) ~path ~lexbuf) +;; + +let parse_file_exn (type t) (module S : S with type t = t) ~path = + parse_file (module S) ~path |> Parsing_result.ok_exn +;; diff --git a/src/parsing-utils/parsing_utils.mli b/src/parsing-utils/parsing_utils.mli new file mode 100644 index 0000000..7140d66 --- /dev/null +++ b/src/parsing-utils/parsing_utils.mli @@ -0,0 +1,71 @@ +(*_********************************************************************************) +(*_ parsing-utils: Making it easier to use generated Parsers/Lexers *) +(*_ SPDX-FileCopyrightText: 2023-2026 Mathieu Barbin *) +(*_ SPDX-License-Identifier: MIT *) +(*_********************************************************************************) + +(** This modules implements utils to call parsing functions given a Parser/Lexer + pair. The pattern here is for a library to implement the [S] interface, + and then use the functions provided here by supplying [S] as a first class + module. For example: + + {v + let result = + Parsing_utils.parse_file + (module Parser) + ~path + in + ... + v} + + There are several styles offered depending on the context: + + 1. Using the [Parsing_result] type. + 2. Using [Err]. + + In all cases, the functions take care of producing located error messages + containing the name of the file and the position of the syntax error if any. + + The functions below that do not read the contents from a file still require + a path to be provided, which will be used for error messages only (example + when parsing the contents from stdin or a string). *) + +module type S = sig + type token + type t + + val lexer : Lexing.lexbuf -> token + val parser : (Lexing.lexbuf -> token) -> Lexing.lexbuf -> t +end + +module Parsing_result : sig + type error = + { loc : Loc.t + ; exn : exn + } + + type 'a t = ('a, error) Result.t + + (** [ok_exn r] is [x] when r is [Ok x]. Otherwise it raises an [Err.E] + exception. *) + val ok_exn : 'a t -> 'a +end + +(** {1 Lexbuf interface} *) + +val parse_lexbuf + : (module S with type t = 'a) + -> path:Fpath.t + -> lexbuf:Lexing.lexbuf + -> 'a Parsing_result.t + +val parse_lexbuf_exn + : (module S with type t = 'a) + -> path:Fpath.t + -> lexbuf:Lexing.lexbuf + -> 'a + +(** {1 File interface} *) + +val parse_file : (module S with type t = 'a) -> path:Fpath.t -> 'a Parsing_result.t +val parse_file_exn : (module S with type t = 'a) -> path:Fpath.t -> 'a From 0b39395e648fa4a23639102e2c5d73f71670f134 Mon Sep 17 00:00:00 2001 From: Mathieu Barbin Date: Mon, 17 Aug 2026 22:18:37 +0200 Subject: [PATCH 07/26] Add local stdlib extension --- src/stdlib/absolute_path0.ml | 7 ++ src/stdlib/absolute_path0.mli | 9 +++ src/stdlib/central_stdlib.ml | 7 ++ src/stdlib/central_stdlib.mli | 11 +++ src/stdlib/dyn0.ml | 46 ++++++++++++ src/stdlib/dyn0.mli | 23 ++++++ src/stdlib/json0.ml | 11 +++ src/stdlib/json0.mli | 16 +++++ src/stdlib/list0.ml | 14 ++++ src/stdlib/list0.mli | 9 +++ src/stdlib/loc0.ml | 7 ++ src/stdlib/loc0.mli | 9 +++ src/stdlib/myers0.ml | 7 ++ src/stdlib/myers0.mli | 9 +++ src/stdlib/ref0.ml | 11 +++ src/stdlib/ref0.mli | 7 ++ src/stdlib/stdlib0.ml | 19 +++++ src/stdlib/stdlib0.mli | 27 +++++++ src/stdlib/string0.ml | 132 ++++++++++++++++++++++++++++++++++ src/stdlib/string0.mli | 36 ++++++++++ src/stdlib/string_id0.ml | 57 +++++++++++++++ src/stdlib/string_id0.mli | 35 +++++++++ 22 files changed, 509 insertions(+) create mode 100644 src/stdlib/absolute_path0.ml create mode 100644 src/stdlib/absolute_path0.mli create mode 100644 src/stdlib/central_stdlib.ml create mode 100644 src/stdlib/central_stdlib.mli create mode 100644 src/stdlib/dyn0.ml create mode 100644 src/stdlib/dyn0.mli create mode 100644 src/stdlib/json0.ml create mode 100644 src/stdlib/json0.mli create mode 100644 src/stdlib/list0.ml create mode 100644 src/stdlib/list0.mli create mode 100644 src/stdlib/loc0.ml create mode 100644 src/stdlib/loc0.mli create mode 100644 src/stdlib/myers0.ml create mode 100644 src/stdlib/myers0.mli create mode 100644 src/stdlib/ref0.ml create mode 100644 src/stdlib/ref0.mli create mode 100644 src/stdlib/stdlib0.ml create mode 100644 src/stdlib/stdlib0.mli create mode 100644 src/stdlib/string0.ml create mode 100644 src/stdlib/string0.mli create mode 100644 src/stdlib/string_id0.ml create mode 100644 src/stdlib/string_id0.mli diff --git a/src/stdlib/absolute_path0.ml b/src/stdlib/absolute_path0.ml new file mode 100644 index 0000000..da32f17 --- /dev/null +++ b/src/stdlib/absolute_path0.ml @@ -0,0 +1,7 @@ +(*********************************************************************************) +(* central - Manage history between sub-repos and their monorepo *) +(* SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(* SPDX-License-Identifier: MIT *) +(*********************************************************************************) + +include Fpath_sexp0.Absolute_path diff --git a/src/stdlib/absolute_path0.mli b/src/stdlib/absolute_path0.mli new file mode 100644 index 0000000..b8da35a --- /dev/null +++ b/src/stdlib/absolute_path0.mli @@ -0,0 +1,9 @@ +(*_********************************************************************************) +(*_ central - Manage history between sub-repos and their monorepo *) +(*_ SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(*_ SPDX-License-Identifier: MIT *) +(*_********************************************************************************) + +include module type of struct + include Fpath_sexp0.Absolute_path +end diff --git a/src/stdlib/central_stdlib.ml b/src/stdlib/central_stdlib.ml new file mode 100644 index 0000000..0e8ab9c --- /dev/null +++ b/src/stdlib/central_stdlib.ml @@ -0,0 +1,7 @@ +(*********************************************************************************) +(* central - Manage history between sub-repos and their monorepo *) +(* SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(* SPDX-License-Identifier: MIT *) +(*********************************************************************************) + +include Stdlib0 diff --git a/src/stdlib/central_stdlib.mli b/src/stdlib/central_stdlib.mli new file mode 100644 index 0000000..5f53017 --- /dev/null +++ b/src/stdlib/central_stdlib.mli @@ -0,0 +1,11 @@ +(*_********************************************************************************) +(*_ central - Manage history between sub-repos and their monorepo *) +(*_ SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(*_ SPDX-License-Identifier: MIT *) +(*_********************************************************************************) + +(** Extending [Stdlib] for use in this project. *) + +include module type of struct + include Stdlib0 +end diff --git a/src/stdlib/dyn0.ml b/src/stdlib/dyn0.ml new file mode 100644 index 0000000..b4bcb4a --- /dev/null +++ b/src/stdlib/dyn0.ml @@ -0,0 +1,46 @@ +(*********************************************************************************) +(* central - Manage history between sub-repos and their monorepo *) +(* SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(* SPDX-License-Identifier: MIT *) +(*********************************************************************************) + +module List = List0 +module Sexp = Sexplib0.Sexp +include Dyn + +let inline_record cons fields = Dyn.variant cons [ Dyn.record fields ] + +let to_sexp = + let rec aux (dyn : Dyn.t) : Sexp.t = + match[@coverage off] dyn with + | Opaque -> Atom "" + | Unit -> List [] + | Int i -> Sexplib0.Sexp_conv.sexp_of_int i + | Int32 i -> Sexplib0.Sexp_conv.sexp_of_int32 i + | Record fields -> + List (List.map fields ~f:(fun (field, t) -> Sexp.List [ Atom field; aux t ])) + | Variant (v, args) -> + (* Special pretty print of variants holding records. *) + (match args with + | [] -> Atom v + | [ Record fields ] -> + List + (Atom v + :: List.map fields ~f:(fun (field, t) -> Sexp.List [ Atom field; aux t ])) + | _ -> List (Atom v :: List.map args ~f:aux)) + | Bool b -> Sexplib0.Sexp_conv.sexp_of_bool b + | String a -> Sexplib0.Sexp_conv.sexp_of_string a + | Bytes a -> Sexplib0.Sexp_conv.sexp_of_bytes a + | Int64 i -> Sexplib0.Sexp_conv.sexp_of_int64 i + | Nativeint i -> Sexplib0.Sexp_conv.sexp_of_nativeint i + | Char c -> Sexplib0.Sexp_conv.sexp_of_char c + | Float f -> Sexplib0.Sexp_conv.sexp_of_float f + | Option o -> Sexplib0.Sexp_conv.sexp_of_option aux o + | List l -> Sexplib0.Sexp_conv.sexp_of_list aux l + | Array a -> Sexplib0.Sexp_conv.sexp_of_array aux a + | Tuple t -> List (List.map t ~f:aux) + | Map m -> List (List.map m ~f:(fun (k, v) -> Sexp.List [ aux k; aux v ])) + | Set s -> List (List.map s ~f:aux) + in + aux +;; diff --git a/src/stdlib/dyn0.mli b/src/stdlib/dyn0.mli new file mode 100644 index 0000000..6d3cfda --- /dev/null +++ b/src/stdlib/dyn0.mli @@ -0,0 +1,23 @@ +(*_********************************************************************************) +(*_ central - Manage history between sub-repos and their monorepo *) +(*_ SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(*_ SPDX-License-Identifier: MIT *) +(*_********************************************************************************) + +include module type of struct + include Dyn +end + +(** {1 Builder} + + This extends the existing interface to build dyn values with a helper + that we've found convenient while working with this abstraction. *) + +val inline_record : string -> (string * Dyn.t) list -> Dyn.t + +(** {1 Alternate syntax} + + Produces a sexp representation of a dyn value, focused on readability + for debugging, error messages and expect tests - not a round-trip + serialization framework. *) +val to_sexp : Dyn.t -> Sexplib0.Sexp.t diff --git a/src/stdlib/json0.ml b/src/stdlib/json0.ml new file mode 100644 index 0000000..678955b --- /dev/null +++ b/src/stdlib/json0.ml @@ -0,0 +1,11 @@ +(*********************************************************************************) +(* central - Manage history between sub-repos and their monorepo *) +(* SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(* SPDX-License-Identifier: MIT *) +(*********************************************************************************) + +type t = Yojson.Basic.t + +exception Error of t * string + +let to_string t = Yojson.Basic.pretty_to_string ~std:true t diff --git a/src/stdlib/json0.mli b/src/stdlib/json0.mli new file mode 100644 index 0000000..2f5ac5f --- /dev/null +++ b/src/stdlib/json0.mli @@ -0,0 +1,16 @@ +(*_********************************************************************************) +(*_ central - Manage history between sub-repos and their monorepo *) +(*_ SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(*_ SPDX-License-Identifier: MIT *) +(*_********************************************************************************) + +(** A thin wrapper around JSON handling, abstracting over the underlying + library (currently Yojson). *) + +type t = Yojson.Basic.t + +(** Raised when JSON parsing or validation fails. *) +exception Error of t * string + +(** Pretty-print a JSON value to a string. *) +val to_string : t -> string diff --git a/src/stdlib/list0.ml b/src/stdlib/list0.ml new file mode 100644 index 0000000..422cc7f --- /dev/null +++ b/src/stdlib/list0.ml @@ -0,0 +1,14 @@ +(*********************************************************************************) +(* central - Manage history between sub-repos and their monorepo *) +(* SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(* SPDX-License-Identifier: MIT *) +(*********************************************************************************) + +include Stdlib.ListLabels + +let min_elt t ~compare = + match t with + | [] -> None + | hd :: tl -> + Some (Stdlib.List.fold_left (fun a b -> if compare a b <= 0 then a else b) hd tl) +;; diff --git a/src/stdlib/list0.mli b/src/stdlib/list0.mli new file mode 100644 index 0000000..aea3167 --- /dev/null +++ b/src/stdlib/list0.mli @@ -0,0 +1,9 @@ +(*_********************************************************************************) +(*_ central - Manage history between sub-repos and their monorepo *) +(*_ SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(*_ SPDX-License-Identifier: MIT *) +(*_********************************************************************************) + +include module type of Stdlib.ListLabels + +val min_elt : 'a list -> compare:('a -> 'a -> int) -> 'a option diff --git a/src/stdlib/loc0.ml b/src/stdlib/loc0.ml new file mode 100644 index 0000000..20df5ef --- /dev/null +++ b/src/stdlib/loc0.ml @@ -0,0 +1,7 @@ +(*********************************************************************************) +(* central - Manage history between sub-repos and their monorepo *) +(* SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(* SPDX-License-Identifier: MIT *) +(*********************************************************************************) + +include Loc diff --git a/src/stdlib/loc0.mli b/src/stdlib/loc0.mli new file mode 100644 index 0000000..c18ce3f --- /dev/null +++ b/src/stdlib/loc0.mli @@ -0,0 +1,9 @@ +(*_********************************************************************************) +(*_ central - Manage history between sub-repos and their monorepo *) +(*_ SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(*_ SPDX-License-Identifier: MIT *) +(*_********************************************************************************) + +include module type of struct + include Loc +end diff --git a/src/stdlib/myers0.ml b/src/stdlib/myers0.ml new file mode 100644 index 0000000..c2ede64 --- /dev/null +++ b/src/stdlib/myers0.ml @@ -0,0 +1,7 @@ +(*********************************************************************************) +(* central - Manage history between sub-repos and their monorepo *) +(* SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(* SPDX-License-Identifier: MIT *) +(*********************************************************************************) + +include Central_myers.Myers diff --git a/src/stdlib/myers0.mli b/src/stdlib/myers0.mli new file mode 100644 index 0000000..9f08ed3 --- /dev/null +++ b/src/stdlib/myers0.mli @@ -0,0 +1,9 @@ +(*_********************************************************************************) +(*_ central - Manage history between sub-repos and their monorepo *) +(*_ SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(*_ SPDX-License-Identifier: MIT *) +(*_********************************************************************************) + +include module type of struct + include Central_myers.Myers +end diff --git a/src/stdlib/ref0.ml b/src/stdlib/ref0.ml new file mode 100644 index 0000000..6cbad70 --- /dev/null +++ b/src/stdlib/ref0.ml @@ -0,0 +1,11 @@ +(*********************************************************************************) +(* central - Manage history between sub-repos and their monorepo *) +(* SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(* SPDX-License-Identifier: MIT *) +(*********************************************************************************) + +let set_temporarily t a ~f = + let x = !t in + t := a; + Fun.protect ~finally:(fun () -> t := x) f +;; diff --git a/src/stdlib/ref0.mli b/src/stdlib/ref0.mli new file mode 100644 index 0000000..df38d8d --- /dev/null +++ b/src/stdlib/ref0.mli @@ -0,0 +1,7 @@ +(*_********************************************************************************) +(*_ central - Manage history between sub-repos and their monorepo *) +(*_ SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(*_ SPDX-License-Identifier: MIT *) +(*_********************************************************************************) + +val set_temporarily : 'a ref -> 'a -> f:(unit -> 'b) -> 'b diff --git a/src/stdlib/stdlib0.ml b/src/stdlib/stdlib0.ml new file mode 100644 index 0000000..e28bff3 --- /dev/null +++ b/src/stdlib/stdlib0.ml @@ -0,0 +1,19 @@ +(*********************************************************************************) +(* central - Manage history between sub-repos and their monorepo *) +(* SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(* SPDX-License-Identifier: MIT *) +(*********************************************************************************) + +module Absolute_path = Absolute_path0 +module Dyn = Dyn0 +module Json = Json0 +module List = List0 +module Loc = Loc0 +module Myers = Myers0 +module Ref = Ref0 +module String = String0 +module String_id = String_id0 + +let ( let@ ) f k = f k +let print pp = Format.printf "%a@." Pp.to_fmt pp +let print_dyn dyn = print (Dyn.pp dyn) diff --git a/src/stdlib/stdlib0.mli b/src/stdlib/stdlib0.mli new file mode 100644 index 0000000..fccb43d --- /dev/null +++ b/src/stdlib/stdlib0.mli @@ -0,0 +1,27 @@ +(*_********************************************************************************) +(*_ central - Manage history between sub-repos and their monorepo *) +(*_ SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(*_ SPDX-License-Identifier: MIT *) +(*_********************************************************************************) + +(** Extending [Stdlib] for use in this project. Populated on demand: modules + here re-export the plain stdlib (or a vendored / public library) as-is, + growing extra helpers only once something actually needs them. *) + +module Absolute_path = Absolute_path0 +module Dyn = Dyn0 +module Json = Json0 +module List = List0 +module Loc = Loc0 +module Myers = Myers0 +module Ref = Ref0 +module String = String0 +module String_id = String_id0 + +(** Binding operator for pass-through / resource-style callbacks. + + [let@ x = with_resource in body] is equivalent to + [with_resource @@ fun x -> body]. *) +val ( let@ ) : (('a -> 'b) -> 'c) -> ('a -> 'b) -> 'c + +val print_dyn : Dyn.t -> unit diff --git a/src/stdlib/string0.ml b/src/stdlib/string0.ml new file mode 100644 index 0000000..c8c164d --- /dev/null +++ b/src/stdlib/string0.ml @@ -0,0 +1,132 @@ +(*********************************************************************************) +(* central - Manage history between sub-repos and their monorepo *) +(* SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(* SPDX-License-Identifier: MIT *) +(*********************************************************************************) + +(* Some functions below are copied from [Base] version [v0.17], which is + released under MIT and may be found at + [https://github.com/janestreet/base]. + + See Base's LICENSE below: + + ---------------------------------------------------------------------------- + + The MIT License + + Copyright (c) 2016--2024 Jane Street Group, LLC + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + + ---------------------------------------------------------------------------- + + When this is the case, we clearly indicate it next to the copied function. *) + +include Stdlib.StringLabels + +let to_string t = t + +let prefix t len = + let len = if len < 0 then 0 else if len > length t then length t else len in + sub t ~pos:0 ~len +;; + +let is_prefix t ~prefix = + let plen = length prefix in + length t >= plen && Stdlib.String.equal (sub t ~pos:0 ~len:plen) prefix +;; + +let is_whitespace = function + | ' ' | '\t' | '\n' | '\r' | '\012' -> true + | _ -> false +;; + +(* ---------------------------------------------------------------------------- *) +(* The functions below are copied from [Base]. See notice at the top of the + file for licensing information. *) + +let rfindi t ~f = + let rec loop i = if i < 0 then None else if f i t.[i] then Some i else loop (i - 1) in + let pos = length t - 1 in + (loop pos [@nontail]) +;; + +let lfindi ?(pos = 0) t ~f = + let n = length t in + let rec loop i = if i = n then None else if f i t.[i] then Some i else loop (i + 1) in + (loop pos [@nontail]) +;; + +let last_non_drop ~drop t = rfindi t ~f:(fun _ c -> not (drop c)) [@nontail] +let first_non_drop ~drop t = lfindi t ~f:(fun _ c -> not (drop c)) [@nontail] + +let rstrip ?(drop = is_whitespace) t = + match last_non_drop t ~drop with + | None -> "" + | Some i -> if i = length t - 1 then t else sub t ~pos:0 ~len:(i + 1) +;; + +let lstrip ?(drop = is_whitespace) t = + match first_non_drop t ~drop with + | None -> "" + | Some 0 -> t + | Some n -> sub t ~pos:n ~len:(length t - n) +;; + +let strip ?drop t = + match drop with + | None -> trim t + | Some drop -> lstrip ~drop (rstrip ~drop t) +;; + +(* ---------------------------------------------------------------------------- *) + +(* The function [split_lines] below was copied from [Base.String0.split_lines] + version [v0.17], which is released under MIT and may be found at + [https://github.com/janestreet/base]. See notice at the top of the file + for licensing information. *) + +let split_lines = + let back_up_at_newline ~t ~pos ~eol = + pos := !pos - if !pos > 0 && Stdlib.Char.equal t.[!pos - 1] '\r' then 2 else 1; + eol := !pos + 1 + in + fun t -> + let n = length t in + if n = 0 + then [] + else ( + (* Invariant: [-1 <= pos < eol]. *) + let pos = ref (n - 1) in + let eol = ref n in + let ac = ref [] in + (* We treat the end of the string specially, because if the string ends with a + newline, we don't want an extra empty string at the end of the output. *) + if Stdlib.Char.equal t.[!pos] '\n' then back_up_at_newline ~t ~pos ~eol; + while !pos >= 0 do + if not (Stdlib.Char.equal t.[!pos] '\n') + then decr pos + else ( + (* Because [pos < eol], we know that [start <= eol]. *) + let start = !pos + 1 in + ac := sub t ~pos:start ~len:(!eol - start) :: !ac; + back_up_at_newline ~t ~pos ~eol) + done; + sub t ~pos:0 ~len:!eol :: !ac) +;; diff --git a/src/stdlib/string0.mli b/src/stdlib/string0.mli new file mode 100644 index 0000000..95ebe1d --- /dev/null +++ b/src/stdlib/string0.mli @@ -0,0 +1,36 @@ +(*_********************************************************************************) +(*_ central - Manage history between sub-repos and their monorepo *) +(*_ SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(*_ SPDX-License-Identifier: MIT *) +(*_********************************************************************************) + +include module type of Stdlib.StringLabels + +(** The identity function, so [(module String)] can be used where a + [Stringable]-like interface is expected (e.g. [Pp_tty.kwd]). *) +val to_string : t -> t + +(** [prefix t len] returns the first [len] characters of [t], clamped to + [t]'s own length if [len] exceeds it (or to [""] if [len] is + negative). *) +val prefix : t -> int -> t + +val is_prefix : t -> prefix:t -> bool + +(** Trim characters matching [drop] (whitespace by default) from the right + end only, unlike {!trim}/{!strip} which trim both ends. *) +val rstrip : ?drop:(char -> bool) -> t -> t + +(** Trim characters matching [drop] (whitespace by default) from the left + end only, unlike {!trim}/{!strip} which trim both ends. *) +val lstrip : ?drop:(char -> bool) -> t -> t + +(** An alias for {!trim} when [drop] is omitted; otherwise trims characters + matching [drop] from both ends. *) +val strip : ?drop:(char -> bool) -> t -> t + +(** Split on ['\n'], also stripping a trailing ['\r'] from each line (so + both Unix and Windows line endings are handled), and - unlike a plain + [split_on_char '\n'] - without a spurious trailing empty line when [t] + itself ends with a newline. *) +val split_lines : t -> t list diff --git a/src/stdlib/string_id0.ml b/src/stdlib/string_id0.ml new file mode 100644 index 0000000..c3e6d9c --- /dev/null +++ b/src/stdlib/string_id0.ml @@ -0,0 +1,57 @@ +(*********************************************************************************) +(* central - Manage history between sub-repos and their monorepo *) +(* SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(* SPDX-License-Identifier: MIT *) +(*********************************************************************************) + +module type S = sig + type t + + val to_string : t -> string + val of_string : string -> (t, [ `Msg of string ]) Result.t + val v : string -> t + val equal : t -> t -> bool + val compare : t -> t -> int + val hash : t -> int + val to_dyn : t -> Dyn0.t +end + +module type X = sig + (** The module name is used for error messages only. *) + val module_name : string + + (** This is the validation function that should be run on the untrusted input + string. Return [true] on valid input. *) + val invariant : string -> bool +end + +module Make (X : X) = struct + type t = string + + let equal = String0.equal + let compare = String0.compare + let hash = Stdlib.Hashtbl.hash + let to_dyn = Dyn0.string + let to_string t = t + + let of_string s = + if X.invariant s + then Ok s + else ( + let shown_s = + if String0.length s > 40 + then + String0.sub s ~pos:0 ~len:40 + ^ "..." + ^ Printf.sprintf " (%d characters total)" (String0.length s) + else s + in + Error (`Msg (Printf.sprintf "%S: invalid %s" shown_s X.module_name))) + ;; + + let v s = + match of_string s with + | Ok t -> t + | Error (`Msg m) -> raise (Invalid_argument m) + ;; +end diff --git a/src/stdlib/string_id0.mli b/src/stdlib/string_id0.mli new file mode 100644 index 0000000..e0a22cc --- /dev/null +++ b/src/stdlib/string_id0.mli @@ -0,0 +1,35 @@ +(*_********************************************************************************) +(*_ central - Manage history between sub-repos and their monorepo *) +(*_ SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(*_ SPDX-License-Identifier: MIT *) +(*_********************************************************************************) + +(** Validated string identifiers with structural identity, built on top of a + caller-supplied invariant. The caller supplies the [invariant] (via + {!X}), so this is suitable for any validated-string id. *) + +module type S = sig + type t + + val to_string : t -> string + val of_string : string -> (t, [ `Msg of string ]) Result.t + val v : string -> t + val equal : t -> t -> bool + val compare : t -> t -> int + val hash : t -> int + val to_dyn : t -> Dyn0.t +end + +module type X = sig + (** The module name is used for error messages only. *) + val module_name : string + + (** This is the validation function that should be run on the untrusted input + string. Return [true] on valid input. + + By construction, [invariant t = true] is an invariant of any value of + type [t], since it is verified during [of_string _]. *) + val invariant : string -> bool +end + +module Make (_ : X) : S with type t = string From 43902cb6ff5485572fdcbd85976458f10865eeec Mon Sep 17 00:00:00 2001 From: Mathieu Barbin Date: Mon, 17 Aug 2026 22:19:32 +0200 Subject: [PATCH 08/26] Add vendor myers and merge3 --- src/merge3/COPYING.HEADER | 3 + src/merge3/merge3.ml | 157 ++++++++++++++++++++++++++ src/merge3/merge3.mli | 56 ++++++++++ src/merge3/vendor.json | 13 +++ src/myers/COPYING.HEADER | 3 + src/myers/myers.ml | 230 ++++++++++++++++++++++++++++++++++++++ src/myers/myers.mli | 70 ++++++++++++ src/myers/vendor.json | 13 +++ 8 files changed, 545 insertions(+) create mode 100644 src/merge3/COPYING.HEADER create mode 100644 src/merge3/merge3.ml create mode 100644 src/merge3/merge3.mli create mode 100644 src/merge3/vendor.json create mode 100644 src/myers/COPYING.HEADER create mode 100644 src/myers/myers.ml create mode 100644 src/myers/myers.mli create mode 100644 src/myers/vendor.json diff --git a/src/merge3/COPYING.HEADER b/src/merge3/COPYING.HEADER new file mode 100644 index 0000000..1122f86 --- /dev/null +++ b/src/merge3/COPYING.HEADER @@ -0,0 +1,3 @@ +central-merge3 - Myers shortest-edit-script computation +SPDX-FileCopyrightText: 2026 Mathieu Barbin +SPDX-License-Identifier: ISC diff --git a/src/merge3/merge3.ml b/src/merge3/merge3.ml new file mode 100644 index 0000000..0235bd9 --- /dev/null +++ b/src/merge3/merge3.ml @@ -0,0 +1,157 @@ +(****************************************************************************) +(* central-merge3 - Myers shortest-edit-script computation *) +(* SPDX-FileCopyrightText: 2026 Mathieu Barbin *) +(* SPDX-License-Identifier: ISC *) +(****************************************************************************) + +(* Copyright (c) 2024-2026 Thomas Gazagnaire + + Permission to use, copy, modify, and distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. *) + +(* Notice: This file was vendored from gazagnaire/ocaml-merge3 (the [Merge3] + module, [lib/merge3.ml]) as documented in [vendor.json] and the project's + root [NOTICE.md]. + + List of changes: + + - Applied local project ocamlformat (janestreet profile). + - Removed the parts unused by this project. + + - Replace use of globally-visible [Stdlib.Exit] exception by a custom one. + An [eq] that itself raised [Exit] would be silently swallowed and yield + a wrong diff. *) + +(** {1 Myers' O(ND) Diff Algorithm} + + E. W. Myers, "An O(ND) Difference Algorithm and Its Variations", + Algorithmica 1(2), 1986, pp. 251–266. + + The algorithm finds the shortest edit script (SES) between two sequences. It + works by computing the furthest-reaching D-paths for increasing edit + distances D = 0, 1, 2, ... The key insight is that diagonal k = x - y + represents a state where x characters from [a] and y from [b] have been + consumed, and only even/odd diagonals are reachable at each step. + + Time: O(ND) where N = |a| + |b| and D = edit distance. Space: O(D²) for the + trace (one V-array per step). *) + +type 'a edit = + | Keep of 'a + | Delete of 'a + | Insert of 'a + +(** Compute the furthest-reaching D-paths. + + Records snapshots of the active V range [-d..d] (size 2d+1) at each step + instead of the full V array (size 2*max_d+1). This is the standard Myers + space optimisation: at step d only diagonals -d..d are reachable, so the + rest of V is unused. The trace becomes O(D²) instead of O(D*N), which is a + substantial win when D ≪ N (typical for incremental edits). + + Returns [(D, trace)] where [trace.(d)] is an array of length [2*d+1] indexed + by [k+d] (so trace.(d).(0) holds V[-d], trace.(d).(2*d) holds V[d]). *) + +exception Myers_done + +let myers_forward ~eq ~off a b ~max_d = + let n = Array.length a + and m = Array.length b in + let vlen = (2 * max_d) + 1 in + let v = Array.make vlen 0 in + v.(off + 1) <- 0; + let trace = Array.make (max_d + 1) [||] in + let final_d = ref 0 in + (try + for d = 0 to max_d do + (* Snapshot only the active range used at step d (diagonals -d..d). *) + trace.(d) <- Array.sub v (off - d) ((2 * d) + 1); + for k0 = 0 to d do + let k = -d + (2 * k0) in + let x0 = + if k = -d || (k <> d && v.(off + k - 1) < v.(off + k + 1)) + then v.(off + k + 1) + else v.(off + k - 1) + 1 + in + let x = ref x0 + and y = ref (x0 - k) in + while !x < n && !y < m && eq a.(!x) b.(!y) do + incr x; + incr y + done; + v.(off + k) <- !x; + if !x >= n && !y >= m + then ( + final_d := d; + raise_notrace Myers_done) + done + done + with + | Myers_done -> ()); + !final_d, trace +;; + +(** Backtrack one step in the Myers trace, emitting the snake's [Keep] + operations and the single non-diagonal edit. Returns the previous [(x, y)] + position. + + [vv] is the snapshot at step [dd]: an array of length [2*dd+1] where + [vv.(k+dd)] holds the V value for diagonal [k]. *) +let backtrack_step ~vv ~dd ~x ~y a b edits = + let k = x - y in + (* The previous snapshot only has diagonals -(dd-1)..(dd-1), but we read + V[k-1] and V[k+1] from the current step's snapshot — those are guaranteed + to be in range because k ∈ [-dd, dd] and k±1 ∈ [-(dd+1), dd+1], but + critically when we make the choice we look at V[k-1] and V[k+1] from + the SAME snapshot (saved at the start of step dd, which is the V state + after step dd-1), so they're both in [-(dd-1), dd-1] ⊆ [-dd, dd]. *) + let v_at i = vv.(i + dd) in + let is_insert = k = -dd || (k <> dd && v_at (k - 1) < v_at (k + 1)) in + let snake_x = if is_insert then v_at (k + 1) else v_at (k - 1) + 1 in + for i = x - 1 downto snake_x do + edits := Keep a.(i) :: !edits + done; + if is_insert + then edits := Insert b.(snake_x - k - 1) :: !edits + else edits := Delete a.(snake_x - 1) :: !edits; + let prev_k = if is_insert then k + 1 else k - 1 in + let prev_x = v_at prev_k in + prev_x, prev_x - prev_k +;; + +let diff ~eq (a : 'a array) (b : 'a array) : 'a edit list = + let n = Array.length a + and m = Array.length b in + if n = 0 && m = 0 + then [] + else if n = 0 + then Array.to_list b |> List.map (fun x -> Insert x) + else if m = 0 + then Array.to_list a |> List.map (fun x -> Delete x) + else ( + let max_d = n + m in + let off = max_d in + let d, trace = myers_forward ~eq ~off a b ~max_d in + let edits = ref [] in + let x = ref n + and y = ref m in + for step = 0 to d - 1 do + let dd = d - step in + let nx, ny = backtrack_step ~vv:trace.(dd) ~dd ~x:!x ~y:!y a b edits in + x := nx; + y := ny + done; + for i = !x - 1 downto 0 do + edits := Keep a.(i) :: !edits + done; + !edits) +;; diff --git a/src/merge3/merge3.mli b/src/merge3/merge3.mli new file mode 100644 index 0000000..48a3ff0 --- /dev/null +++ b/src/merge3/merge3.mli @@ -0,0 +1,56 @@ +(*_***************************************************************************) +(*_ central-merge3 - Myers shortest-edit-script computation *) +(*_ SPDX-FileCopyrightText: 2026 Mathieu Barbin *) +(*_ SPDX-License-Identifier: ISC *) +(*_***************************************************************************) + +(*_ Copyright (c) 2024-2026 Thomas Gazagnaire + + Permission to use, copy, modify, and distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. *) + +(*_ Notice: This file was vendored from gazagnaire/ocaml-merge3 (the [Merge3] + module, [lib/merge3.mli]) as documented in [vendor.json] and the project's + root [NOTICE.md]. + + List of changes: + + - Applied local project ocamlformat (janestreet profile). + - Removed the parts unused by this project: [lcs], the diff3 3-way merge + ([merge], [to_string], [has_conflicts], [conflicts], [pp], the [conflict] + / [merged_chunk] / [t] types), and the Irmin-style merge combinators + ([default], [option], [pair], [alist]). + - Reworded [edit]'s per-constructor doc comments ("Element" -> "Line", + matching this project's line-oriented usage). *) + +(** Myers' O(ND) shortest-edit-script, vendored from gazagnaire/ocaml-merge3. + + Only the pure diff computation is vendored; see [merge3.ml] and the root + [NOTICE.md] for the list of (non-algorithmic) parts removed. *) + +(** An edit operation in the shortest edit script. *) +type 'a edit = + | Keep of 'a (** Line present in both sequences. *) + | Delete of 'a (** Line present in old, absent in new. *) + | Insert of 'a (** Line absent in old, present in new. *) + +(** [diff ~eq a b] computes the shortest edit script from [a] to [b] using + Myers' O(ND) algorithm. [eq] is the equality predicate. + + The result is a list of edits that transforms [a] into [b]: + - [Keep x]: line [x] is present in both + - [Delete x]: line [x] from [a] is removed + - [Insert x]: line [x] from [b] is added + + Time: O(ND) where N = |a| + |b| and D = edit distance. Space: O(D²) for the + trace. *) +val diff : eq:('a -> 'a -> bool) -> 'a array -> 'a array -> 'a edit list diff --git a/src/merge3/vendor.json b/src/merge3/vendor.json new file mode 100644 index 0000000..418e26b --- /dev/null +++ b/src/merge3/vendor.json @@ -0,0 +1,13 @@ +{ + "note": "This directory vendors the Myers shortest-edit-script computation from gazagnaire/ocaml-merge3. See the in-file Notice block for the list of changes.", + "sources": [ + { + "file": "merge3.ml", + "description": "Myers shortest-edit-script computation", + "url": "https://tangled.sh/@gazagnaire.org/monopampam", + "gitRev": "f09e84825149d6b76365bd7af347577182fe9c79", + "path": "ocaml-merge3/lib/merge3.ml", + "license": "ISC" + } + ] +} diff --git a/src/myers/COPYING.HEADER b/src/myers/COPYING.HEADER new file mode 100644 index 0000000..3089d9f --- /dev/null +++ b/src/myers/COPYING.HEADER @@ -0,0 +1,3 @@ +central-myers - Unified-diff renderer built on a vendored Myers algorithm +SPDX-FileCopyrightText: 2026 Mathieu Barbin +SPDX-License-Identifier: ISC diff --git a/src/myers/myers.ml b/src/myers/myers.ml new file mode 100644 index 0000000..b7e8548 --- /dev/null +++ b/src/myers/myers.ml @@ -0,0 +1,230 @@ +(*******************************************************************************) +(* central-myers - Unified-diff renderer built on a vendored Myers algorithm *) +(* SPDX-FileCopyrightText: 2026 Mathieu Barbin *) +(* SPDX-License-Identifier: ISC *) +(*******************************************************************************) + +(* Copyright (c) 2026 Invariant Systems. All rights reserved. + + Permission to use, copy, modify, and/or distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. *) + +(* Notice: The unified-diff renderer and the [Equal] / [Line] / [compute] API + shape in this file were vendored from windtrap (the [Myers] module, + [lib/myers/myers.ml]) as documented in [vendor.json] and the project's root + [NOTICE.md]. Only the shortest-edit-script computation was replaced (it now + lives in [merge3.ml], vendored from gazagnaire/ocaml-merge3); the rendering + logic — [type hunk], [lines_of_string], [hunks_of_lines], [diff] — derives + from windtrap. + + List of changes relative to windtrap: + + - Applied local project ocamlformat (janestreet profile). + - [compute] delegates to the vendored {!Merge3.diff}. + - Hunk lines use the [Line] variant instead of windtrap's [(char * string)] + encoding ([type hunk.lines : string Line.t list]). + - [lines_of_string] returns a [string list] and the intermediate [Array] + representation in [hunks_of_lines] was removed. + - Diff rendering tweaks: line prefixes are ["-|"] / ["+|"] / [" "], and the + [--- / +++] header is emitted only when a label is explicitly provided + (windtrap always emitted it with the defaults "expected" / "actual"). + - Added an optional [?color:bool] flag to [diff] for ANSI coloring (cyan + hunk headers, red deletions, green insertions). + - [print_diff] is not exposed (this project only uses [diff]). *) + +module type Equal = sig + type t + + val equal : t -> t -> bool +end + +module Line = struct + (* Defined equal to [Merge3.edit] so [compute] needs no conversion and + [Merge3] need not be exposed. *) + type 'a t = 'a Merge3.edit = + | Keep of 'a + | Delete of 'a + | Insert of 'a +end + +let compute (type a) (module E : Equal with type t = a) (before : a list) (after : a list) + : a Line.t list + = + Merge3.diff ~eq:E.equal (Array.of_list before) (Array.of_list after) +;; + +let lines_of_string s = + let parts = String.split_on_char '\n' s in + match List.rev parts with + | "" :: rev_rest -> List.rev rev_rest + | _ -> parts +;; + +type hunk = + { exp_start : int + ; exp_len : int + ; act_start : int + ; act_len : int + ; lines : string Line.t list + } + +let hunks_of_lines ~context expected actual = + let ops = compute (module String) expected actual in + let pre = Queue.create () in + let hunks_rev = ref [] in + let in_hunk = ref false in + let trailing = ref 0 in + let cur_lines_rev = ref [] in + let cur_exp_start = ref 0 in + let cur_act_start = ref 0 in + let cur_exp_len = ref 0 in + let cur_act_len = ref 0 in + let exp_line = ref 1 in + let act_line = ref 1 in + let queue_trim () = + while Queue.length pre > context do + ignore (Queue.take pre : string) + done + in + let queue_to_list () = + let acc = ref [] in + Queue.iter (fun x -> acc := x :: !acc) pre; + List.rev !acc + in + let start_hunk () = + in_hunk := true; + let pre_lines = queue_to_list () in + Queue.clear pre; + cur_lines_rev := List.rev_map (fun l -> Line.Keep l) pre_lines; + cur_exp_start := !exp_line - List.length pre_lines; + cur_act_start := !act_line - List.length pre_lines; + cur_exp_len := List.length pre_lines; + cur_act_len := List.length pre_lines; + trailing := 0 + in + let finish_hunk () = + if !in_hunk + then ( + let h = + { exp_start = !cur_exp_start + ; exp_len = !cur_exp_len + ; act_start = !cur_act_start + ; act_len = !cur_act_len + ; lines = List.rev !cur_lines_rev + } + in + hunks_rev := h :: !hunks_rev; + cur_lines_rev := []; + in_hunk := false; + trailing := 0) + in + let add_hunk_line (line : string Line.t) = + cur_lines_rev := line :: !cur_lines_rev; + match line with + | Keep _ -> + cur_exp_len := !cur_exp_len + 1; + cur_act_len := !cur_act_len + 1 + | Delete _ -> cur_exp_len := !cur_exp_len + 1 + | Insert _ -> cur_act_len := !cur_act_len + 1 + in + List.iter + (function + | Line.Keep line -> + if !in_hunk + then + if !trailing > 0 + then ( + add_hunk_line (Keep line); + trailing := !trailing - 1) + else ( + finish_hunk (); + Queue.add line pre; + queue_trim ()) + else ( + Queue.add line pre; + queue_trim ()); + exp_line := !exp_line + 1; + act_line := !act_line + 1 + | Line.Delete line -> + if not !in_hunk then start_hunk (); + add_hunk_line (Delete line); + trailing := context; + exp_line := !exp_line + 1 + | Line.Insert line -> + if not !in_hunk then start_hunk (); + add_hunk_line (Insert line); + trailing := context; + act_line := !act_line + 1) + ops; + finish_hunk (); + List.rev !hunks_rev +;; + +let diff ?(context = 3) ?(color = false) ?expected_label ?actual_label expected actual = + let a = lines_of_string expected in + let b = lines_of_string actual in + let hunks = hunks_of_lines ~context a b in + let buf = Buffer.create 2048 in + let reset = if color then "\027[m" else "" in + let red = if color then "\027[31m" else "" in + let green = if color then "\027[32m" else "" in + let cyan = if color then "\027[36m" else "" in + if Option.is_some expected_label || Option.is_some actual_label + then + Buffer.add_string + buf + (Printf.sprintf + "--- %s\n+++ %s\n" + (Option.value expected_label ~default:"expected") + (Option.value actual_label ~default:"actual")); + (* Reorder lines in each change group so deletions appear before insertions. *) + let output_line (line : string Line.t) = + Buffer.add_string + buf + (match line with + | Delete line -> Printf.sprintf "%s-|%s%s\n" red line reset + | Insert line -> Printf.sprintf "%s+|%s%s\n" green line reset + | Keep line -> Printf.sprintf " %s\n" line) + in + let flush_changes dels adds = + List.iter output_line (List.rev dels); + List.iter output_line (List.rev adds) + in + List.iter + (fun h -> + Buffer.add_string + buf + (Printf.sprintf + "%s@@ -%d,%d +%d,%d @@%s\n" + cyan + h.exp_start + h.exp_len + h.act_start + h.act_len + reset); + let dels = ref [] in + let adds = ref [] in + List.iter + (fun (line : string Line.t) -> + match line with + | Delete _ -> dels := line :: !dels + | Insert _ -> adds := line :: !adds + | Keep _ -> + flush_changes !dels !adds; + dels := []; + adds := []; + output_line line) + h.lines; + flush_changes !dels !adds) + hunks; + Buffer.contents buf +;; diff --git a/src/myers/myers.mli b/src/myers/myers.mli new file mode 100644 index 0000000..91e277a --- /dev/null +++ b/src/myers/myers.mli @@ -0,0 +1,70 @@ +(*_******************************************************************************) +(*_ central-myers - Unified-diff renderer built on a vendored Myers algorithm *) +(*_ SPDX-FileCopyrightText: 2026 Mathieu Barbin *) +(*_ SPDX-License-Identifier: ISC *) +(*_******************************************************************************) + +(*_ Copyright (c) 2026 Invariant Systems. All rights reserved. + + Permission to use, copy, modify, and/or distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. *) + +(*_ Notice: The unified-diff renderer and the [Equal] / [Line] / [compute] API + shape in this file were vendored from windtrap (the [Myers] module, + [lib/myers/myers.mli]) as documented in [vendor.json] and the project's root + [NOTICE.md]. Only the shortest-edit-script computation was replaced (it now + lives in [merge3.mli], vendored from gazagnaire/ocaml-merge3); the rest of + the interface derives from windtrap. + + List of changes relative to windtrap: + + - Applied local project ocamlformat (janestreet profile), including + reordering [Line.t]'s constructors and dropping their per-constructor + doc comments. + - Added an optional [?color:bool] flag to [diff] for ANSI coloring. + - [print_diff] is not exposed (this project only uses [diff]). *) + +(** Unified-diff renderer built on a vendored Myers shortest-edit-script. + + The renderer is vendored from windtrap; the shortest-edit-script + computation is vendored from gazagnaire/ocaml-merge3 (kept as a private + [Merge3] module). See [myers.ml], [merge3.ml] and the root [NOTICE.md] for + provenance and the list of changes. *) + +module type Equal = sig + type t + + val equal : t -> t -> bool +end + +module Line : sig + type 'a t = + | Keep of 'a + | Delete of 'a + | Insert of 'a +end + +(** [compute (module E) before after] returns a shortest edit script from + [before] to [after], computed with Myers' O(ND) algorithm. *) +val compute : (module Equal with type t = 'a) -> 'a list -> 'a list -> 'a Line.t list + +(** [diff expected actual] renders a unified diff for text inputs. When [color] + is [true], ANSI escape codes are used: cyan for hunk headers, red for + deletions, green for insertions. *) +val diff + : ?context:int + -> ?color:bool + -> ?expected_label:string + -> ?actual_label:string + -> string + -> string + -> string diff --git a/src/myers/vendor.json b/src/myers/vendor.json new file mode 100644 index 0000000..df76531 --- /dev/null +++ b/src/myers/vendor.json @@ -0,0 +1,13 @@ +{ + "note": "This directory vendors the unified-diff renderer and the Equal/Line/compute API shape from windtrap. The Myers shortest-edit-script computation it calls into lives in ../merge3, vendored separately from gazagnaire/ocaml-merge3. See the in-file Notice block for the list of changes.", + "sources": [ + { + "file": "myers.ml", + "description": "Unified-diff renderer and Equal/Line/compute API shape", + "url": "https://github.com/invariant-hq/windtrap.git", + "gitRev": "5a6d0e2f470047306435a7d46e443d6eadb07be2", + "path": "lib/myers/myers.ml", + "license": "ISC" + } + ] +} From 355bd89200f8c2ef1dd7f02f5b88b8bc1c3fcfd5 Mon Sep 17 00:00:00 2001 From: Mathieu Barbin Date: Mon, 17 Aug 2026 22:20:10 +0200 Subject: [PATCH 09/26] Add core lib --- src/central/central.ml | 10 +++ src/central/central.mli | 17 ++++ src/central/next_step.ml | 151 ++++++++++++++++++++++++++++++++++++ src/central/next_step.mli | 68 ++++++++++++++++ src/central/repo_config.ml | 47 +++++++++++ src/central/repo_config.mli | 44 +++++++++++ src/central/subrepo.ml | 41 ++++++++++ src/central/subrepo.mli | 33 ++++++++ src/central/user_config.ml | 43 ++++++++++ src/central/user_config.mli | 34 ++++++++ 10 files changed, 488 insertions(+) create mode 100644 src/central/central.ml create mode 100644 src/central/central.mli create mode 100644 src/central/next_step.ml create mode 100644 src/central/next_step.mli create mode 100644 src/central/repo_config.ml create mode 100644 src/central/repo_config.mli create mode 100644 src/central/subrepo.ml create mode 100644 src/central/subrepo.mli create mode 100644 src/central/user_config.ml create mode 100644 src/central/user_config.mli diff --git a/src/central/central.ml b/src/central/central.ml new file mode 100644 index 0000000..7b1b5af --- /dev/null +++ b/src/central/central.ml @@ -0,0 +1,10 @@ +(*********************************************************************************) +(* central - Manage history between sub-repos and their monorepo *) +(* SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(* SPDX-License-Identifier: MIT *) +(*********************************************************************************) + +module Next_step = Next_step +module Repo_config = Repo_config +module Subrepo = Subrepo +module User_config = User_config diff --git a/src/central/central.mli b/src/central/central.mli new file mode 100644 index 0000000..66daabd --- /dev/null +++ b/src/central/central.mli @@ -0,0 +1,17 @@ +(*_********************************************************************************) +(*_ central - Manage history between sub-repos and their monorepo *) +(*_ SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(*_ SPDX-License-Identifier: MIT *) +(*_********************************************************************************) + +(** [central] is the core library backing the [central] CLI. + + Central helps manage changes and git history between individual + sub-repos and a monorepo that aggregates them, allowing changes to be + promoted bidirectionally between the two. This project is under active + development; modules are added here as they are made available. *) + +module Next_step = Next_step +module Repo_config = Repo_config +module Subrepo = Subrepo +module User_config = User_config diff --git a/src/central/next_step.ml b/src/central/next_step.ml new file mode 100644 index 0000000..256e203 --- /dev/null +++ b/src/central/next_step.ml @@ -0,0 +1,151 @@ +(*********************************************************************************) +(* central - Manage history between sub-repos and their monorepo *) +(* SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(* SPDX-License-Identifier: MIT *) +(*********************************************************************************) + +type t = + | Advance_main + | Advance_subrepo + | Export + | Import + | Push + +let all = [ Advance_main; Advance_subrepo; Export; Import; Push ] +let equal : t -> t -> bool = Stdlib.( = ) +let compare : t -> t -> int = Stdlib.compare +let hash : t -> int = Stdlib.Hashtbl.hash + +let to_dyn = function + | Advance_main -> Dyn.Variant ("Advance_main", []) + | Advance_subrepo -> Dyn.Variant ("Advance_subrepo", []) + | Export -> Dyn.Variant ("Export", []) + | Import -> Dyn.Variant ("Import", []) + | Push -> Dyn.Variant ("Push", []) +;; + +let to_string_hum = function + | Advance_main -> "advance-main" + | Advance_subrepo -> "advance-subrepo" + | Export -> "export" + | Import -> "import" + | Push -> "push" +;; + +let to_string = to_string_hum + +module Facts = struct + module Subrepo_head_status = struct + type t = + | Unknown_central_gitrepo_rev + | Central_gitrepo_rev_compared_to_subrepo_head of + { descendance : Vcs.Graph.Descendance.t } + + let all = + Unknown_central_gitrepo_rev + :: List.map Vcs.Graph.Descendance.all ~f:(fun descendance -> + Central_gitrepo_rev_compared_to_subrepo_head { descendance }) + ;; + + let to_dyn = function + | Unknown_central_gitrepo_rev -> Dyn.Variant ("Unknown_central_gitrepo_rev", []) + | Central_gitrepo_rev_compared_to_subrepo_head { descendance } -> + Dyn.inline_record + "Central_gitrepo_rev_compared_to_subrepo_head" + [ "descendance", Vcs.Graph.Descendance.to_dyn descendance ] + ;; + end + + type t = + { central_has_changes_in_subrepo : bool + ; subrepo_head_status : Subrepo_head_status.t + ; main_is_strict_ancestor_of_subrepo : bool + ; remote_main_is_strict_ancestor_of_local_main : bool + } + + let all : t list = + let ( let* ) x f = List.concat_map x ~f in + let bool = [ false; true ] in + let* central_has_changes_in_subrepo = bool in + let* subrepo_head_status = Subrepo_head_status.all in + let* main_is_strict_ancestor_of_subrepo = bool in + let* remote_main_is_strict_ancestor_of_local_main = bool in + [ { central_has_changes_in_subrepo + ; subrepo_head_status + ; main_is_strict_ancestor_of_subrepo + ; remote_main_is_strict_ancestor_of_local_main + } + ] + ;; + + let to_dyn t = + Dyn.Record + [ "central_has_changes_in_subrepo", Dyn.bool t.central_has_changes_in_subrepo + ; "subrepo_head_status", Subrepo_head_status.to_dyn t.subrepo_head_status + ; ( "main_is_strict_ancestor_of_subrepo" + , Dyn.bool t.main_is_strict_ancestor_of_subrepo ) + ; ( "remote_main_is_strict_ancestor_of_local_main" + , Dyn.bool t.remote_main_is_strict_ancestor_of_local_main ) + ] + ;; +end + +let next_step_priority = function + | Advance_main -> 0 + | Advance_subrepo -> 1 + | Import -> 2 + | Export -> 3 + | Push -> 4 +;; + +let is_applicable + (t : t) + ~facts: + { Facts.central_has_changes_in_subrepo + ; subrepo_head_status + ; main_is_strict_ancestor_of_subrepo + ; remote_main_is_strict_ancestor_of_local_main + } + = + match t with + | Advance_main -> main_is_strict_ancestor_of_subrepo + | Advance_subrepo -> + (match subrepo_head_status with + | Central_gitrepo_rev_compared_to_subrepo_head { descendance = Strict_descendant } -> + true + | Central_gitrepo_rev_compared_to_subrepo_head + { descendance = Same_node | Strict_ancestor | Other } + | Unknown_central_gitrepo_rev -> false) + | Export -> + central_has_changes_in_subrepo + && + (match subrepo_head_status with + | Central_gitrepo_rev_compared_to_subrepo_head { descendance = Same_node } -> true + | Central_gitrepo_rev_compared_to_subrepo_head + { descendance = Other | Strict_ancestor | Strict_descendant } + | Unknown_central_gitrepo_rev -> false) + | Import -> + (* Unlike [Export], this doesn't also require + [not central_has_changes_in_subrepo]: when central has no changes of + its own under the subrepo path, [import] applies the subrepo's diff + directly onto HEAD; otherwise it builds its commit as a child of the + last sync point and merges it in, so either way it's just as capable + of bringing the subrepo's new commits in when central *also* has + local changes of its own under the subrepo path - any conflict + between the two surfaces as an ordinary merge conflict, the same way + it would if central's local changes had landed after the import + instead of before it. *) + (match subrepo_head_status with + | Central_gitrepo_rev_compared_to_subrepo_head { descendance = Strict_ancestor } -> + true + | Central_gitrepo_rev_compared_to_subrepo_head + { descendance = Same_node | Other | Strict_descendant } + | Unknown_central_gitrepo_rev -> false) + | Push -> remote_main_is_strict_ancestor_of_local_main +;; + +let compute facts = + let applicable_next_steps = List.filter all ~f:(fun t -> is_applicable t ~facts) in + List.min_elt applicable_next_steps ~compare:(fun t1 t2 -> + Int.compare (next_step_priority t1) (next_step_priority t2)) +;; diff --git a/src/central/next_step.mli b/src/central/next_step.mli new file mode 100644 index 0000000..54b9d19 --- /dev/null +++ b/src/central/next_step.mli @@ -0,0 +1,68 @@ +(*_********************************************************************************) +(*_ central - Manage history between sub-repos and their monorepo *) +(*_ SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(*_ SPDX-License-Identifier: MIT *) +(*_********************************************************************************) + +(** The next most logical step to make progress on a sub-repo. + + Only the cases covered so far ([advance-main], [advance-subrepo], + [export], [import], [push]) are represented here; more will be added as + the CLI grows. *) + +type t = + | Advance_main + | Advance_subrepo + | Export + | Import + | Push + +val all : t list +val equal : t -> t -> bool +val compare : t -> t -> int +val hash : t -> int +val to_dyn : t -> Dyn.t +val to_string_hum : t -> string + +(** An alias for {!to_string_hum}. *) +val to_string : t -> string + +module Facts : sig + (** The input type regroups all the facts that the computation of next step + depends on, from the perspective of a particular sub-repo. *) + + module Subrepo_head_status : sig + (** This type captures the relationship between the [commit] revision that + is indicated in the [.gitrepo] file against the location of the + [subrepo] branch in the subrepo. *) + type t = + | Unknown_central_gitrepo_rev + | Central_gitrepo_rev_compared_to_subrepo_head of + { descendance : Vcs.Graph.Descendance.t } + + val to_dyn : t -> Dyn.t + end + + type t = + { central_has_changes_in_subrepo : bool + (** Since the last [export], there are some changes in the monorepo in + the subrepo directory. *) + ; subrepo_head_status : Subrepo_head_status.t + ; main_is_strict_ancestor_of_subrepo : bool + (** As long as we rely on a [subrepo] branch, sometimes this branch + advances more than [main]. *) + ; remote_main_is_strict_ancestor_of_local_main : bool + } + + val all : t list + val to_dyn : t -> Dyn.t +end + +(** [compute input] computes the next step to make progress. *) +val compute : Facts.t -> t option + +(** Given the facts, tell whether a particular next-step is currently + applicable. Note that the todo may favor a different next-step as the + suggested way to make immediate progress in the case where several next-steps + are applicable. *) +val is_applicable : t -> facts:Facts.t -> bool diff --git a/src/central/repo_config.ml b/src/central/repo_config.ml new file mode 100644 index 0000000..d09223a --- /dev/null +++ b/src/central/repo_config.ml @@ -0,0 +1,47 @@ +(*********************************************************************************) +(* central - Manage history between sub-repos and their monorepo *) +(* SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(* SPDX-License-Identifier: MIT *) +(*********************************************************************************) + +type t = { root_repo_name : string } + +let root_repo_name t = t.root_repo_name +let default = { root_repo_name = "central" } +let create ?(root_repo_name = default.root_repo_name) () = { root_repo_name } +let to_json t : Json.t = `Assoc [ "rootRepoName", `String t.root_repo_name ] +let path_in_repo = Vcs.Path_in_repo.v ".central/repo-config.json" + +let of_json json ~loc : t = + match (json : Json.t) with + | `Assoc fields -> + let root_repo_name_ref = ref None in + List.iter fields ~f:(fun (field_name, value) -> + match field_name with + | "$schema" -> + (* This allows [$schema] to be present without causing an error. *) + () + | "rootRepoName" -> + (match value with + | `String s -> root_repo_name_ref := Some s + | _ -> + Err.raise + ~loc + [ Pp.text "Field \"rootRepoName\" expected to be a json string." ]) + | _ -> Err.raise ~loc [ Pp.textf "Unknown config field \"%s\"." field_name ]); + { root_repo_name = Option.value !root_repo_name_ref ~default:default.root_repo_name } + | _ -> Err.raise ~loc [ Pp.text "Config expected to be a json object." ] +;; + +let load_exn ~path = + let loc = Loc.of_file ~path in + match Yojson.Basic.from_file (Fpath.to_string path) with + | json -> of_json json ~loc + | exception Yojson.Json_error msg -> + Err.raise ~loc [ Pp.text "Not a valid json file."; Pp.text msg ] +;; + +let find_and_load ~repo_root = + let path = (Vcs.Repo_root.append repo_root path_in_repo :> Fpath.t) in + if Sys.file_exists (Fpath.to_string path) then load_exn ~path else default +;; diff --git a/src/central/repo_config.mli b/src/central/repo_config.mli new file mode 100644 index 0000000..6e60056 --- /dev/null +++ b/src/central/repo_config.mli @@ -0,0 +1,44 @@ +(*_********************************************************************************) +(*_ central - Manage history between sub-repos and their monorepo *) +(*_ SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(*_ SPDX-License-Identifier: MIT *) +(*_********************************************************************************) + +(** Per-repository configuration, optionally read from + [.central/repo-config.json] at the root of the enclosing monorepo. + + We are using JSON as the serialization format. This is an early, + minimal version of the config - more fields will be added as the CLI + grows. See [schema/central-repo-config.schema.json] at the root of this + repository. *) + +type t + +val to_json : t -> Json.t + +(** {1 Fields} *) + +(** The name of the monorepo itself, as shown e.g. in [central todo]'s + table, and used to resolve [central] as a "which repos" selector on the + command line. Defaults to ["central"]. *) +val root_repo_name : t -> string + +(** {1 Create configs} *) + +val create : ?root_repo_name:string -> unit -> t + +(** The config used when no [.central/repo-config.json] file is found. *) +val default : t + +(** {1 Loading} *) + +(** [path_in_repo] is [.central/repo-config.json], relative to the root of + the enclosing monorepo. *) +val path_in_repo : Vcs.Path_in_repo.t + +val of_json : Json.t -> loc:Loc.t -> t +val load_exn : path:Fpath.t -> t + +(** [find_and_load ~repo_root] reads {!path_in_repo} under [repo_root] if + it exists, or returns {!default} otherwise. *) +val find_and_load : repo_root:Vcs.Repo_root.t -> t diff --git a/src/central/subrepo.ml b/src/central/subrepo.ml new file mode 100644 index 0000000..cd9e2ba --- /dev/null +++ b/src/central/subrepo.ml @@ -0,0 +1,41 @@ +(*********************************************************************************) +(* central - Manage history between sub-repos and their monorepo *) +(* SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(* SPDX-License-Identifier: MIT *) +(*********************************************************************************) + +module Id = String_id.Make (struct + let module_name = "Subrepo" + + (* A subrepo name is a directory name under [repo/]: non-empty, and not + itself a path (no "/"). *) + let invariant s = String.length s > 0 && not (String.contains s '/') + end) + +type t = Id.t + +let to_string = Id.to_string +let of_string = Id.of_string +let v = Id.v +let equal = Id.equal +let compare = Id.compare +let hash = Id.hash +let to_dyn = Id.to_dyn +let root t = Vcs.Path_in_repo.v (Printf.sprintf "repo/%s" (to_string t)) + +let gitrepo_file_path t = + Vcs.Path_in_repo.v (Printf.sprintf "repo/%s/.gitrepo" (to_string t)) +;; + +let all ~repo_root = + let subrepos_dir = Filename.concat (Vcs.Repo_root.to_string repo_root) "repo" in + match Sys.readdir subrepos_dir with + | exception Sys_error _ -> [] + | entries -> + Array.to_list entries + |> List.filter ~f:(fun name -> + let dir = Filename.concat subrepos_dir name in + Sys.is_directory dir && Sys.file_exists (Filename.concat dir ".gitrepo")) + |> List.sort ~cmp:String.compare + |> List.map ~f:v +;; diff --git a/src/central/subrepo.mli b/src/central/subrepo.mli new file mode 100644 index 0000000..d3f7cc2 --- /dev/null +++ b/src/central/subrepo.mli @@ -0,0 +1,33 @@ +(*_********************************************************************************) +(*_ central - Manage history between sub-repos and their monorepo *) +(*_ SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(*_ SPDX-License-Identifier: MIT *) +(*_********************************************************************************) + +(** Identifies one of the sub-repos vendored under [repo/] in the enclosing + monorepo. + + [t] is not a fixed, hand-maintained enum: it is just a validated string + (the directory name under [repo/]), and the set of known sub-repos is + discovered dynamically by {!all}, by walking the filesystem. *) + +type t + +val to_string : t -> string +val of_string : string -> (t, [ `Msg of string ]) Result.t +val v : string -> t +val equal : t -> t -> bool +val compare : t -> t -> int +val hash : t -> int +val to_dyn : t -> Dyn.t + +(** [repo/NAME], relative to the root of the enclosing monorepo. *) +val root : t -> Vcs.Path_in_repo.t + +(** [repo/NAME/.gitrepo], relative to the root of the enclosing monorepo. *) +val gitrepo_file_path : t -> Vcs.Path_in_repo.t + +(** Discover the sub-repos vendored under [repo/] in [repo_root]: this walks + its direct children and keeps the ones that contain a [.gitrepo] file. + The result is sorted by name. *) +val all : repo_root:Vcs.Repo_root.t -> t list diff --git a/src/central/user_config.ml b/src/central/user_config.ml new file mode 100644 index 0000000..930f6ac --- /dev/null +++ b/src/central/user_config.ml @@ -0,0 +1,43 @@ +(*********************************************************************************) +(* central - Manage history between sub-repos and their monorepo *) +(* SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(* SPDX-License-Identifier: MIT *) +(*********************************************************************************) + +type t = unit + +let create () = () +let to_json (() : t) : Json.t = `Assoc [] +let xdg = lazy (Xdg.create ~env:Stdlib.Sys.getenv_opt ()) + +let config_path = + lazy + (let config_dir = Xdg.config_dir (Lazy.force xdg) in + Fpath.(v config_dir / "central" / "user-config.json")) +;; + +let of_json json ~loc : t = + match (json : Json.t) with + | `Assoc fields -> + List.iter fields ~f:(fun (field_name, _) -> + match field_name with + | "$schema" -> + (* This allows [$schema] to be present without causing an error. *) + () + | _ -> Err.raise ~loc [ Pp.textf "Unknown config field \"%s\"." field_name ]) + | _ -> Err.raise ~loc [ Pp.text "Config expected to be a json object." ] +;; + +let load_exn ~path = + let loc = Loc.of_file ~path in + match Yojson.Basic.from_file (Fpath.to_string path) with + | json -> of_json json ~loc + | exception Yojson.Json_error msg -> + Err.raise ~loc [ Pp.text "Not a valid json file."; Pp.text msg ] +;; + +let default = + lazy + (let path = Lazy.force config_path in + if Sys.file_exists (Fpath.to_string path) then load_exn ~path else create ()) +;; diff --git a/src/central/user_config.mli b/src/central/user_config.mli new file mode 100644 index 0000000..83813b7 --- /dev/null +++ b/src/central/user_config.mli @@ -0,0 +1,34 @@ +(*_********************************************************************************) +(*_ central - Manage history between sub-repos and their monorepo *) +(*_ SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(*_ SPDX-License-Identifier: MIT *) +(*_********************************************************************************) + +(** Per-user configuration, read from the XDG config directory (typically + [~/.config/central/user-config.json]). + + We are using JSON as the serialization format. This is currently an + empty placeholder - fields will be added as the CLI grows. See + [schema/central-user-config.schema.json] at the root of this + repository. *) + +type t + +val to_json : t -> Json.t + +(** {1 Create configs} *) + +val create : unit -> t + +(** {1 Loading} *) + +(** [config_path] is the [central]-specific file under the XDG config + directory, typically [~/.config/central/user-config.json]. *) +val config_path : Fpath.t Lazy.t + +val of_json : Json.t -> loc:Loc.t -> t +val load_exn : path:Fpath.t -> t + +(** Reads {!config_path} if it exists, or returns a config equivalent to + {!create} otherwise. *) +val default : t Lazy.t From ac4321003c1e245506ad71fa8a23c533aebc666e Mon Sep 17 00:00:00 2001 From: Mathieu Barbin Date: Mon, 17 Aug 2026 22:20:45 +0200 Subject: [PATCH 10/26] Initiate cli --- src/bin/main.ml | 13 ++ src/cli/app_log.ml | 48 +++++ src/cli/app_log.mli | 18 ++ src/cli/central_cli.ml | 25 +++ src/cli/central_cli.mli | 20 ++ src/cli/central_log.ml | 13 ++ src/cli/central_log.mli | 11 + src/cli/cmd__advance_main.ml | 55 +++++ src/cli/cmd__advance_main.mli | 7 + src/cli/cmd__advance_subrepo.ml | 81 ++++++++ src/cli/cmd__advance_subrepo.mli | 7 + src/cli/cmd__export.ml | 201 ++++++++++++++++++ src/cli/cmd__export.mli | 47 +++++ src/cli/cmd__import.ml | 342 +++++++++++++++++++++++++++++++ src/cli/cmd__import.mli | 46 +++++ src/cli/cmd__push.ml | 136 ++++++++++++ src/cli/cmd__push.mli | 15 ++ src/cli/cmd__stitch.ml | 158 ++++++++++++++ src/cli/cmd__stitch.mli | 40 ++++ src/cli/cmd__todo.ml | 129 ++++++++++++ src/cli/cmd__todo.mli | 33 +++ src/cli/common_helpers.ml | 50 +++++ src/cli/common_helpers.mli | 31 +++ src/cli/gitrepo_update.ml | 39 ++++ src/cli/gitrepo_update.mli | 26 +++ src/cli/prompt.ml | 125 +++++++++++ src/cli/prompt.mli | 50 +++++ src/cli/subrepo_facts.ml | 198 ++++++++++++++++++ src/cli/subrepo_facts.mli | 60 ++++++ src/cli/vcs_extra.ml | 99 +++++++++ src/cli/vcs_extra.mli | 41 ++++ src/cli/which_repos.ml | 123 +++++++++++ src/cli/which_repos.mli | 48 +++++ 33 files changed, 2335 insertions(+) create mode 100644 src/bin/main.ml create mode 100644 src/cli/app_log.ml create mode 100644 src/cli/app_log.mli create mode 100644 src/cli/central_cli.ml create mode 100644 src/cli/central_cli.mli create mode 100644 src/cli/central_log.ml create mode 100644 src/cli/central_log.mli create mode 100644 src/cli/cmd__advance_main.ml create mode 100644 src/cli/cmd__advance_main.mli create mode 100644 src/cli/cmd__advance_subrepo.ml create mode 100644 src/cli/cmd__advance_subrepo.mli create mode 100644 src/cli/cmd__export.ml create mode 100644 src/cli/cmd__export.mli create mode 100644 src/cli/cmd__import.ml create mode 100644 src/cli/cmd__import.mli create mode 100644 src/cli/cmd__push.ml create mode 100644 src/cli/cmd__push.mli create mode 100644 src/cli/cmd__stitch.ml create mode 100644 src/cli/cmd__stitch.mli create mode 100644 src/cli/cmd__todo.ml create mode 100644 src/cli/cmd__todo.mli create mode 100644 src/cli/common_helpers.ml create mode 100644 src/cli/common_helpers.mli create mode 100644 src/cli/gitrepo_update.ml create mode 100644 src/cli/gitrepo_update.mli create mode 100644 src/cli/prompt.ml create mode 100644 src/cli/prompt.mli create mode 100644 src/cli/subrepo_facts.ml create mode 100644 src/cli/subrepo_facts.mli create mode 100644 src/cli/vcs_extra.ml create mode 100644 src/cli/vcs_extra.mli create mode 100644 src/cli/which_repos.ml create mode 100644 src/cli/which_repos.mli diff --git a/src/bin/main.ml b/src/bin/main.ml new file mode 100644 index 0000000..568265c --- /dev/null +++ b/src/bin/main.ml @@ -0,0 +1,13 @@ +(*********************************************************************************) +(* central - Manage history between sub-repos and their monorepo *) +(* SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(* SPDX-License-Identifier: MIT *) +(*********************************************************************************) + +let version = + match Build_info.V1.version () with + | None -> "n/a" + | Some v -> Build_info.V1.Version.to_string v [@coverage off] +;; + +let () = Cmdlang_cmdliner_err_runner.run Central_cli.main ~name:"central" ~version diff --git a/src/cli/app_log.ml b/src/cli/app_log.ml new file mode 100644 index 0000000..956f762 --- /dev/null +++ b/src/cli/app_log.ml @@ -0,0 +1,48 @@ +(*********************************************************************************) +(* central - Manage history between sub-repos and their monorepo *) +(* SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(* SPDX-License-Identifier: MIT *) +(*********************************************************************************) + +module Status = struct + type t = + [ `Ok + | `Fail + | `Skip + ] + + let to_string = function + | `Ok -> " OK " + | `Fail -> "FAIL" + | `Skip -> "SKIP" + ;; +end + +let result status pp = + Log.app (fun () -> + Pp.O. + [ Pp_tty.brackets + (Pp_tty.ansi + (module Status) + status + (match status with + | `Ok -> [ `Fg_green ] + | `Fail -> [ `Fg_red ] + | `Skip -> [ `Fg_yellow ])) + ++ Pp.space + ++ pp + ]) +;; + +module String = struct + type t = string + + let to_string s = s +end + +let success pp = result `Ok pp +let skip pp = result `Skip pp + +let status pp = + Log.app (fun () -> Pp.O.[ Pp_tty.kwd (module String) "-" ++ Pp.verbatim " " ++ pp ]) +;; diff --git a/src/cli/app_log.mli b/src/cli/app_log.mli new file mode 100644 index 0000000..30e3ec6 --- /dev/null +++ b/src/cli/app_log.mli @@ -0,0 +1,18 @@ +(*_********************************************************************************) +(*_ central - Manage history between sub-repos and their monorepo *) +(*_ SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(*_ SPDX-License-Identifier: MIT *) +(*_********************************************************************************) + +(** A module to log messages at the application level. *) + +(** A general log message, such as writing a message before running a slow + action. *) +val status : Pp_tty.t -> unit + +(** A success message reported after the fact. *) +val success : Pp_tty.t -> unit + +(** {1 Skipping elements} *) + +val skip : Pp_tty.t -> unit diff --git a/src/cli/central_cli.ml b/src/cli/central_cli.ml new file mode 100644 index 0000000..588aeaf --- /dev/null +++ b/src/cli/central_cli.ml @@ -0,0 +1,25 @@ +(*********************************************************************************) +(* central - Manage history between sub-repos and their monorepo *) +(* SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(* SPDX-License-Identifier: MIT *) +(*********************************************************************************) + +let main = + Command.group + ~summary:"Central CLI" + [ "advance-main", Cmd__advance_main.main + ; "advance-subrepo", Cmd__advance_subrepo.main + ; "export", Cmd__export.main + ; "import", Cmd__import.main + ; "push", Cmd__push.main + ; "stitch", Cmd__stitch.main + ; "todo", Cmd__todo.main + ] +;; + +module Cmd__advance_main = Cmd__advance_main +module Cmd__advance_subrepo = Cmd__advance_subrepo +module Cmd__export = Cmd__export +module Cmd__import = Cmd__import +module Cmd__push = Cmd__push +module Cmd__stitch = Cmd__stitch diff --git a/src/cli/central_cli.mli b/src/cli/central_cli.mli new file mode 100644 index 0000000..24797a6 --- /dev/null +++ b/src/cli/central_cli.mli @@ -0,0 +1,20 @@ +(*_********************************************************************************) +(*_ central - Manage history between sub-repos and their monorepo *) +(*_ SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(*_ SPDX-License-Identifier: MIT *) +(*_********************************************************************************) + +val main : unit Command.t + +(** [central_cli] is otherwise an "unwrapped main module" that only exposes + {!val:main} - these submodules are exposed separately so that other + tools built on top of the same conventions (a monorepo with subrepos + vendored under [repo//]) can embed these commands directly, + without going through command-line parsing or a subprocess. *) +module Cmd__advance_main = Cmd__advance_main + +module Cmd__advance_subrepo = Cmd__advance_subrepo +module Cmd__export = Cmd__export +module Cmd__import = Cmd__import +module Cmd__push = Cmd__push +module Cmd__stitch = Cmd__stitch diff --git a/src/cli/central_log.ml b/src/cli/central_log.ml new file mode 100644 index 0000000..b42e33d --- /dev/null +++ b/src/cli/central_log.ml @@ -0,0 +1,13 @@ +(*********************************************************************************) +(* central - Manage history between sub-repos and their monorepo *) +(* SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(* SPDX-License-Identifier: MIT *) +(*********************************************************************************) + +let skip_step step = + App_log.skip + Pp.O.( + Pp.text "Skipping " + ++ Pp_tty.id (module Central.Next_step) step + ++ Pp.text " (not applicable).") +;; diff --git a/src/cli/central_log.mli b/src/cli/central_log.mli new file mode 100644 index 0000000..d590abb --- /dev/null +++ b/src/cli/central_log.mli @@ -0,0 +1,11 @@ +(*_********************************************************************************) +(*_ central - Manage history between sub-repos and their monorepo *) +(*_ SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(*_ SPDX-License-Identifier: MIT *) +(*_********************************************************************************) + +(** A module to log messages at the application level. *) + +(** A convenient wrapper for [skip] dedicated to skipping actions when a + next-step is not applicable. *) +val skip_step : Central.Next_step.t -> unit diff --git a/src/cli/cmd__advance_main.ml b/src/cli/cmd__advance_main.ml new file mode 100644 index 0000000..5aa730a --- /dev/null +++ b/src/cli/cmd__advance_main.ml @@ -0,0 +1,55 @@ +(*********************************************************************************) +(* central - Manage history between sub-repos and their monorepo *) +(* SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(* SPDX-License-Identifier: MIT *) +(*********************************************************************************) + +let main = + Command.make + ~summary:"Advance a subrepo's main branch to its subrepo branch." + ~readme:(fun () -> + "After having exported to the $(b,subrepo) branch, the $(b,main) branch is \ + typically behind, as a strict ancestor. This command checks out $(b,main), then \ + fast-forwards it to the revision the $(b,subrepo) branch is at.\n\n\ + This fails if $(b,main) is not an ancestor of $(b,subrepo).") + (let open Command.Std in + let+ () = Log_cli.set_config () + and+ force = + Arg.flag + [ "force" ] + ~doc: + "By default, this command only proceeds if the next step for the given \ + subrepo is advance-main (and does nothing otherwise). Pass this flag to \ + force advancing main even when this isn't the immediate next step." + and+ which_subrepos = Which_repos.Subrepos.arg in + let vcs = Volgo_git_unix.create () in + let cwd = Unix.getcwd () |> Absolute_path.v in + let central_root = Common_helpers.find_enclosing_repo_root vcs ~from:cwd in + let central_graph = Vcs.graph vcs ~repo_root:central_root in + let subrepos = Which_repos.Subrepos.resolve which_subrepos ~repo_root:central_root in + Which_repos.Subrepos.iter subrepos ~f:(fun subrepo -> + let facts = Subrepo_facts.compute ~vcs ~central_root ~central_graph ~subrepo in + let is_applicable = + Central.Next_step.is_applicable + Advance_main + ~facts:(Subrepo_facts.next_step_facts facts) + in + if is_applicable || force + then ( + let () = + Vcs.git + vcs + ~repo_root:(Subrepo_facts.subrepo_repo_root facts) + ~args:[ "checkout"; "main" ] + ~f:Vcs.Git.exit0 + in + let output = + Vcs.git + vcs + ~repo_root:(Subrepo_facts.subrepo_repo_root facts) + ~args:[ "merge"; "--ff-only"; "subrepo" ] + ~f:Vcs.Git.exit0_and_stdout + in + print_string output) + else Central_log.skip_step Advance_main)) +;; diff --git a/src/cli/cmd__advance_main.mli b/src/cli/cmd__advance_main.mli new file mode 100644 index 0000000..f01125d --- /dev/null +++ b/src/cli/cmd__advance_main.mli @@ -0,0 +1,7 @@ +(*_********************************************************************************) +(*_ central - Manage history between sub-repos and their monorepo *) +(*_ SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(*_ SPDX-License-Identifier: MIT *) +(*_********************************************************************************) + +val main : unit Command.t diff --git a/src/cli/cmd__advance_subrepo.ml b/src/cli/cmd__advance_subrepo.ml new file mode 100644 index 0000000..ed4c809 --- /dev/null +++ b/src/cli/cmd__advance_subrepo.ml @@ -0,0 +1,81 @@ +(*********************************************************************************) +(* central - Manage history between sub-repos and their monorepo *) +(* SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(* SPDX-License-Identifier: MIT *) +(*********************************************************************************) + +let main = + Command.make + ~summary:"Advance a subrepo's subrepo & main branches to the monorepo's commit." + ~readme:(fun () -> + "This command may occasionally be useful when moving from one computer to another. \ + By pulling the $(b,main) branch of the monorepo, you'll effectively access the \ + knowledge of where the $(b,subrepo) branches must point to in each subrepo. This \ + information is read from the $(b,.gitrepo) files.\n\n\ + This command advances both the $(b,main) and $(b,subrepo) branches in the \ + subrepos to these revisions (assuming such an update hasn't been done in a while \ + on that particular computer).") + (let open Command.Std in + let+ () = Log_cli.set_config () + and+ force = Arg.flag [ "force" ] ~doc:"Force advance even if not applicable." + and+ which_subrepos = Which_repos.Subrepos.arg in + let vcs = Volgo_git_unix.create () in + let cwd = Unix.getcwd () |> Absolute_path.v in + let central_root = Common_helpers.find_enclosing_repo_root vcs ~from:cwd in + let central_graph = Vcs.graph vcs ~repo_root:central_root in + let compute_facts ~subrepo = + Subrepo_facts.compute ~vcs ~central_root ~central_graph ~subrepo + in + let subrepos = Which_repos.Subrepos.resolve which_subrepos ~repo_root:central_root in + Which_repos.Subrepos.iter subrepos ~f:(fun subrepo -> + let facts = compute_facts ~subrepo in + let is_applicable = + Central.Next_step.is_applicable + Advance_subrepo + ~facts:(Subrepo_facts.next_step_facts facts) + in + if not (is_applicable || force) + then Central_log.skip_step Advance_subrepo + else ( + let gitrepo_rev = (Subrepo_facts.gitrepo_file facts).commit.txt in + let () = + Vcs.git + vcs + ~repo_root:(Subrepo_facts.subrepo_repo_root facts) + ~args:[ "checkout"; "subrepo" ] + ~f:Vcs.Git.exit0 + in + let output = + Vcs.git + vcs + ~repo_root:(Subrepo_facts.subrepo_repo_root facts) + ~args:[ "merge"; "--ff-only"; Vcs.Rev.to_string gitrepo_rev ] + ~f:Vcs.Git.exit0_and_stdout + in + print_string output; + let () = + Vcs.git + vcs + ~repo_root:(Subrepo_facts.subrepo_repo_root facts) + ~args:[ "checkout"; "main" ] + ~f:Vcs.Git.exit0 + in + (* And we advance main as well if applicable. *) + let facts = compute_facts ~subrepo in + let is_applicable = + Central.Next_step.is_applicable + Advance_main + ~facts:(Subrepo_facts.next_step_facts facts) + in + if not is_applicable + then Central_log.skip_step Advance_main + else ( + let output = + Vcs.git + vcs + ~repo_root:(Subrepo_facts.subrepo_repo_root facts) + ~args:[ "merge"; "--ff-only"; "subrepo" ] + ~f:Vcs.Git.exit0_and_stdout + in + print_string output)))) +;; diff --git a/src/cli/cmd__advance_subrepo.mli b/src/cli/cmd__advance_subrepo.mli new file mode 100644 index 0000000..f01125d --- /dev/null +++ b/src/cli/cmd__advance_subrepo.mli @@ -0,0 +1,7 @@ +(*_********************************************************************************) +(*_ central - Manage history between sub-repos and their monorepo *) +(*_ SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(*_ SPDX-License-Identifier: MIT *) +(*_********************************************************************************) + +val main : unit Command.t diff --git a/src/cli/cmd__export.ml b/src/cli/cmd__export.ml new file mode 100644 index 0000000..f8957ef --- /dev/null +++ b/src/cli/cmd__export.ml @@ -0,0 +1,201 @@ +(*********************************************************************************) +(* central - Manage history between sub-repos and their monorepo *) +(* SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(* SPDX-License-Identifier: MIT *) +(*********************************************************************************) + +(* This command computes the diff of everything that changed under the + subrepo's directory in the monorepo since the last sync recorded in its + [.gitrepo] file, strips the directory path prefix, and applies the result + as a single new commit onto the subrepo's [subrepo] branch, in the + subrepo repository itself. + + This deliberately squashes the whole range into a single commit rather + than replaying central's commits one by one - the caller supplies the + commit message to use with [-m]. *) + +(* [is_applicable] ties this guard to the same [Next_step.Export] predicate + that drives [next_step] - the two failure cases below are just its two + ways of being [false], spelled out with a specific, actionable message + instead of a generic "not applicable". [--force] lets the caller push + through the "branch moved" case at their own risk; there is no forcing an + empty export, so that case stays a hard error below regardless (see the + diff-emptiness check in [export]). *) +let verify_applicable ~subrepo ~subrepo_dir ~force facts = + let next_step_facts = Subrepo_facts.next_step_facts facts in + let is_applicable = Central.Next_step.is_applicable Export ~facts:next_step_facts in + if is_applicable || force + then () + else ( + match next_step_facts.subrepo_head_status with + | Central_gitrepo_rev_compared_to_subrepo_head { descendance = Same_node } -> + Err.raise + Pp.O. + [ Pp.text "Nothing to export: no changes under " + ++ Pp_tty.path (module String) subrepo_dir + ++ Pp.text " since the last sync." + ] + | Central_gitrepo_rev_compared_to_subrepo_head + { descendance = Strict_ancestor | Strict_descendant | Other } + | Unknown_central_gitrepo_rev -> + Err.raise + Pp.O. + [ Pp.text "The " + ++ Pp_tty.kwd (module String) "subrepo" + ++ Pp.text " branch of " + ++ Pp_tty.id (module Central.Subrepo) subrepo + ++ Pp.text " has moved since the last sync recorded in " + ++ Pp_tty.kwd (module String) ".gitrepo" + ++ Pp.text "." + ] + ~hints: + Pp.O. + [ Pp.text "Bring those changes into central first with " + ++ Pp_tty.kwd (module String) "central import" + ++ Pp.text " before exporting, or pass " + ++ Pp_tty.kwd (module String) "--force" + ++ Pp.text " to export anyway." + ]) +;; + +let compute_diff ~vcs ~central_root ~subrepo_dir ~gitrepo_path ~base_rev ~head_rev = + Vcs.git + vcs + ~repo_root:central_root + ~args: + [ "diff" + ; "--relative=" ^ subrepo_dir + ; Vcs.Rev.to_string base_rev + ; Vcs.Rev.to_string head_rev + ; "--" + ; subrepo_dir + ; ":(exclude)" ^ gitrepo_path + ] + ~f:Vcs.Git.exit0_and_stdout +;; + +let apply_patch_and_commit + ~vcs + ~subrepo_repo_root + ~subrepo_branch + ~commit_message + ~patch_file + = + Vcs.git + vcs + ~repo_root:subrepo_repo_root + ~args:[ "checkout"; Vcs.Branch_name.to_string subrepo_branch ] + ~f:Vcs.Git.exit0; + Vcs.git + vcs + ~repo_root:subrepo_repo_root + ~args:[ "apply"; "--3way"; "--index"; patch_file ] + ~f:Vcs.Git.exit0; + App_log.success (Pp.text "Applied patch in the subrepo."); + Vcs.commit vcs ~repo_root:subrepo_repo_root ~commit_message +;; + +let export ~vcs ~central_root ~subrepo ~message ?(force = false) () = + Common_helpers.ensure_clean_working_tree ~vcs ~repo_root:central_root; + let central_graph = Vcs.graph vcs ~repo_root:central_root in + let facts = Subrepo_facts.compute ~vcs ~central_root ~central_graph ~subrepo in + let subrepo_dir = Central.Subrepo.root subrepo |> Vcs.Path_in_repo.to_string in + verify_applicable ~subrepo ~subrepo_dir ~force facts; + Common_helpers.ensure_clean_working_tree + ~vcs + ~repo_root:(Subrepo_facts.subrepo_repo_root facts); + let gitrepo_file_path = Subrepo_facts.gitrepo_file_path facts in + let base_rev = Subrepo_facts.base facts in + let head_rev = Vcs.current_revision vcs ~repo_root:central_root in + let diff = + compute_diff + ~vcs + ~central_root + ~subrepo_dir + ~gitrepo_path:(Vcs.Path_in_repo.to_string gitrepo_file_path) + ~base_rev + ~head_rev + in + if String.equal (String.strip diff) "" + then + Err.raise + Pp.O. + [ Pp.text "Nothing to export: no changes under " + ++ Pp_tty.path (module String) subrepo_dir + ++ Pp.text " since the last sync." + ]; + let patch_file = Filename.temp_file "central-export" ".patch" in + Fun.protect + ~finally:(fun () -> + try Sys.remove patch_file with + | Sys_error _ -> ()) + (fun () -> + Out_channel.with_open_bin patch_file (fun oc -> Out_channel.output_string oc diff); + let subrepo_repo_root = Subrepo_facts.subrepo_repo_root facts in + let subrepo_branch = (Subrepo_facts.gitrepo_file facts).branch.txt in + let new_subrepo_rev = + apply_patch_and_commit + ~vcs + ~subrepo_repo_root + ~subrepo_branch + ~commit_message:(Vcs.Commit_message.v message) + ~patch_file + in + Gitrepo_update.update + ~repo_root:central_root + ~gitrepo_file_path + ~new_commit:new_subrepo_rev + ~new_parent:head_rev; + Vcs.add vcs ~repo_root:central_root ~path:gitrepo_file_path; + let (_ : Vcs.Rev.t) = + Vcs.commit + vcs + ~repo_root:central_root + ~commit_message: + (Vcs.Commit_message.v + (Printf.sprintf "export %s" (Central.Subrepo.to_string subrepo))) + in + App_log.success + Pp.O.( + Pp.text "Exported to " + ++ Pp_tty.id (module Central.Subrepo) subrepo + ++ Pp.text ".")) +;; + +let main = + Command.make + ~summary:"Export monorepo changes into a subrepo, as a single commit." + ~readme:(fun () -> + "This computes the diff of everything that changed under a subrepo's directory in \ + the monorepo since the last sync recorded in its $(b,.gitrepo) file, strips the \ + directory path prefix, and applies the result as a single new commit onto the tip \ + of the subrepo's $(b,subrepo) branch, directly in the subrepo repository.\n\n\ + This requires the $(b,subrepo) branch to not have moved since the last sync: if \ + new commits landed there in the meantime, bring them into central first, or pass \ + $(b,--force) to export anyway.\n\n\ + Several $(b,REPO)s may be given at once (or $(b,--all) for every subrepo), for \ + chores that make the same systematic change across many of them: each is exported \ + in turn, in the order given, printing a separator between repos; $(b,-m) is \ + reused as-is for every commit. Export stops at the first repo that fails, leaving \ + the ones after it untouched.") + (let open Command.Std in + let+ () = Log_cli.set_config () + and+ which_subrepos = Which_repos.Subrepos.arg + and+ message = + Arg.named + [ "m" ] + Param.string + ~docv:"MSG" + ~doc: + "Commit message to use for the commit created in the subrepo - reused as-is \ + for every subrepo when several are given." + and+ force = Arg.flag [ "force" ] ~doc:"Force export even if not applicable." in + let vcs = Volgo_git_unix.create () in + let cwd = Unix.getcwd () |> Absolute_path.v in + let central_root = Common_helpers.find_enclosing_repo_root vcs ~from:cwd in + let subrepos = Which_repos.Subrepos.resolve which_subrepos ~repo_root:central_root in + let do_export (subrepo : Central.Subrepo.t) : unit = + export ~vcs ~central_root ~subrepo ~message ~force () + in + Which_repos.Subrepos.iter subrepos ~f:do_export) +;; diff --git a/src/cli/cmd__export.mli b/src/cli/cmd__export.mli new file mode 100644 index 0000000..b309d5b --- /dev/null +++ b/src/cli/cmd__export.mli @@ -0,0 +1,47 @@ +(*_********************************************************************************) +(*_ central - Manage history between sub-repos and their monorepo *) +(*_ SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(*_ SPDX-License-Identifier: MIT *) +(*_********************************************************************************) + +(** [export ~vcs ~central_root ~subrepo ~message] is the implementation behind + [central export]. Exposed separately from {!val:main} so that tests can + drive it directly against a fake central repo, without going through + command-line parsing. See {!val:main} for the full behavior. + + Progress messages ("Applied patch...", "Exported to...") always go + through the normal [App_log]/[Logs] machinery - they only show up once a + reporter has been installed (e.g. via [Log_cli.set_config], as + {!val:main} does), so callers driving this programmatically (tests, other + commands) see nothing by default without any extra effort. + + The unified diff of the [.gitrepo] file before/after is logged at + [Debug] level - it only shows up when the caller has configured [Logs] + for debug output (e.g. the CLI's own [--verbosity=debug], via + {!val:main}'s [Log_cli.set_config]). + + [force] (default [false]) bypasses the guard that requires the subrepo's + [subrepo] branch to not have moved since the last sync - see + {!val:main}'s [--force]. It cannot make an empty export succeed: that + check stays unconditional. *) +val export + : vcs: + < Vcs.Trait.add + ; Vcs.Trait.commit + ; Vcs.Trait.current_revision + ; Vcs.Trait.git + ; Vcs.Trait.log + ; Vcs.Trait.name_status + ; Vcs.Trait.num_status + ; Vcs.Trait.refs + ; Vcs.Trait.show + ; .. > + Vcs.t + -> central_root:Vcs.Repo_root.t + -> subrepo:Central.Subrepo.t + -> message:string + -> ?force:bool + -> unit + -> unit + +val main : unit Command.t diff --git a/src/cli/cmd__import.ml b/src/cli/cmd__import.ml new file mode 100644 index 0000000..7cdb55e --- /dev/null +++ b/src/cli/cmd__import.ml @@ -0,0 +1,342 @@ +(*********************************************************************************) +(* central - Manage history between sub-repos and their monorepo *) +(* SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(* SPDX-License-Identifier: MIT *) +(*********************************************************************************) + +(* This command brings new commits from the subrepo's [subrepo] branch into + central. It picks between two ways of doing that, depending on whether + central has local changes of its own under the subrepo's directory since + the last sync recorded in [.gitrepo] - see + [Central.Next_step.Facts.central_has_changes_in_subrepo], computed once + by [Subrepo_facts] and reused here. + + The common case - central has no changes under the subrepo's directory + since the last sync: then that directory at central's current HEAD is, + by construction, byte-for-byte the same as it was at the last sync + point (nothing has touched it since), so the subrepo's own diff since + then is guaranteed to apply there too, exactly as well as it would at + the last sync point itself. There is nothing to merge, so we don't + build one: the subrepo's diff is applied straight onto central's + current checkout, as a single new commit that is a direct child of + HEAD - a plain, linear commit, same as any other. This is the default, + and it's what keeps history simple in the overwhelmingly common case + where nothing central did could possibly conflict with what the + subrepo brings in. See [apply_directly]. + + The fallback - central *does* have local changes of its own under the + subrepo's directory, which may or may not conflict with the incoming + ones: rather than applying the subrepo's diff directly onto central's + current HEAD (where it might collide with those local changes), we + build a new commit as a direct child of [Subrepo_facts.base] - the + central commit that last recorded a sync point in [.gitrepo]. At that + revision, the subrepo's directory is - by construction - in exactly the + state the subrepo was in as of the last sync, so the subrepo's own diff + since then is guaranteed to apply there cleanly, no matter what else + has happened on central's actual HEAD since. We call this the "import + commit". We then [git merge --no-ff] it into whatever branch was + checked out in central (normally [main]): an ordinary two-parent merge + between "HEAD, plus whatever central did since the base" and "the + base, plus the subrepo's new content" - so any real conflict between + the two is surfaced by git itself, the usual way, left for the caller + to resolve and commit. See [build_import_commit] and + [merge_import_commit]. + + Either way, the commit that carries the subrepo's new content also + updates [.gitrepo] to record the new sync point - it's the only commit + that touches [.gitrepo], so a later [export] finds it exactly like it + would after its own commit. *) + +(* [is_applicable] ties this guard to the same [Next_step.Import] predicate + that drives [next_step] - see there for why it doesn't care whether + central also has local changes of its own: [import] picks between + applying directly and merging precisely to handle that case either way. + The two cases below are its remaining ways of being [false], each + spelled out with a specific, actionable message instead of a generic + "not applicable". [--force] lets the caller push through either of them + at their own risk. *) +let verify_applicable ~subrepo ~force facts = + let next_step_facts = Subrepo_facts.next_step_facts facts in + let is_applicable = Central.Next_step.is_applicable Import ~facts:next_step_facts in + if is_applicable || force + then () + else ( + match next_step_facts.subrepo_head_status with + | Central_gitrepo_rev_compared_to_subrepo_head { descendance = Same_node } -> + Err.raise + Pp.O. + [ Pp.text "Nothing to import: the " + ++ Pp_tty.kwd (module String) "subrepo" + ++ Pp.text " branch of " + ++ Pp_tty.id (module Central.Subrepo) subrepo + ++ Pp.text " has not moved since the last sync." + ] + | Central_gitrepo_rev_compared_to_subrepo_head { descendance = Strict_ancestor } -> + (* Unreachable: this is exactly the case [is_applicable] requires. *) + assert false + | Central_gitrepo_rev_compared_to_subrepo_head + { descendance = Strict_descendant | Other } + | Unknown_central_gitrepo_rev -> + Err.raise + Pp.O. + [ Pp.text "Cannot import: the " + ++ Pp_tty.kwd (module String) "subrepo" + ++ Pp.text " branch of " + ++ Pp_tty.id (module Central.Subrepo) subrepo + ++ Pp.text " is not a descendant of the commit recorded in " + ++ Pp_tty.kwd (module String) ".gitrepo" + ++ Pp.text "." + ] + ~hints: + [ Pp.text + "This can happen if the subrepo branch was reset or rewritten \ + independently of central - this needs to be sorted out manually." + ]) +;; + +let compute_diff ~vcs ~subrepo_repo_root ~old_rev ~new_rev = + Vcs.git + vcs + ~repo_root:subrepo_repo_root + ~args:[ "diff"; Vcs.Rev.to_string old_rev; Vcs.Rev.to_string new_rev ] + ~f:Vcs.Git.exit0_and_stdout +;; + +(* A temporary, detached worktree at [base_rev] - so we can build the import + commit without disturbing whatever [central_root]'s own checkout + currently has checked out or in progress. *) +let with_temp_worktree vcs ~central_root ~base_rev f = + let tmp_dir = Filename.temp_dir "central-import" "" in + Vcs.git + vcs + ~repo_root:central_root + ~args:[ "worktree"; "add"; "--detach"; tmp_dir; Vcs.Rev.to_string base_rev ] + ~f:Vcs.Git.exit0; + Fun.protect + ~finally:(fun () -> + try + Vcs.git + vcs + ~repo_root:central_root + ~args:[ "worktree"; "remove"; "--force"; tmp_dir ] + ~f:Vcs.Git.exit0 + with + | _ -> ()) + (fun () -> f (Vcs.Repo_root.of_absolute_path (Absolute_path.v tmp_dir))) +;; + +let current_branch_name_or_head ~vcs ~central_root = + match Vcs.current_branch_opt vcs ~repo_root:central_root with + | Some branch_name -> Vcs.Branch_name.to_string branch_name + | None -> "HEAD" +;; + +(* The fast path: central has no changes of its own under the subrepo's + directory since the last sync, so the subrepo's diff applies just as + well onto the current checkout as it would at the last sync point - no + separate worktree, no merge, just a single new commit as a direct child + of HEAD. [new_parent] is that same HEAD, since it is now, genuinely, + the new commit's git-parent. *) +let apply_directly + ~vcs + ~central_root + ~subrepo_dir + ~gitrepo_file_path + ~patch_file + ~new_subrepo_rev + ~commit_message + = + let head_rev = Vcs.current_revision vcs ~repo_root:central_root in + Vcs.git + vcs + ~repo_root:central_root + ~args:[ "apply"; "--3way"; "--index"; "--directory=" ^ subrepo_dir; patch_file ] + ~f:Vcs.Git.exit0; + Gitrepo_update.update + ~repo_root:central_root + ~gitrepo_file_path + ~new_commit:new_subrepo_rev + ~new_parent:head_rev; + Vcs.add vcs ~repo_root:central_root ~path:gitrepo_file_path; + let (_ : Vcs.Rev.t) = Vcs.commit vcs ~repo_root:central_root ~commit_message in + let branch_name = current_branch_name_or_head ~vcs ~central_root in + App_log.success + Pp.O.( + Pp.text "Imported into " + ++ Pp_tty.id (module String) branch_name + ++ Pp.text " directly (no merge needed).") +;; + +let build_import_commit + ~vcs + ~worktree_root + ~subrepo_dir + ~gitrepo_file_path + ~patch_file + ~new_subrepo_rev + ~base_rev + ~commit_message + = + Vcs.git + vcs + ~repo_root:worktree_root + ~args:[ "apply"; "--3way"; "--index"; "--directory=" ^ subrepo_dir; patch_file ] + ~f:Vcs.Git.exit0; + Gitrepo_update.update + ~repo_root:worktree_root + ~gitrepo_file_path + ~new_commit:new_subrepo_rev + ~new_parent:base_rev; + Vcs.add vcs ~repo_root:worktree_root ~path:gitrepo_file_path; + App_log.success (Pp.text "Built the import commit."); + Vcs.commit vcs ~repo_root:worktree_root ~commit_message +;; + +let merge_import_commit ~vcs ~central_root ~import_rev ~merge_message = + let branch_name = current_branch_name_or_head ~vcs ~central_root in + let output = + Vcs.git + vcs + ~repo_root:central_root + ~args:[ "merge"; "--no-ff"; "-m"; merge_message; Vcs.Rev.to_string import_rev ] + ~f:(fun output -> output) + in + print_string output.stdout; + print_string output.stderr; + match output.exit_code with + | 0 -> + App_log.success + Pp.O.( + Pp.text "Imported into " ++ Pp_tty.id (module String) branch_name ++ Pp.text ".") + | 1 -> + Err.raise + Pp.O. + [ Pp.text "Merge conflict while importing - resolve the conflicts above in " + ++ Pp_tty.id (module String) branch_name + ++ Pp.text ", then " + ++ Pp_tty.kwd (module String) "git add" + ++ Pp.text " the resolved files and " + ++ Pp_tty.kwd (module String) "git commit" + ++ Pp.text " to finish the merge." + ] + ~hints: + [ Pp.text + ".gitrepo has already been updated as part of the import commit being merged \ + - no further action needed there once the merge is complete." + ] + | exit_code -> + Err.raise [ Pp.textf "git merge exited with unexpected code %d." exit_code ] +;; + +let import ~vcs ~central_root ~subrepo ~message ?(force = false) () = + Common_helpers.ensure_clean_working_tree ~vcs ~repo_root:central_root; + let central_graph = Vcs.graph vcs ~repo_root:central_root in + let facts = Subrepo_facts.compute ~vcs ~central_root ~central_graph ~subrepo in + verify_applicable ~subrepo ~force facts; + let subrepo_dir = Central.Subrepo.root subrepo |> Vcs.Path_in_repo.to_string in + let gitrepo_file_path = Subrepo_facts.gitrepo_file_path facts in + let base_rev = Subrepo_facts.base facts in + let gitrepo_rev = (Subrepo_facts.gitrepo_file facts).commit.txt in + let subrepo_repo_root = Subrepo_facts.subrepo_repo_root facts in + let subrepo_graph = Subrepo_facts.subrepo_graph facts in + let new_subrepo_rev = + Vcs.Graph.rev subrepo_graph ~node:(Subrepo_facts.subrepo_head facts) + in + let diff = + compute_diff ~vcs ~subrepo_repo_root ~old_rev:gitrepo_rev ~new_rev:new_subrepo_rev + in + if String.equal (String.strip diff) "" + then + Err.raise + [ Pp.text "Nothing to import: no changes in the subrepo since the last sync." ]; + let patch_file = Filename.temp_file "central-import" ".patch" in + Fun.protect + ~finally:(fun () -> + try Sys.remove patch_file with + | Sys_error _ -> ()) + (fun () -> + Out_channel.with_open_bin patch_file (fun oc -> Out_channel.output_string oc diff); + let central_has_changes_in_subrepo = + (Subrepo_facts.next_step_facts facts).central_has_changes_in_subrepo + in + if central_has_changes_in_subrepo + then ( + let import_rev = + with_temp_worktree vcs ~central_root ~base_rev (fun worktree_root -> + build_import_commit + ~vcs + ~worktree_root + ~subrepo_dir + ~gitrepo_file_path + ~patch_file + ~new_subrepo_rev + ~base_rev + ~commit_message:(Vcs.Commit_message.v message)) + in + let merge_message = + Printf.sprintf "Merge %s import" (Central.Subrepo.to_string subrepo) + in + merge_import_commit ~vcs ~central_root ~import_rev ~merge_message) + else + apply_directly + ~vcs + ~central_root + ~subrepo_dir + ~gitrepo_file_path + ~patch_file + ~new_subrepo_rev + ~commit_message:(Vcs.Commit_message.v message)) +;; + +let main = + Command.make + ~summary:"Import new subrepo commits into central." + ~readme:(fun () -> + "This brings commits from the tip of a subrepo's $(b,subrepo) branch that are not \ + yet reflected in central into its directory.\n\n\ + If central has no local changes of its own under that directory since the last \ + sync recorded in $(b,.gitrepo), the subrepo's changes are applied directly as a \ + single new commit on top of the current HEAD - no merge, since the directory is \ + byte-for-byte what it was at the last sync point, so there is nothing for the \ + incoming changes to conflict with. This is the common case.\n\n\ + Otherwise, this falls back to an ordinary two-parent $(b,git merge): a new commit \ + is first built as a direct child of the central revision recorded in \ + $(b,.gitrepo) - not of the current HEAD - so applying the subrepo's own diff \ + there is guaranteed to succeed regardless of what else changed in central since. \ + It is then merged into the active branch (normally $(b,main)): if central has no \ + conflicting local changes this completes on its own, otherwise git leaves the \ + usual conflict markers for you to resolve, then $(b,git add) and $(b,git commit) \ + to finish.\n\n\ + Either way, the commit that brings the subrepo's changes in also updates \ + $(b,.gitrepo) to record the new sync point, so $(b,export) can be used again \ + right after.\n\n\ + This requires the $(b,subrepo) branch to actually be ahead of the last recorded \ + sync point; pass $(b,--force) to import anyway.") + (let open Command.Std in + let+ () = Log_cli.set_config () + and+ subrepo = + Arg.pos + ~pos:0 + (Param.validated_string (module Central.Subrepo)) + ~docv:"REPO" + ~doc:"The subrepo to import changes from." + and+ message = + Arg.named_opt + [ "m" ] + Param.string + ~docv:"MSG" + ~doc: + "Commit message for the commit that brings the subrepo's changes in. Defaults \ + to \"Import changes from REPO\"." + and+ force = Arg.flag [ "force" ] ~doc:"Force import even if not applicable." in + let vcs = Volgo_git_unix.create () in + let cwd = Unix.getcwd () |> Absolute_path.v in + let central_root = Common_helpers.find_enclosing_repo_root vcs ~from:cwd in + let message = + match message with + | Some message -> message + | None -> + Printf.sprintf "Import changes from %s" (Central.Subrepo.to_string subrepo) + in + import ~vcs ~central_root ~subrepo ~message ~force ()) +;; diff --git a/src/cli/cmd__import.mli b/src/cli/cmd__import.mli new file mode 100644 index 0000000..70e9119 --- /dev/null +++ b/src/cli/cmd__import.mli @@ -0,0 +1,46 @@ +(*_********************************************************************************) +(*_ central - Manage history between sub-repos and their monorepo *) +(*_ SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(*_ SPDX-License-Identifier: MIT *) +(*_********************************************************************************) + +(** [import ~vcs ~central_root ~subrepo ~message] is the implementation + behind [central import]. Exposed separately from {!val:main} so that + tests can drive it directly against a fake central repo, without going + through command-line parsing. See {!val:main} for the full behavior. + + Like {!val:Cmd__export.export}: progress messages go through the normal + [App_log]/[Logs] machinery (silent without a reporter); a merge + conflict is always reported regardless, since it is the primary + actionable outcome of this command when a merge is needed at all. When + central has no local changes of its own under the subrepo's directory + since the last sync, the subrepo's changes are instead applied directly + as a single new commit on top of HEAD - no merge, so nothing can + conflict. The unified diff of the [.gitrepo] file update is logged at + [Debug] level (see [Gitrepo_update.update]). + + [force] (default [false]) bypasses the guard that otherwise requires the + subrepo's [subrepo] branch to actually be ahead of the last recorded + sync point - see {!val:main}'s [--force]. *) +val import + : vcs: + < Vcs.Trait.add + ; Vcs.Trait.commit + ; Vcs.Trait.current_branch + ; Vcs.Trait.current_revision + ; Vcs.Trait.git + ; Vcs.Trait.log + ; Vcs.Trait.name_status + ; Vcs.Trait.num_status + ; Vcs.Trait.refs + ; Vcs.Trait.show + ; .. > + Vcs.t + -> central_root:Vcs.Repo_root.t + -> subrepo:Central.Subrepo.t + -> message:string + -> ?force:bool + -> unit + -> unit + +val main : unit Command.t diff --git a/src/cli/cmd__push.ml b/src/cli/cmd__push.ml new file mode 100644 index 0000000..4fce7ed --- /dev/null +++ b/src/cli/cmd__push.ml @@ -0,0 +1,136 @@ +(*********************************************************************************) +(* central - Manage history between sub-repos and their monorepo *) +(* SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(* SPDX-License-Identifier: MIT *) +(*********************************************************************************) + +(* Opens [gitk --all] in [repo_root] so the person confirming the push can + see what's actually about to go out before answering the prompt (or, in + [Dry_run] mode, before the run is skipped anyway) - skipped entirely in + [Yes] mode, where nobody is watching. *) +let visualize_with_gitk ~repo_root = + App_log.status + Pp.O.( + Pp.text "Visualizing the history of the repository with " + ++ Pp_tty.kwd (module String) "gitk" + ++ Pp.text "."); + let prog = "gitk" in + let argv = [ "gitk"; "--all" ] in + let cwd = Spawn.Working_dir.Path (Vcs.Repo_root.to_string repo_root) in + try + let prog = Common_helpers.resolve_in_path ~prog in + let pid = Spawn.spawn ~prog ~argv ~cwd () in + match Unix.waitpid [] pid with + | _, WEXITED 0 -> () + | _ -> + Err.raise + Pp.O. + [ Pp.text "Running process " + ++ Pp_tty.kwd (module String) "gitk" + ++ Pp.text " failed." + ] + with + | exn -> + Err.raise + Pp.O. + [ Pp.text "Running process " + ++ Pp_tty.kwd (module String) "gitk" + ++ Pp.text " failed." + ; Err.exn exn + ] +;; + +let push ~vcs ~repo_root ~is_applicable ~force ~confirm_mode = + if not (is_applicable || force) + then Central_log.skip_step Push + else ( + (match (confirm_mode : Prompt.Confirm_mode.t) with + | Yes -> () + | Interactive | Dry_run -> visualize_with_gitk ~repo_root); + let remote = + Vcs_extra.branch_tracking_exn vcs ~repo_root ~branch_name:Vcs.Branch_name.main + in + let confirmed = + match (confirm_mode : Prompt.Confirm_mode.t) with + | Yes -> true + | Dry_run -> + App_log.skip (Pp.text "Push skipped (dry-run)."); + false + | Interactive -> + Prompt.ask_yn + ~prompt: + (Printf.sprintf + "Confirming push to %s/main in %s?" + (Prompt.styled Loc (Vcs.Remote_name.to_string remote.remote_name)) + (Prompt.styled Loc (Vcs.Repo_root.to_string repo_root))) + ~default:(Some false) + in + if not confirmed + then ( + match confirm_mode with + | Dry_run -> () + | Interactive -> App_log.skip (Pp.text "Push not confirmed.") + | Yes -> assert false (* unreachable *)) + else ( + let output = + Vcs.git + vcs + ~repo_root + ~args:[ "push"; Vcs.Remote_name.to_string remote.remote_name; "main" ] + ~f:Vcs.Git.exit0_and_stdout + in + print_string output; + App_log.success (Pp.text "Pushed."))) +;; + +let main = + Command.make + ~summary:"Push repo(s) to remote." + (let open Command.Std in + let+ () = Log_cli.set_config () + and+ force = Arg.flag [ "force" ] ~doc:"Force the push." + and+ which_repos = Which_repos.arg ~default_to_central:false + and+ confirm_mode = Prompt.Confirm_mode.arg in + let vcs = Volgo_git_unix.create () in + let cwd = Unix.getcwd () |> Absolute_path.v in + let central_root = Common_helpers.find_enclosing_repo_root vcs ~from:cwd in + let repo_config = Central.Repo_config.find_and_load ~repo_root:central_root in + let central_graph = Vcs.graph vcs ~repo_root:central_root in + let central_main_head = + Vcs_extra.find_local_branch_exn + ~repo_root:central_root + ~graph:central_graph + ~branch_name:Vcs.Branch_name.main + in + let central_remote_main_head = + Vcs_extra.find_remote_tracking_node_exn + vcs + ~repo_root:central_root + ~graph:central_graph + ~branch_name:Vcs.Branch_name.main + in + let repos = Which_repos.resolve which_repos ~repo_config ~repo_root:central_root in + Which_repos.iter repos ~repo_config ~f:(fun repo -> + match (repo : Which_repos.repo) with + | Central -> + let is_applicable = + Vcs.Graph.is_strict_ancestor + central_graph + ~ancestor:central_remote_main_head + ~descendant:central_main_head + in + push ~vcs ~repo_root:central_root ~is_applicable ~force ~confirm_mode + | Subrepo subrepo -> + let facts = Subrepo_facts.compute ~vcs ~central_root ~central_graph ~subrepo in + let is_applicable = + Central.Next_step.is_applicable + Push + ~facts:(Subrepo_facts.next_step_facts facts) + in + push + ~vcs + ~repo_root:(Subrepo_facts.subrepo_repo_root facts) + ~is_applicable + ~force + ~confirm_mode)) +;; diff --git a/src/cli/cmd__push.mli b/src/cli/cmd__push.mli new file mode 100644 index 0000000..935cd2f --- /dev/null +++ b/src/cli/cmd__push.mli @@ -0,0 +1,15 @@ +(*_********************************************************************************) +(*_ central - Manage history between sub-repos and their monorepo *) +(*_ SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(*_ SPDX-License-Identifier: MIT *) +(*_********************************************************************************) + +val push + : vcs:< Vcs.Trait.git ; .. > Vcs.t + -> repo_root:Vcs.Repo_root.t + -> is_applicable:bool + -> force:bool + -> confirm_mode:Prompt.Confirm_mode.t + -> unit + +val main : unit Command.t diff --git a/src/cli/cmd__stitch.ml b/src/cli/cmd__stitch.ml new file mode 100644 index 0000000..227e755 --- /dev/null +++ b/src/cli/cmd__stitch.ml @@ -0,0 +1,158 @@ +(*********************************************************************************) +(* central - Manage history between sub-repos and their monorepo *) +(* SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(* SPDX-License-Identifier: MIT *) +(*********************************************************************************) + +(* This is used after a subrepo push whose history was rewritten afterwards + without changing the resulting tree - typically: [export], then rework + the just-exported commit into a nicer sequence of commits, arriving at + the exact same state. Since the tree hasn't actually changed, there is + nothing to bring in via [import] (its diff would be empty, and it would + refuse to run) - the only thing left stale is the commit central's + [.gitrepo] file points at, which still names the pre-rewrite commit. That + is all [stitch] fixes: it points [.gitrepo] at the subrepo's new tip + instead. *) + +(* The required pre-conditions, each checked below with its own actionable + message: + + 1. The subrepo branch has actually moved since the last sync - otherwise + there is nothing to stitch. + + 2. The subrepo has no real content changes between the commit recorded in + [.gitrepo] and its current tip - i.e. this really is a pure history + rewrite, "merely changing the commit" rather than its content. If it + isn't, [stitch] would silently point [.gitrepo] at a tip whose tree + doesn't match what central has under the subrepo's directory - [import] + is what's needed instead. + + 3. Central has no local changes of its own under the subrepo's directory + since the last sync - otherwise there would be a real diff between + central's checkout and the subrepo's new tip that a plain re-pointing of + [.gitrepo] can't account for. *) +let verify_applicable + ~subrepo + ~subrepo_dir + ~(gitrepo_abs_path : Absolute_path.t) + ~gitrepo_rev + ~subrepo_head_rev + ~name_status + ~central_has_changes_in_subrepo + = + let loc = Loc.of_file ~path:(gitrepo_abs_path :> Fpath.t) in + if Vcs.Rev.equal gitrepo_rev subrepo_head_rev + then + Err.raise + ~loc + Pp.O. + [ Pp.text "Nothing to stitch: the " + ++ Pp_tty.kwd (module String) "subrepo" + ++ Pp.text " branch of " + ++ Pp_tty.id (module Central.Subrepo) subrepo + ++ Pp.text " is already the commit recorded in " + ++ Pp_tty.kwd (module String) ".gitrepo" + ++ Pp.text "." + ]; + if not (List.is_empty name_status) + then + Err.raise + ~loc + Pp.O. + [ Pp.text "Cannot stitch: " + ++ Pp_tty.path (module String) subrepo_dir + ++ Pp.text " has content changes between the commit recorded in " + ++ Pp_tty.kwd (module String) ".gitrepo" + ++ Pp.text " and its current tip - this isn't a pure history rewrite." + ] + ~hints: + Pp.O. + [ Pp.text "Use " + ++ Pp_tty.kwd (module String) "central import" + ++ Pp.text " instead to bring those changes in." + ]; + if central_has_changes_in_subrepo + then + Err.raise + ~loc + Pp.O. + [ Pp.text "Cannot stitch: central has local changes of its own under " + ++ Pp_tty.path (module String) subrepo_dir + ++ Pp.text " since the last sync." + ] + ~hints:[ Pp.text "Export or import those changes first, then stitch." ] +;; + +let stitch ~vcs ~central_root ~subrepo facts = + let subrepo_dir = Central.Subrepo.root subrepo |> Vcs.Path_in_repo.to_string in + let gitrepo_file_path = Subrepo_facts.gitrepo_file_path facts in + let gitrepo_rev = (Subrepo_facts.gitrepo_file facts).commit.txt in + let subrepo_head_rev = + Vcs.Graph.rev + (Subrepo_facts.subrepo_graph facts) + ~node:(Subrepo_facts.subrepo_head facts) + in + let name_status = + Vcs.name_status + vcs + ~repo_root:(Subrepo_facts.subrepo_repo_root facts) + ~changed:(Between { src = gitrepo_rev; dst = subrepo_head_rev }) + in + verify_applicable + ~subrepo + ~subrepo_dir + ~gitrepo_abs_path:(Vcs.Repo_root.append central_root gitrepo_file_path) + ~gitrepo_rev + ~subrepo_head_rev + ~name_status + ~central_has_changes_in_subrepo: + (Subrepo_facts.next_step_facts facts).central_has_changes_in_subrepo; + let central_head_rev = Vcs.current_revision vcs ~repo_root:central_root in + Gitrepo_update.update + ~repo_root:central_root + ~gitrepo_file_path + ~new_commit:subrepo_head_rev + ~new_parent:central_head_rev; + Vcs.add vcs ~repo_root:central_root ~path:gitrepo_file_path; + let commit_message = + Vcs.Commit_message.v + (Printf.sprintf "Stitch repo %s" (Central.Subrepo.to_string subrepo)) + in + let (_ : Vcs.Rev.t) = Vcs.commit vcs ~repo_root:central_root ~commit_message in + App_log.success + Pp.O.( + Pp.text "Stitched " ++ Pp_tty.id (module Central.Subrepo) subrepo ++ Pp.text ".") +;; + +let main = + Command.make + ~summary:"Stitch a subrepo's tip to the monorepo." + ~readme:(fun () -> + "After a push to a subrepo, if the subrepo history is rewritten, its tip will \ + change while the $(b,.gitrepo) file will still contain the tip of the subrepo as \ + of prior to the history edit.\n\n\ + When the history edit is only about reordering commits, there is no need for a \ + complex merge, nor to run $(b,import) - its diff would be empty. Instead we can \ + simply set the new tip of the subrepo in the $(b,.gitrepo) file.\n\n\ + This is what we call a $(b,stitch). The required pre-conditions are:\n\n\ + 1. The subrepo branch has actually moved since the last sync.\n\n\ + 2. The subrepo has no changes between the $(b,.gitrepo) file and the actual tip \ + of the subrepo.\n\n\ + 3. The monorepo has no changes in the subrepo between the revision when the \ + subrepo push was done and the current tip.\n\n\ + Unlike $(b,export), there is nothing worth writing by hand here: the commit that \ + updates the $(b,.gitrepo) file is created automatically, with the message \ + $(b,\"Stitch repo REPO\").") + (let open Command.Std in + let+ () = Log_cli.set_config () + and+ which_subrepos = Which_repos.Subrepos.arg in + let vcs = Volgo_git_unix.create () in + let cwd = Unix.getcwd () |> Absolute_path.v in + let central_root = Common_helpers.find_enclosing_repo_root vcs ~from:cwd in + Common_helpers.ensure_clean_working_tree ~vcs ~repo_root:central_root; + let central_graph = Vcs.graph vcs ~repo_root:central_root in + let subrepos = Which_repos.Subrepos.resolve which_subrepos ~repo_root:central_root in + Which_repos.Subrepos.iter subrepos ~f:(fun subrepo -> + let facts = Subrepo_facts.compute ~vcs ~central_root ~central_graph ~subrepo in + stitch ~vcs ~central_root ~subrepo facts)) +;; diff --git a/src/cli/cmd__stitch.mli b/src/cli/cmd__stitch.mli new file mode 100644 index 0000000..93782f1 --- /dev/null +++ b/src/cli/cmd__stitch.mli @@ -0,0 +1,40 @@ +(*_********************************************************************************) +(*_ central - Manage history between sub-repos and their monorepo *) +(*_ SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(*_ SPDX-License-Identifier: MIT *) +(*_********************************************************************************) + +(** [stitch ~vcs ~central_root ~subrepo facts] is the implementation behind + [central stitch] for a single [subrepo], given its {!Subrepo_facts.t}. + Exposed separately from {!val:main} so that tests can drive it directly + against a fake central repo, without going through command-line + parsing. + + This requires the subrepo's [subrepo] branch to have moved since the + last sync recorded in [.gitrepo], with no actual content changes between + the two - i.e. a pure history rewrite - and no local changes of + central's own under the subrepo's directory since the last sync; see + {!val:main} for the full behavior. Unlike {!Cmd__export.export}, there + is no message to supply: the commit that updates [.gitrepo] is created + automatically, with the message ["Stitch repo REPO"] - the subrepo's + copy in the monorepo isn't public history, so there's nothing worth + writing by hand here. *) +val stitch + : vcs: + < Vcs.Trait.add + ; Vcs.Trait.commit + ; Vcs.Trait.current_revision + ; Vcs.Trait.git + ; Vcs.Trait.log + ; Vcs.Trait.name_status + ; Vcs.Trait.num_status + ; Vcs.Trait.refs + ; Vcs.Trait.show + ; .. > + Vcs.t + -> central_root:Vcs.Repo_root.t + -> subrepo:Central.Subrepo.t + -> Subrepo_facts.t + -> unit + +val main : unit Command.t diff --git a/src/cli/cmd__todo.ml b/src/cli/cmd__todo.ml new file mode 100644 index 0000000..e45ef22 --- /dev/null +++ b/src/cli/cmd__todo.ml @@ -0,0 +1,129 @@ +(*********************************************************************************) +(* central - Manage history between sub-repos and their monorepo *) +(* SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(* SPDX-License-Identifier: MIT *) +(*********************************************************************************) + +module Row = struct + type t = + { repo : Which_repos.repo + ; next_step : Central.Next_step.t option + ; num_lines_to_review : int + } + + let is_shown { repo = _; next_step; num_lines_to_review } = + Option.is_some next_step || num_lines_to_review > 0 + ;; +end + +module Todo_table = struct + type t = Row.t list + + let int_hum_if_not_zero i = if i = 0 then "" else Int.to_string i + + (* Subrepo rows are indented under central's own, to visually group them + as "belonging to" the enclosing monorepo in the table. *) + let repo_label ~repo_config (repo : Which_repos.repo) = + match repo with + | Central -> Which_repos.name ~repo_config repo + | Subrepo _ -> " " ^ Which_repos.name ~repo_config repo + ;; + + let columns ~repo_config = + Print_table.O. + [ Column.make ~header:"Repo" (fun (t : Row.t) -> + Cell.text (repo_label ~repo_config t.repo)) + ; Column.make ~header:"Next step" (fun (t : Row.t) -> + Cell.text + (match t.next_step with + | None -> "" + | Some next_step -> Central.Next_step.to_string_hum next_step)) + ; Column.make ~align:Right ~header:"Diff" (fun (t : Row.t) -> + Cell.text (int_hum_if_not_zero t.num_lines_to_review)) + ] + ;; + + let to_string t ~repo_config = + Print_table.to_string_text (Print_table.make ~columns:(columns ~repo_config) ~rows:t) + ;; + + (* This should be generalized to be more like [Subrepo_facts]/[Next_step], + when we need more next steps for central's own row. *) + let central_row ~vcs ~central_graph ~central_root = + let central_head = Vcs.current_revision vcs ~repo_root:central_root in + let central_head_node = + match Vcs.Graph.find_rev central_graph ~rev:central_head with + | Some node -> node + | None -> + Err.raise + [ Pp.textf "Cannot find central head '%s'." (Vcs.Rev.to_string central_head) ] + in + let central_remote_main_head = + Vcs_extra.find_remote_tracking_node_exn + vcs + ~repo_root:central_root + ~graph:central_graph + ~branch_name:Vcs.Branch_name.main + in + let num_lines_to_review = + let num_status = + Vcs.num_status + vcs + ~repo_root:central_root + ~changed: + (Between + { src = Vcs.Graph.rev central_graph ~node:central_remote_main_head + ; dst = central_head + }) + in + List.fold_left num_status ~init:0 ~f:(fun acc (change : Vcs.Num_status.Change.t) -> + acc + + + match change.num_stat with + | Num_lines_in_diff n -> Vcs.Num_lines_in_diff.total n + | Binary_file -> 1) + in + let next_step = + if + Vcs.Graph.is_strict_ancestor + central_graph + ~ancestor:central_remote_main_head + ~descendant:central_head_node + then Some Central.Next_step.Push + else None + in + { Row.repo = Which_repos.Central; next_step; num_lines_to_review } + ;; + + let subrepo_row ~vcs ~central_root ~central_graph subrepo = + let facts = Subrepo_facts.compute ~vcs ~central_root ~central_graph ~subrepo in + { Row.repo = Which_repos.Subrepo subrepo + ; next_step = Subrepo_facts.next_step facts + ; num_lines_to_review = Subrepo_facts.num_lines_to_review facts + } + ;; + + let compute ~vcs ~central_root : t = + let central_graph = Vcs.graph vcs ~repo_root:central_root in + let rows = + central_row ~vcs ~central_graph ~central_root + :: List.map + (Central.Subrepo.all ~repo_root:central_root) + ~f:(subrepo_row ~vcs ~central_root ~central_graph) + in + List.filter rows ~f:Row.is_shown + ;; +end + +let main = + Command.make + ~summary:"Build and print central's todo table." + (let open Command.Std in + let+ () = Log_cli.set_config () in + let vcs = Volgo_git_unix.create () in + let cwd = Unix.getcwd () |> Absolute_path.v in + let central_root = Common_helpers.find_enclosing_repo_root vcs ~from:cwd in + let repo_config = Central.Repo_config.find_and_load ~repo_root:central_root in + let todo_table = Todo_table.compute ~vcs ~central_root in + print_string (Todo_table.to_string todo_table ~repo_config)) +;; diff --git a/src/cli/cmd__todo.mli b/src/cli/cmd__todo.mli new file mode 100644 index 0000000..cf61e04 --- /dev/null +++ b/src/cli/cmd__todo.mli @@ -0,0 +1,33 @@ +(*_********************************************************************************) +(*_ central - Manage history between sub-repos and their monorepo *) +(*_ SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(*_ SPDX-License-Identifier: MIT *) +(*_********************************************************************************) + +(** Not shared with the internal tool's own [Cmd__todo]: that one also + scans for CRs and factors in release/changelog state, neither of which + is in scope here yet. This is a standalone, trimmed-down version: + central plus each subrepo, with their next step and outstanding diff + size. *) + +module Todo_table : sig + type t + + val compute + : vcs: + < Vcs.Trait.current_revision + ; Vcs.Trait.git + ; Vcs.Trait.log + ; Vcs.Trait.name_status + ; Vcs.Trait.num_status + ; Vcs.Trait.refs + ; Vcs.Trait.show + ; .. > + Vcs.t + -> central_root:Vcs.Repo_root.t + -> t + + val to_string : t -> repo_config:Central.Repo_config.t -> string +end + +val main : unit Command.t diff --git a/src/cli/common_helpers.ml b/src/cli/common_helpers.ml new file mode 100644 index 0000000..e705718 --- /dev/null +++ b/src/cli/common_helpers.ml @@ -0,0 +1,50 @@ +(*********************************************************************************) +(* central - Manage history between sub-repos and their monorepo *) +(* SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(* SPDX-License-Identifier: MIT *) +(*********************************************************************************) + +let find_enclosing_repo_root vcs ~from = + match Vcs.find_enclosing_git_repo_root vcs ~from with + | Some repo_root -> repo_root + | None -> + Err.raise + Pp.O. + [ Pp.text "Failed to locate enclosing repo root from '" + ++ Pp_tty.path (module Absolute_path) from + ++ Pp.text "'." + ] +;; + +let resolve_in_path ~prog = + if Filename.is_relative prog + then ( + let path = + match Sys.getenv_opt "PATH" with + | Some p -> Stdlib.String.split_on_char ':' p + | None -> [] + in + match + List.find_map path ~f:(fun dir -> + let candidate = Filename.concat dir prog in + if Sys.file_exists candidate then Some candidate else None) + with + | Some resolved -> resolved + | None -> prog) + else prog +;; + +let ensure_clean_working_tree ~vcs ~repo_root = + let status = + Vcs.git vcs ~repo_root ~args:[ "status"; "--porcelain" ] ~f:Vcs.Git.exit0_and_stdout + in + if not (String.equal (String.strip status) "") + then + Err.raise + Pp.O. + [ Pp.text "Repo " + ++ Pp_tty.path (module String) (Vcs.Repo_root.to_string repo_root) + ++ Pp.text " has uncommitted changes - commit or stash them first." + ] + ~hints:[ Pp.verbatim (String.strip status) ] +;; diff --git a/src/cli/common_helpers.mli b/src/cli/common_helpers.mli new file mode 100644 index 0000000..afdfb3d --- /dev/null +++ b/src/cli/common_helpers.mli @@ -0,0 +1,31 @@ +(*_********************************************************************************) +(*_ central - Manage history between sub-repos and their monorepo *) +(*_ SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(*_ SPDX-License-Identifier: MIT *) +(*_********************************************************************************) + +(** Helpers for command line arguments. *) + +(** Find enclosing repo or raise an error compatible with the command handler in + use. *) +val find_enclosing_repo_root + : < Vcs.Trait.file_system ; .. > Vcs.t + -> from:Absolute_path.t + -> Vcs.Repo_root.t + +(** When using [Spawn.spawn] we need to supply the full path to the [prog] we + want to run (e.g. "gitk"). Spawn won't look in the PATH for us, so this + is what this function does. *) +val resolve_in_path : prog:string -> string + +(** Raises if [repo_root] has any uncommitted change (staged or not, tracked + or untracked) - i.e. if [git status --porcelain] would print anything. + Meant as a precondition for commands that create commits or move + branches programmatically (e.g. [export]/[import]): with a dirty + working tree, "the diff since the last sync" or "checkout, then apply" + could pick up unrelated local changes, or a [git merge] could refuse in + a confusing way. *) +val ensure_clean_working_tree + : vcs:< Vcs.Trait.git ; .. > Vcs.t + -> repo_root:Vcs.Repo_root.t + -> unit diff --git a/src/cli/gitrepo_update.ml b/src/cli/gitrepo_update.ml new file mode 100644 index 0000000..fc936fe --- /dev/null +++ b/src/cli/gitrepo_update.ml @@ -0,0 +1,39 @@ +(*********************************************************************************) +(* central - Manage history between sub-repos and their monorepo *) +(* SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(* SPDX-License-Identifier: MIT *) +(*********************************************************************************) + +let update ~repo_root ~gitrepo_file_path ~new_commit ~new_parent = + let path = Vcs.Repo_root.append repo_root gitrepo_file_path in + let original_contents = + In_channel.with_open_bin (Absolute_path.to_string path) In_channel.input_all + in + let gitrepo_file = + Parsing_utils.parse_lexbuf_exn + (module Gitrepo_file_parser) + ~path:(path :> Fpath.t) + ~lexbuf:(Lexing.from_string original_contents) + in + let file_rewriter = File_rewriter.create ~path:(path :> Fpath.t) ~original_contents in + File_rewriter.replace + file_rewriter + ~range:(Loc.range gitrepo_file.commit.loc) + ~text:(Vcs.Rev.to_string new_commit); + File_rewriter.replace + file_rewriter + ~range:(Loc.range gitrepo_file.parent.loc) + ~text:(Vcs.Rev.to_string new_parent); + let updated_contents = File_rewriter.contents file_rewriter in + Out_channel.with_open_bin (Absolute_path.to_string path) (fun oc -> + Out_channel.output_string oc updated_contents); + Log.debug (fun () -> + [ Pp.verbatim + (Myers.diff + ~color:true + ~expected_label:".gitrepo (before)" + ~actual_label:".gitrepo (after)" + original_contents + updated_contents) + ]) +;; diff --git a/src/cli/gitrepo_update.mli b/src/cli/gitrepo_update.mli new file mode 100644 index 0000000..b76912a --- /dev/null +++ b/src/cli/gitrepo_update.mli @@ -0,0 +1,26 @@ +(*_********************************************************************************) +(*_ central - Manage history between sub-repos and their monorepo *) +(*_ SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(*_ SPDX-License-Identifier: MIT *) +(*_********************************************************************************) + +(** Shared by [export] and [import]: both record a new sync point by + rewriting [.gitrepo]'s [commit] and [parent] fields in place, in + whichever repo checkout [repo_root] points at (the caller's own working + copy for [export], a temporary worktree for [import]). + + [new_commit] is the subrepo revision the new sync point corresponds to; + [new_parent] is the central revision it corresponds to - normally the + git-parent of the commit doing the rewriting, so that + [Subrepo_facts.compute]'s search for the sync-point commit finds it + again later. + + The unified diff of the file before/after is logged at [Debug] level - + invisible by default, shown when the command is run with e.g. + [--verbosity=debug]. *) +val update + : repo_root:Vcs.Repo_root.t + -> gitrepo_file_path:Vcs.Path_in_repo.t + -> new_commit:Vcs.Rev.t + -> new_parent:Vcs.Rev.t + -> unit diff --git a/src/cli/prompt.ml b/src/cli/prompt.ml new file mode 100644 index 0000000..33e4600 --- /dev/null +++ b/src/cli/prompt.ml @@ -0,0 +1,125 @@ +(*********************************************************************************) +(* central - Manage history between sub-repos and their monorepo *) +(* SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(* SPDX-License-Identifier: MIT *) +(*********************************************************************************) + +(* This is a trimmed copy of a [prompt] library maintained elsewhere by the + same author, relicensed here as MIT. See prompt.mli. *) + +let read_line () = + match In_channel.input_line In_channel.stdin with + | Some line -> line + | None -> raise End_of_file +;; + +let aprintf fmt = + Format.kasprintf + (fun str -> + Out_channel.output_string Out_channel.stdout str; + Out_channel.flush Out_channel.stdout) + fmt +;; + +let read_char () = + let str = read_line () in + let len = String.length str in + if len = 1 + then Ok (Some (Char.lowercase_ascii str.[0])) + else if len = 0 + then Ok None + else Error () +;; + +let styled style s = + if Pp_tty.Private.Color_mode.should_enable_color Unix.stdout + then Pp_tty.to_string (Pp.tag style (Pp.verbatim s)) + else s +;; + +let choose (type a) ~(choices : (char * a) list) : char option -> (a, unit) Result.t + = function + | None -> + (match + List.filter choices ~f:(fun (c, _) -> Char.equal (Char.uppercase_ascii c) c) + with + | _ :: _ :: _ as l -> + raise + (Invalid_argument + (Printf.sprintf + "[Prompt.choose] supplied multiple defaults %S." + (String.concat ~sep:"" (List.map l ~f:(fun (c, _) -> String.make 1 c))))) + | [ (_, a) ] -> Ok a + | [] -> Error ()) + | Some ch -> + let filter (reply, _) = + Char.equal (Char.lowercase_ascii reply) (Char.lowercase_ascii ch) + in + (match List.find_opt choices ~f:filter with + | Some (_, a) -> Ok a + | None -> Error ()) +;; + +let ask_internal (type a) ~prompt ~(choices : (char * a) list) = + let prompt = + let cs = List.map choices ~f:(fun (c, _) -> String.make 1 c) in + Printf.sprintf "%s [%s]" prompt (String.concat ~sep:"/" cs) + in + let please_answer () = + let num_choices = List.length choices in + let choices = + List.mapi choices ~f:(fun i (char, _value) -> + let sep = if i = 0 then "" else if i = num_choices - 1 then " or " else ", " in + Printf.sprintf "%s'%c'" sep (Char.lowercase_ascii char)) + |> String.concat ~sep:"" + in + aprintf "[%s] Please answer %s.\n\n" (styled Error "!") choices + in + let rec loop () = + aprintf "[%s] %s: " (styled Warning "?") prompt; + match read_char () with + | Error () -> + please_answer (); + loop () + | Ok char -> + (match choose ~choices char with + | Ok res -> res + | Error () -> + please_answer (); + loop ()) + in + loop () +;; + +let ask_yn ~prompt ~default = + let y, n = + match default with + | None -> 'y', 'n' + | Some true -> 'Y', 'n' + | Some false -> 'y', 'N' + in + ask_internal ~prompt ~choices:[ y, true; n, false ] +;; + +module Confirm_mode = struct + type t = + | Interactive + | Yes + | Dry_run + + let arg = + let open Command.Std in + let+ yes = Arg.flag [ "yes" ] ~doc:"Do not prompt for confirmation." + and+ dry_run = + Arg.flag [ "dry-run" ] ~doc:"Run without prompting and without side effects." + in + match yes, dry_run with + | false, false -> Interactive + | true, false -> Yes + | false, true -> Dry_run + | true, true -> + Err.raise + ~exit_code:Err.Exit_code.cli_error + [ Pp.text "Conflicting flags --yes and --dry-run. Please choose one." ] + ;; +end diff --git a/src/cli/prompt.mli b/src/cli/prompt.mli new file mode 100644 index 0000000..901e755 --- /dev/null +++ b/src/cli/prompt.mli @@ -0,0 +1,50 @@ +(*_********************************************************************************) +(*_ central - Manage history between sub-repos and their monorepo *) +(*_ SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(*_ SPDX-License-Identifier: MIT *) +(*_********************************************************************************) + +(*_ This is a trimmed copy of a [prompt] library maintained elsewhere by the + same author, relicensed here as MIT. Only [ask_yn], [Confirm_mode] and + [styled] (and their [ask_internal]/[choose] dependency chain) are copied + over; [ask], [Choice] and [ask_gen] are not needed by [push] and were + dropped. + + [prompt] itself was inspired by [async_interactive.v0.17.0] + (https://github.com/janestreet/async_interactive), released under MIT: + + Copyright (c) 2014--2024 Jane Street Group, LLC + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. *) + +(** A library to prompt the user for simple answers in the terminal. *) + +val ask_yn : prompt:string -> default:bool option -> bool + +module Confirm_mode : sig + type t = + | Interactive + | Yes + | Dry_run + + val arg : t Command.Arg.t +end + +(** You can use this to insert style in the prompt. *) +val styled : Pp_tty.Style.t -> string -> string diff --git a/src/cli/subrepo_facts.ml b/src/cli/subrepo_facts.ml new file mode 100644 index 0000000..096a7a7 --- /dev/null +++ b/src/cli/subrepo_facts.ml @@ -0,0 +1,198 @@ +(*********************************************************************************) +(* central - Manage history between sub-repos and their monorepo *) +(* SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(* SPDX-License-Identifier: MIT *) +(*********************************************************************************) + +type t = + { subrepo : Central.Subrepo.t + ; subrepo_repo_root : Vcs.Repo_root.t + ; subrepo_graph : Vcs.Graph.t + ; subrepo_head : Vcs.Graph.Node.t + ; subrepo_base : Vcs.Rev.t + ; central_num_status : Vcs.Num_status.t + ; gitrepo_file_path : Vcs.Path_in_repo.t + ; gitrepo_file : Gitrepo_file.t + ; next_step_facts : Central.Next_step.Facts.t + } + +let gitrepo_file t = t.gitrepo_file +let gitrepo_file_path t = t.gitrepo_file_path +let next_step_facts t = t.next_step_facts +let subrepo_graph t = t.subrepo_graph +let subrepo_head t = t.subrepo_head +let subrepo_repo_root t = t.subrepo_repo_root +let base t = t.subrepo_base + +let is_subrepo_path ~subrepo ~central_path = + let subrepo_root = Central.Subrepo.root subrepo |> Vcs.Path_in_repo.to_string in + let central_path = Vcs.Path_in_repo.to_string central_path in + String.starts_with ~prefix:(subrepo_root ^ "/") central_path + && not (String.equal central_path (subrepo_root ^ "/.gitrepo")) +;; + +let subrepo_branch_name = Vcs.Branch_name.v "subrepo" + +let compute ~vcs ~central_root ~central_graph ~subrepo = + let gitrepo_file_path = Central.Subrepo.gitrepo_file_path subrepo in + let gitrepo_file = + Parsing_utils.parse_file_exn + (module Gitrepo_file_parser) + ~path:(Vcs.Repo_root.append central_root gitrepo_file_path :> Fpath.t) + in + let subrepo_repo_root = + match gitrepo_file.remote.txt with + | `Repo_root repo_root -> repo_root + in + let subrepo_graph = Vcs.graph vcs ~repo_root:subrepo_repo_root in + let subrepo_head = + match + Vcs.Graph.find_ref + subrepo_graph + ~ref_kind:(Local_branch { branch_name = subrepo_branch_name }) + with + | Some node -> node + | None -> + Err.raise + Pp.O. + [ Pp.text "Cannot find branch " + ++ Pp_tty.kwd (module String) "subrepo" + ++ Pp.text "." + ; Dyn.pp (Dyn.Record [ "subrepo", Central.Subrepo.to_dyn subrepo ]) + ] + in + let subrepo_main_head = + Vcs_extra.find_local_branch_exn + ~repo_root:subrepo_repo_root + ~graph:subrepo_graph + ~branch_name:Vcs.Branch_name.main + in + let subrepo_remote_main_head = + Vcs_extra.find_remote_tracking_node_exn + vcs + ~repo_root:subrepo_repo_root + ~graph:subrepo_graph + ~branch_name:Vcs.Branch_name.main + in + let central_head = Vcs.current_revision vcs ~repo_root:central_root in + let subrepo_base = + let parent_rev = gitrepo_file.parent.txt in + match Vcs.Graph.find_rev central_graph ~rev:parent_rev with + | None -> + Err.raise + [ Pp.text "Cannot find subrepo parent rev." + ; Dyn.pp + (Dyn.Record + [ "subrepo", Central.Subrepo.to_dyn subrepo + ; "parent_rev", Vcs.Rev.to_dyn parent_rev + ]) + ] + | Some parent_node -> + (* To be perfectly safe, in case the parent had multiple children, we + should only select the one that has modified the gitrepo file to + set its parent revision. *) + let is_child_revision ~rev = + match + Vcs.show_file_at_rev vcs ~repo_root:central_root ~rev ~path:gitrepo_file_path + with + | `Absent -> false + | `Present file_contents -> + let gitrepo_file = + Parsing_utils.parse_lexbuf_exn + (module Gitrepo_file_parser) + ~path:(Vcs.Repo_root.append central_root gitrepo_file_path :> Fpath.t) + ~lexbuf:(Lexing.from_string (file_contents :> string)) + in + Vcs.Rev.equal gitrepo_file.parent.txt parent_rev + in + let node_count = Vcs.Graph.node_count central_graph in + let rec find_child index = + if index >= node_count + then + Err.raise + [ Pp.text "Cannot find subrepo parent rev child." + ; Dyn.pp + (Dyn.Record + [ "subrepo", Central.Subrepo.to_dyn subrepo + ; "parent_rev", Vcs.Rev.to_dyn parent_rev + ]) + ] + else ( + let node = Vcs.Graph.get_node_exn central_graph ~index in + match + let rev = Vcs.Graph.rev central_graph ~node in + if + List.exists (Vcs.Graph.parents central_graph ~node) ~f:(fun parent -> + Vcs.Graph.Node.equal parent parent_node) + then if is_child_revision ~rev then Some rev else None + else None + with + | Some rev -> rev + | None -> find_child (index + 1)) + in + find_child (Vcs.Graph.node_index parent_node + 1) + in + let central_changed = + Vcs.Name_status.Changed.Between { src = subrepo_base; dst = central_head } + in + let central_name_status = + Vcs.name_status vcs ~repo_root:central_root ~changed:central_changed + in + let central_num_status = + Vcs.num_status vcs ~repo_root:central_root ~changed:central_changed + in + let central_has_changes_in_subrepo = + List.exists (Vcs.Name_status.files central_name_status) ~f:(fun central_path -> + is_subrepo_path ~subrepo ~central_path) + in + let subrepo_head_status : Central.Next_step.Facts.Subrepo_head_status.t = + let gitrepo_rev = gitrepo_file.commit.txt in + match Vcs.Graph.find_rev subrepo_graph ~rev:gitrepo_rev with + | None -> Unknown_central_gitrepo_rev + | Some gitrepo_node -> + Central_gitrepo_rev_compared_to_subrepo_head + { descendance = Vcs.Graph.descendance subrepo_graph gitrepo_node subrepo_head } + in + { subrepo + ; subrepo_repo_root + ; subrepo_graph + ; subrepo_head + ; subrepo_base + ; central_num_status + ; gitrepo_file_path + ; gitrepo_file + ; next_step_facts = + { central_has_changes_in_subrepo + ; subrepo_head_status + ; main_is_strict_ancestor_of_subrepo = + Vcs.Graph.is_strict_ancestor + subrepo_graph + ~ancestor:subrepo_main_head + ~descendant:subrepo_head + ; remote_main_is_strict_ancestor_of_local_main = + Vcs.Graph.is_strict_ancestor + subrepo_graph + ~ancestor:subrepo_remote_main_head + ~descendant:subrepo_main_head + } + } +;; + +let next_step t = Central.Next_step.compute t.next_step_facts + +let num_lines_to_review t = + let is_subrepo_path ~central_path = is_subrepo_path ~subrepo:t.subrepo ~central_path in + let num_status = + List.filter t.central_num_status ~f:(fun (change : Vcs.Num_status.Change.t) -> + match change.key with + | One_file central_path -> is_subrepo_path ~central_path + | Two_files { src; dst } -> + is_subrepo_path ~central_path:src || is_subrepo_path ~central_path:dst) + in + List.fold_left num_status ~init:0 ~f:(fun acc (change : Vcs.Num_status.Change.t) -> + acc + + + match change.num_stat with + | Num_lines_in_diff n -> Vcs.Num_lines_in_diff.total n + | Binary_file -> 1) +;; diff --git a/src/cli/subrepo_facts.mli b/src/cli/subrepo_facts.mli new file mode 100644 index 0000000..af3e4d2 --- /dev/null +++ b/src/cli/subrepo_facts.mli @@ -0,0 +1,60 @@ +(*_********************************************************************************) +(*_ central - Manage history between sub-repos and their monorepo *) +(*_ SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(*_ SPDX-License-Identifier: MIT *) +(*_********************************************************************************) + +(** Gathering facts about a subrepo. + + This is used by [export], [import], [push], [stitch], [advance-main], + [advance-subrepo] and [todo] to verify some invariants, compute the next + step, and size the outstanding diff. Only what those commands need is + included for now (no changelog / release facts yet). *) + +type t + +val compute + : vcs: + < Vcs.Trait.current_revision + ; Vcs.Trait.git + ; Vcs.Trait.log + ; Vcs.Trait.name_status + ; Vcs.Trait.num_status + ; Vcs.Trait.refs + ; Vcs.Trait.show + ; .. > + Vcs.t + -> central_root:Vcs.Repo_root.t + -> central_graph:Vcs.Graph.t + -> subrepo:Central.Subrepo.t + -> t + +(** {1 Next steps} *) + +val next_step_facts : t -> Central.Next_step.Facts.t +val next_step : t -> Central.Next_step.t option + +(** {1 Subrepo graph} *) + +val subrepo_repo_root : t -> Vcs.Repo_root.t +val subrepo_graph : t -> Vcs.Graph.t +val subrepo_head : t -> Vcs.Graph.Node.t + +(** {1 Gitrepo file (git subrepo)} *) + +val gitrepo_file : t -> Gitrepo_file.t +val gitrepo_file_path : t -> Vcs.Path_in_repo.t + +(** {1 Diffs} + + Computing the diffs of all that has changed between the last time we + synced with the subrepo and the current head of the subrepo. *) + +(** The revision used as the base of diffs. This is the last revision known + by central when it last synced with the subrepo. *) +val base : t -> Vcs.Rev.t + +(** The total number of changed lines (added + removed, and 1 per binary + file) under the subrepo's directory in the monorepo, since {!base}. Used + to populate the "Diff" column in [todo]'s table. *) +val num_lines_to_review : t -> int diff --git a/src/cli/vcs_extra.ml b/src/cli/vcs_extra.ml new file mode 100644 index 0000000..3a21dc9 --- /dev/null +++ b/src/cli/vcs_extra.ml @@ -0,0 +1,99 @@ +(*********************************************************************************) +(* central - Manage history between sub-repos and their monorepo *) +(* SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(* SPDX-License-Identifier: MIT *) +(*********************************************************************************) + +(* This is a trimmed copy of a [vcs_extra] library maintained elsewhere by + the same author, relicensed here as MIT. See vcs_extra.mli. *) + +let branch_tracking vcs ~repo_root ~branch_name = + Vcs.Result.git + vcs + ~repo_root + ~args: + [ "rev-parse" + ; "--abbrev-ref" + ; "--symbolic-full-name" + ; Printf.sprintf "%s@{u}" (Vcs.Branch_name.to_string branch_name) + ] + ~f:(fun output -> + Result.bind + (Vcs.Git.Result.exit_code output ~accept:[ 0, `Tracking; 128, `No_tracking ]) + (function + | `No_tracking -> Ok None + | `Tracking -> + (match Vcs.Remote_branch_name.of_string (String.strip output.stdout) with + | Ok ok -> Ok (Some ok) + | Error (`Msg m) -> Error (Err.create [ Pp.verbatim m ])))) +;; + +let branch_tracking_opt_exn vcs ~repo_root ~branch_name = + match branch_tracking vcs ~repo_root ~branch_name with + | Ok info -> info + | Error err -> + Err.raise + Pp.O. + [ Pp.text "Error computing remote tracking information for " + ++ Pp_tty.kwd (module Vcs.Branch_name) branch_name + ++ Pp.text "." + ; Err.dyn (err |> Err.to_dyn) + ; Pp.text "Repo: " ++ Pp_tty.path (module Vcs.Repo_root) repo_root + ] +;; + +let branch_tracking_exn vcs ~repo_root ~branch_name = + match branch_tracking_opt_exn vcs ~repo_root ~branch_name with + | Some remote -> remote + | None -> + Err.raise + Pp.O. + [ Pp.text "No remote tracking information for " + ++ Pp_tty.kwd (module Vcs.Branch_name) branch_name + ++ Pp.text "." + ; Pp.text "Repo: " ++ Pp_tty.path (module Vcs.Repo_root) repo_root + ] + ~hints: + [ Pp.text "Tracking information was obtained with:" + ; Pp_tty.simple_quotes + (Pp.verbatim + (Printf.sprintf + "git rev-parse --abbrev-ref --symbolic-full-name %s@{u}" + (Vcs.Branch_name.to_string branch_name))) + ; Pp.text "You may set it using:" + ; Pp_tty.simple_quotes + (Pp.verbatim + (Printf.sprintf + "git branch --set-upstream-to=/ %s" + (Vcs.Branch_name.to_string branch_name))) + ] +;; + +let find_local_branch_exn ~repo_root ~graph ~branch_name = + match Vcs.Graph.find_ref graph ~ref_kind:(Local_branch { branch_name }) with + | Some node -> node + | None -> + Err.raise + Pp.O. + [ Pp.text "Cannot find branch " + ++ Pp_tty.kwd (module Vcs.Branch_name) branch_name + ++ Pp.text " in " + ++ Pp_tty.path (module Vcs.Repo_root) repo_root + ++ Pp.text "." + ] +;; + +let find_remote_tracking_node_exn vcs ~repo_root ~graph ~branch_name = + let remote_branch_name = branch_tracking_exn vcs ~repo_root ~branch_name in + match Vcs.Graph.find_ref graph ~ref_kind:(Remote_branch { remote_branch_name }) with + | Some node -> node + | None -> + Err.raise + Pp.O. + [ Pp.text "Cannot find remote tracking branch " + ++ Pp_tty.kwd (module Vcs.Remote_branch_name) remote_branch_name + ++ Pp.text " in " + ++ Pp_tty.path (module Vcs.Repo_root) repo_root + ++ Pp.text "." + ] +;; diff --git a/src/cli/vcs_extra.mli b/src/cli/vcs_extra.mli new file mode 100644 index 0000000..b88e789 --- /dev/null +++ b/src/cli/vcs_extra.mli @@ -0,0 +1,41 @@ +(*_********************************************************************************) +(*_ central - Manage history between sub-repos and their monorepo *) +(*_ SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(*_ SPDX-License-Identifier: MIT *) +(*_********************************************************************************) + +(*_ This is a trimmed copy of a [vcs_extra] library maintained elsewhere by + the same author, relicensed here as MIT. Only what [export]/[import]/ + [push] actually need is copied over: [find_local_branch_exn], + [find_remote_tracking_node_exn] and their [branch_tracking] dependency + chain. *) + +(** Return the remote branch that is configured as the branch that a local + branch is tracking. Reports to [Err] when looking for tracking information + failed. *) +val branch_tracking_opt_exn + : < Vcs.Trait.git ; .. > Vcs.t + -> repo_root:Vcs.Repo_root.t + -> branch_name:Vcs.Branch_name.t + -> Vcs.Remote_branch_name.t option + +(** A convenient wrapper for [branch_tracking] that reports to [Err] when no + remote tracking branch is found. *) +val branch_tracking_exn + : < Vcs.Trait.git ; .. > Vcs.t + -> repo_root:Vcs.Repo_root.t + -> branch_name:Vcs.Branch_name.t + -> Vcs.Remote_branch_name.t + +val find_local_branch_exn + : repo_root:Vcs.Repo_root.t + -> graph:Vcs.Graph.t + -> branch_name:Vcs.Branch_name.t + -> Vcs.Graph.Node.t + +val find_remote_tracking_node_exn + : < Vcs.Trait.git ; .. > Vcs.t + -> repo_root:Vcs.Repo_root.t + -> graph:Vcs.Graph.t + -> branch_name:Vcs.Branch_name.t + -> Vcs.Graph.Node.t diff --git a/src/cli/which_repos.ml b/src/cli/which_repos.ml new file mode 100644 index 0000000..2aab1aa --- /dev/null +++ b/src/cli/which_repos.ml @@ -0,0 +1,123 @@ +(*********************************************************************************) +(* central - Manage history between sub-repos and their monorepo *) +(* SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(* SPDX-License-Identifier: MIT *) +(*********************************************************************************) + +type repo = + | Central + | Subrepo of Central.Subrepo.t + +let name ~repo_config = function + | Central -> Central.Repo_config.root_repo_name repo_config + | Subrepo subrepo -> Central.Subrepo.to_string subrepo +;; + +(* Each positional argument is only format-validated at parse time (same + shape as a subrepo name - non-empty, no '/'); which one of them actually + refers to the central repo can only be decided once [repo_config] is + loaded, which requires [repo_root], not known until well after argument + parsing - see {!val:resolve}. *) +type t = + | All + | Default_central + | Named of Central.Subrepo.t list + +let arg ~default_to_central = + let open Command.Std in + let+ repos = + Arg.pos_all + (Param.validated_string (module Central.Subrepo)) + ~docv:"REPO" + ~doc:"The repos to operate on." + and+ all = Arg.flag [ "all" ] ~doc:"Select all repos" in + match repos, all with + | [], false -> + if default_to_central + then Default_central + else Err.raise ~exit_code:Err.Exit_code.cli_error [ Pp.text "No repo specified." ] + | _ :: _, true -> + Err.raise + ~exit_code:Err.Exit_code.cli_error + Pp.O. + [ Pp.text "Cannot specify both " + ++ Pp_tty.kwd (module String) "repos" + ++ Pp.text " and " + ++ Pp_tty.kwd (module String) "--all" + ++ Pp.text "." + ] + | (_ :: _ as repos), false -> Named repos + | [], true -> All +;; + +let resolve t ~repo_config ~repo_root = + let classify (subrepo : Central.Subrepo.t) : repo = + if + String.equal + (Central.Subrepo.to_string subrepo) + (Central.Repo_config.root_repo_name repo_config) + then Central + else Subrepo subrepo + in + match t with + | Default_central -> [ Central ] + | Named repos -> List.map repos ~f:classify + | All -> Central :: List.map (Central.Subrepo.all ~repo_root) ~f:(fun s -> Subrepo s) +;; + +let iter_aux list ~name ~f = + List.iter list ~f:(fun repo -> + let name = name repo in + let sep = String.make 20 '=' in + let pp_sep = Pp_tty.ansi (module String) sep [ `Dim ] in + Log.app (fun () -> + Pp.O. + [ pp_sep + ++ Pp.verbatim " " + ++ Pp_tty.ansi (module String) name [ `Fg_bright_cyan ] + ++ Pp.verbatim " " + ++ pp_sep + ]); + f repo) +;; + +let iter list ~repo_config ~f = iter_aux list ~name:(name ~repo_config) ~f + +module Subrepos = struct + type t = + | All + | Named of Central.Subrepo.t list + + let arg = + let open Command.Std in + let+ repos = + Arg.pos_all + (Param.validated_string (module Central.Subrepo)) + ~docv:"REPO" + ~doc:"The repos to operate on." + and+ all = Arg.flag [ "all" ] ~doc:"Select all subrepos." in + match repos, all with + | [], false -> + Err.raise ~exit_code:Err.Exit_code.cli_error [ Pp.text "No subrepo specified." ] + | _ :: _, true -> + Err.raise + ~exit_code:Err.Exit_code.cli_error + Pp.O. + [ Pp.text "Cannot specify both " + ++ Pp_tty.kwd (module String) "subrepos" + ++ Pp.text " and " + ++ Pp_tty.kwd (module String) "--all" + ++ Pp.text "." + ] + | (_ :: _ as repos), false -> Named repos + | [], true -> All + ;; + + let resolve t ~repo_root = + match t with + | Named repos -> repos + | All -> Central.Subrepo.all ~repo_root + ;; + + let iter list ~f = iter_aux list ~name:Central.Subrepo.to_string ~f +end diff --git a/src/cli/which_repos.mli b/src/cli/which_repos.mli new file mode 100644 index 0000000..d0201f8 --- /dev/null +++ b/src/cli/which_repos.mli @@ -0,0 +1,48 @@ +(*_********************************************************************************) +(*_ central - Manage history between sub-repos and their monorepo *) +(*_ SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(*_ SPDX-License-Identifier: MIT *) +(*_********************************************************************************) + +(** Selecting which repo(s) a command should operate on. + + [Central.Subrepo.t] isn't a static enum, so [--all] can't be resolved to + a concrete list of repos at argument-parsing time (that requires walking + the filesystem from the monorepo root, which isn't known yet at that + point). Likewise, which positional argument (if any) refers to the + central repo itself depends on {!Central.Repo_config.root_repo_name}, which also + requires [repo_root] to load. Instead, {!val:arg} returns a [t] that + defers both; call {!val:resolve} once [repo_root] and the loaded + {!Central.Repo_config.t} are known (typically right after + [Common_helpers.find_enclosing_repo_root]). *) + +type repo = + | Central + | Subrepo of Central.Subrepo.t + +val name : repo_config:Central.Repo_config.t -> repo -> string + +type t = + | All + | Default_central + | Named of Central.Subrepo.t list + +val arg : default_to_central:bool -> t Command.Arg.t + +val resolve + : t + -> repo_config:Central.Repo_config.t + -> repo_root:Vcs.Repo_root.t + -> repo list + +val iter : repo list -> repo_config:Central.Repo_config.t -> f:(repo -> unit) -> unit + +module Subrepos : sig + type t = + | All + | Named of Central.Subrepo.t list + + val arg : t Command.Arg.t + val resolve : t -> repo_root:Vcs.Repo_root.t -> Central.Subrepo.t list + val iter : Central.Subrepo.t list -> f:(Central.Subrepo.t -> unit) -> unit +end From 43e626a7dbbe8d2d3e88ce4404a654850828225d Mon Sep 17 00:00:00 2001 From: Mathieu Barbin Date: Mon, 17 Aug 2026 22:21:20 +0200 Subject: [PATCH 11/26] Initiate testing --- src/test-harness/central_test_harness.ml | 258 ++++++++++++++ src/test-harness/central_test_harness.mli | 76 ++++ src/test-helpers/central_test_helpers.ml | 254 ++++++++++++++ src/test-helpers/central_test_helpers.mli | 141 ++++++++ test/README.md | 50 +++ test/SUMMARY.md | 12 + test/book.toml | 20 ++ test/expect/advance.md | 82 +++++ test/expect/advance.ml | 242 +++++++++++++ test/expect/advance.mli | 5 + test/expect/config.md | 23 ++ test/expect/config.ml | 79 +++++ test/expect/config.mli | 5 + test/expect/export.md | 171 +++++++++ test/expect/export.ml | 397 +++++++++++++++++++++ test/expect/export.mli | 5 + test/expect/import.md | 173 +++++++++ test/expect/import.ml | 407 ++++++++++++++++++++++ test/expect/import.mli | 5 + test/expect/push.md | 80 +++++ test/expect/push.ml | 213 +++++++++++ test/expect/push.mli | 5 + test/expect/stitch.md | 147 ++++++++ test/expect/stitch.ml | 352 +++++++++++++++++++ test/expect/stitch.mli | 5 + test/expect/test__central.ml | 16 + test/expect/test__central.mli | 5 + test/expect/todo.md | 23 ++ test/expect/todo.ml | 171 +++++++++ test/expect/todo.mli | 5 + test/expect/workflow.md | 101 ++++++ test/expect/workflow.ml | 182 ++++++++++ test/expect/workflow.mli | 5 + 33 files changed, 3715 insertions(+) create mode 100644 src/test-harness/central_test_harness.ml create mode 100644 src/test-harness/central_test_harness.mli create mode 100644 src/test-helpers/central_test_helpers.ml create mode 100644 src/test-helpers/central_test_helpers.mli create mode 100644 test/README.md create mode 100644 test/SUMMARY.md create mode 100644 test/book.toml create mode 100644 test/expect/advance.md create mode 100644 test/expect/advance.ml create mode 100644 test/expect/advance.mli create mode 100644 test/expect/config.md create mode 100644 test/expect/config.ml create mode 100644 test/expect/config.mli create mode 100644 test/expect/export.md create mode 100644 test/expect/export.ml create mode 100644 test/expect/export.mli create mode 100644 test/expect/import.md create mode 100644 test/expect/import.ml create mode 100644 test/expect/import.mli create mode 100644 test/expect/push.md create mode 100644 test/expect/push.ml create mode 100644 test/expect/push.mli create mode 100644 test/expect/stitch.md create mode 100644 test/expect/stitch.ml create mode 100644 test/expect/stitch.mli create mode 100644 test/expect/test__central.ml create mode 100644 test/expect/test__central.mli create mode 100644 test/expect/todo.md create mode 100644 test/expect/todo.ml create mode 100644 test/expect/todo.mli create mode 100644 test/expect/workflow.md create mode 100644 test/expect/workflow.ml create mode 100644 test/expect/workflow.mli diff --git a/src/test-harness/central_test_harness.ml b/src/test-harness/central_test_harness.ml new file mode 100644 index 0000000..552407f --- /dev/null +++ b/src/test-harness/central_test_harness.ml @@ -0,0 +1,258 @@ +(*********************************************************************************) +(* central - Manage history between sub-repos and their monorepo *) +(* SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(* SPDX-License-Identifier: MIT *) +(*********************************************************************************) + +(* Resolved once, before any test has a chance to [chdir] away (see [run]) - + the copy of the [central] executable that dune's [(deps central.exe)] + places next to the test binary, at whatever the test runner's cwd was at + startup. *) +let executable = Filename.concat (Sys.getcwd ()) "central.exe" + +let is_hex_char = function + | '0' .. '9' | 'a' .. 'f' -> true + | _ -> false +;; + +(* Finds 40-character lowercase hex substrings (git revisions) not embedded + in a longer run of hex characters. *) +let find_shas text = + let len = String.length text in + let results = ref [] in + let i = ref 0 in + while !i <= len - 40 do + let preceded_by_hex = !i > 0 && is_hex_char text.[!i - 1] in + let followed_by_hex = !i + 40 < len && is_hex_char text.[!i + 40] in + if (not preceded_by_hex) && not followed_by_hex + then ( + let ok = ref true in + for j = 0 to 39 do + if not (is_hex_char text.[!i + j]) then ok := false + done; + if !ok + then ( + results := String.sub text ~pos:!i ~len:40 :: !results; + i := !i + 40) + else incr i) + else incr i + done; + List.rev !results +;; + +let replace_all text ~pattern ~with_ = + let plen = String.length pattern in + let tlen = String.length text in + if plen = 0 || plen > tlen + then text + else ( + let buf = Buffer.create tlen in + let i = ref 0 in + while !i <= tlen - plen do + if String.equal (String.sub text ~pos:!i ~len:plen) pattern + then ( + Buffer.add_string buf with_; + i := !i + plen) + else ( + Buffer.add_char buf text.[!i]; + incr i) + done; + if !i < tlen then Buffer.add_string buf (String.sub text ~pos:!i ~len:(tlen - !i)); + Buffer.contents buf) +;; + +type t = + { mock_revs : Vcs.Mock_revs.t + ; repo_root : string * string (* (absolute path, "$CENTRAL_ROOT") *) + ; mutable registered_revs : (string * string) list (* (full sha, full mock sha) *) + } + +let create ~repo_root = + { mock_revs = Vcs.Mock_revs.create () + ; repo_root = Vcs.Repo_root.to_string repo_root, "$CENTRAL_ROOT" + ; registered_revs = [] + } +;; + +let to_mock_rev t ~rev = + let sha = Vcs.Rev.to_string rev in + let mock = Vcs.Mock_revs.to_mock t.mock_revs ~rev in + let mock_sha = Vcs.Rev.to_string mock in + if not (List.exists t.registered_revs ~f:(fun (r, _) -> String.equal r sha)) + then t.registered_revs <- (sha, mock_sha) :: t.registered_revs; + mock +;; + +let register_rev t ~rev = ignore (to_mock_rev t ~rev : Vcs.Rev.t) + +(* Longest match first, so a full sha is substituted before its own shorter + (abbreviated) prefix. *) +let sorted_by_length_desc pairs = + List.sort pairs ~cmp:(fun (a, _) (b, _) -> + Int.compare (String.length b) (String.length a)) +;; + +(* git's own abbreviated shas (as printed by e.g. [git merge --ff-only]'s + "Updating X..Y" summary) don't have a fixed length - [core.abbrev] + defaults to the shortest prefix that is currently unambiguous in the + repo, which for a small test repo is usually 7 characters but isn't + guaranteed to be. Rather than assume a length, register every prefix in + the range git realistically uses ([min_abbrev_len] up to the full sha), + longest first, so whatever length actually shows up in the text finds an + exact match. *) +let min_abbrev_len = 4 + +let prefixes_of_rev ~sha ~mock_sha = + List.init + ~len:(String.length sha - min_abbrev_len + 1) + ~f:(fun i -> + let len = String.length sha - i in + String.prefix sha len, String.prefix mock_sha len) +;; + +(* Abbreviated shas are only caught here if their full-length counterpart was + already registered - either auto-detected via [find_shas] earlier in this + same text, or registered explicitly with [register_rev] beforehand. *) +let replacements t text = + let shas = find_shas text in + List.iter shas ~f:(fun sha -> ignore (to_mock_rev t ~rev:(Vcs.Rev.v sha) : Vcs.Rev.t)); + let rev_replacements = + List.concat_map t.registered_revs ~f:(fun (sha, mock_sha) -> + prefixes_of_rev ~sha ~mock_sha) + in + sorted_by_length_desc (t.repo_root :: rev_replacements) +;; + +let redact t text = + List.fold_left (replacements t text) ~init:text ~f:(fun acc (pattern, with_) -> + replace_all acc ~pattern ~with_) +;; + +(* Keep the first group attached to the program name when it doesn't start + with a flag (so e.g. [$ central export foo] reads on one line rather than + [$ central \ export foo]), box each group so it doesn't break internally, + and join groups with a soft break that only kicks in if the whole command + doesn't fit on one line - in which case continuation lines get a trailing + [\] like a shell command split across lines. *) +let command_pp_to_string pp = + let buffer = Buffer.create 23 in + let formatter = Format.formatter_of_buffer buffer in + Format.fprintf formatter "%a%!" Pp.to_fmt pp; + Buffer.contents buffer + |> String.split_lines + |> List.map ~f:String.rstrip + |> String.concat ~sep:" \\\n" +;; + +(* A person typing [-m Several words] at a shell would need to quote it as + [-m "Several words"] for it to be seen as one argument rather than three - + so the printed header does the same whenever an argument isn't a single + shell "word" on its own. *) +let needs_quoting arg = + String.is_empty arg + || String.exists arg ~f:(fun c -> + match c with + | ' ' + | '\t' + | '\n' + | '"' + | '\'' + | '\\' + | '$' + | '`' + | '*' + | '?' + | '[' + | ']' + | '(' + | ')' + | '{' + | '}' + | ';' + | '&' + | '|' + | '<' + | '>' + | '~' + | '#' -> true + | _ -> false) +;; + +let quote_arg_if_needed arg = if needs_quoting arg then Printf.sprintf "%S" arg else arg + +let command_header groups = + let groups = + match groups with + | (first_arg :: _ as first_group) :: rest + when not (String.is_prefix first_arg ~prefix:"-") -> + ("central" :: first_group) :: rest + | _ -> [ "central" ] :: groups + in + let groups = + List.map groups ~f:(fun group -> + Pp.hbox + (Pp.concat_map group ~sep:Pp.space ~f:(fun arg -> + Pp.verbatim (quote_arg_if_needed arg)))) + in + Pp.concat [ Pp.verbatim "$ "; Pp.hvbox ~indent:2 (Pp.concat ~sep:Pp.space groups) ] + |> command_pp_to_string +;; + +let run t ~cwd groups = + let args = List.concat groups in + print_endline (command_header groups); + let original_cwd = Sys.getcwd () in + Sys.chdir (Vcs.Repo_root.to_string cwd); + let temp_stdout = Filename.temp_file "central_test_harness" ".stdout" in + let temp_stderr = Filename.temp_file "central_test_harness" ".stderr" in + Fun.protect + ~finally:(fun () -> + Sys.chdir original_cwd; + (try Sys.remove temp_stdout with + | Sys_error _ -> ()); + try Sys.remove temp_stderr with + | Sys_error _ -> ()) + (fun () -> + let stdout_fd = + Unix.openfile temp_stdout [ Unix.O_WRONLY; Unix.O_CREAT; Unix.O_TRUNC ] 0o666 + in + let stderr_fd = + Unix.openfile temp_stderr [ Unix.O_WRONLY; Unix.O_CREAT; Unix.O_TRUNC ] 0o666 + in + let pid = + Fun.protect + ~finally:(fun () -> + Unix.close stdout_fd; + Unix.close stderr_fd) + (fun () -> + Unix.create_process + executable + (Array.of_list (executable :: args)) + Unix.stdin + stdout_fd + stderr_fd) + in + let _, status = Unix.waitpid [] pid in + let flush_output () = + let read_file path = + In_channel.with_open_bin path In_channel.input_all |> redact t + in + let out = read_file temp_stdout in + if not (String.equal out "") then print_string out; + let err = read_file temp_stderr in + if not (String.equal err "") then print_string err + in + match status with + | Unix.WEXITED 0 -> flush_output () + | Unix.WEXITED code -> + flush_output (); + Printf.printf "[%d]\n" code + | Unix.WSIGNALED signal -> + flush_output (); + Printf.printf "Killed by signal %d\n" signal + | Unix.WSTOPPED signal -> + flush_output (); + Printf.printf "Stopped by signal %d\n" signal) +;; + +let with_cli t ~cwd f = f (run t ~cwd) diff --git a/src/test-harness/central_test_harness.mli b/src/test-harness/central_test_harness.mli new file mode 100644 index 0000000..bb7ffe2 --- /dev/null +++ b/src/test-harness/central_test_harness.mli @@ -0,0 +1,76 @@ +(*_********************************************************************************) +(*_ central - Manage history between sub-repos and their monorepo *) +(*_ SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(*_ SPDX-License-Identifier: MIT *) +(*_********************************************************************************) + +(** Running the real [central] executable from within expect tests. + + This spawns the actual compiled binary as a subprocess, so a test built + on this harness shows precisely what a [central] user would see typing + the same command in their terminal, including its command-line parsing, + its exit code, and anything it prints. + + [central] has no server to manage, so this is much simpler than a + client/server harness: create one [t] per test, then call {!run} once per + command. *) + +type t + +(** [repo_root] is required so a harness can never forget to mask it: any + occurrence of its absolute path in a transcript is rewritten to + [$CENTRAL_ROOT]. *) +val create : repo_root:Vcs.Repo_root.t -> t + +(** {1 Mock revisions} + + {!run} auto-detects and rewrites full 40-character git revisions + wherever they appear in a transcript. It cannot do the same for + *abbreviated* ones (e.g. the "Updating a1b2c3d..e4f5678" summary line + printed by a fast-forward [git merge]) unless the full revision was + already registered - either because it appeared in full somewhere + earlier in the same transcript, or because it was registered ahead of + time with one of the functions below. *) + +(** Map a real revision to its deterministic mock counterpart, and register + it (and every abbreviated prefix a person might plausibly see printed by + git for it) for rewriting. Idempotent: calling it multiple times with the + same revision returns the same mock. *) +val to_mock_rev : t -> rev:Vcs.Rev.t -> Vcs.Rev.t + +(** Same as {!to_mock_rev}, discarding the result - use when you only need + the side effect of registering [rev] for output rewriting. *) +val register_rev : t -> rev:Vcs.Rev.t -> unit + +(** Apply the same rewriting {!run} applies to a command's captured output to + an arbitrary piece of text - e.g. a file read directly off disk (which + doesn't go through {!run} at all), such as one left with unresolved merge + conflict markers, whose trailing [>>>>>>> ] would otherwise be + non-deterministic. *) +val redact : t -> string -> string + +(** [run t ~cwd [ [ "export"; "foo" ]; [ "-m"; "msg" ] ]] spawns the real + [central] executable with the concatenation of the given argument groups, + with [cwd] as its working directory (so [central]'s own "find the + enclosing repo" logic resolves against a fake repo built by + [Central_test_helpers], exactly as it would resolve against a real + checkout), and prints a cram-like transcript: a header line built from + the groups (kept visually grouped, the way a person would type them), + then the process's stdout and stderr (in that order - the two streams + are captured separately, so true interleaving is not preserved), with + any registered repo roots substituted and any 40-character hex string (a + git revision) rewritten to a deterministic mock so the transcript is + stable across runs. A non-zero exit code is appended as a trailing + [[N]] line, matching cram's own convention. + + The grouping exists purely for readability of the printed header - e.g. + [ [ "export"; "foo" ]; [ "-m"; "msg" ] ] and + [ [ "export"; "foo"; "-m"; "msg" ] ] run the identical command; split + into groups when that reads more like something a person would actually + type. *) +val run : t -> cwd:Vcs.Repo_root.t -> string list list -> unit + +(** [let@ central = with_cli t ~cwd in central [ [ "todo" ] ]] - the same as + {!run}, curried so a test can bind [central] once per scenario and call + it like a shell prompt for each command that follows. *) +val with_cli : t -> cwd:Vcs.Repo_root.t -> ((string list list -> unit) -> unit) -> unit diff --git a/src/test-helpers/central_test_helpers.ml b/src/test-helpers/central_test_helpers.ml new file mode 100644 index 0000000..6d646ff --- /dev/null +++ b/src/test-helpers/central_test_helpers.ml @@ -0,0 +1,254 @@ +(*********************************************************************************) +(* central - Manage history between sub-repos and their monorepo *) +(* SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(* SPDX-License-Identifier: MIT *) +(*********************************************************************************) + +module Fake_subrepo = struct + type t = + { subrepo : Central.Subrepo.t + ; repo_root : Vcs.Repo_root.t + ; remote_root : Vcs.Repo_root.t + ; initial_rev : Vcs.Rev.t + } +end + +module Fake_central = struct + type t = + { central_root : Vcs.Repo_root.t + ; central_remote_root : Vcs.Repo_root.t + ; subrepos : Fake_subrepo.t list + } + + let find_exn t ~subrepo = + match + List.find_opt t.subrepos ~f:(fun (fake_subrepo : Fake_subrepo.t) -> + Central.Subrepo.equal fake_subrepo.subrepo subrepo) + with + | Some fake_subrepo -> fake_subrepo + | None -> + Err.raise + Pp.O. + [ Pp.text "No fake subrepo was created for " + ++ Pp_tty.id (module Central.Subrepo) subrepo + ++ Pp.text "." + ] + ;; +end + +let read_file ~repo_root ~path_in_repo = + In_channel.with_open_bin + (Vcs.Repo_root.append repo_root path_in_repo |> Absolute_path.to_string) + In_channel.input_all +;; + +let print_file ~repo_root ~path_in_repo = + print_string (String.trim (read_file ~repo_root ~path_in_repo)) +;; + +let print_log_subjects ?(first_parent = false) ~vcs ~repo_root ~ref_ () = + let args = + List.concat + [ [ "log"; "--format=%s" ] + ; (if first_parent then [ "--first-parent" ] else []) + ; [ ref_ ] + ] + in + print_string (String.trim (Vcs.git vcs ~repo_root ~args ~f:Vcs.Git.exit0_and_stdout)) +;; + +let print_graph ~vcs ~repo_root ~refs = + print_string + (String.trim + (Vcs.git + vcs + ~repo_root + ~args:(List.concat [ [ "log"; "--graph"; "--format=%s" ]; refs ]) + ~f:Vcs.Git.exit0_and_stdout)) +;; + +let rec mkdir_p dir = + if not (Sys.file_exists dir) + then ( + mkdir_p (Filename.dirname dir); + try Unix.mkdir dir 0o755 with + | Unix.Unix_error (Unix.EEXIST, _, _) -> ()) +;; + +let write_file ~repo_root ~path_in_repo ~contents = + let path = Vcs.Repo_root.append repo_root path_in_repo |> Absolute_path.to_string in + mkdir_p (Filename.dirname path); + Out_channel.with_open_bin path (fun oc -> Out_channel.output_string oc contents) +;; + +let append_file ~repo_root ~path_in_repo ~text = + let path = Vcs.Repo_root.append repo_root path_in_repo |> Absolute_path.to_string in + Out_channel.with_open_gen [ Open_append; Open_binary ] 0o644 path (fun oc -> + Out_channel.output_string oc text) +;; + +let commit ~vcs ~repo_root ~commit_message = + Vcs.commit vcs ~repo_root ~commit_message:(Vcs.Commit_message.v commit_message) +;; + +(* The README's content is derived from the subrepo's name so that fake + central and fake subrepo repos start off in sync, and so that failures are + easy to attribute to a particular fake subrepo when several are created in + the same test. *) +let fake_readme_contents subrepo = + Printf.sprintf + "# %s\n\nThis is a fake [%s] repo, generated by [Central_test_helpers] for tests.\n" + (Central.Subrepo.to_string subrepo) + (Central.Subrepo.to_string subrepo) +;; + +let temp_dir prefix = Filename.temp_dir prefix "" |> Absolute_path.v + +(* This takes care of setting the user config with dummy values, so that + [Vcs.commit] can be used without depending on the ambient user config - + isolating the test from the local machine's settings, and making things + work in CI environments where no default user config exists. *) +let init_repo vcs ~prefix = + let repo_root = Vcs.init vcs ~path:(temp_dir prefix) in + Vcs.set_user_name vcs ~repo_root ~user_name:(Vcs.User_name.v "Test User"); + Vcs.set_user_email vcs ~repo_root ~user_email:(Vcs.User_email.v "test@example.com"); + repo_root +;; + +(* Renaming the current branch to [main] right after the first commit keeps + the fake repos deterministic regardless of the ambient [init.defaultBranch] + git config. *) +let commit_and_ensure_main_branch vcs ~repo_root ~commit_message = + let rev = + Vcs.commit vcs ~repo_root ~commit_message:(Vcs.Commit_message.v commit_message) + in + Vcs.rename_current_branch vcs ~repo_root ~to_:Vcs.Branch_name.main; + rev +;; + +(* A bare repo in its own throwaway temporary directory - never to be + confused with any real, production remote. Bare (as any real remote + effectively is), so pushing [main] to it - even while [main] is checked + out in the pushing repo - never runs into git's "refusing to update the + current branch" guard. *) +let create_bare_remote vcs ~prefix = + let remote_root = temp_dir (prefix ^ "-remote") |> Vcs.Repo_root.of_absolute_path in + Vcs.git vcs ~repo_root:remote_root ~args:[ "init"; "--bare" ] ~f:Vcs.Git.exit0; + remote_root +;; + +(* Both [Subrepo_facts.compute] and a "next step"-style overview both require + [main] to have remote-tracking information (as a real checkout would, + tracking its [origin]). Rather than fake that tracking info, a real, + separate bare repo is created and [main] is genuinely pushed to it - so a + test can also exercise [push] itself and inspect what actually landed on + the far end, not just whether [central] thinks a push is due. *) +let push_to_new_remote vcs ~repo_root ~prefix = + let remote_root = create_bare_remote vcs ~prefix in + Vcs.git + vcs + ~repo_root + ~args:[ "remote"; "add"; "origin"; Vcs.Repo_root.to_string remote_root ] + ~f:Vcs.Git.exit0; + Vcs.git + vcs + ~repo_root + ~args:[ "push"; "--set-upstream"; "origin"; "main" ] + ~f:Vcs.Git.exit0; + remote_root +;; + +let create_fake_subrepo vcs ~subrepo = + let repo_root = init_repo vcs ~prefix:(Central.Subrepo.to_string subrepo) in + let readme = Vcs.Path_in_repo.v "README.md" in + write_file ~repo_root ~path_in_repo:readme ~contents:(fake_readme_contents subrepo); + Vcs.add vcs ~repo_root ~path:readme; + let initial_rev = + commit_and_ensure_main_branch vcs ~repo_root ~commit_message:"Initial commit" + in + Vcs.git vcs ~repo_root ~args:[ "branch"; "subrepo" ] ~f:Vcs.Git.exit0; + let remote_root = + push_to_new_remote vcs ~repo_root ~prefix:(Central.Subrepo.to_string subrepo) + in + { Fake_subrepo.subrepo; repo_root; remote_root; initial_rev } +;; + +(* Adds a commit to the (already created) central repo that introduces + [repo//README.md] and [repo//.gitrepo], exactly as if the + subrepo had just been set up - the commit's own parent is used as + [.gitrepo]'s [parent] field, matching the invariant relied upon by + [Subrepo_facts.compute] to locate the base revision of a subrepo. *) +let add_fake_subrepo_to_central vcs ~central_root (fake_subrepo : Fake_subrepo.t) = + let { Fake_subrepo.subrepo; repo_root; remote_root = _; initial_rev } = fake_subrepo in + let readme_path = + Vcs.Path_in_repo.v + (Filename.concat + (Vcs.Path_in_repo.to_string (Central.Subrepo.root subrepo)) + "README.md") + in + write_file + ~repo_root:central_root + ~path_in_repo:readme_path + ~contents:(fake_readme_contents subrepo); + Vcs.add vcs ~repo_root:central_root ~path:readme_path; + (* [parent_rev] is captured here, before README.md/[.gitrepo] are committed + below - so it points at a central revision where those files don't + exist yet. [Subrepo_facts.compute] doesn't diff from [parent_rev] + directly, it searches for the child commit whose own [.gitrepo] records + this exact [parent_rev] - which here is precisely the "Add fake + subrepo" commit below, the one that *does* already contain these files. + So this does not create a spurious outgoing diff at the moment + [create] returns. *) + let parent_rev = Vcs.current_revision vcs ~repo_root:central_root in + let gitrepo_path = Central.Subrepo.gitrepo_file_path subrepo in + let gitrepo_contents = + Gitrepo_file.create + ~remote:(`Repo_root repo_root) + ~branch:(Vcs.Branch_name.v "subrepo") + ~commit:initial_rev + ~parent:parent_rev + () + |> Gitrepo_file.write + in + write_file ~repo_root:central_root ~path_in_repo:gitrepo_path ~contents:gitrepo_contents; + Vcs.add vcs ~repo_root:central_root ~path:gitrepo_path; + let (_ : Vcs.Rev.t) = + Vcs.commit + vcs + ~repo_root:central_root + ~commit_message: + (Vcs.Commit_message.v + (Printf.sprintf "Add fake subrepo %s" (Central.Subrepo.to_string subrepo))) + in + () +;; + +let create ~vcs ~subrepos = + let central_root = init_repo vcs ~prefix:"central" in + let readme = Vcs.Path_in_repo.v "README.md" in + write_file + ~repo_root:central_root + ~path_in_repo:readme + ~contents:"# central\n\nA fake central repo, generated by [Central_test_helpers].\n"; + Vcs.add vcs ~repo_root:central_root ~path:readme; + let (_ : Vcs.Rev.t) = + commit_and_ensure_main_branch + vcs + ~repo_root:central_root + ~commit_message:"Initial commit" + in + let fake_subrepos = + List.map subrepos ~f:(fun subrepo -> + let fake_subrepo = create_fake_subrepo vcs ~subrepo in + add_fake_subrepo_to_central vcs ~central_root fake_subrepo; + fake_subrepo) + in + (* Central's remote is only pushed to now, once every subrepo's scaffolding + commit has landed - so a freshly [create]d repo looks like a checkout + that is fully up to date with its remote, not one sitting on unpushed + commits. *) + let central_remote_root = + push_to_new_remote vcs ~repo_root:central_root ~prefix:"central" + in + { Fake_central.central_root; central_remote_root; subrepos = fake_subrepos } +;; diff --git a/src/test-helpers/central_test_helpers.mli b/src/test-helpers/central_test_helpers.mli new file mode 100644 index 0000000..dce7829 --- /dev/null +++ b/src/test-helpers/central_test_helpers.mli @@ -0,0 +1,141 @@ +(*_********************************************************************************) +(*_ central - Manage history between sub-repos and their monorepo *) +(*_ SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(*_ SPDX-License-Identifier: MIT *) +(*_********************************************************************************) + +(** Building fake [central] repos for tests, and small helpers shared by + expect tests exercising them. + + [Central.Subrepo.t] is fully dynamic (a validated directory name, not a + closed enum), so this harness can pick any subrepo name it likes for a + test: a temporary directory standing in for the central repo, and one + additional temporary directory per selected subrepo standing in for what + would normally live in its own standalone checkout. + + Both are real git repos, wired together with a [.gitrepo] file exactly + as a real subrepo setup would leave them - so [Subrepo_facts] (and + everything built on top of it, such as [export]/[import]/[push]) can + operate on the result unmodified. + + Each of the two also has its own real, separate bare repo standing in + for its [origin] (see [remote_root] in {!type:Fake_subrepo.t} and + [central_remote_root] in {!type:Fake_central.t}) - so [push] can be + exercised for real, not just its next-step computation. *) + +module Fake_subrepo : sig + type t = + { subrepo : Central.Subrepo.t + ; repo_root : Vcs.Repo_root.t + (** The root of the subrepo's own repo - what the [.gitrepo] file's + [remote] field points to. *) + ; remote_root : Vcs.Repo_root.t + (** A real, separate bare repo that [repo_root] genuinely pushes to + and tracks as [origin] - not to be confused with any real, + production remote. Read it directly (e.g. with [git log]) to check + what a [push ] actually landed there. *) + ; initial_rev : Vcs.Rev.t + (** The single commit created in the subrepo repo, which both its + [main] and [subrepo] local branches point to. Also the revision + recorded as [.gitrepo]'s [commit] field in the central repo. *) + } +end + +module Fake_central : sig + type t = + { central_root : Vcs.Repo_root.t + ; central_remote_root : Vcs.Repo_root.t + (** Same idea as [Fake_subrepo.t.remote_root], for [central_root]. *) + ; subrepos : Fake_subrepo.t list + } + + (** Raises if no fake subrepo with this identity was created as part of + [t]. *) + val find_exn : t -> subrepo:Central.Subrepo.t -> Fake_subrepo.t +end + +(** Create a fresh fake central repo (in a new temporary directory), with one + fake subrepo (each in its own fresh temporary directory) per element of + [subrepos]. + + Each created directory is a real, standalone git repo: + + - The central repo has an initial commit, then one additional commit per + subrepo that adds both a made up [repo//README.md] and the + corresponding [repo//.gitrepo] file. + - Each subrepo repo has a single commit (the same content as the + [README.md] added to central), with both [main] and [subrepo] local + branches pointing to it. + - Both the central repo and each subrepo repo have already pushed + [main] to their own (also freshly created) bare remote, and track it + as [origin] - so a fresh [create]d repo looks like a checkout that is + fully up to date, not one sitting on unpushed commits. + + None of the created directories - repos or remotes alike - are removed + by this function - callers are expected to run this from a context that + already takes care of cleaning up temporary directories, or to remove + them individually when appropriate. *) +val create + : vcs: + < Vcs.Trait.add + ; Vcs.Trait.branch + ; Vcs.Trait.commit + ; Vcs.Trait.config + ; Vcs.Trait.current_revision + ; Vcs.Trait.git + ; Vcs.Trait.init + ; .. > + Vcs.t + -> subrepos:Central.Subrepo.t list + -> Fake_central.t + +(** {1 Small doc/test helpers} + + Not tied to [Fake_central]/[Fake_subrepo] specifically - just small + conveniences for reading/writing files and printing stable output in an + expect test. *) + +val read_file : repo_root:Vcs.Repo_root.t -> path_in_repo:Vcs.Path_in_repo.t -> string +val print_file : repo_root:Vcs.Repo_root.t -> path_in_repo:Vcs.Path_in_repo.t -> unit + +(** [~first_parent] walks a single deterministic chain (git's own + [--first-parent]) instead of the full history - use it whenever [ref_]'s + history includes a merge, so the printed order doesn't depend on the + unstable tie-break between sibling commits created within the same + second (a real source of flakiness otherwise: git's default order for + same-timestamp commits isn't guaranteed stable). *) +val print_log_subjects + : ?first_parent:bool + -> vcs:< Vcs.Trait.git ; .. > Vcs.t + -> repo_root:Vcs.Repo_root.t + -> ref_:string + -> unit + -> unit + +(** Prints [git log --graph --format=%s ] - [refs] is passed through + as-is (revs, branch names, [--all], ...), so a commit not reachable from + any ref can still be shown by naming it explicitly. *) +val print_graph + : vcs:< Vcs.Trait.git ; .. > Vcs.t + -> repo_root:Vcs.Repo_root.t + -> refs:string list + -> unit + +(** Creates any missing parent directories under [repo_root] as needed. *) +val write_file + : repo_root:Vcs.Repo_root.t + -> path_in_repo:Vcs.Path_in_repo.t + -> contents:string + -> unit + +val append_file + : repo_root:Vcs.Repo_root.t + -> path_in_repo:Vcs.Path_in_repo.t + -> text:string + -> unit + +val commit + : vcs:< Vcs.Trait.commit ; Vcs.Trait.current_revision ; .. > Vcs.t + -> repo_root:Vcs.Repo_root.t + -> commit_message:string + -> Vcs.Rev.t diff --git a/test/README.md b/test/README.md new file mode 100644 index 0000000..76e0165 --- /dev/null +++ b/test/README.md @@ -0,0 +1,50 @@ +# Central Test Suite + +This is `central`'s own test suite, and also its book: pages are generated +from OCaml source files (via [mdexp](https://github.com/mbarbin/mdexp)) +that are also real, running `dune runtest`s, so the narrative prose, the +code, and the snapshots you see embedded in a page are never allowed to +drift from what actually happens when the code runs. + +## Layout + +- `expect/` holds every test file, both the literate, book-generating ones + (`workflow.ml`, `export.ml`, `import.ml`, `stitch.ml`, `push.ml`, + `advance.ml`, `todo.ml`, `config.ml` - each carrying `@mdexp` directives + and a generated `.md` counterpart, checked in next to the `.ml`) and the + plain ones (`test__central.ml`). +- `gitrepo/` and `gitrepo-file-parser/` test the `.gitrepo` file parser in + isolation. + +This is part of the effort to move `central`'s subrepo workflow off +`git-subrepo` and onto small, dedicated pieces of OCaml logic: each command +gets a page here explaining what it does and demonstrating it end to end. +The intent is for this book to grow to cover `central` more broadly, not +just the subrepo commands. + +## How does it relate to the user documentation? + +There is inevitable overlap between this test book and the documentation in +`doc/`. The key difference is intent: + +- **`doc/`** is focused on the **user experience** - how to install, + configure, and use `central`. It omits low-level details. +- **`test/`** is focused on **correctness** - every guardrail, every error + case, every CLI invocation. It includes details that would overwhelm a + user guide but are essential for someone modifying the code. + +When both cover the same topic, the doc version explains *what to do* while +the test version proves *that it works*. + +## Building + +```bash +dune runtest +``` + +regenerates the book's pages from their source `.ml` files, and runs every +other test in the tree. To browse the book itself: + +```bash +cd test && mdbook serve --open +``` diff --git a/test/SUMMARY.md b/test/SUMMARY.md new file mode 100644 index 0000000..22eb6f0 --- /dev/null +++ b/test/SUMMARY.md @@ -0,0 +1,12 @@ +# Summary + +[Introduction](README.md) + +- [A day-to-day workflow](expect/workflow.md) +- [Export](expect/export.md) +- [Import](expect/import.md) +- [Stitch](expect/stitch.md) +- [Push](expect/push.md) +- [Advance Main, Advance Subrepo](expect/advance.md) +- [Todo](expect/todo.md) +- [Config](expect/config.md) diff --git a/test/book.toml b/test/book.toml new file mode 100644 index 0000000..b550580 --- /dev/null +++ b/test/book.toml @@ -0,0 +1,20 @@ +[book] +title = "Central Test Suite" +authors = ["Mathieu Barbin"] +description = "Literate walkthroughs of central's commands, doubling as regression tests via mdexp" +language = "en" +src = "." + +[build] +build-dir = "../doc/static/book/test-suite" + +[output.html] +git-repository-url = "https://github.com/mbarbin/central-cli" +default-theme = "light" +preferred-dark-theme = "navy" +theme = "../doc/book/shared-theme" +additional-js = ["../doc/book/shared-theme/ansi-plugin.js"] + +[output.html.fold] +enable = true +level = 0 diff --git a/test/expect/advance.md b/test/expect/advance.md new file mode 100644 index 0000000..252dee8 --- /dev/null +++ b/test/expect/advance.md @@ -0,0 +1,82 @@ +# Advance Main, Advance Subrepo + +`export` only ever moves a subrepo's `subrepo` branch - it never touches +`main`, so `main` falls one commit behind after every export. Two commands +catch it back up, at different scopes: + +- `central advance-main ` fast-forwards that subrepo's local `main` + branch to match `subrepo`, from the same machine an `export` (or `import`) + just ran on - the everyday, single-machine case. +- `central advance-subrepo ` is the more thorough version, aimed at a + *second* machine: after pulling central's own `main` (which brings in + whatever `.gitrepo` now says), it fast-forwards that subrepo's local + `subrepo` *and* `main` branches to match - both in one go, provided the + commit `.gitrepo` points to is already present locally (e.g. already + fetched from the subrepo's real remote - `advance-subrepo` itself never + fetches anything). + +## Advance-main, right after an export + +`export` lands a commit on `widget`'s `subrepo` branch - `main` hasn't +moved: + +```ansi +$ central advance-main widget +==================== widget ==================== +Updating 1185512..f452a6f +Fast-forward + README.md | 2 ++ + 1 file changed, 2 insertions(+) +``` + +`main` is fast-forwarded to the same commit `subrepo` already carried: + +```ansi +Document installation +Initial commit +``` + +Right after `Central_test_helpers.create`, there's nothing for `main` to +catch up to: + +```ansi +$ central advance-main widget +==================== widget ==================== +[SKIP] Skipping [advance-main] (not applicable). +``` + +## Advance-subrepo, catching up a fresh checkout on another machine + +Say a change was already exported and pushed from elsewhere: central's own +`.gitrepo` (as pulled from its real remote) now points at a newer `widget` +commit, already fetched into this machine's own checkout of `widget` (e.g. +by a plain `git fetch`, run once ahead of time), but not yet merged into +either its `subrepo` or `main` branch. `advance-subrepo` brings both up to +date in one command: + +```ansi +$ central advance-subrepo widget +==================== widget ==================== +Updating f452a6f..1185512 +Fast-forward + README.md | 2 ++ + 1 file changed, 2 insertions(+) +Updating f452a6f..1185512 +Fast-forward + README.md | 2 ++ + 1 file changed, 2 insertions(+) +``` + +`subrepo` is on the new commit now: + +```ansi +Document installation +Initial commit +``` + +And so is `main` - both branches now point at the very same commit: + +```ansi +Document installation +Initial commit +``` diff --git a/test/expect/advance.ml b/test/expect/advance.ml new file mode 100644 index 0000000..435f450 --- /dev/null +++ b/test/expect/advance.ml @@ -0,0 +1,242 @@ +(*********************************************************************************) +(* central - Manage history between sub-repos and their monorepo *) +(* SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(* SPDX-License-Identifier: MIT *) +(*********************************************************************************) + +(* Like [export.ml], this runs the real [central] executable (see + [Central_test_harness]) rather than calling into the CLI's OCaml + implementation directly. *) + +(* @mdexp.config { snapshot: { lang: "ansi" } } *) + +(* @mdexp + +# Advance Main, Advance Subrepo + +`export` only ever moves a subrepo's `subrepo` branch - it never touches +`main`, so `main` falls one commit behind after every export. Two commands +catch it back up, at different scopes: + +- `central advance-main ` fast-forwards that subrepo's local `main` + branch to match `subrepo`, from the same machine an `export` (or `import`) + just ran on - the everyday, single-machine case. +- `central advance-subrepo ` is the more thorough version, aimed at a + *second* machine: after pulling central's own `main` (which brings in + whatever `.gitrepo` now says), it fast-forwards that subrepo's local + `subrepo` *and* `main` branches to match - both in one go, provided the + commit `.gitrepo` points to is already present locally (e.g. already + fetched from the subrepo's real remote - `advance-subrepo` itself never + fetches anything). + +## Advance-main, right after an export + +`export` lands a commit on `widget`'s `subrepo` branch - `main` hasn't +moved: *) + +let%expect_test "advance-main after export" = + let vcs = Volgo_git_unix.create () in + let widget = Central.Subrepo.v "widget" in + let fake_central = Central_test_helpers.create ~vcs ~subrepos:[ widget ] in + let { Central_test_helpers.Fake_central.central_root; _ } = fake_central in + let fake_widget = + Central_test_helpers.Fake_central.find_exn fake_central ~subrepo:widget + in + let readme_path = Vcs.Path_in_repo.v "repo/widget/README.md" in + Central_test_helpers.append_file + ~repo_root:central_root + ~path_in_repo:readme_path + ~text:"\nAdded a line about installation.\n"; + Vcs.add vcs ~repo_root:central_root ~path:readme_path; + let (_ : Vcs.Rev.t) = + Central_test_helpers.commit + ~vcs + ~repo_root:central_root + ~commit_message:"Document installation" + in + let harness = Central_test_harness.create ~repo_root:central_root in + let@ central = Central_test_harness.with_cli harness ~cwd:central_root in + central [ [ "export"; "widget" ]; [ "-m"; "Document installation" ] ]; + [%expect + {| + $ central export widget -m "Document installation" + ==================== widget ==================== + [ OK ] Applied patch in the subrepo. + [ OK ] Exported to [widget]. + |}]; + (* [export] only moved [subrepo] - [main] is now a strict ancestor of it. + The "Updating X..Y" summary line below prints abbreviated shas that + [Central_test_harness] can only redact if the full sha was registered + first - it isn't printed in full anywhere in this transcript otherwise. *) + let rev_of ~ref_ = + Vcs.git + vcs + ~repo_root:fake_widget.repo_root + ~args:[ "rev-parse"; ref_ ] + ~f:(fun output -> Vcs.Git.exit0_and_stdout output |> String.strip |> Vcs.Rev.v) + in + Central_test_harness.register_rev harness ~rev:(rev_of ~ref_:"main"); + Central_test_harness.register_rev harness ~rev:(rev_of ~ref_:"subrepo"); + central [ [ "advance-main"; "widget" ] ]; + (* @mdexp.snapshot *) + [%expect + {| + $ central advance-main widget + ==================== widget ==================== + Updating 1185512..f452a6f + Fast-forward + README.md | 2 ++ + 1 file changed, 2 insertions(+) + |}]; + (* @mdexp `main` is fast-forwarded to the same commit `subrepo` already carried: *) + Central_test_helpers.print_log_subjects + ~vcs + ~repo_root:fake_widget.repo_root + ~ref_:"main" + (); + (* @mdexp.snapshot *) + [%expect + {| + Document installation + Initial commit + |}] +;; + +(* @mdexp + +Right after `Central_test_helpers.create`, there's nothing for `main` to +catch up to: *) + +let%expect_test "advance-main not applicable" = + let vcs = Volgo_git_unix.create () in + let widget = Central.Subrepo.v "widget" in + let fake_central = Central_test_helpers.create ~vcs ~subrepos:[ widget ] in + let { Central_test_helpers.Fake_central.central_root; _ } = fake_central in + let harness = Central_test_harness.create ~repo_root:central_root in + let@ central = Central_test_harness.with_cli harness ~cwd:central_root in + central [ [ "advance-main"; "widget" ] ]; + (* @mdexp.snapshot *) + [%expect + {| + $ central advance-main widget + ==================== widget ==================== + [SKIP] Skipping [advance-main] (not applicable). + |}] +;; + +(* @mdexp + +## Advance-subrepo, catching up a fresh checkout on another machine + +Say a change was already exported and pushed from elsewhere: central's own +`.gitrepo` (as pulled from its real remote) now points at a newer `widget` +commit, already fetched into this machine's own checkout of `widget` (e.g. +by a plain `git fetch`, run once ahead of time), but not yet merged into +either its `subrepo` or `main` branch. `advance-subrepo` brings both up to +date in one command: *) + +let%expect_test "advance-subrepo catches up a stale local checkout" = + let vcs = Volgo_git_unix.create () in + let widget = Central.Subrepo.v "widget" in + let fake_central = Central_test_helpers.create ~vcs ~subrepos:[ widget ] in + let { Central_test_helpers.Fake_central.central_root; _ } = fake_central in + let fake_widget = + Central_test_helpers.Fake_central.find_exn fake_central ~subrepo:widget + in + let readme_path = Vcs.Path_in_repo.v "repo/widget/README.md" in + Central_test_helpers.append_file + ~repo_root:central_root + ~path_in_repo:readme_path + ~text:"\nAdded a line about installation.\n"; + Vcs.add vcs ~repo_root:central_root ~path:readme_path; + let (_ : Vcs.Rev.t) = + Central_test_helpers.commit + ~vcs + ~repo_root:central_root + ~commit_message:"Document installation" + in + let harness = Central_test_harness.create ~repo_root:central_root in + Central_test_harness.run + harness + ~cwd:central_root + [ [ "export"; "widget" ]; [ "-m"; "Document installation" ] ]; + [%expect + {| + $ central export widget -m "Document installation" + ==================== widget ==================== + [ OK ] Applied patch in the subrepo. + [ OK ] Exported to [widget]. + |}]; + (* Simulate this machine's local checkout of [widget] not having its + [subrepo]/[main] branches advanced to the latest commit yet, even + though central's [.gitrepo] (as synced from another machine) already + points at it. The commit itself must stay reachable from some local + ref for [advance-subrepo] to find it at all - as it would be in + practice via a remote-tracking ref, once fetched - so tag it before + winding [subrepo] back, rather than orphaning it outright. *) + let rev_of ~ref_ = + Vcs.git + vcs + ~repo_root:fake_widget.repo_root + ~args:[ "rev-parse"; ref_ ] + ~f:(fun output -> Vcs.Git.exit0_and_stdout output |> String.strip) + in + let new_rev = rev_of ~ref_:"subrepo" in + Vcs.git + vcs + ~repo_root:fake_widget.repo_root + ~args:[ "tag"; "keep-reachable"; new_rev ] + ~f:Vcs.Git.exit0; + Central_test_harness.register_rev harness ~rev:(Vcs.Rev.v new_rev); + Central_test_harness.register_rev harness ~rev:(Vcs.Rev.v (rev_of ~ref_:"main")); + Vcs.git + vcs + ~repo_root:fake_widget.repo_root + ~args:[ "checkout"; "subrepo" ] + ~f:Vcs.Git.exit0; + Vcs.git + vcs + ~repo_root:fake_widget.repo_root + ~args:[ "reset"; "--hard"; "HEAD~1" ] + ~f:Vcs.Git.exit0; + let@ central = Central_test_harness.with_cli harness ~cwd:central_root in + central [ [ "advance-subrepo"; "widget" ] ]; + (* @mdexp.snapshot *) + [%expect + {| + $ central advance-subrepo widget + ==================== widget ==================== + Updating f452a6f..1185512 + Fast-forward + README.md | 2 ++ + 1 file changed, 2 insertions(+) + Updating f452a6f..1185512 + Fast-forward + README.md | 2 ++ + 1 file changed, 2 insertions(+) + |}]; + (* @mdexp `subrepo` is on the new commit now: *) + Central_test_helpers.print_log_subjects + ~vcs + ~repo_root:fake_widget.repo_root + ~ref_:"subrepo" + (); + (* @mdexp.snapshot *) + [%expect + {| + Document installation + Initial commit + |}]; + (* @mdexp And so is `main` - both branches now point at the very same commit: *) + Central_test_helpers.print_log_subjects + ~vcs + ~repo_root:fake_widget.repo_root + ~ref_:"main" + (); + (* @mdexp.snapshot *) + [%expect + {| + Document installation + Initial commit + |}] +;; diff --git a/test/expect/advance.mli b/test/expect/advance.mli new file mode 100644 index 0000000..bdaa586 --- /dev/null +++ b/test/expect/advance.mli @@ -0,0 +1,5 @@ +(*_********************************************************************************) +(*_ central - Manage history between sub-repos and their monorepo *) +(*_ SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(*_ SPDX-License-Identifier: MIT *) +(*_********************************************************************************) diff --git a/test/expect/config.md b/test/expect/config.md new file mode 100644 index 0000000..2c41088 --- /dev/null +++ b/test/expect/config.md @@ -0,0 +1,23 @@ +# Config + +`central` reads two small, optional JSON config files, each with a +`$schema` under `schema/` for editor support: + +- `Repo_config` - read from `.central/repo-config.json`, at the root of the + monorepo itself. Currently just `name`, the string that identifies "the + central repo" among the positional arguments to commands like `push` and + `todo` (as opposed to a subrepo) - defaults to `"central"`. +- `User_config` - read from the XDG config directory, + `~/.config/central/user-config.json`. Empty for now; a placeholder for + per-user settings to come. + +Both are entirely optional: a missing file just means the default. + +`Repo_config.find_and_load` is what commands actually call: it looks for +`.central/repo-config.json` in the repo and falls back to the default if +it isn't there. + +Unknown fields are rejected rather than silently ignored - a typo in the +config file is a loud error, not a silently-dropped setting: + +`User_config` follows the same shape, currently an empty record: diff --git a/test/expect/config.ml b/test/expect/config.ml new file mode 100644 index 0000000..c5635b2 --- /dev/null +++ b/test/expect/config.ml @@ -0,0 +1,79 @@ +(*********************************************************************************) +(* central - Manage history between sub-repos and their monorepo *) +(* SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(* SPDX-License-Identifier: MIT *) +(*********************************************************************************) + +open! Central + +(* @mdexp + +# Config + +`central` reads two small, optional JSON config files, each with a +`$schema` under `schema/` for editor support: + +- `Repo_config` - read from `.central/repo-config.json`, at the root of the + monorepo itself. Currently just `name`, the string that identifies "the + central repo" among the positional arguments to commands like `push` and + `todo` (as opposed to a subrepo) - defaults to `"central"`. +- `User_config` - read from the XDG config directory, + `~/.config/central/user-config.json`. Empty for now; a placeholder for + per-user settings to come. + +Both are entirely optional: a missing file just means the default. *) + +let%expect_test "Repo_config.default" = + print_string (Json.to_string (Repo_config.to_json Repo_config.default)); + [%expect {| { "rootRepoName": "central" } |}] +;; + +let%expect_test "Repo_config round trip" = + let t = Repo_config.create ~root_repo_name:"my-monorepo" () in + print_endline (Repo_config.root_repo_name t); + [%expect {| my-monorepo |}]; + print_string (Json.to_string (Repo_config.to_json t)); + [%expect {| { "rootRepoName": "my-monorepo" } |}] +;; + +(* @mdexp + +`Repo_config.find_and_load` is what commands actually call: it looks for +`.central/repo-config.json` in the repo and falls back to the default if +it isn't there. *) + +let%expect_test "Repo_config.find_and_load" = + let vcs = Volgo_git_unix.create () in + let fake_central = Central_test_helpers.create ~vcs ~subrepos:[] in + let { Central_test_helpers.Fake_central.central_root; _ } = fake_central in + (* No [.central/repo-config.json] yet - falls back to the default. *) + let t = Central.Repo_config.find_and_load ~repo_root:central_root in + print_endline (Repo_config.root_repo_name t); + [%expect {| central |}]; + Central_test_helpers.write_file + ~repo_root:central_root + ~path_in_repo:Repo_config.path_in_repo + ~contents:{|{ "rootRepoName": "my-monorepo" }|}; + let t = Central.Repo_config.find_and_load ~repo_root:central_root in + print_endline (Repo_config.root_repo_name t); + [%expect {| my-monorepo |}] +;; + +(* @mdexp + +Unknown fields are rejected rather than silently ignored - a typo in the +config file is a loud error, not a silently-dropped setting: *) + +let%expect_test "Repo_config.of_json rejects unknown fields" = + (match Repo_config.of_json (`Assoc [ "unknown_field", `String "x" ]) ~loc:Loc.none with + | (_ : Repo_config.t) -> assert false + | exception Err.E err -> print_endline (Err.to_string_hum err)); + [%expect {| "Unknown config field \"unknown_field\"." |}] +;; + +(* @mdexp `User_config` follows the same shape, currently an empty record: *) + +let%expect_test "User_config" = + print_string (Json.to_string (User_config.to_json (User_config.create ()))); + [%expect {| {} |}] +;; diff --git a/test/expect/config.mli b/test/expect/config.mli new file mode 100644 index 0000000..bdaa586 --- /dev/null +++ b/test/expect/config.mli @@ -0,0 +1,5 @@ +(*_********************************************************************************) +(*_ central - Manage history between sub-repos and their monorepo *) +(*_ SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(*_ SPDX-License-Identifier: MIT *) +(*_********************************************************************************) diff --git a/test/expect/export.md b/test/expect/export.md new file mode 100644 index 0000000..cc85650 --- /dev/null +++ b/test/expect/export.md @@ -0,0 +1,171 @@ +# Export + +`central export -m MSG` moves changes made directly in `central`, +under `repo//`, out into ``'s own standalone git repository - as +a single new commit on top of its `subrepo` branch. + +It is the native-OCaml replacement for the one `git subrepo` command that +sits on `central`'s critical, day-to-day path: `git subrepo push`. Unlike +that command, `export` always squashes whatever changed since the last sync +into exactly one new commit, using the message supplied with `-m`. + +Concretely, it: + +1. Verifies the subrepo's `subrepo` branch has not moved since the last sync + recorded in `repo//.gitrepo` (if it has, someone likely pushed there + directly - bring that in first before exporting). +2. Computes the diff of everything under `repo//` since that sync, + using `git diff --relative=`, which also strips the `repo//` path + prefix from the patch. +3. Applies that patch as one commit onto the subrepo's `subrepo` branch, via + `git apply --3way --index`. +4. Updates `.gitrepo` in `central` to record the new sync point. + +## A fake repo to work with + +This walkthrough uses `Central_test_helpers` to build a fake `central` +repo with a made-up subrepo, `widget`, and a fake standalone repo standing +in for `widget`'s own history, wired together with a `.gitrepo` file +exactly as `central subrepo init` would leave them. + +Right after that, `repo/widget/README.md` in `central` and `widget`'s own +`README.md` are identical: + +```markdown +# widget + +This is a fake [widget] repo, generated by [Central_test_helpers] for tests. +``` + +## Editing directly in central, then exporting + +Suppose someone edits `repo/widget/README.md` directly from within `central` +and commits it there - the way most day-to-day changes happen. Running +`export` then brings that change out, as a single new commit on top of +`widget`'s `subrepo` branch: + +```ansi +$ central export widget -m "Document installation" +==================== widget ==================== +[ OK ] Applied patch in the subrepo. +[ OK ] Exported to [widget]. +``` + +It landed in `widget`'s own history as one new commit on the `subrepo` +branch, carrying exactly that change: + +```ansi +Document installation +Initial commit +``` + +```markdown +# widget + +This is a fake [widget] repo, generated by [Central_test_helpers] for tests. + +Added a line about installation. +``` + +## Guardrails + +Running `export` again right away, with nothing new to export, is a clean +error rather than an empty commit: + +```ansi +$ central export widget -m "Nothing changed" +==================== widget ==================== +Error: Nothing to export: no changes under "repo/widget" since the last sync. +[123] +``` + +And if the subrepo's `subrepo` branch moved since the last sync - for +example because someone pushed to it directly, bypassing `central` - `export` +refuses rather than silently basing the new commit on the wrong parent: + +```ansi +$ central export widget -m "Should not apply" +==================== widget ==================== +Error: The [subrepo] branch of [widget] has moved since the last sync +recorded in [.gitrepo]. +Hint: Bring those changes into central first with [central import] before +exporting, or pass [--force] to export anyway. +[123] +``` + +And as a precondition, `export` first checks that `central`'s own working +tree is clean - an unstaged edit is rejected outright, before anything else +is even looked at: + +```ansi +$ central export widget -m "Should not apply" +==================== widget ==================== +Error: Repo "$CENTRAL_ROOT" has uncommitted changes - +commit or stash them first. +Hint: M repo/widget/README.md +[123] +``` + +## Exporting several subrepos at once + +Chores that make the same systematic change across many subrepos are common +enough that `export` accepts more than one `REPO` at a time (or `--all` for +every subrepo it knows about) - each is exported in turn, in the order +given, with a separator banner between them, reusing the same `-m` message +for every commit: + +```ansi +$ central export widget gadget -m "Add license footer" +==================== widget ==================== +[ OK ] Applied patch in the subrepo. +[ OK ] Exported to [widget]. +==================== gadget ==================== +[ OK ] Applied patch in the subrepo. +[ OK ] Exported to [gadget]. +``` + +Both landed the same commit message on their own `subrepo` branch: + +```ansi +-- widget -- +Add license footer +Initial commit +-- gadget -- +Add license footer +Initial commit +``` + +`central todo` picks up both subrepos as done - since `export` doesn't +touch `central`'s own history, both are shown ready for their next +step, `advance-main`, while `central` itself needs a `push` for the +`.gitrepo` updates: + +```ansi +$ central todo +┌──────────┬──────────────┬──────┐ +│ Repo │ Next step │ Diff │ +├──────────┼──────────────┼──────┤ +│ central │ push │ 12 │ +│ gadget │ advance-main │ │ +│ widget │ advance-main │ │ +└──────────┴──────────────┴──────┘ +``` + +`export` stops at the first repo that fails, leaving the ones after it in +the list untouched - it doesn't try to skip ahead and report a summary at +the end. Say `gadget`'s `subrepo` branch moved independently in the +meantime (someone pushed to it directly, bypassing central) while `widget` +is perfectly exportable: + +```ansi +$ central export widget gadget -m "Add license footer" +==================== widget ==================== +[ OK ] Applied patch in the subrepo. +[ OK ] Exported to [widget]. +==================== gadget ==================== +Error: The [subrepo] branch of [gadget] has moved since the last sync +recorded in [.gitrepo]. +Hint: Bring those changes into central first with [central import] before +exporting, or pass [--force] to export anyway. +[123] +``` diff --git a/test/expect/export.ml b/test/expect/export.ml new file mode 100644 index 0000000..7297cde --- /dev/null +++ b/test/expect/export.ml @@ -0,0 +1,397 @@ +(*********************************************************************************) +(* central - Manage history between sub-repos and their monorepo *) +(* SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(* SPDX-License-Identifier: MIT *) +(*********************************************************************************) + +(* Like [import.ml] and [push.ml], this page runs the real [central] + executable (see [Central_test_harness]) rather than calling into + [Central_cli]'s OCaml implementation directly - so what you read below is, + command for command, what a [central] user would type and see in their own + terminal. *) + +(* @mdexp.config { snapshot: { lang: "ansi" } } *) + +(* @mdexp + +# Export + +`central export -m MSG` moves changes made directly in `central`, +under `repo//`, out into ``'s own standalone git repository - as +a single new commit on top of its `subrepo` branch. + +It is the native-OCaml replacement for the one `git subrepo` command that +sits on `central`'s critical, day-to-day path: `git subrepo push`. Unlike +that command, `export` always squashes whatever changed since the last sync +into exactly one new commit, using the message supplied with `-m`. + +Concretely, it: + +1. Verifies the subrepo's `subrepo` branch has not moved since the last sync + recorded in `repo//.gitrepo` (if it has, someone likely pushed there + directly - bring that in first before exporting). +2. Computes the diff of everything under `repo//` since that sync, + using `git diff --relative=`, which also strips the `repo//` path + prefix from the patch. +3. Applies that patch as one commit onto the subrepo's `subrepo` branch, via + `git apply --3way --index`. +4. Updates `.gitrepo` in `central` to record the new sync point. + +## A fake repo to work with + +This walkthrough uses `Central_test_helpers` to build a fake `central` +repo with a made-up subrepo, `widget`, and a fake standalone repo standing +in for `widget`'s own history, wired together with a `.gitrepo` file +exactly as `central subrepo init` would leave them. + +Right after that, `repo/widget/README.md` in `central` and `widget`'s own +`README.md` are identical: *) + +let%expect_test "before" = + let vcs = Volgo_git_unix.create () in + let widget = Central.Subrepo.v "widget" in + let fake_central = Central_test_helpers.create ~vcs ~subrepos:[ widget ] in + let { Central_test_helpers.Fake_central.central_root; _ } = fake_central in + Central_test_helpers.print_file + ~repo_root:central_root + ~path_in_repo:(Vcs.Path_in_repo.v "repo/widget/README.md"); + (* @mdexp.snapshot { lang: "markdown" } *) + [%expect + {| + # widget + + This is a fake [widget] repo, generated by [Central_test_helpers] for tests. + |}] +;; + +(* @mdexp + +## Editing directly in central, then exporting + +Suppose someone edits `repo/widget/README.md` directly from within `central` +and commits it there - the way most day-to-day changes happen. Running +`export` then brings that change out, as a single new commit on top of +`widget`'s `subrepo` branch: *) + +let%expect_test "edit and export" = + let vcs = Volgo_git_unix.create () in + let widget = Central.Subrepo.v "widget" in + let fake_central = Central_test_helpers.create ~vcs ~subrepos:[ widget ] in + let { Central_test_helpers.Fake_central.central_root; _ } = fake_central in + let fake_widget = + Central_test_helpers.Fake_central.find_exn fake_central ~subrepo:widget + in + let readme_path = Vcs.Path_in_repo.v "repo/widget/README.md" in + Central_test_helpers.append_file + ~repo_root:central_root + ~path_in_repo:readme_path + ~text:"\nAdded a line about installation.\n"; + Vcs.add vcs ~repo_root:central_root ~path:readme_path; + let (_ : Vcs.Rev.t) = + Central_test_helpers.commit + ~vcs + ~repo_root:central_root + ~commit_message:"Document installation in widget's README" + in + let harness = Central_test_harness.create ~repo_root:central_root in + let@ central = Central_test_harness.with_cli harness ~cwd:central_root in + central [ [ "export"; "widget" ]; [ "-m"; "Document installation" ] ]; + (* @mdexp.snapshot *) + [%expect + {| + $ central export widget -m "Document installation" + ==================== widget ==================== + [ OK ] Applied patch in the subrepo. + [ OK ] Exported to [widget]. + |}]; + (* @mdexp + + It landed in `widget`'s own history as one new commit on the `subrepo` + branch, carrying exactly that change: *) + Central_test_helpers.print_log_subjects + ~vcs + ~repo_root:fake_widget.repo_root + ~ref_:"subrepo" + (); + (* @mdexp.snapshot *) + [%expect + {| + Document installation + Initial commit + |}]; + Central_test_helpers.print_file + ~repo_root:fake_widget.repo_root + ~path_in_repo:(Vcs.Path_in_repo.v "README.md"); + (* @mdexp.snapshot { lang: "markdown" } *) + [%expect + {| + # widget + + This is a fake [widget] repo, generated by [Central_test_helpers] for tests. + + Added a line about installation. + |}] +;; + +(* @mdexp + +## Guardrails + +Running `export` again right away, with nothing new to export, is a clean +error rather than an empty commit: *) + +let%expect_test "nothing to export" = + let vcs = Volgo_git_unix.create () in + let widget = Central.Subrepo.v "widget" in + let fake_central = Central_test_helpers.create ~vcs ~subrepos:[ widget ] in + let { Central_test_helpers.Fake_central.central_root; _ } = fake_central in + let harness = Central_test_harness.create ~repo_root:central_root in + let@ central = Central_test_harness.with_cli harness ~cwd:central_root in + central [ [ "export"; "widget" ]; [ "-m"; "Nothing changed" ] ]; + (* @mdexp.snapshot *) + [%expect + {| + $ central export widget -m "Nothing changed" + ==================== widget ==================== + Error: Nothing to export: no changes under "repo/widget" since the last sync. + [123] + |}] +;; + +(* @mdexp + +And if the subrepo's `subrepo` branch moved since the last sync - for +example because someone pushed to it directly, bypassing `central` - `export` +refuses rather than silently basing the new commit on the wrong parent: *) + +let%expect_test "branch moved" = + let vcs = Volgo_git_unix.create () in + let widget = Central.Subrepo.v "widget" in + let fake_central = Central_test_helpers.create ~vcs ~subrepos:[ widget ] in + let { Central_test_helpers.Fake_central.central_root; _ } = fake_central in + let fake_widget = + Central_test_helpers.Fake_central.find_exn fake_central ~subrepo:widget + in + let readme = Vcs.Path_in_repo.v "README.md" in + Vcs.git + vcs + ~repo_root:fake_widget.repo_root + ~args:[ "checkout"; "subrepo" ] + ~f:Vcs.Git.exit0; + Central_test_helpers.append_file + ~repo_root:fake_widget.repo_root + ~path_in_repo:readme + ~text:"\nEdited directly upstream.\n"; + Vcs.add vcs ~repo_root:fake_widget.repo_root ~path:readme; + let (_ : Vcs.Rev.t) = + Central_test_helpers.commit + ~vcs + ~repo_root:fake_widget.repo_root + ~commit_message:"Edited directly upstream" + in + let harness = Central_test_harness.create ~repo_root:central_root in + let@ central = Central_test_harness.with_cli harness ~cwd:central_root in + central [ [ "export"; "widget" ]; [ "-m"; "Should not apply" ] ]; + (* @mdexp.snapshot *) + [%expect + {| + $ central export widget -m "Should not apply" + ==================== widget ==================== + Error: The [subrepo] branch of [widget] has moved since the last sync + recorded in [.gitrepo]. + Hint: Bring those changes into central first with [central import] before + exporting, or pass [--force] to export anyway. + [123] + |}] +;; + +(* @mdexp + +And as a precondition, `export` first checks that `central`'s own working +tree is clean - an unstaged edit is rejected outright, before anything else +is even looked at: *) + +let%expect_test "dirty working tree" = + let vcs = Volgo_git_unix.create () in + let widget = Central.Subrepo.v "widget" in + let fake_central = Central_test_helpers.create ~vcs ~subrepos:[ widget ] in + let { Central_test_helpers.Fake_central.central_root; _ } = fake_central in + (* Edited, but never [Vcs.add]ed - left unstaged. *) + Central_test_helpers.append_file + ~repo_root:central_root + ~path_in_repo:(Vcs.Path_in_repo.v "repo/widget/README.md") + ~text:"\nStray, unstaged edit.\n"; + let harness = Central_test_harness.create ~repo_root:central_root in + let@ central = Central_test_harness.with_cli harness ~cwd:central_root in + central [ [ "export"; "widget" ]; [ "-m"; "Should not apply" ] ]; + (* @mdexp.snapshot *) + [%expect + {| + $ central export widget -m "Should not apply" + ==================== widget ==================== + Error: Repo "$CENTRAL_ROOT" has uncommitted changes - + commit or stash them first. + Hint: M repo/widget/README.md + [123] + |}] +;; + +(* @mdexp + +## Exporting several subrepos at once + +Chores that make the same systematic change across many subrepos are common +enough that `export` accepts more than one `REPO` at a time (or `--all` for +every subrepo it knows about) - each is exported in turn, in the order +given, with a separator banner between them, reusing the same `-m` message +for every commit: *) + +let%expect_test "export several subrepos at once" = + let vcs = Volgo_git_unix.create () in + let widget = Central.Subrepo.v "widget" in + let gadget = Central.Subrepo.v "gadget" in + let fake_central = Central_test_helpers.create ~vcs ~subrepos:[ widget; gadget ] in + let { Central_test_helpers.Fake_central.central_root; _ } = fake_central in + let fake_widget = + Central_test_helpers.Fake_central.find_exn fake_central ~subrepo:widget + in + let fake_gadget = + Central_test_helpers.Fake_central.find_exn fake_central ~subrepo:gadget + in + List.iter [ widget; gadget ] ~f:(fun subrepo -> + let path_in_repo = + Vcs.Path_in_repo.v + (Printf.sprintf "repo/%s/README.md" (Central.Subrepo.to_string subrepo)) + in + Central_test_helpers.append_file + ~repo_root:central_root + ~path_in_repo + ~text:"\nSee LICENSE for details.\n"; + Vcs.add vcs ~repo_root:central_root ~path:path_in_repo); + let (_ : Vcs.Rev.t) = + Central_test_helpers.commit + ~vcs + ~repo_root:central_root + ~commit_message:"Add license footer to every subrepo" + in + let harness = Central_test_harness.create ~repo_root:central_root in + let@ central = Central_test_harness.with_cli harness ~cwd:central_root in + central [ [ "export"; "widget"; "gadget" ]; [ "-m"; "Add license footer" ] ]; + (* @mdexp.snapshot *) + [%expect + {| + $ central export widget gadget -m "Add license footer" + ==================== widget ==================== + [ OK ] Applied patch in the subrepo. + [ OK ] Exported to [widget]. + ==================== gadget ==================== + [ OK ] Applied patch in the subrepo. + [ OK ] Exported to [gadget]. + |}]; + (* @mdexp Both landed the same commit message on their own `subrepo` branch: *) + List.iter + [ fake_widget; fake_gadget ] + ~f:(fun (fake_subrepo : Central_test_helpers.Fake_subrepo.t) -> + Printf.printf "-- %s --\n" (Central.Subrepo.to_string fake_subrepo.subrepo); + Central_test_helpers.print_log_subjects + ~vcs + ~repo_root:fake_subrepo.repo_root + ~ref_:"subrepo" + (); + print_newline ()); + (* @mdexp.snapshot *) + [%expect + {| + -- widget -- + Add license footer + Initial commit + -- gadget -- + Add license footer + Initial commit + |}]; + (* @mdexp + + `central todo` picks up both subrepos as done - since `export` doesn't + touch `central`'s own history, both are shown ready for their next + step, `advance-main`, while `central` itself needs a `push` for the + `.gitrepo` updates: *) + central [ [ "todo" ] ]; + (* @mdexp.snapshot *) + [%expect + {| + $ central todo + ┌──────────┬──────────────┬──────┐ + │ Repo │ Next step │ Diff │ + ├──────────┼──────────────┼──────┤ + │ central │ push │ 12 │ + │ gadget │ advance-main │ │ + │ widget │ advance-main │ │ + └──────────┴──────────────┴──────┘ + |}] +;; + +(* @mdexp + +`export` stops at the first repo that fails, leaving the ones after it in +the list untouched - it doesn't try to skip ahead and report a summary at +the end. Say `gadget`'s `subrepo` branch moved independently in the +meantime (someone pushed to it directly, bypassing central) while `widget` +is perfectly exportable: *) + +let%expect_test "stops at the first repo that fails" = + let vcs = Volgo_git_unix.create () in + let widget = Central.Subrepo.v "widget" in + let gadget = Central.Subrepo.v "gadget" in + let fake_central = Central_test_helpers.create ~vcs ~subrepos:[ widget; gadget ] in + let { Central_test_helpers.Fake_central.central_root; _ } = fake_central in + let fake_gadget = + Central_test_helpers.Fake_central.find_exn fake_central ~subrepo:gadget + in + let widget_readme = Vcs.Path_in_repo.v "repo/widget/README.md" in + Central_test_helpers.append_file + ~repo_root:central_root + ~path_in_repo:widget_readme + ~text:"\nSee LICENSE for details.\n"; + Vcs.add vcs ~repo_root:central_root ~path:widget_readme; + let (_ : Vcs.Rev.t) = + Central_test_helpers.commit + ~vcs + ~repo_root:central_root + ~commit_message:"Add license footer to widget" + in + (* [gadget]'s own history moved on its own, bypassing central entirely. *) + let readme = Vcs.Path_in_repo.v "README.md" in + Vcs.git + vcs + ~repo_root:fake_gadget.repo_root + ~args:[ "checkout"; "subrepo" ] + ~f:Vcs.Git.exit0; + Central_test_helpers.append_file + ~repo_root:fake_gadget.repo_root + ~path_in_repo:readme + ~text:"\nEdited directly upstream.\n"; + Vcs.add vcs ~repo_root:fake_gadget.repo_root ~path:readme; + let (_ : Vcs.Rev.t) = + Central_test_helpers.commit + ~vcs + ~repo_root:fake_gadget.repo_root + ~commit_message:"Edited directly upstream" + in + let harness = Central_test_harness.create ~repo_root:central_root in + let@ central = Central_test_harness.with_cli harness ~cwd:central_root in + central [ [ "export"; "widget"; "gadget" ]; [ "-m"; "Add license footer" ] ]; + (* @mdexp.snapshot *) + [%expect + {| + $ central export widget gadget -m "Add license footer" + ==================== widget ==================== + [ OK ] Applied patch in the subrepo. + [ OK ] Exported to [widget]. + ==================== gadget ==================== + Error: The [subrepo] branch of [gadget] has moved since the last sync + recorded in [.gitrepo]. + Hint: Bring those changes into central first with [central import] before + exporting, or pass [--force] to export anyway. + [123] + |}] +;; diff --git a/test/expect/export.mli b/test/expect/export.mli new file mode 100644 index 0000000..bdaa586 --- /dev/null +++ b/test/expect/export.mli @@ -0,0 +1,5 @@ +(*_********************************************************************************) +(*_ central - Manage history between sub-repos and their monorepo *) +(*_ SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(*_ SPDX-License-Identifier: MIT *) +(*_********************************************************************************) diff --git a/test/expect/import.md b/test/expect/import.md new file mode 100644 index 0000000..df5868f --- /dev/null +++ b/test/expect/import.md @@ -0,0 +1,173 @@ +# Import + +`central import ` brings commits from the tip of the subrepo's +`subrepo` branch - normally landed there by fetching from the subrepo's real +remote - into `repo/REPO`. `-m MSG` is optional and defaults to `"Import +changes from REPO"`. + +Unlike `export`, this has to account for central having moved on since the +last sync - so it picks between two ways of bringing the change in, +depending on whether central has any local changes of its own under +`repo/REPO`: + +- If central hasn't touched `repo/REPO` at all since the last sync, + `repo/REPO` at central's current HEAD is - by construction - exactly what + it was at the last sync point, so the subrepo's diff is guaranteed to + apply there too. There is nothing to merge, so `import` doesn't build + one: it applies the diff directly as a single new commit on top of HEAD. + This is the default, and it keeps history linear in the common case where + central had no reason to conflict with what the subrepo brings in. +- Otherwise - central *does* have local changes of its own under + `repo/REPO` - `import` falls back to an ordinary two-parent `git merge`: + it builds a new commit as a direct child of the central revision recorded + in `repo/REPO/.gitrepo` - not of the current HEAD - and applies the + subrepo's diff there, where it is guaranteed to apply cleanly regardless + of what else happened on central's actual HEAD since. That commit (the + "import commit") also updates `.gitrepo` to record the new sync point. + Merging it into the active branch is then an ordinary `git merge`: if + there is a real conflict, git surfaces it exactly as it always does, for + a human to resolve. + +## Applying directly + +A fake `central` and a fake `widget`, as usual - someone pushes directly to +`widget`'s `subrepo` branch (standing in for something fetched from its +real remote), while central hasn't touched `repo/widget/` since the last +sync. So `import` applies the upstream change straight onto HEAD, no merge +needed: + +```ansi +$ central import widget +[ OK ] Imported into [main] directly (no merge needed). +``` + +A single, linear commit lands directly on top of HEAD - no second +parent, no merge commit. `-m` was left out here, so the message falls +back to `"Import changes from widget"`: + +```ansi +Import changes from widget +Add fake subrepo widget +Initial commit +``` + +```markdown +# widget + +This is a fake [widget] repo, generated by [Central_test_helpers] for tests. + +Edited directly upstream. +``` + +## Guardrails + +Running `import` again right away, with nothing new upstream, is a clean +error rather than an empty commit - symmetric to `export`'s own guard: + +```ansi +$ central import widget +Error: Nothing to import: the [subrepo] branch of [widget] has not moved +since the last sync. +[123] +``` + +And just like `export`, `import` first checks that `central`'s own working +tree is clean - an unstaged edit is rejected outright: + +```ansi +$ central import widget +Error: Repo "$CENTRAL_ROOT" has uncommitted changes - +commit or stash them first. +Hint: M README.md +[123] +``` + +## Falling back to a merge + +This time, central *does* touch something under `repo/widget/` since the +last sync - a new file of its own, `NOTES.md`, unrelated to the file +upstream changed. `import` can no longer assume `repo/widget/` is +untouched, so it falls back to building an import commit as a child of the +last sync point and merging it in. Since the two sides touch different +files, the merge still completes cleanly on its own: + +```ansi +$ central import widget +[ OK ] Built the import commit. +Merge made by the 'ort' strategy. + repo/widget/.gitrepo | 4 ++-- + repo/widget/README.md | 2 ++ + 2 files changed, 4 insertions(+), 2 deletions(-) +[ OK ] Imported into [main]. +``` + +Unlike the direct case above, the import commit sits as a child of the +*old* sync point, not of central's HEAD at the time - a separate line +of history, joined by the merge: + +```ansi +Merge widget import +Add local notes under widget +Add fake subrepo widget +Initial commit +``` + +## A real conflict + +Same fallback, but this time central and the subrepo both edit the very +same line under `repo/widget/` since the last sync: + +```ansi +$ central import widget -m "Bring in upstream retitle" +[ OK ] Built the import commit. +Auto-merging repo/widget/README.md +CONFLICT (content): Merge conflict in repo/widget/README.md +Automatic merge failed; fix conflicts and then commit the result. +Error: Merge conflict while importing - resolve the conflicts above in +[main], then [git add] the resolved files and [git commit] to finish the +merge. +Hint: .gitrepo has already been updated as part of the import commit being +merged - no further action needed there once the merge is complete. +[123] +``` + +Central is left in the middle of the merge, exactly as an ordinary +`git merge` would - conflict markers included, with the trailing +revision on `>>>>>>>` naming the import commit, the side being merged +in: + +```text +<<<<<<< HEAD +# widget, edited by central +======= +# widget, retitled upstream +>>>>>>> 1185512b92d612b25613f2e5b473e5231185512b +``` + +Resolving it is the same as for any git merge conflict - pick a +resolution, `git add`, `git commit`: + +```ansi +Resolve README retitle conflict +Central retitles the README +Add fake subrepo widget +Initial commit +``` + +`.gitrepo` was already updated as part of the import commit, so +`export` is available again right away - it carries the resolution +itself out to `widget`, since that's what central's own history now +disagrees with: + +```ansi +$ central export widget -m "Resolve conflicting retitle" +==================== widget ==================== +[ OK ] Applied patch in the subrepo. +[ OK ] Exported to [widget]. +``` + +```ansi +Resolve conflicting retitle +Upstream retitles the README +Initial commit +``` diff --git a/test/expect/import.ml b/test/expect/import.ml new file mode 100644 index 0000000..b82b307 --- /dev/null +++ b/test/expect/import.ml @@ -0,0 +1,407 @@ +(*********************************************************************************) +(* central - Manage history between sub-repos and their monorepo *) +(* SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(* SPDX-License-Identifier: MIT *) +(*********************************************************************************) + +(* Like [export.ml], this runs the real [central] executable (see + [Central_test_harness]) rather than calling into the CLI's OCaml + implementation directly. *) + +(* @mdexp.config { snapshot: { lang: "ansi" } } *) + +(* @mdexp + +# Import + +`central import ` brings commits from the tip of the subrepo's +`subrepo` branch - normally landed there by fetching from the subrepo's real +remote - into `repo/REPO`. `-m MSG` is optional and defaults to `"Import +changes from REPO"`. + +Unlike `export`, this has to account for central having moved on since the +last sync - so it picks between two ways of bringing the change in, +depending on whether central has any local changes of its own under +`repo/REPO`: + +- If central hasn't touched `repo/REPO` at all since the last sync, + `repo/REPO` at central's current HEAD is - by construction - exactly what + it was at the last sync point, so the subrepo's diff is guaranteed to + apply there too. There is nothing to merge, so `import` doesn't build + one: it applies the diff directly as a single new commit on top of HEAD. + This is the default, and it keeps history linear in the common case where + central had no reason to conflict with what the subrepo brings in. +- Otherwise - central *does* have local changes of its own under + `repo/REPO` - `import` falls back to an ordinary two-parent `git merge`: + it builds a new commit as a direct child of the central revision recorded + in `repo/REPO/.gitrepo` - not of the current HEAD - and applies the + subrepo's diff there, where it is guaranteed to apply cleanly regardless + of what else happened on central's actual HEAD since. That commit (the + "import commit") also updates `.gitrepo` to record the new sync point. + Merging it into the active branch is then an ordinary `git merge`: if + there is a real conflict, git surfaces it exactly as it always does, for + a human to resolve. + +## Applying directly + +A fake `central` and a fake `widget`, as usual - someone pushes directly to +`widget`'s `subrepo` branch (standing in for something fetched from its +real remote), while central hasn't touched `repo/widget/` since the last +sync. So `import` applies the upstream change straight onto HEAD, no merge +needed: *) + +let%expect_test "direct import (central has no local changes)" = + let vcs = Volgo_git_unix.create () in + let widget = Central.Subrepo.v "widget" in + let fake_central = Central_test_helpers.create ~vcs ~subrepos:[ widget ] in + let { Central_test_helpers.Fake_central.central_root; _ } = fake_central in + let fake_widget = + Central_test_helpers.Fake_central.find_exn fake_central ~subrepo:widget + in + (* Someone pushes directly to the [subrepo] branch, bypassing central. *) + let readme = Vcs.Path_in_repo.v "README.md" in + Vcs.git + vcs + ~repo_root:fake_widget.repo_root + ~args:[ "checkout"; "subrepo" ] + ~f:Vcs.Git.exit0; + Central_test_helpers.append_file + ~repo_root:fake_widget.repo_root + ~path_in_repo:readme + ~text:"\nEdited directly upstream.\n"; + Vcs.add vcs ~repo_root:fake_widget.repo_root ~path:readme; + let (_ : Vcs.Rev.t) = + Central_test_helpers.commit + ~vcs + ~repo_root:fake_widget.repo_root + ~commit_message:"Edited directly upstream" + in + let harness = Central_test_harness.create ~repo_root:central_root in + let@ central = Central_test_harness.with_cli harness ~cwd:central_root in + central [ [ "import"; "widget" ] ]; + (* @mdexp.snapshot *) + [%expect + {| + $ central import widget + [ OK ] Imported into [main] directly (no merge needed). + |}]; + (* @mdexp + + A single, linear commit lands directly on top of HEAD - no second + parent, no merge commit. `-m` was left out here, so the message falls + back to `"Import changes from widget"`: *) + Central_test_helpers.print_log_subjects ~vcs ~repo_root:central_root ~ref_:"main" (); + (* @mdexp.snapshot *) + [%expect + {| + Import changes from widget + Add fake subrepo widget + Initial commit + |}]; + Central_test_helpers.print_file + ~repo_root:central_root + ~path_in_repo:(Vcs.Path_in_repo.v "repo/widget/README.md"); + (* @mdexp.snapshot { lang: "markdown" } *) + [%expect + {| + # widget + + This is a fake [widget] repo, generated by [Central_test_helpers] for tests. + + Edited directly upstream. + |}] +;; + +(* @mdexp + +## Guardrails + +Running `import` again right away, with nothing new upstream, is a clean +error rather than an empty commit - symmetric to `export`'s own guard: *) + +let%expect_test "nothing to import" = + let vcs = Volgo_git_unix.create () in + let widget = Central.Subrepo.v "widget" in + let fake_central = Central_test_helpers.create ~vcs ~subrepos:[ widget ] in + let { Central_test_helpers.Fake_central.central_root; _ } = fake_central in + let harness = Central_test_harness.create ~repo_root:central_root in + let@ central = Central_test_harness.with_cli harness ~cwd:central_root in + central [ [ "import"; "widget" ] ]; + (* @mdexp.snapshot *) + [%expect + {| + $ central import widget + Error: Nothing to import: the [subrepo] branch of [widget] has not moved + since the last sync. + [123] + |}] +;; + +(* @mdexp + +And just like `export`, `import` first checks that `central`'s own working +tree is clean - an unstaged edit is rejected outright: *) + +let%expect_test "dirty working tree" = + let vcs = Volgo_git_unix.create () in + let widget = Central.Subrepo.v "widget" in + let fake_central = Central_test_helpers.create ~vcs ~subrepos:[ widget ] in + let { Central_test_helpers.Fake_central.central_root; _ } = fake_central in + (* Edited, but never [Vcs.add]ed - left unstaged. *) + Central_test_helpers.append_file + ~repo_root:central_root + ~path_in_repo:(Vcs.Path_in_repo.v "README.md") + ~text:"\nStray, unstaged edit.\n"; + let harness = Central_test_harness.create ~repo_root:central_root in + let@ central = Central_test_harness.with_cli harness ~cwd:central_root in + central [ [ "import"; "widget" ] ]; + (* @mdexp.snapshot *) + [%expect + {| + $ central import widget + Error: Repo "$CENTRAL_ROOT" has uncommitted changes - + commit or stash them first. + Hint: M README.md + [123] + |}] +;; + +(* @mdexp + +## Falling back to a merge + +This time, central *does* touch something under `repo/widget/` since the +last sync - a new file of its own, `NOTES.md`, unrelated to the file +upstream changed. `import` can no longer assume `repo/widget/` is +untouched, so it falls back to building an import commit as a child of the +last sync point and merging it in. Since the two sides touch different +files, the merge still completes cleanly on its own: *) + +let%expect_test "import with merge (central has local changes too)" = + let vcs = Volgo_git_unix.create () in + let widget = Central.Subrepo.v "widget" in + let fake_central = Central_test_helpers.create ~vcs ~subrepos:[ widget ] in + let { Central_test_helpers.Fake_central.central_root; _ } = fake_central in + let fake_widget = + Central_test_helpers.Fake_central.find_exn fake_central ~subrepo:widget + in + (* Someone pushes directly to the [subrepo] branch, bypassing central. *) + let subrepo_readme = Vcs.Path_in_repo.v "README.md" in + Vcs.git + vcs + ~repo_root:fake_widget.repo_root + ~args:[ "checkout"; "subrepo" ] + ~f:Vcs.Git.exit0; + Central_test_helpers.append_file + ~repo_root:fake_widget.repo_root + ~path_in_repo:subrepo_readme + ~text:"\nEdited directly upstream.\n"; + Vcs.add vcs ~repo_root:fake_widget.repo_root ~path:subrepo_readme; + let (_ : Vcs.Rev.t) = + Central_test_helpers.commit + ~vcs + ~repo_root:fake_widget.repo_root + ~commit_message:"Edited directly upstream" + in + (* Meanwhile, central also gets its own, unrelated change under + [repo/widget/] - a new file, so it cannot conflict textually with the + incoming change, but it does mean central has changes of its own since + the last sync, forcing the merge path. *) + let notes_path = Vcs.Path_in_repo.v "repo/widget/NOTES.md" in + Central_test_helpers.write_file + ~repo_root:central_root + ~path_in_repo:notes_path + ~contents:"Some local notes.\n"; + Vcs.add vcs ~repo_root:central_root ~path:notes_path; + let (_ : Vcs.Rev.t) = + Central_test_helpers.commit + ~vcs + ~repo_root:central_root + ~commit_message:"Add local notes under widget" + in + let harness = Central_test_harness.create ~repo_root:central_root in + let@ central = Central_test_harness.with_cli harness ~cwd:central_root in + central [ [ "import"; "widget" ] ]; + (* @mdexp.snapshot *) + [%expect + {| + $ central import widget + [ OK ] Built the import commit. + Merge made by the 'ort' strategy. + repo/widget/.gitrepo | 4 ++-- + repo/widget/README.md | 2 ++ + 2 files changed, 4 insertions(+), 2 deletions(-) + [ OK ] Imported into [main]. + |}]; + (* @mdexp + + Unlike the direct case above, the import commit sits as a child of the + *old* sync point, not of central's HEAD at the time - a separate line + of history, joined by the merge: *) + Central_test_helpers.print_log_subjects + ~vcs + ~repo_root:central_root + ~ref_:"main" + ~first_parent:true + (); + (* @mdexp.snapshot *) + [%expect + {| + Merge widget import + Add local notes under widget + Add fake subrepo widget + Initial commit + |}] +;; + +(* @mdexp + +## A real conflict + +Same fallback, but this time central and the subrepo both edit the very +same line under `repo/widget/` since the last sync: *) + +let%expect_test "conflict" = + let vcs = Volgo_git_unix.create () in + let widget = Central.Subrepo.v "widget" in + let fake_central = Central_test_helpers.create ~vcs ~subrepos:[ widget ] in + let { Central_test_helpers.Fake_central.central_root; _ } = fake_central in + let fake_widget = + Central_test_helpers.Fake_central.find_exn fake_central ~subrepo:widget + in + let central_readme = Vcs.Path_in_repo.v "repo/widget/README.md" in + (* Central edits the first line of widget's README directly. *) + Central_test_helpers.write_file + ~repo_root:central_root + ~path_in_repo:central_readme + ~contents:"# widget, edited by central\n"; + Vcs.add vcs ~repo_root:central_root ~path:central_readme; + let (_ : Vcs.Rev.t) = + Central_test_helpers.commit + ~vcs + ~repo_root:central_root + ~commit_message:"Central retitles the README" + in + (* Upstream edits the very same line, differently. *) + let subrepo_readme = Vcs.Path_in_repo.v "README.md" in + Vcs.git + vcs + ~repo_root:fake_widget.repo_root + ~args:[ "checkout"; "subrepo" ] + ~f:Vcs.Git.exit0; + Central_test_helpers.write_file + ~repo_root:fake_widget.repo_root + ~path_in_repo:subrepo_readme + ~contents:"# widget, retitled upstream\n"; + Vcs.add vcs ~repo_root:fake_widget.repo_root ~path:subrepo_readme; + let (_ : Vcs.Rev.t) = + Central_test_helpers.commit + ~vcs + ~repo_root:fake_widget.repo_root + ~commit_message:"Upstream retitles the README" + in + let harness = Central_test_harness.create ~repo_root:central_root in + let@ central = Central_test_harness.with_cli harness ~cwd:central_root in + central [ [ "import"; "widget" ]; [ "-m"; "Bring in upstream retitle" ] ]; + (* @mdexp.snapshot *) + [%expect + {| + $ central import widget -m "Bring in upstream retitle" + [ OK ] Built the import commit. + Auto-merging repo/widget/README.md + CONFLICT (content): Merge conflict in repo/widget/README.md + Automatic merge failed; fix conflicts and then commit the result. + Error: Merge conflict while importing - resolve the conflicts above in + [main], then [git add] the resolved files and [git commit] to finish the + merge. + Hint: .gitrepo has already been updated as part of the import commit being + merged - no further action needed there once the merge is complete. + [123] + |}]; + (* @mdexp + + Central is left in the middle of the merge, exactly as an ordinary + `git merge` would - conflict markers included, with the trailing + revision on `>>>>>>>` naming the import commit, the side being merged + in: *) + let merge_head = + Vcs.git + vcs + ~repo_root:central_root + ~args:[ "rev-parse"; "MERGE_HEAD" ] + ~f:(fun output -> Vcs.Git.exit0_and_stdout output |> String.strip |> Vcs.Rev.v) + in + Central_test_harness.register_rev harness ~rev:merge_head; + print_string + (Central_test_harness.redact + harness + (String.strip + (Central_test_helpers.read_file + ~repo_root:central_root + ~path_in_repo:central_readme))); + (* @mdexp.snapshot { lang: "text" } *) + [%expect + {| + <<<<<<< HEAD + # widget, edited by central + ======= + # widget, retitled upstream + >>>>>>> 1185512b92d612b25613f2e5b473e5231185512b + |}]; + (* @mdexp + + Resolving it is the same as for any git merge conflict - pick a + resolution, `git add`, `git commit`: *) + Central_test_helpers.write_file + ~repo_root:central_root + ~path_in_repo:central_readme + ~contents:"# widget, retitled (resolved)\n"; + Vcs.add vcs ~repo_root:central_root ~path:central_readme; + let (_ : Vcs.Rev.t) = + Central_test_helpers.commit + ~vcs + ~repo_root:central_root + ~commit_message:"Resolve README retitle conflict" + in + Central_test_helpers.print_log_subjects + ~vcs + ~repo_root:central_root + ~ref_:"main" + ~first_parent:true + (); + (* @mdexp.snapshot *) + [%expect + {| + Resolve README retitle conflict + Central retitles the README + Add fake subrepo widget + Initial commit + |}]; + (* @mdexp + + `.gitrepo` was already updated as part of the import commit, so + `export` is available again right away - it carries the resolution + itself out to `widget`, since that's what central's own history now + disagrees with: *) + central [ [ "export"; "widget" ]; [ "-m"; "Resolve conflicting retitle" ] ]; + (* @mdexp.snapshot *) + [%expect + {| + $ central export widget -m "Resolve conflicting retitle" + ==================== widget ==================== + [ OK ] Applied patch in the subrepo. + [ OK ] Exported to [widget]. + |}]; + Central_test_helpers.print_log_subjects + ~vcs + ~repo_root:fake_widget.repo_root + ~ref_:"subrepo" + (); + (* @mdexp.snapshot *) + [%expect + {| + Resolve conflicting retitle + Upstream retitles the README + Initial commit + |}] +;; diff --git a/test/expect/import.mli b/test/expect/import.mli new file mode 100644 index 0000000..bdaa586 --- /dev/null +++ b/test/expect/import.mli @@ -0,0 +1,5 @@ +(*_********************************************************************************) +(*_ central - Manage history between sub-repos and their monorepo *) +(*_ SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(*_ SPDX-License-Identifier: MIT *) +(*_********************************************************************************) diff --git a/test/expect/push.md b/test/expect/push.md new file mode 100644 index 0000000..67e90ed --- /dev/null +++ b/test/expect/push.md @@ -0,0 +1,80 @@ +# Push + +`central push ...` pushes the given repo's (or repos') `main` branch +to its real remote - the final step once a change has made its way all the +way to a subrepo's own `main` (via `export`, then `advance-main` if needed), +or simply for central's own local commits. + +By default, before pushing, it opens `gitk --all` to visualize the history +and asks for confirmation; every example below passes `--yes` to skip both, +the way this would run non-interactively (e.g. from a script or CI). + +## Nothing to push + +Right after `Central_test_helpers.create`, every repo's `main` is already +up to date with its own remote - pushing is a clean no-op: + +```ansi +$ central push central --yes +==================== central ==================== +[SKIP] Skipping [push] (not applicable). +``` + +## Pushing central's own commits + +Once central has a local commit its remote doesn't have yet, `push` is +applicable, and sends it there. Here, that commit comes from `import`ing an +upstream change from `widget` - the everyday way central ends up with +something to push: + +```ansi +$ central import widget +[ OK ] Imported into [main] directly (no merge needed). +Add fake subrepo widget +Initial commit +``` + +```ansi +$ central push central --yes +==================== central ==================== +[ OK ] Pushed. +``` + +The commit really is on the remote now - reading its log directly +(rather than trusting `central`'s own say-so) confirms it: + +```ansi +Import changes from widget +Add fake subrepo widget +Initial commit +``` + +## Pushing a subrepo + +The same, for a subrepo's own `main` - as it would be after a change went +through `export` (and `advance-main`, catching `main` up to the `subrepo` +branch export landed on). Here, to isolate what `push` itself does, `main` +gets a commit directly: + +```ansi +$ central push widget --yes +==================== widget ==================== +[ OK ] Pushed. +``` + +```ansi +Direct edit in widget +Initial commit +``` + +`--dry-run`/interactive mode (the default) opens `gitk --all` to preview the +history before confirming - which needs a real display and isn't something +this book can exercise deterministically. The "not applicable" guard is +checked before the preview, though, so that much is safe to demonstrate +regardless of confirm mode: + +```ansi +$ central push central --dry-run +==================== central ==================== +[SKIP] Skipping [push] (not applicable). +``` diff --git a/test/expect/push.ml b/test/expect/push.ml new file mode 100644 index 0000000..fc80332 --- /dev/null +++ b/test/expect/push.ml @@ -0,0 +1,213 @@ +(*********************************************************************************) +(* central - Manage history between sub-repos and their monorepo *) +(* SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(* SPDX-License-Identifier: MIT *) +(*********************************************************************************) + +(* Like [export.ml], this runs the real [central] executable (see + [Central_test_harness]) rather than calling into the CLI's OCaml + implementation directly. Each fake repo built by [Central_test_helpers] + has its own real, separate bare remote (never a real, production one), so + the pushes below are genuine - their effect is verified by reading + straight from that remote afterwards. *) + +(* @mdexp.config { snapshot: { lang: "ansi" } } *) + +(* @mdexp + +# Push + +`central push ...` pushes the given repo's (or repos') `main` branch +to its real remote - the final step once a change has made its way all the +way to a subrepo's own `main` (via `export`, then `advance-main` if needed), +or simply for central's own local commits. + +By default, before pushing, it opens `gitk --all` to visualize the history +and asks for confirmation; every example below passes `--yes` to skip both, +the way this would run non-interactively (e.g. from a script or CI). + +## Nothing to push + +Right after `Central_test_helpers.create`, every repo's `main` is already +up to date with its own remote - pushing is a clean no-op: *) + +let%expect_test "nothing to push" = + let vcs = Volgo_git_unix.create () in + let fake_central = Central_test_helpers.create ~vcs ~subrepos:[] in + let { Central_test_helpers.Fake_central.central_root; _ } = fake_central in + let harness = Central_test_harness.create ~repo_root:central_root in + let@ central = Central_test_harness.with_cli harness ~cwd:central_root in + central [ [ "push"; "central"; "--yes" ] ]; + (* @mdexp.snapshot *) + [%expect + {| + $ central push central --yes + ==================== central ==================== + [SKIP] Skipping [push] (not applicable). + |}] +;; + +(* @mdexp + +## Pushing central's own commits + +Once central has a local commit its remote doesn't have yet, `push` is +applicable, and sends it there. Here, that commit comes from `import`ing an +upstream change from `widget` - the everyday way central ends up with +something to push: *) + +(* Creates a fresh fake central repo with one commit not yet pushed to its + remote - by importing an upstream change from the [widget] subrepo. *) +let create_central_with_unpushed_commit vcs = + let widget = Central.Subrepo.v "widget" in + let fake_central = Central_test_helpers.create ~vcs ~subrepos:[ widget ] in + let { Central_test_helpers.Fake_central.central_root; central_remote_root; subrepos } = + fake_central + in + let fake_widget = + Central_test_helpers.Fake_central.find_exn fake_central ~subrepo:widget + in + let readme = Vcs.Path_in_repo.v "README.md" in + Vcs.git + vcs + ~repo_root:fake_widget.repo_root + ~args:[ "checkout"; "subrepo" ] + ~f:Vcs.Git.exit0; + Central_test_helpers.append_file + ~repo_root:fake_widget.repo_root + ~path_in_repo:readme + ~text:"\nEdited directly upstream.\n"; + Vcs.add vcs ~repo_root:fake_widget.repo_root ~path:readme; + let (_ : Vcs.Rev.t) = + Central_test_helpers.commit + ~vcs + ~repo_root:fake_widget.repo_root + ~commit_message:"Edited directly upstream" + in + let harness = Central_test_harness.create ~repo_root:central_root in + Central_test_harness.run harness ~cwd:central_root [ [ "import"; "widget" ] ]; + ignore (subrepos : Central_test_helpers.Fake_subrepo.t list); + harness, central_root, central_remote_root +;; + +let%expect_test "push central" = + let vcs = Volgo_git_unix.create () in + let harness, central_root, central_remote_root = + create_central_with_unpushed_commit vcs + in + (* Before pushing, the remote hasn't seen the import commit yet. *) + Central_test_helpers.print_log_subjects + ~vcs + ~repo_root:central_remote_root + ~ref_:"main" + (); + (* @mdexp.snapshot *) + [%expect + {| + $ central import widget + [ OK ] Imported into [main] directly (no merge needed). + Add fake subrepo widget + Initial commit + |}]; + let@ central = Central_test_harness.with_cli harness ~cwd:central_root in + central [ [ "push"; "central"; "--yes" ] ]; + (* @mdexp.snapshot *) + [%expect + {| + $ central push central --yes + ==================== central ==================== + [ OK ] Pushed. + |}]; + (* @mdexp + + The commit really is on the remote now - reading its log directly + (rather than trusting `central`'s own say-so) confirms it: *) + Central_test_helpers.print_log_subjects + ~vcs + ~repo_root:central_remote_root + ~ref_:"main" + (); + (* @mdexp.snapshot *) + [%expect + {| + Import changes from widget + Add fake subrepo widget + Initial commit + |}] +;; + +(* @mdexp + +## Pushing a subrepo + +The same, for a subrepo's own `main` - as it would be after a change went +through `export` (and `advance-main`, catching `main` up to the `subrepo` +branch export landed on). Here, to isolate what `push` itself does, `main` +gets a commit directly: *) + +let%expect_test "push subrepo" = + let vcs = Volgo_git_unix.create () in + let widget = Central.Subrepo.v "widget" in + let fake_central = Central_test_helpers.create ~vcs ~subrepos:[ widget ] in + let { Central_test_helpers.Fake_central.central_root; _ } = fake_central in + let fake_widget = + Central_test_helpers.Fake_central.find_exn fake_central ~subrepo:widget + in + let readme = Vcs.Path_in_repo.v "README.md" in + Central_test_helpers.append_file + ~repo_root:fake_widget.repo_root + ~path_in_repo:readme + ~text:"\nDirect edit in widget.\n"; + Vcs.add vcs ~repo_root:fake_widget.repo_root ~path:readme; + let (_ : Vcs.Rev.t) = + Central_test_helpers.commit + ~vcs + ~repo_root:fake_widget.repo_root + ~commit_message:"Direct edit in widget" + in + let harness = Central_test_harness.create ~repo_root:central_root in + let@ central = Central_test_harness.with_cli harness ~cwd:central_root in + central [ [ "push"; "widget" ]; [ "--yes" ] ]; + (* @mdexp.snapshot *) + [%expect + {| + $ central push widget --yes + ==================== widget ==================== + [ OK ] Pushed. + |}]; + Central_test_helpers.print_log_subjects + ~vcs + ~repo_root:fake_widget.remote_root + ~ref_:"main" + (); + (* @mdexp.snapshot *) + [%expect + {| + Direct edit in widget + Initial commit + |}] +;; + +(* @mdexp + +`--dry-run`/interactive mode (the default) opens `gitk --all` to preview the +history before confirming - which needs a real display and isn't something +this book can exercise deterministically. The "not applicable" guard is +checked before the preview, though, so that much is safe to demonstrate +regardless of confirm mode: *) + +let%expect_test "push with --dry-run, nothing to push" = + let vcs = Volgo_git_unix.create () in + let fake_central = Central_test_helpers.create ~vcs ~subrepos:[] in + let { Central_test_helpers.Fake_central.central_root; _ } = fake_central in + let harness = Central_test_harness.create ~repo_root:central_root in + let@ central = Central_test_harness.with_cli harness ~cwd:central_root in + central [ [ "push"; "central"; "--dry-run" ] ]; + (* @mdexp.snapshot *) + [%expect + {| + $ central push central --dry-run + ==================== central ==================== + [SKIP] Skipping [push] (not applicable). + |}] +;; diff --git a/test/expect/push.mli b/test/expect/push.mli new file mode 100644 index 0000000..bdaa586 --- /dev/null +++ b/test/expect/push.mli @@ -0,0 +1,5 @@ +(*_********************************************************************************) +(*_ central - Manage history between sub-repos and their monorepo *) +(*_ SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(*_ SPDX-License-Identifier: MIT *) +(*_********************************************************************************) diff --git a/test/expect/stitch.md b/test/expect/stitch.md new file mode 100644 index 0000000..0f3ceb5 --- /dev/null +++ b/test/expect/stitch.md @@ -0,0 +1,147 @@ +# Stitch + +`central stitch ` is for a narrower situation than `import`: +someone `export`ed a change, then reworked the commit(s) that just landed in +the subrepo's own history - splitting one commit into a nicer sequence, +squashing, reordering, rewording - without changing the tree they arrive at. +`repo//.gitrepo` in central still names the pre-rewrite commit, which +no longer exists on the subrepo's `subrepo` branch. + +Running `import` at this point would either fail outright (the old commit +isn't an ancestor of the new tip any more) or, if it somehow went through, +apply an empty patch for no reason - there is nothing to actually bring in, +since the tree hasn't changed. `stitch` is the narrow fix: it just repoints +`.gitrepo` at the subrepo's new tip, and commits that update with an +auto-generated message - central's copy of a subrepo isn't public history, +so unlike `export` there's nothing worth writing by hand here. + +The required pre-conditions: + +1. The `subrepo` branch has actually moved since the last sync. +2. There is no real content diff between the commit recorded in `.gitrepo` + and the subrepo's current tip - i.e. this really is a pure history + rewrite. +3. Central has no local changes of its own under `repo//` since the + last sync. + +## Rewriting history after an export + +Suppose a change lands in central and gets exported as usual, as one +squashed commit: + +```ansi +$ central export widget -m "Document feature A and B" +==================== widget ==================== +[ OK ] Applied patch in the subrepo. +[ OK ] Exported to [widget]. +``` + +Now, imagine that squashed commit gets reworked directly in `widget`'s +own history into two smaller, better organized commits - reaching the +exact same final `README.md` either way. `.gitrepo` still names the +abandoned squash commit, which no longer exists on `subrepo`: + +`import` would refuse here - the commit `.gitrepo` names is gone, so it +can't tell this apart from a more troubling rewrite. `stitch` recognizes +it for what it is and just catches `.gitrepo` up, committing the update +itself with an auto-generated message: + +```ansi +$ central stitch widget +==================== widget ==================== +[ OK ] Stitched [widget]. +``` + +```ansi +Stitch repo widget +export widget +Document feature A and B +Add fake subrepo widget +Initial commit +``` + +Running `stitch` again right away is a clean error - `.gitrepo` is +already caught up, so there is nothing left to stitch: + +```ansi +$ central stitch widget +==================== widget ==================== +File "$CENTRAL_ROOT/repo/widget/.gitrepo", line 1, characters 0-0: +Error: Nothing to stitch: the [subrepo] branch of [widget] is already the +commit recorded in [.gitrepo]. +[123] +``` + +And `central todo` confirms both sides agree - `widget`'s own `main` +just needs to catch up, same as after any ordinary `export`: + +```ansi +$ central todo +┌──────────┬──────────────┬──────┐ +│ Repo │ Next step │ Diff │ +├──────────┼──────────────┼──────┤ +│ central │ push │ 8 │ +│ widget │ advance-main │ │ +└──────────┴──────────────┴──────┘ +``` + +## Guardrails + +`stitch` only repoints `.gitrepo` - it never brings in real content changes. +If the subrepo's tip actually differs in substance from what `.gitrepo` +records, this isn't a pure history rewrite any more, and `stitch` refuses +rather than silently pretending the trees still match: + +```ansi +$ central stitch widget +==================== widget ==================== +File "$CENTRAL_ROOT/repo/widget/.gitrepo", line 1, characters 0-0: +Error: Nothing to stitch: the [subrepo] branch of [widget] is already the +commit recorded in [.gitrepo]. +[123] +``` + +```ansi +$ central stitch widget +==================== widget ==================== +File "$CENTRAL_ROOT/repo/widget/.gitrepo", line 1, characters 0-0: +Error: Cannot stitch: "repo/widget" has content changes between the commit +recorded in [.gitrepo] and its current tip - this isn't a pure history +rewrite. +Hint: Use [central import] instead to bring those changes in. +[123] +``` + +And if central itself has moved on with local changes of its own under +`repo//` since the last sync - even alongside an otherwise legitimate +history rewrite upstream - `stitch` refuses too, since a plain re-pointing +of `.gitrepo` can no longer account for the full picture: + +```ansi +$ central export widget -m "Document feature A" +==================== widget ==================== +[ OK ] Applied patch in the subrepo. +[ OK ] Exported to [widget]. +``` + +```ansi +$ central stitch widget +==================== widget ==================== +File "$CENTRAL_ROOT/repo/widget/.gitrepo", line 1, characters 0-0: +Error: Cannot stitch: central has local changes of its own under +"repo/widget" since the last sync. +Hint: Export or import those changes first, then stitch. +[123] +``` + +And as a precondition, `stitch` first checks that `central`'s own working +tree is clean - an unstaged edit is rejected outright, before anything else +is even looked at: + +```ansi +$ central stitch widget +Error: Repo "$CENTRAL_ROOT" has uncommitted changes - +commit or stash them first. +Hint: M README.md +[123] +``` diff --git a/test/expect/stitch.ml b/test/expect/stitch.ml new file mode 100644 index 0000000..8cc7277 --- /dev/null +++ b/test/expect/stitch.ml @@ -0,0 +1,352 @@ +(*********************************************************************************) +(* central - Manage history between sub-repos and their monorepo *) +(* SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(* SPDX-License-Identifier: MIT *) +(*********************************************************************************) + +(* Like [export.ml] and [import.ml], this page runs the real [central] + executable (see [Central_test_harness]) rather than calling into the + CLI's OCaml implementation directly. *) + +(* @mdexp.config { snapshot: { lang: "ansi" } } *) + +(* @mdexp + +# Stitch + +`central stitch ` is for a narrower situation than `import`: +someone `export`ed a change, then reworked the commit(s) that just landed in +the subrepo's own history - splitting one commit into a nicer sequence, +squashing, reordering, rewording - without changing the tree they arrive at. +`repo//.gitrepo` in central still names the pre-rewrite commit, which +no longer exists on the subrepo's `subrepo` branch. + +Running `import` at this point would either fail outright (the old commit +isn't an ancestor of the new tip any more) or, if it somehow went through, +apply an empty patch for no reason - there is nothing to actually bring in, +since the tree hasn't changed. `stitch` is the narrow fix: it just repoints +`.gitrepo` at the subrepo's new tip, and commits that update with an +auto-generated message - central's copy of a subrepo isn't public history, +so unlike `export` there's nothing worth writing by hand here. + +The required pre-conditions: + +1. The `subrepo` branch has actually moved since the last sync. +2. There is no real content diff between the commit recorded in `.gitrepo` + and the subrepo's current tip - i.e. this really is a pure history + rewrite. +3. Central has no local changes of its own under `repo//` since the + last sync. + +## Rewriting history after an export + +Suppose a change lands in central and gets exported as usual, as one +squashed commit: *) + +let%expect_test "stitch after a pure history rewrite" = + let vcs = Volgo_git_unix.create () in + let widget = Central.Subrepo.v "widget" in + let fake_central = Central_test_helpers.create ~vcs ~subrepos:[ widget ] in + let { Central_test_helpers.Fake_central.central_root; _ } = fake_central in + let fake_widget = + Central_test_helpers.Fake_central.find_exn fake_central ~subrepo:widget + in + let harness = Central_test_harness.create ~repo_root:central_root in + let@ central = Central_test_harness.with_cli harness ~cwd:central_root in + (* Export a change, then rewrite the subrepo's history into two commits + that arrive at the exact same tree - a pure history rewrite. *) + let readme_path = Vcs.Path_in_repo.v "repo/widget/README.md" in + Central_test_helpers.append_file + ~repo_root:central_root + ~path_in_repo:readme_path + ~text:"\nDescribe feature A.\n\nDescribe feature B.\n"; + Vcs.add vcs ~repo_root:central_root ~path:readme_path; + let (_ : Vcs.Rev.t) = + Central_test_helpers.commit + ~vcs + ~repo_root:central_root + ~commit_message:"Document feature A and B" + in + central [ [ "export"; "widget" ]; [ "-m"; "Document feature A and B" ] ]; + (* @mdexp.snapshot *) + [%expect + {| + $ central export widget -m "Document feature A and B" + ==================== widget ==================== + [ OK ] Applied patch in the subrepo. + [ OK ] Exported to [widget]. + |}]; + (* @mdexp + + Now, imagine that squashed commit gets reworked directly in `widget`'s + own history into two smaller, better organized commits - reaching the + exact same final `README.md` either way. `.gitrepo` still names the + abandoned squash commit, which no longer exists on `subrepo`: *) + let readme = Vcs.Path_in_repo.v "README.md" in + Vcs.git + vcs + ~repo_root:fake_widget.repo_root + ~args:[ "checkout"; "subrepo" ] + ~f:Vcs.Git.exit0; + Vcs.git + vcs + ~repo_root:fake_widget.repo_root + ~args:[ "reset"; "--hard"; "HEAD~1" ] + ~f:Vcs.Git.exit0; + Central_test_helpers.append_file + ~repo_root:fake_widget.repo_root + ~path_in_repo:readme + ~text:"\nDescribe feature A.\n"; + Vcs.add vcs ~repo_root:fake_widget.repo_root ~path:readme; + let (_ : Vcs.Rev.t) = + Central_test_helpers.commit + ~vcs + ~repo_root:fake_widget.repo_root + ~commit_message:"Document feature A" + in + Central_test_helpers.append_file + ~repo_root:fake_widget.repo_root + ~path_in_repo:readme + ~text:"\nDescribe feature B.\n"; + Vcs.add vcs ~repo_root:fake_widget.repo_root ~path:readme; + let (_ : Vcs.Rev.t) = + Central_test_helpers.commit + ~vcs + ~repo_root:fake_widget.repo_root + ~commit_message:"Document feature B" + in + (* @mdexp + + `import` would refuse here - the commit `.gitrepo` names is gone, so it + can't tell this apart from a more troubling rewrite. `stitch` recognizes + it for what it is and just catches `.gitrepo` up, committing the update + itself with an auto-generated message: *) + central [ [ "stitch"; "widget" ] ]; + (* @mdexp.snapshot *) + [%expect + {| + $ central stitch widget + ==================== widget ==================== + [ OK ] Stitched [widget]. + |}]; + Central_test_helpers.print_log_subjects ~vcs ~repo_root:central_root ~ref_:"main" (); + (* @mdexp.snapshot *) + [%expect + {| + Stitch repo widget + export widget + Document feature A and B + Add fake subrepo widget + Initial commit + |}]; + (* @mdexp + + Running `stitch` again right away is a clean error - `.gitrepo` is + already caught up, so there is nothing left to stitch: *) + central [ [ "stitch"; "widget" ] ]; + (* @mdexp.snapshot *) + [%expect + {| + $ central stitch widget + ==================== widget ==================== + File "$CENTRAL_ROOT/repo/widget/.gitrepo", line 1, characters 0-0: + Error: Nothing to stitch: the [subrepo] branch of [widget] is already the + commit recorded in [.gitrepo]. + [123] + |}]; + (* @mdexp + + And `central todo` confirms both sides agree - `widget`'s own `main` + just needs to catch up, same as after any ordinary `export`: *) + central [ [ "todo" ] ]; + (* @mdexp.snapshot *) + [%expect + {| + $ central todo + ┌──────────┬──────────────┬──────┐ + │ Repo │ Next step │ Diff │ + ├──────────┼──────────────┼──────┤ + │ central │ push │ 8 │ + │ widget │ advance-main │ │ + └──────────┴──────────────┴──────┘ + |}] +;; + +(* @mdexp + +## Guardrails + +`stitch` only repoints `.gitrepo` - it never brings in real content changes. +If the subrepo's tip actually differs in substance from what `.gitrepo` +records, this isn't a pure history rewrite any more, and `stitch` refuses +rather than silently pretending the trees still match: *) + +let%expect_test "nothing to stitch" = + let vcs = Volgo_git_unix.create () in + let widget = Central.Subrepo.v "widget" in + let fake_central = Central_test_helpers.create ~vcs ~subrepos:[ widget ] in + let { Central_test_helpers.Fake_central.central_root; _ } = fake_central in + let harness = Central_test_harness.create ~repo_root:central_root in + let@ central = Central_test_harness.with_cli harness ~cwd:central_root in + central [ [ "stitch"; "widget" ] ]; + (* @mdexp.snapshot *) + [%expect + {| + $ central stitch widget + ==================== widget ==================== + File "$CENTRAL_ROOT/repo/widget/.gitrepo", line 1, characters 0-0: + Error: Nothing to stitch: the [subrepo] branch of [widget] is already the + commit recorded in [.gitrepo]. + [123] + |}] +;; + +let%expect_test "cannot stitch: real content changes" = + let vcs = Volgo_git_unix.create () in + let widget = Central.Subrepo.v "widget" in + let fake_central = Central_test_helpers.create ~vcs ~subrepos:[ widget ] in + let { Central_test_helpers.Fake_central.central_root; _ } = fake_central in + let fake_widget = + Central_test_helpers.Fake_central.find_exn fake_central ~subrepo:widget + in + let readme = Vcs.Path_in_repo.v "README.md" in + Vcs.git + vcs + ~repo_root:fake_widget.repo_root + ~args:[ "checkout"; "subrepo" ] + ~f:Vcs.Git.exit0; + Central_test_helpers.append_file + ~repo_root:fake_widget.repo_root + ~path_in_repo:readme + ~text:"\nGenuinely new content.\n"; + Vcs.add vcs ~repo_root:fake_widget.repo_root ~path:readme; + let (_ : Vcs.Rev.t) = + Central_test_helpers.commit + ~vcs + ~repo_root:fake_widget.repo_root + ~commit_message:"Genuinely new content" + in + let harness = Central_test_harness.create ~repo_root:central_root in + let@ central = Central_test_harness.with_cli harness ~cwd:central_root in + central [ [ "stitch"; "widget" ] ]; + (* @mdexp.snapshot *) + [%expect + {| + $ central stitch widget + ==================== widget ==================== + File "$CENTRAL_ROOT/repo/widget/.gitrepo", line 1, characters 0-0: + Error: Cannot stitch: "repo/widget" has content changes between the commit + recorded in [.gitrepo] and its current tip - this isn't a pure history + rewrite. + Hint: Use [central import] instead to bring those changes in. + [123] + |}] +;; + +(* @mdexp + +And if central itself has moved on with local changes of its own under +`repo//` since the last sync - even alongside an otherwise legitimate +history rewrite upstream - `stitch` refuses too, since a plain re-pointing +of `.gitrepo` can no longer account for the full picture: *) + +let%expect_test "central has changes of its own" = + let vcs = Volgo_git_unix.create () in + let widget = Central.Subrepo.v "widget" in + let fake_central = Central_test_helpers.create ~vcs ~subrepos:[ widget ] in + let { Central_test_helpers.Fake_central.central_root; _ } = fake_central in + let fake_widget = + Central_test_helpers.Fake_central.find_exn fake_central ~subrepo:widget + in + let readme_path = Vcs.Path_in_repo.v "repo/widget/README.md" in + Central_test_helpers.append_file + ~repo_root:central_root + ~path_in_repo:readme_path + ~text:"\nDescribe feature A.\n"; + Vcs.add vcs ~repo_root:central_root ~path:readme_path; + let (_ : Vcs.Rev.t) = + Central_test_helpers.commit + ~vcs + ~repo_root:central_root + ~commit_message:"Document feature A" + in + let harness = Central_test_harness.create ~repo_root:central_root in + let@ central = Central_test_harness.with_cli harness ~cwd:central_root in + central [ [ "export"; "widget" ]; [ "-m"; "Document feature A" ] ]; + (* @mdexp.snapshot *) + [%expect + {| + $ central export widget -m "Document feature A" + ==================== widget ==================== + [ OK ] Applied patch in the subrepo. + [ OK ] Exported to [widget]. + |}]; + (* A pure reword upstream - same tree, different message, so it would + otherwise be a perfectly legitimate stitch. *) + Vcs.git + vcs + ~repo_root:fake_widget.repo_root + ~args:[ "checkout"; "subrepo" ] + ~f:Vcs.Git.exit0; + Vcs.git + vcs + ~repo_root:fake_widget.repo_root + ~args:[ "commit"; "--amend"; "-m"; "Document feature A (reworded)" ] + ~f:Vcs.Git.exit0; + (* Central, meanwhile, made its own unrelated edit under [repo/widget/] + after the export. *) + let notes_path = Vcs.Path_in_repo.v "repo/widget/NOTES.md" in + Central_test_helpers.write_file + ~repo_root:central_root + ~path_in_repo:notes_path + ~contents:"Internal note, never meant for upstream.\n"; + Vcs.add vcs ~repo_root:central_root ~path:notes_path; + let (_ : Vcs.Rev.t) = + Central_test_helpers.commit + ~vcs + ~repo_root:central_root + ~commit_message:"Add internal note" + in + central [ [ "stitch"; "widget" ] ]; + (* @mdexp.snapshot *) + [%expect + {| + $ central stitch widget + ==================== widget ==================== + File "$CENTRAL_ROOT/repo/widget/.gitrepo", line 1, characters 0-0: + Error: Cannot stitch: central has local changes of its own under + "repo/widget" since the last sync. + Hint: Export or import those changes first, then stitch. + [123] + |}] +;; + +(* @mdexp + +And as a precondition, `stitch` first checks that `central`'s own working +tree is clean - an unstaged edit is rejected outright, before anything else +is even looked at: *) + +let%expect_test "dirty working tree" = + let vcs = Volgo_git_unix.create () in + let widget = Central.Subrepo.v "widget" in + let fake_central = Central_test_helpers.create ~vcs ~subrepos:[ widget ] in + let { Central_test_helpers.Fake_central.central_root; _ } = fake_central in + (* Edited, but never [Vcs.add]ed - left unstaged. *) + Central_test_helpers.append_file + ~repo_root:central_root + ~path_in_repo:(Vcs.Path_in_repo.v "README.md") + ~text:"\nStray, unstaged edit.\n"; + let harness = Central_test_harness.create ~repo_root:central_root in + let@ central = Central_test_harness.with_cli harness ~cwd:central_root in + central [ [ "stitch"; "widget" ] ]; + (* @mdexp.snapshot *) + [%expect + {| + $ central stitch widget + Error: Repo "$CENTRAL_ROOT" has uncommitted changes - + commit or stash them first. + Hint: M README.md + [123] + |}] +;; diff --git a/test/expect/stitch.mli b/test/expect/stitch.mli new file mode 100644 index 0000000..bdaa586 --- /dev/null +++ b/test/expect/stitch.mli @@ -0,0 +1,5 @@ +(*_********************************************************************************) +(*_ central - Manage history between sub-repos and their monorepo *) +(*_ SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(*_ SPDX-License-Identifier: MIT *) +(*_********************************************************************************) diff --git a/test/expect/test__central.ml b/test/expect/test__central.ml new file mode 100644 index 0000000..c4443e4 --- /dev/null +++ b/test/expect/test__central.ml @@ -0,0 +1,16 @@ +(*********************************************************************************) +(* central - Manage history between sub-repos and their monorepo *) +(* SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(* SPDX-License-Identifier: MIT *) +(*********************************************************************************) + +(* At the moment this test is empty, however this library needs to exist so + that expect tests have somewhere to live as [central] grows. *) + +open! Central + +let%expect_test "empty" = + (); + [%expect {||}]; + () +;; diff --git a/test/expect/test__central.mli b/test/expect/test__central.mli new file mode 100644 index 0000000..bdaa586 --- /dev/null +++ b/test/expect/test__central.mli @@ -0,0 +1,5 @@ +(*_********************************************************************************) +(*_ central - Manage history between sub-repos and their monorepo *) +(*_ SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(*_ SPDX-License-Identifier: MIT *) +(*_********************************************************************************) diff --git a/test/expect/todo.md b/test/expect/todo.md new file mode 100644 index 0000000..ba68756 --- /dev/null +++ b/test/expect/todo.md @@ -0,0 +1,23 @@ +# Todo + +`central todo` is the dashboard: one row for central itself, plus one row +per subrepo that has something outstanding - each with its `Next step` +(what `central` command to run next) and, for subrepos, `Diff` (how many +lines under `repo//` differ from what's already been dealt with). +Subrepos with nothing outstanding simply don't appear, so the table only +ever shows what actually needs attention - see [the day-to-day +workflow](workflow.md) for it used end to end. + +`central`'s own row uses `Repo_config.root_repo_name` as its label - `"central"` by +default, but configurable per-repo (see [config.md](config.md)). + +Once `repo/widget/` has an uncommitted-to-upstream change, `widget` shows +up with `export` as its next step, and `central` itself already needs a +`push` for the commit that introduced it: + +After `export`, `widget`'s next step becomes `advance-main` - the `Diff` +column goes blank, since there's no longer a content diff to size, only a +branch to fast-forward: + +And with a `.central/repo-config.json` setting a custom `name`, that name - +not `"central"` - is what labels the top row: diff --git a/test/expect/todo.ml b/test/expect/todo.ml new file mode 100644 index 0000000..f85c402 --- /dev/null +++ b/test/expect/todo.ml @@ -0,0 +1,171 @@ +(*********************************************************************************) +(* central - Manage history between sub-repos and their monorepo *) +(* SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(* SPDX-License-Identifier: MIT *) +(*********************************************************************************) + +(* Like [export.ml], this runs the real [central] executable (see + [Central_test_harness]) rather than calling into the CLI's OCaml + implementation directly. *) + +(* @mdexp.config { snapshot: { lang: "ansi" } } *) + +(* @mdexp + +# Todo + +`central todo` is the dashboard: one row for central itself, plus one row +per subrepo that has something outstanding - each with its `Next step` +(what `central` command to run next) and, for subrepos, `Diff` (how many +lines under `repo//` differ from what's already been dealt with). +Subrepos with nothing outstanding simply don't appear, so the table only +ever shows what actually needs attention - see [the day-to-day +workflow](workflow.md) for it used end to end. + +`central`'s own row uses `Repo_config.root_repo_name` as its label - `"central"` by +default, but configurable per-repo (see [config.md](config.md)). *) + +let%expect_test "empty todo, right after create" = + let vcs = Volgo_git_unix.create () in + let widget = Central.Subrepo.v "widget" in + let fake_central = Central_test_helpers.create ~vcs ~subrepos:[ widget ] in + let { Central_test_helpers.Fake_central.central_root; _ } = fake_central in + let harness = Central_test_harness.create ~repo_root:central_root in + let@ central = Central_test_harness.with_cli harness ~cwd:central_root in + central [ [ "todo" ] ]; + [%expect {| $ central todo |}] +;; + +(* @mdexp + +Once `repo/widget/` has an uncommitted-to-upstream change, `widget` shows +up with `export` as its next step, and `central` itself already needs a +`push` for the commit that introduced it: *) + +let%expect_test "export shows up as a next step for the subrepo" = + let vcs = Volgo_git_unix.create () in + let widget = Central.Subrepo.v "widget" in + let fake_central = Central_test_helpers.create ~vcs ~subrepos:[ widget ] in + let { Central_test_helpers.Fake_central.central_root; _ } = fake_central in + let readme_path = Vcs.Path_in_repo.v "repo/widget/README.md" in + Central_test_helpers.append_file + ~repo_root:central_root + ~path_in_repo:readme_path + ~text:"\nAdded a line about installation.\n"; + Vcs.add vcs ~repo_root:central_root ~path:readme_path; + let (_ : Vcs.Rev.t) = + Central_test_helpers.commit + ~vcs + ~repo_root:central_root + ~commit_message:"Document installation" + in + let harness = Central_test_harness.create ~repo_root:central_root in + let@ central = Central_test_harness.with_cli harness ~cwd:central_root in + central [ [ "todo" ] ]; + [%expect + {| + $ central todo + ┌──────────┬───────────┬──────┐ + │ Repo │ Next step │ Diff │ + ├──────────┼───────────┼──────┤ + │ central │ push │ 2 │ + │ widget │ export │ 2 │ + └──────────┴───────────┴──────┘ + |}] +;; + +(* @mdexp + +After `export`, `widget`'s next step becomes `advance-main` - the `Diff` +column goes blank, since there's no longer a content diff to size, only a +branch to fast-forward: *) + +let%expect_test "after export, central itself needs a push" = + let vcs = Volgo_git_unix.create () in + let widget = Central.Subrepo.v "widget" in + let fake_central = Central_test_helpers.create ~vcs ~subrepos:[ widget ] in + let { Central_test_helpers.Fake_central.central_root; _ } = fake_central in + let readme_path = Vcs.Path_in_repo.v "repo/widget/README.md" in + Central_test_helpers.append_file + ~repo_root:central_root + ~path_in_repo:readme_path + ~text:"\nAdded a line about installation.\n"; + Vcs.add vcs ~repo_root:central_root ~path:readme_path; + let (_ : Vcs.Rev.t) = + Central_test_helpers.commit + ~vcs + ~repo_root:central_root + ~commit_message:"Document installation" + in + let harness = Central_test_harness.create ~repo_root:central_root in + let@ central = Central_test_harness.with_cli harness ~cwd:central_root in + central [ [ "export"; "widget" ]; [ "-m"; "Document installation" ] ]; + [%expect + {| + $ central export widget -m "Document installation" + ==================== widget ==================== + [ OK ] Applied patch in the subrepo. + [ OK ] Exported to [widget]. + |}]; + central [ [ "todo" ] ]; + [%expect + {| + $ central todo + ┌──────────┬──────────────┬──────┐ + │ Repo │ Next step │ Diff │ + ├──────────┼──────────────┼──────┤ + │ central │ push │ 6 │ + │ widget │ advance-main │ │ + └──────────┴──────────────┴──────┘ + |}] +;; + +(* @mdexp + +And with a `.central/repo-config.json` setting a custom `name`, that name - +not `"central"` - is what labels the top row: *) + +let%expect_test "a custom repo_config name is used for central's own row" = + let vcs = Volgo_git_unix.create () in + let widget = Central.Subrepo.v "widget" in + let fake_central = Central_test_helpers.create ~vcs ~subrepos:[ widget ] in + let { Central_test_helpers.Fake_central.central_root; _ } = fake_central in + Central_test_helpers.write_file + ~repo_root:central_root + ~path_in_repo:Central.Repo_config.path_in_repo + ~contents:{|{ "rootRepoName": "my-monorepo" }|}; + Vcs.add vcs ~repo_root:central_root ~path:Central.Repo_config.path_in_repo; + let readme_path = Vcs.Path_in_repo.v "repo/widget/README.md" in + Central_test_helpers.append_file + ~repo_root:central_root + ~path_in_repo:readme_path + ~text:"\nAdded a line about installation.\n"; + Vcs.add vcs ~repo_root:central_root ~path:readme_path; + let (_ : Vcs.Rev.t) = + Central_test_helpers.commit + ~vcs + ~repo_root:central_root + ~commit_message:"Add repo config and document installation" + in + let harness = Central_test_harness.create ~repo_root:central_root in + let@ central = Central_test_harness.with_cli harness ~cwd:central_root in + central [ [ "export"; "widget" ]; [ "-m"; "Document installation" ] ]; + [%expect + {| + $ central export widget -m "Document installation" + ==================== widget ==================== + [ OK ] Applied patch in the subrepo. + [ OK ] Exported to [widget]. + |}]; + central [ [ "todo" ] ]; + [%expect + {| + $ central todo + ┌─────────────┬──────────────┬──────┐ + │ Repo │ Next step │ Diff │ + ├─────────────┼──────────────┼──────┤ + │ my-monorepo │ push │ 7 │ + │ widget │ advance-main │ │ + └─────────────┴──────────────┴──────┘ + |}] +;; diff --git a/test/expect/todo.mli b/test/expect/todo.mli new file mode 100644 index 0000000..bdaa586 --- /dev/null +++ b/test/expect/todo.mli @@ -0,0 +1,5 @@ +(*_********************************************************************************) +(*_ central - Manage history between sub-repos and their monorepo *) +(*_ SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(*_ SPDX-License-Identifier: MIT *) +(*_********************************************************************************) diff --git a/test/expect/workflow.md b/test/expect/workflow.md new file mode 100644 index 0000000..e7471ff --- /dev/null +++ b/test/expect/workflow.md @@ -0,0 +1,101 @@ +# A day-to-day workflow + +This walks through the everyday loop of working in `central`: edit +something, check `central todo` for what needs attention, act on it, and +confirm the dashboard is clear again. + +`central todo`'s dashboard covers every subrepo `central` knows about, so +this fake repo (unlike the one on the [Export](export.md) page) is built +with a few of them, not just `widget` - enough to show the dashboard +correctly narrows down to only what needs attention. + +With nothing out of the ordinary going on, the dashboard is empty: + +```ansi +$ central todo +``` + +## Editing directly in central + +Suppose someone edits `repo/widget/README.md` directly from within `central` +and commits it there - the way most day-to-day changes happen. + +`central todo` now shows `widget` needs attention: + +```ansi +$ central todo +┌──────────┬───────────┬──────┐ +│ Repo │ Next step │ Diff │ +├──────────┼───────────┼──────┤ +│ central │ push │ 2 │ +│ widget │ export │ 2 │ +└──────────┴───────────┴──────┘ +``` + +Following it means exporting: + +```ansi +$ central export widget -m "Document installation" +==================== widget ==================== +[ OK ] Applied patch in the subrepo. +[ OK ] Exported to [widget]. +``` + +`widget`'s row is still there, but the next step changed: `export` only +advances the subrepo's `subrepo` branch, so `widget`'s own `main` branch +is now behind it. This is a genuine second step, not a leftover of the +first: + +```ansi +$ central todo +┌──────────┬──────────────┬──────┐ +│ Repo │ Next step │ Diff │ +├──────────┼──────────────┼──────┤ +│ central │ push │ 6 │ +│ widget │ advance-main │ │ +└──────────┴──────────────┴──────┘ +``` + +`advance-main` is exactly that: catch `widget`'s `main` branch up: + +```ansi +$ central advance-main widget +==================== widget ==================== +Updating 1185512..f452a6f +Fast-forward + README.md | 2 ++ + 1 file changed, 2 insertions(+) +``` + +`widget` is left with a `push` next step, same as `central` itself: both +now have local commits their own remote doesn't have yet - central's +edit and the `.gitrepo` update `export` made, and the commit +`advance-main` just fast-forwarded `widget`'s own `main` to: + +```ansi +$ central todo +┌──────────┬───────────┬──────┐ +│ Repo │ Next step │ Diff │ +├──────────┼───────────┼──────┤ +│ central │ push │ 6 │ +│ widget │ push │ │ +└──────────┴───────────┴──────┘ +``` + +`push` closes the loop for both at once - a real `git push` to each +one's own remote: + +```ansi +$ central push central widget --yes +==================== central ==================== +[ OK ] Pushed. +==================== widget ==================== +[ OK ] Pushed. +``` + +And the dashboard is clear again - back where we started, the change +now genuinely out, all the way to both real remotes: + +```ansi +$ central todo +``` diff --git a/test/expect/workflow.ml b/test/expect/workflow.ml new file mode 100644 index 0000000..7dc4a0b --- /dev/null +++ b/test/expect/workflow.ml @@ -0,0 +1,182 @@ +(*********************************************************************************) +(* central - Manage history between sub-repos and their monorepo *) +(* SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(* SPDX-License-Identifier: MIT *) +(*********************************************************************************) + +(* Like [export.ml], this page runs the real [central] executable (see + [Central_test_harness]) rather than calling into the CLI's OCaml + implementation directly - so what you read below is, command for command, + what a [central] user would type and see in their own terminal. *) + +(* @mdexp.config { snapshot: { lang: "ansi" } } *) + +(* @mdexp + +# A day-to-day workflow + +This walks through the everyday loop of working in `central`: edit +something, check `central todo` for what needs attention, act on it, and +confirm the dashboard is clear again. + +`central todo`'s dashboard covers every subrepo `central` knows about, so +this fake repo (unlike the one on the [Export](export.md) page) is built +with a few of them, not just `widget` - enough to show the dashboard +correctly narrows down to only what needs attention. *) + +let%expect_test "empty dashboard" = + let vcs = Volgo_git_unix.create () in + let widget = Central.Subrepo.v "widget" in + let gadget = Central.Subrepo.v "gadget" in + let sprocket = Central.Subrepo.v "sprocket" in + let fake_central = + Central_test_helpers.create ~vcs ~subrepos:[ widget; gadget; sprocket ] + in + let { Central_test_helpers.Fake_central.central_root; _ } = fake_central in + let harness = Central_test_harness.create ~repo_root:central_root in + let@ central = Central_test_harness.with_cli harness ~cwd:central_root in + (* @mdexp With nothing out of the ordinary going on, the dashboard is empty: *) + central [ [ "todo" ] ]; + (* @mdexp.snapshot *) + [%expect {| $ central todo |}] +;; + +(* @mdexp + +## Editing directly in central + +Suppose someone edits `repo/widget/README.md` directly from within `central` +and commits it there - the way most day-to-day changes happen. *) + +let%expect_test "edit, todo, export, todo again" = + let vcs = Volgo_git_unix.create () in + let widget = Central.Subrepo.v "widget" in + let gadget = Central.Subrepo.v "gadget" in + let sprocket = Central.Subrepo.v "sprocket" in + let fake_central = + Central_test_helpers.create ~vcs ~subrepos:[ widget; gadget; sprocket ] + in + let { Central_test_helpers.Fake_central.central_root; _ } = fake_central in + let fake_widget = + Central_test_helpers.Fake_central.find_exn fake_central ~subrepo:widget + in + let harness = Central_test_harness.create ~repo_root:central_root in + let readme_path = Vcs.Path_in_repo.v "repo/widget/README.md" in + Central_test_helpers.append_file + ~repo_root:central_root + ~path_in_repo:readme_path + ~text:"\nAdded a line about installation.\n"; + Vcs.add vcs ~repo_root:central_root ~path:readme_path; + let (_ : Vcs.Rev.t) = + Central_test_helpers.commit + ~vcs + ~repo_root:central_root + ~commit_message:"Document installation in widget's README" + in + let@ central = Central_test_harness.with_cli harness ~cwd:central_root in + (* @mdexp `central todo` now shows `widget` needs attention: *) + central [ [ "todo" ] ]; + (* @mdexp.snapshot *) + [%expect + {| + $ central todo + ┌──────────┬───────────┬──────┐ + │ Repo │ Next step │ Diff │ + ├──────────┼───────────┼──────┤ + │ central │ push │ 2 │ + │ widget │ export │ 2 │ + └──────────┴───────────┴──────┘ + |}]; + (* @mdexp Following it means exporting: *) + central [ [ "export"; "widget" ]; [ "-m"; "Document installation" ] ]; + (* @mdexp.snapshot *) + [%expect + {| + $ central export widget -m "Document installation" + ==================== widget ==================== + [ OK ] Applied patch in the subrepo. + [ OK ] Exported to [widget]. + |}]; + (* @mdexp + + `widget`'s row is still there, but the next step changed: `export` only + advances the subrepo's `subrepo` branch, so `widget`'s own `main` branch + is now behind it. This is a genuine second step, not a leftover of the + first: *) + central [ [ "todo" ] ]; + (* @mdexp.snapshot *) + [%expect + {| + $ central todo + ┌──────────┬──────────────┬──────┐ + │ Repo │ Next step │ Diff │ + ├──────────┼──────────────┼──────┤ + │ central │ push │ 6 │ + │ widget │ advance-main │ │ + └──────────┴──────────────┴──────┘ + |}]; + (* [git merge --ff-only]'s own "Updating .." summary below prints + abbreviated revisions, which the harness can only rewrite if it already + knows the full ones - register widget's [main] (the "old" side) and + [subrepo] (the "new" side, [export]'s new commit) ahead of time. *) + let rev_of ~ref_ = + Vcs.git + vcs + ~repo_root:fake_widget.repo_root + ~args:[ "rev-parse"; ref_ ] + ~f:(fun output -> Vcs.Git.exit0_and_stdout output |> String.strip |> Vcs.Rev.v) + in + Central_test_harness.register_rev harness ~rev:(rev_of ~ref_:"main"); + Central_test_harness.register_rev harness ~rev:(rev_of ~ref_:"subrepo"); + (* @mdexp `advance-main` is exactly that: catch `widget`'s `main` branch up: *) + central [ [ "advance-main"; "widget" ] ]; + (* @mdexp.snapshot *) + [%expect + {| + $ central advance-main widget + ==================== widget ==================== + Updating 1185512..f452a6f + Fast-forward + README.md | 2 ++ + 1 file changed, 2 insertions(+) + |}]; + (* @mdexp + + `widget` is left with a `push` next step, same as `central` itself: both + now have local commits their own remote doesn't have yet - central's + edit and the `.gitrepo` update `export` made, and the commit + `advance-main` just fast-forwarded `widget`'s own `main` to: *) + central [ [ "todo" ] ]; + (* @mdexp.snapshot *) + [%expect + {| + $ central todo + ┌──────────┬───────────┬──────┐ + │ Repo │ Next step │ Diff │ + ├──────────┼───────────┼──────┤ + │ central │ push │ 6 │ + │ widget │ push │ │ + └──────────┴───────────┴──────┘ + |}]; + (* @mdexp + + `push` closes the loop for both at once - a real `git push` to each + one's own remote: *) + central [ [ "push"; "central"; "widget" ]; [ "--yes" ] ]; + (* @mdexp.snapshot *) + [%expect + {| + $ central push central widget --yes + ==================== central ==================== + [ OK ] Pushed. + ==================== widget ==================== + [ OK ] Pushed. + |}]; + (* @mdexp + + And the dashboard is clear again - back where we started, the change + now genuinely out, all the way to both real remotes: *) + central [ [ "todo" ] ]; + (* @mdexp.snapshot *) + [%expect {| $ central todo |}] +;; diff --git a/test/expect/workflow.mli b/test/expect/workflow.mli new file mode 100644 index 0000000..bdaa586 --- /dev/null +++ b/test/expect/workflow.mli @@ -0,0 +1,5 @@ +(*_********************************************************************************) +(*_ central - Manage history between sub-repos and their monorepo *) +(*_ SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(*_ SPDX-License-Identifier: MIT *) +(*_********************************************************************************) From 3203d01d40fee20086114e96931a456d6e7cdb73 Mon Sep 17 00:00:00 2001 From: Mathieu Barbin Date: Mon, 17 Aug 2026 22:21:50 +0200 Subject: [PATCH 12/26] Initiate documentation --- doc/.gitignore | 11 + doc/Makefile | 16 + doc/NOTICE-zolanight | 25 + .../introduction-to-central-cli/.gitignore | 1 + doc/book/introduction-to-central-cli/Makefile | 10 + .../introduction-to-central-cli/README.md | 59 + .../introduction-to-central-cli/SUMMARY.md | 8 + .../introduction-to-central-cli/book.toml | 26 + .../introduction-to-central-cli/export.md | 118 ++ .../introduction-to-central-cli/export.ml | 232 ++++ .../introduction-to-central-cli/export.mli | 5 + .../introduction-to-central-cli/import.md | 186 +++ .../introduction-to-central-cli/import.ml | 419 ++++++ .../introduction-to-central-cli/import.mli | 5 + doc/book/introduction-to-central-cli/push.md | 98 ++ doc/book/introduction-to-central-cli/push.ml | 173 +++ doc/book/introduction-to-central-cli/push.mli | 5 + .../shared-theme/ansi-plugin.js | 123 ++ .../shared-theme/highlight.js | 1226 +++++++++++++++++ .../introduction-to-central-cli/stitch.md | 74 + .../introduction-to-central-cli/stitch.ml | 170 +++ .../introduction-to-central-cli/stitch.mli | 5 + doc/book/shared-theme/ansi-plugin.js | 123 ++ doc/book/shared-theme/highlight.js | 1226 +++++++++++++++++ doc/config.toml | 33 + doc/content/_index.md | 9 + doc/content/blog/_index.md | 5 + doc/content/dune | 1 + doc/content/explanation/_index.md | 5 + doc/content/guides/_index.md | 5 + doc/content/reference/_index.md | 5 + doc/content/resources/_index.md | 20 + doc/content/tutorials/_index.md | 9 + doc/dune | 1 + doc/sass/_colors.scss | 51 + doc/sass/_reset.scss | 53 + doc/sass/style.scss | 382 +++++ doc/static/.gitignore | 1 + doc/static/.nojekyll | 0 doc/templates/404.html | 11 + doc/templates/base.html | 101 ++ doc/templates/index.html | 40 + doc/templates/page.html | 54 + doc/templates/section.html | 64 + doc/templates/taxonomy_list.html | 14 + doc/templates/taxonomy_single.html | 14 + schema/central-repo-config.schema.json | 20 + schema/central-user-config.schema.json | 14 + 48 files changed, 5256 insertions(+) create mode 100644 doc/.gitignore create mode 100644 doc/Makefile create mode 100644 doc/NOTICE-zolanight create mode 100644 doc/book/introduction-to-central-cli/.gitignore create mode 100644 doc/book/introduction-to-central-cli/Makefile create mode 100644 doc/book/introduction-to-central-cli/README.md create mode 100644 doc/book/introduction-to-central-cli/SUMMARY.md create mode 100644 doc/book/introduction-to-central-cli/book.toml create mode 100644 doc/book/introduction-to-central-cli/export.md create mode 100644 doc/book/introduction-to-central-cli/export.ml create mode 100644 doc/book/introduction-to-central-cli/export.mli create mode 100644 doc/book/introduction-to-central-cli/import.md create mode 100644 doc/book/introduction-to-central-cli/import.ml create mode 100644 doc/book/introduction-to-central-cli/import.mli create mode 100644 doc/book/introduction-to-central-cli/push.md create mode 100644 doc/book/introduction-to-central-cli/push.ml create mode 100644 doc/book/introduction-to-central-cli/push.mli create mode 100644 doc/book/introduction-to-central-cli/shared-theme/ansi-plugin.js create mode 100644 doc/book/introduction-to-central-cli/shared-theme/highlight.js create mode 100644 doc/book/introduction-to-central-cli/stitch.md create mode 100644 doc/book/introduction-to-central-cli/stitch.ml create mode 100644 doc/book/introduction-to-central-cli/stitch.mli create mode 100644 doc/book/shared-theme/ansi-plugin.js create mode 100644 doc/book/shared-theme/highlight.js create mode 100644 doc/config.toml create mode 100644 doc/content/_index.md create mode 100644 doc/content/blog/_index.md create mode 100644 doc/content/dune create mode 100644 doc/content/explanation/_index.md create mode 100644 doc/content/guides/_index.md create mode 100644 doc/content/reference/_index.md create mode 100644 doc/content/resources/_index.md create mode 100644 doc/content/tutorials/_index.md create mode 100644 doc/dune create mode 100644 doc/sass/_colors.scss create mode 100644 doc/sass/_reset.scss create mode 100644 doc/sass/style.scss create mode 100644 doc/static/.gitignore create mode 100644 doc/static/.nojekyll create mode 100644 doc/templates/404.html create mode 100644 doc/templates/base.html create mode 100644 doc/templates/index.html create mode 100644 doc/templates/page.html create mode 100644 doc/templates/section.html create mode 100644 doc/templates/taxonomy_list.html create mode 100644 doc/templates/taxonomy_single.html create mode 100644 schema/central-repo-config.schema.json create mode 100644 schema/central-user-config.schema.json diff --git a/doc/.gitignore b/doc/.gitignore new file mode 100644 index 0000000..a4c6e83 --- /dev/null +++ b/doc/.gitignore @@ -0,0 +1,11 @@ +# Zola build output +/public + +# mdbook build output (generated by mdbook into static/ for zola to pick up) +/static/book + +# Sass cache +.sass-cache + +# Misc +.DS_Store diff --git a/doc/Makefile b/doc/Makefile new file mode 100644 index 0000000..8340d0b --- /dev/null +++ b/doc/Makefile @@ -0,0 +1,16 @@ +.PHONY: build build-books build-site serve clean + +build: build-books build-site + +build-books: + mdbook build book/introduction-to-central-cli + cd ../test && mdbook build + +build-site: + zola build + +serve: build-books + zola serve + +clean: + rm -rf static/book public diff --git a/doc/NOTICE-zolanight b/doc/NOTICE-zolanight new file mode 100644 index 0000000..11d2a9f --- /dev/null +++ b/doc/NOTICE-zolanight @@ -0,0 +1,25 @@ +The templates and styles in this documentation site are derived from the +ZolaNight theme by mxaddict (https://github.com/mxaddict/zolanight), +licensed under the MIT License: + +MIT License + +Copyright (c) 2022 mxaddict + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/doc/book/introduction-to-central-cli/.gitignore b/doc/book/introduction-to-central-cli/.gitignore new file mode 100644 index 0000000..b630e2f --- /dev/null +++ b/doc/book/introduction-to-central-cli/.gitignore @@ -0,0 +1 @@ +# mdbook output (now built to ../../static/book/introduction-to-central-cli) diff --git a/doc/book/introduction-to-central-cli/Makefile b/doc/book/introduction-to-central-cli/Makefile new file mode 100644 index 0000000..70f5a96 --- /dev/null +++ b/doc/book/introduction-to-central-cli/Makefile @@ -0,0 +1,10 @@ +.PHONY: build serve clean + +build: + mdbook build + +serve: + mdbook serve --open + +clean: + mdbook clean diff --git a/doc/book/introduction-to-central-cli/README.md b/doc/book/introduction-to-central-cli/README.md new file mode 100644 index 0000000..eed1753 --- /dev/null +++ b/doc/book/introduction-to-central-cli/README.md @@ -0,0 +1,59 @@ +# Introduction to central + +*A user-facing tour of the `central` CLI.* + +This book is a short, user-facing tour of `central` - the command-line tool +that manages the relationship between a monorepo and the standalone git +repos ("subrepos") that live inside it. + +> This is an introductory read, not the full reference. Where it matters, +> it points at `central --help` for the details it leaves out. +> For a detailed, developer/agent-facing account of exactly how each +> command behaves, including every guardrail and error case, see central's +> own [test suite](../../../test/expect), which doubles as executable +> documentation. + +## Why subrepos? + +A number of independent projects are sometimes developed together, in one +place, so that changes spanning several of them can be made and reviewed +atomically. But each of those projects is *also* a real, standalone +open-source repository with its own history, its own remote, and its own +life outside the monorepo. + +`central` is what keeps those two things true at once. Each subrepo lives +under `repo//` in the monorepo, with a small `.gitrepo` file +recording where its own, independent git history currently stands relative +to the monorepo's. Two commands keep that relationship moving: + +- **`central export`** - takes changes made directly under + `repo//` in the monorepo and turns them into a real commit in the + subrepo's own history. +- **`central import`** - brings commits made in the subrepo's own + history (typically fetched from its real, public remote) back into + the monorepo. + +Together, they mean you can edit a subrepo's code from within the monorepo +like any other file, and separately, its own history stays a normal, +coherent git history - not a copy, not a submodule, a real independent repo +that happens to also be mirrored here. + +## What's in this book + +- [Exporting a change](export.md) - the everyday case: you edited something + under `repo//`, and want it to become a proper commit in that + subrepo's own history. +- [Importing a change](import.md) - the other direction: bringing commits + made directly in a subrepo's own history back into the monorepo, + conflicts included. +- [Stitching a rewritten history](stitch.md) - a narrower case: the + subrepo's history was reworked after an export without changing its + content, so there's nothing to import, only `.gitrepo` to catch up. +- [Pushing your changes](push.md) - the last step: getting a change that's + landed in a subrepo's own history (or in the monorepo's) out to its real + remote. + +`central todo` - a dashboard of outstanding work across every subrepo - +comes up throughout as the constant thread tying these commands together. +More chapters will follow as `central` grows, notably recovering from less +common situations (a subrepo pushed to directly). diff --git a/doc/book/introduction-to-central-cli/SUMMARY.md b/doc/book/introduction-to-central-cli/SUMMARY.md new file mode 100644 index 0000000..1c1bf8b --- /dev/null +++ b/doc/book/introduction-to-central-cli/SUMMARY.md @@ -0,0 +1,8 @@ +# Summary + +[Introduction](README.md) + +- [Exporting a change](export.md) +- [Importing a change](import.md) +- [Stitching a rewritten history](stitch.md) +- [Pushing your changes](push.md) diff --git a/doc/book/introduction-to-central-cli/book.toml b/doc/book/introduction-to-central-cli/book.toml new file mode 100644 index 0000000..17bdcfb --- /dev/null +++ b/doc/book/introduction-to-central-cli/book.toml @@ -0,0 +1,26 @@ +[book] +title = "Introduction to central" +authors = ["Mathieu Barbin"] +description = "A user-facing tour of the central CLI: what it's for, and how to use it day to day" +language = "en" +src = "." + +[build] +build-dir = "../../static/book/introduction-to-central-cli" + +# shared-theme/ is a local copy of repo/mdexp/doc/book/shared-theme (its +# ansi-plugin.js is what turns a ```ansi fenced snapshot into colored HTML). +# Vendored rather than referenced by a relative path across subrepo +# boundaries - mdbook re-resolves theme/additional-js paths a second time +# from the *output* directory, so a "../shared-theme"-style relative path +# does not survive that. +[output.html] +default-theme = "light" +preferred-dark-theme = "navy" +git-repository-url = "https://github.com/mbarbin/central-cli" +theme = "shared-theme" +additional-js = ["shared-theme/ansi-plugin.js"] + +[output.html.fold] +enable = true +level = 0 diff --git a/doc/book/introduction-to-central-cli/export.md b/doc/book/introduction-to-central-cli/export.md new file mode 100644 index 0000000..dcfadaa --- /dev/null +++ b/doc/book/introduction-to-central-cli/export.md @@ -0,0 +1,118 @@ +# Exporting a change + +Most day-to-day edits to a subrepo happen the easy way: you just edit files +under `repo//` directly in central, like you would any other file, and +commit as usual. The one extra step is telling central to carry that change +out into the subrepo's own history: + +``` +central export -m "" +``` + +Say you've just edited `widget`'s README and committed that in central. Not +sure what to do next? `central todo` always knows: + +```ansi +$ central todo +┌──────────┬───────────┬──────┐ +│ Repo │ Next step │ Diff │ +├──────────┼───────────┼──────┤ +│ central │ push │ 2 │ +│ widget │ export │ 2 │ +└──────────┴───────────┴──────┘ +``` + +Following it means exporting: + +```ansi +$ central export widget -m "Document installation" +==================== widget ==================== +[ OK ] Applied patch in the subrepo. +[ OK ] Exported to [widget]. +``` + +`widget` now has a real new commit, with your message, on top of its +`subrepo` branch: + +```ansi +Document installation +Initial commit +``` + +Checking back in with `central todo` shows `widget`'s row is still +there, but the next step changed: `export` only advances the +`subrepo` branch, so `widget`'s own `main` is now behind it - a +genuine second step, covered in +[Pushing your changes](push.md), not a leftover of the first: + +```ansi +$ central todo +┌──────────┬──────────────┬──────┐ +│ Repo │ Next step │ Diff │ +├──────────┼──────────────┼──────┤ +│ central │ push │ 6 │ +│ widget │ advance-main │ │ +└──────────┴──────────────┴──────┘ +``` + +`export` always squashes everything you changed under `repo//` since +the last export into that one new commit - it doesn't try to replay your +central commits one by one. If you made several commits in central along +the way, only the final state matters; `-m` is the message the subrepo +commit gets. + +## If there's nothing to export + +Running `export` again right away, with nothing new under `repo//`, +is a clean error rather than an empty commit - a useful sanity check if +you're not sure whether your change already went out: + +```ansi +$ central export widget -m "Nothing changed" +==================== widget ==================== +Error: Nothing to export: no changes under "repo/widget" since the last sync. +[123] +``` + +## Exporting several subrepos at once + +Some changes are chores that touch many subrepos the same way - bumping a +shared convention, applying the same fix everywhere. `export` accepts more +than one `REPO` on the command line (or `--all` for every subrepo), and +exports them one after another, in the order given, reusing the same `-m` +message for each: + +```ansi +$ central export widget gadget sprocket -m "Add license footer" +==================== widget ==================== +[ OK ] Applied patch in the subrepo. +[ OK ] Exported to [widget]. +==================== gadget ==================== +[ OK ] Applied patch in the subrepo. +[ OK ] Exported to [gadget]. +==================== sprocket ==================== +[ OK ] Applied patch in the subrepo. +[ OK ] Exported to [sprocket]. +``` + +```ansi +$ central todo +┌────────────┬──────────────┬──────┐ +│ Repo │ Next step │ Diff │ +├────────────┼──────────────┼──────┤ +│ central │ push │ 18 │ +│ gadget │ advance-main │ │ +│ sprocket │ advance-main │ │ +│ widget │ advance-main │ │ +└────────────┴──────────────┴──────┘ +``` + +If one of them fails partway through - say a subrepo's `subrepo` branch +moved on its own since the last sync - `export` stops right there: the +repos before it keep whatever they already got exported, and the ones after +it are never even attempted. See `test/expect/export.ml` for that scenario +in detail, along with every other guardrail covered above. + +That's the everyday case covered. The next chapter, +[Importing a change](import.md), covers the other direction - bringing +commits made directly in a subrepo's own history back into central. diff --git a/doc/book/introduction-to-central-cli/export.ml b/doc/book/introduction-to-central-cli/export.ml new file mode 100644 index 0000000..0070150 --- /dev/null +++ b/doc/book/introduction-to-central-cli/export.ml @@ -0,0 +1,232 @@ +(*********************************************************************************) +(* central - Manage history between sub-repos and their monorepo *) +(* SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(* SPDX-License-Identifier: MIT *) +(*********************************************************************************) + +(* This page runs the real [central] executable (see [Central_test_harness]), + against a throwaway fake repo (see [Central_test_helpers]) - so what you + read below is, command for command, exactly what you'd type and see + yourself, just against a fake `widget` instead of a real subrepo. *) + +(* @mdexp.config { snapshot: { lang: "ansi" } } *) + +(* @mdexp + +# Exporting a change + +Most day-to-day edits to a subrepo happen the easy way: you just edit files +under `repo//` directly in central, like you would any other file, and +commit as usual. The one extra step is telling central to carry that change +out into the subrepo's own history: + +``` +central export -m "" +``` + +Say you've just edited `widget`'s README and committed that in central. Not +sure what to do next? `central todo` always knows: *) + +let widget = Central.Subrepo.v "widget" +let gadget = Central.Subrepo.v "gadget" +let sprocket = Central.Subrepo.v "sprocket" + +let central_path subrepo ~subrepo_path = + Vcs.Path_in_repo.v + (Filename.concat + (Vcs.Path_in_repo.to_string (Central.Subrepo.root subrepo)) + (Vcs.Path_in_repo.to_string subrepo_path)) +;; + +let%expect_test "export" = + let vcs = Volgo_git_unix.create () in + (* [central todo] covers every subrepo present under [repo/], so this fake + repo is built with a few of them (unlike the other scenarios on this + page, which only need [widget]) - enough to show the dashboard + correctly narrows down to only what needs attention - see + [Central_test_helpers.create]. *) + let fake_central = + Central_test_helpers.create ~vcs ~subrepos:[ widget; gadget; sprocket ] + in + let { Central_test_helpers.Fake_central.central_root; _ } = fake_central in + let fake_widget = + Central_test_helpers.Fake_central.find_exn fake_central ~subrepo:widget + in + let readme_path = central_path widget ~subrepo_path:(Vcs.Path_in_repo.v "README.md") in + Central_test_helpers.append_file + ~repo_root:central_root + ~path_in_repo:readme_path + ~text:"\nAdded a line about installation.\n"; + Vcs.add vcs ~repo_root:central_root ~path:readme_path; + let (_ : Vcs.Rev.t) = + Central_test_helpers.commit + ~vcs + ~repo_root:central_root + ~commit_message:"Document installation in widget's README" + in + let harness = Central_test_harness.create ~repo_root:central_root in + let@ central = Central_test_harness.with_cli harness ~cwd:central_root in + central [ [ "todo" ] ]; + (* @mdexp.snapshot *) + [%expect + {| + $ central todo + ┌──────────┬───────────┬──────┐ + │ Repo │ Next step │ Diff │ + ├──────────┼───────────┼──────┤ + │ central │ push │ 2 │ + │ widget │ export │ 2 │ + └──────────┴───────────┴──────┘ + |}]; + (* @mdexp Following it means exporting: *) + central [ [ "export"; "widget" ]; [ "-m"; "Document installation" ] ]; + (* @mdexp.snapshot *) + [%expect + {| + $ central export widget -m "Document installation" + ==================== widget ==================== + [ OK ] Applied patch in the subrepo. + [ OK ] Exported to [widget]. + |}]; + (* @mdexp + + `widget` now has a real new commit, with your message, on top of its + `subrepo` branch: *) + Central_test_helpers.print_log_subjects + ~vcs + ~repo_root:fake_widget.repo_root + ~ref_:"subrepo" + (); + (* @mdexp.snapshot *) + [%expect + {| + Document installation + Initial commit + |}]; + (* @mdexp + + Checking back in with `central todo` shows `widget`'s row is still + there, but the next step changed: `export` only advances the + `subrepo` branch, so `widget`'s own `main` is now behind it - a + genuine second step, covered in + [Pushing your changes](push.md), not a leftover of the first: *) + central [ [ "todo" ] ]; + (* @mdexp.snapshot *) + [%expect + {| + $ central todo + ┌──────────┬──────────────┬──────┐ + │ Repo │ Next step │ Diff │ + ├──────────┼──────────────┼──────┤ + │ central │ push │ 6 │ + │ widget │ advance-main │ │ + └──────────┴──────────────┴──────┘ + |}] +;; + +(* @mdexp + +`export` always squashes everything you changed under `repo//` since +the last export into that one new commit - it doesn't try to replay your +central commits one by one. If you made several commits in central along +the way, only the final state matters; `-m` is the message the subrepo +commit gets. + +## If there's nothing to export + +Running `export` again right away, with nothing new under `repo//`, +is a clean error rather than an empty commit - a useful sanity check if +you're not sure whether your change already went out: *) + +let%expect_test "nothing to export" = + let vcs = Volgo_git_unix.create () in + let fake_central = Central_test_helpers.create ~vcs ~subrepos:[ widget ] in + let { Central_test_helpers.Fake_central.central_root; _ } = fake_central in + let harness = Central_test_harness.create ~repo_root:central_root in + let@ central = Central_test_harness.with_cli harness ~cwd:central_root in + central [ [ "export"; "widget" ]; [ "-m"; "Nothing changed" ] ]; + (* @mdexp.snapshot *) + [%expect + {| + $ central export widget -m "Nothing changed" + ==================== widget ==================== + Error: Nothing to export: no changes under "repo/widget" since the last sync. + [123] + |}] +;; + +(* @mdexp + +## Exporting several subrepos at once + +Some changes are chores that touch many subrepos the same way - bumping a +shared convention, applying the same fix everywhere. `export` accepts more +than one `REPO` on the command line (or `--all` for every subrepo), and +exports them one after another, in the order given, reusing the same `-m` +message for each: *) + +let%expect_test "export several at once" = + let vcs = Volgo_git_unix.create () in + let fake_central = + Central_test_helpers.create ~vcs ~subrepos:[ widget; gadget; sprocket ] + in + let { Central_test_helpers.Fake_central.central_root; _ } = fake_central in + List.iter [ widget; gadget; sprocket ] ~f:(fun subrepo -> + let readme_path = + central_path subrepo ~subrepo_path:(Vcs.Path_in_repo.v "README.md") + in + Central_test_helpers.append_file + ~repo_root:central_root + ~path_in_repo:readme_path + ~text:"\nSee LICENSE for details.\n"; + Vcs.add vcs ~repo_root:central_root ~path:readme_path); + let (_ : Vcs.Rev.t) = + Central_test_helpers.commit + ~vcs + ~repo_root:central_root + ~commit_message:"Add license footer to every subrepo" + in + let harness = Central_test_harness.create ~repo_root:central_root in + let@ central = Central_test_harness.with_cli harness ~cwd:central_root in + central [ [ "export"; "widget"; "gadget"; "sprocket" ]; [ "-m"; "Add license footer" ] ]; + (* @mdexp.snapshot *) + [%expect + {| + $ central export widget gadget sprocket -m "Add license footer" + ==================== widget ==================== + [ OK ] Applied patch in the subrepo. + [ OK ] Exported to [widget]. + ==================== gadget ==================== + [ OK ] Applied patch in the subrepo. + [ OK ] Exported to [gadget]. + ==================== sprocket ==================== + [ OK ] Applied patch in the subrepo. + [ OK ] Exported to [sprocket]. + |}]; + central [ [ "todo" ] ]; + (* @mdexp.snapshot *) + [%expect + {| + $ central todo + ┌────────────┬──────────────┬──────┐ + │ Repo │ Next step │ Diff │ + ├────────────┼──────────────┼──────┤ + │ central │ push │ 18 │ + │ gadget │ advance-main │ │ + │ sprocket │ advance-main │ │ + │ widget │ advance-main │ │ + └────────────┴──────────────┴──────┘ + |}] +;; + +(* @mdexp + +If one of them fails partway through - say a subrepo's `subrepo` branch +moved on its own since the last sync - `export` stops right there: the +repos before it keep whatever they already got exported, and the ones after +it are never even attempted. See `test/expect/export.ml` for that scenario +in detail, along with every other guardrail covered above. + +That's the everyday case covered. The next chapter, +[Importing a change](import.md), covers the other direction - bringing +commits made directly in a subrepo's own history back into central. *) diff --git a/doc/book/introduction-to-central-cli/export.mli b/doc/book/introduction-to-central-cli/export.mli new file mode 100644 index 0000000..bdaa586 --- /dev/null +++ b/doc/book/introduction-to-central-cli/export.mli @@ -0,0 +1,5 @@ +(*_********************************************************************************) +(*_ central - Manage history between sub-repos and their monorepo *) +(*_ SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(*_ SPDX-License-Identifier: MIT *) +(*_********************************************************************************) diff --git a/doc/book/introduction-to-central-cli/import.md b/doc/book/introduction-to-central-cli/import.md new file mode 100644 index 0000000..db38c85 --- /dev/null +++ b/doc/book/introduction-to-central-cli/import.md @@ -0,0 +1,186 @@ +# Importing a change + +The other direction: commits made directly in a subrepo's own history - +typically because someone fetched from its real, public remote - don't show +up under `repo//` in central on their own. Bringing them in is: + +``` +central import +``` + +`-m ""` is optional here - it defaults to `"Import changes from +"`. Unlike `export`, `repo//` in central isn't public history, so +there's rarely anything worth saying beyond that. + +Unlike `export`, central may itself have moved on with changes of its own in +the meantime, so `import` has to pick between two ways of bringing the +subrepo's commits in: + +- If central hasn't touched `repo//` at all since the last sync, the + subrepo's changes are applied straight onto the current commit, as a + single new commit - no merge, because there is nothing under + `repo//` for it to possibly conflict with. This is the default, and + keeps history linear in the common case. +- Otherwise - central *does* have changes of its own under `repo//` - + `import` falls back to an ordinary two-parent `git merge`: it builds a new + commit carrying the subrepo's changes, then merges it into whatever branch + you have checked out (normally `main`). + +## The default: applying directly + +Say new commits landed on `widget`'s own `subrepo` branch (from fetching its +real remote), while central moved on with an unrelated change of its own - +elsewhere, outside `repo/widget/`. `central todo` already knows there's +something to bring in: + +```ansi +$ central todo +┌──────────┬───────────┬──────┐ +│ Repo │ Next step │ Diff │ +├──────────┼───────────┼──────┤ +│ central │ push │ 2 │ +│ widget │ import │ │ +└──────────┴───────────┴──────┘ +``` + +Following it here means importing. Since central never touched +`repo/widget/`, the change lands directly, with no merge commit: + +```ansi +$ central import widget -m "Bring in upstream usage example" +[ OK ] Imported into [main] directly (no merge needed). +``` + +A single, linear commit - no second parent to merge: + +```ansi +* Bring in upstream usage example +* Unrelated central change +* Add fake subrepo sprocket +* Add fake subrepo gadget +* Add fake subrepo widget +* Initial commit +``` + +`repo/widget/README.md` now carries the upstream change: + +```markdown +# widget + +This is a fake [widget] repo, generated by [Central_test_helpers] for tests. + +Upstream added a usage example. +``` + +And `central todo` shows both central and `widget` with the same, +ordinary next step - `push`, covered next: + +```ansi +$ central todo +┌──────────┬───────────┬──────┐ +│ Repo │ Next step │ Diff │ +├──────────┼───────────┼──────┤ +│ central │ push │ 8 │ +│ widget │ push │ │ +└──────────┴───────────┴──────┘ +``` + +## Falling back to a merge + +If central *has* touched `repo/widget/` since the last sync - even in a file +the upstream change never went near - `import` can no longer assume +`repo/widget/` is untouched, so it falls back to building a separate import +commit and merging it in, an ordinary two-parent `git merge`: + +```ansi +$ central import widget -m "Bring in upstream usage example" +[ OK ] Built the import commit. +Merge made by the 'ort' strategy. + repo/widget/.gitrepo | 4 ++-- + repo/widget/README.md | 2 ++ + 2 files changed, 4 insertions(+), 2 deletions(-) +[ OK ] Imported into [main]. +``` + +Unlike the direct case above, the import commit sits as a child of the +*old* sync point, not of central's HEAD at the time - a separate line +of history, joined by the merge: + +```ansi +* Merge widget import +|\ +| * Bring in upstream usage example +* | Add internal note +* | Add fake subrepo sprocket +* | Add fake subrepo gadget +|/ +* Add fake subrepo widget +* Initial commit +``` + +## When it doesn't merge cleanly + +Falling back to a merge means it can behave like an ordinary `git merge` in +every other way too: if your own central changes happen to touch the exact +same lines the upstream commits did, `import` leaves you in the middle of a +real conflict, markers included: + +```ansi +$ central import widget -m "Bring in upstream retitle" +[ OK ] Built the import commit. +Auto-merging repo/widget/README.md +CONFLICT (content): Merge conflict in repo/widget/README.md +Automatic merge failed; fix conflicts and then commit the result. +Error: Merge conflict while importing - resolve the conflicts above in +[main], then [git add] the resolved files and [git commit] to finish the +merge. +Hint: .gitrepo has already been updated as part of the import commit being +merged - no further action needed there once the merge is complete. +[123] +``` + +Resolve it exactly like you would any git merge conflict - edit the +file, then `git add` and `git commit`. `.gitrepo` is already updated at +this point, so there's nothing else to do for the subrepo side of it: + +```text +<<<<<<< HEAD +# widget, edited by central +======= +# widget, retitled upstream +>>>>>>> 1185512b92d612b25613f2e5b473e5231185512b +``` + +With the merge committed, `export` is available again right away - it +carries your resolution out to `widget`, since that's what its own +history now disagrees with: + +```ansi +$ central export widget -m "Resolve conflicting retitle" +==================== widget ==================== +[ OK ] Applied patch in the subrepo. +[ OK ] Exported to [widget]. +``` + +```ansi +Resolve conflicting retitle +Upstream retitles the README +Initial commit +``` + +`central todo` shows the same pattern as after any `export`: `widget`'s +own `main` is left one `advance-main` behind its `subrepo` branch - +nothing to do with the conflict just resolved: + +```ansi +$ central todo +┌──────────┬──────────────┬──────┐ +│ Repo │ Next step │ Diff │ +├──────────┼──────────────┼──────┤ +│ central │ push │ 8 │ +│ widget │ advance-main │ │ +└──────────┴──────────────┴──────┘ +``` + +Either way, once a change has landed in a subrepo's own history, the last +step is [pushing it out for real](push.md). diff --git a/doc/book/introduction-to-central-cli/import.ml b/doc/book/introduction-to-central-cli/import.ml new file mode 100644 index 0000000..81a5a32 --- /dev/null +++ b/doc/book/introduction-to-central-cli/import.ml @@ -0,0 +1,419 @@ +(*********************************************************************************) +(* central - Manage history between sub-repos and their monorepo *) +(* SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(* SPDX-License-Identifier: MIT *) +(*********************************************************************************) + +(* Like [export.ml], this page runs the real [central] executable (see + [Central_test_harness]), against a throwaway fake repo (see + [Central_test_helpers]). Fake repos here are built with a few subrepos, + not just [widget] - [central todo] covers every subrepo present under + [repo/], and this shows it correctly narrows down to only what needs + attention among several. *) + +(* @mdexp.config { snapshot: { lang: "ansi" } } *) + +(* @mdexp + +# Importing a change + +The other direction: commits made directly in a subrepo's own history - +typically because someone fetched from its real, public remote - don't show +up under `repo//` in central on their own. Bringing them in is: + +``` +central import +``` + +`-m ""` is optional here - it defaults to `"Import changes from +"`. Unlike `export`, `repo//` in central isn't public history, so +there's rarely anything worth saying beyond that. + +Unlike `export`, central may itself have moved on with changes of its own in +the meantime, so `import` has to pick between two ways of bringing the +subrepo's commits in: + +- If central hasn't touched `repo//` at all since the last sync, the + subrepo's changes are applied straight onto the current commit, as a + single new commit - no merge, because there is nothing under + `repo//` for it to possibly conflict with. This is the default, and + keeps history linear in the common case. +- Otherwise - central *does* have changes of its own under `repo//` - + `import` falls back to an ordinary two-parent `git merge`: it builds a new + commit carrying the subrepo's changes, then merges it into whatever branch + you have checked out (normally `main`). + +## The default: applying directly + +Say new commits landed on `widget`'s own `subrepo` branch (from fetching its +real remote), while central moved on with an unrelated change of its own - +elsewhere, outside `repo/widget/`. `central todo` already knows there's +something to bring in: *) + +let widget = Central.Subrepo.v "widget" +let gadget = Central.Subrepo.v "gadget" +let sprocket = Central.Subrepo.v "sprocket" + +let central_path subrepo ~subrepo_path = + Vcs.Path_in_repo.v + (Filename.concat + (Vcs.Path_in_repo.to_string (Central.Subrepo.root subrepo)) + (Vcs.Path_in_repo.to_string subrepo_path)) +;; + +let%expect_test "import" = + let vcs = Volgo_git_unix.create () in + let fake_central = + Central_test_helpers.create ~vcs ~subrepos:[ widget; gadget; sprocket ] + in + let { Central_test_helpers.Fake_central.central_root; _ } = fake_central in + let fake_widget = + Central_test_helpers.Fake_central.find_exn fake_central ~subrepo:widget + in + let readme = Vcs.Path_in_repo.v "README.md" in + Vcs.git + vcs + ~repo_root:fake_widget.repo_root + ~args:[ "checkout"; "subrepo" ] + ~f:Vcs.Git.exit0; + Central_test_helpers.append_file + ~repo_root:fake_widget.repo_root + ~path_in_repo:readme + ~text:"\nUpstream added a usage example.\n"; + Vcs.add vcs ~repo_root:fake_widget.repo_root ~path:readme; + let (_ : Vcs.Rev.t) = + Central_test_helpers.commit + ~vcs + ~repo_root:fake_widget.repo_root + ~commit_message:"Upstream: add usage example" + in + (* In real usage, fetching from the real remote and catching [main] up + with [advance-subrepo] tend to happen close together - keeping [main] + caught up here too, so the dashboard below is about importing, not + about a second, unrelated [main] lagging behind. *) + Vcs.git + vcs + ~repo_root:fake_widget.repo_root + ~args:[ "branch"; "-f"; "main"; "subrepo" ] + ~f:Vcs.Git.exit0; + (* Central's own top-level [README.md] - outside [repo/widget/] entirely, + so it has no bearing on whether [repo/widget/] itself has moved. *) + Central_test_helpers.append_file + ~repo_root:central_root + ~path_in_repo:readme + ~text:"\nUnrelated central change.\n"; + Vcs.add vcs ~repo_root:central_root ~path:readme; + let (_ : Vcs.Rev.t) = + Central_test_helpers.commit + ~vcs + ~repo_root:central_root + ~commit_message:"Unrelated central change" + in + let harness = Central_test_harness.create ~repo_root:central_root in + let@ central = Central_test_harness.with_cli harness ~cwd:central_root in + central [ [ "todo" ] ]; + (* @mdexp.snapshot *) + [%expect + {| + $ central todo + ┌──────────┬───────────┬──────┐ + │ Repo │ Next step │ Diff │ + ├──────────┼───────────┼──────┤ + │ central │ push │ 2 │ + │ widget │ import │ │ + └──────────┴───────────┴──────┘ + |}]; + (* @mdexp + + Following it here means importing. Since central never touched + `repo/widget/`, the change lands directly, with no merge commit: *) + central [ [ "import"; "widget" ]; [ "-m"; "Bring in upstream usage example" ] ]; + (* @mdexp.snapshot *) + [%expect + {| + $ central import widget -m "Bring in upstream usage example" + [ OK ] Imported into [main] directly (no merge needed). + |}]; + (* @mdexp A single, linear commit - no second parent to merge: *) + Central_test_helpers.print_graph ~vcs ~repo_root:central_root ~refs:[ "main" ]; + (* @mdexp.snapshot *) + [%expect + {| + * Bring in upstream usage example + * Unrelated central change + * Add fake subrepo sprocket + * Add fake subrepo gadget + * Add fake subrepo widget + * Initial commit + |}]; + (* @mdexp `repo/widget/README.md` now carries the upstream change: *) + Central_test_helpers.print_file + ~repo_root:central_root + ~path_in_repo:(central_path widget ~subrepo_path:readme); + (* @mdexp.snapshot { lang: "markdown" } *) + [%expect + {| + # widget + + This is a fake [widget] repo, generated by [Central_test_helpers] for tests. + + Upstream added a usage example. + |}]; + (* @mdexp + + And `central todo` shows both central and `widget` with the same, + ordinary next step - `push`, covered next: *) + central [ [ "todo" ] ]; + (* @mdexp.snapshot *) + [%expect + {| + $ central todo + ┌──────────┬───────────┬──────┐ + │ Repo │ Next step │ Diff │ + ├──────────┼───────────┼──────┤ + │ central │ push │ 8 │ + │ widget │ push │ │ + └──────────┴───────────┴──────┘ + |}] +;; + +(* @mdexp + +## Falling back to a merge + +If central *has* touched `repo/widget/` since the last sync - even in a file +the upstream change never went near - `import` can no longer assume +`repo/widget/` is untouched, so it falls back to building a separate import +commit and merging it in, an ordinary two-parent `git merge`: *) + +let%expect_test "merge" = + let vcs = Volgo_git_unix.create () in + let fake_central = + Central_test_helpers.create ~vcs ~subrepos:[ widget; gadget; sprocket ] + in + let { Central_test_helpers.Fake_central.central_root; _ } = fake_central in + let fake_widget = + Central_test_helpers.Fake_central.find_exn fake_central ~subrepo:widget + in + let readme = Vcs.Path_in_repo.v "README.md" in + Vcs.git + vcs + ~repo_root:fake_widget.repo_root + ~args:[ "checkout"; "subrepo" ] + ~f:Vcs.Git.exit0; + Central_test_helpers.append_file + ~repo_root:fake_widget.repo_root + ~path_in_repo:readme + ~text:"\nUpstream added a usage example.\n"; + Vcs.add vcs ~repo_root:fake_widget.repo_root ~path:readme; + let (_ : Vcs.Rev.t) = + Central_test_helpers.commit + ~vcs + ~repo_root:fake_widget.repo_root + ~commit_message:"Upstream: add usage example" + in + (* Central adds a file of its own directly under [repo/widget/] - a + different file from the one upstream just touched, so the two are free + to land side by side without conflicting, but this is enough for + `repo/widget/` to no longer be untouched. *) + let notes = central_path widget ~subrepo_path:(Vcs.Path_in_repo.v "NOTES.md") in + Central_test_helpers.write_file + ~repo_root:central_root + ~path_in_repo:notes + ~contents:"Internal note, never meant for upstream.\n"; + Vcs.add vcs ~repo_root:central_root ~path:notes; + let (_ : Vcs.Rev.t) = + Central_test_helpers.commit + ~vcs + ~repo_root:central_root + ~commit_message:"Add internal note" + in + let harness = Central_test_harness.create ~repo_root:central_root in + let@ central = Central_test_harness.with_cli harness ~cwd:central_root in + central [ [ "import"; "widget" ]; [ "-m"; "Bring in upstream usage example" ] ]; + (* @mdexp.snapshot *) + [%expect + {| + $ central import widget -m "Bring in upstream usage example" + [ OK ] Built the import commit. + Merge made by the 'ort' strategy. + repo/widget/.gitrepo | 4 ++-- + repo/widget/README.md | 2 ++ + 2 files changed, 4 insertions(+), 2 deletions(-) + [ OK ] Imported into [main]. + |}]; + (* @mdexp + + Unlike the direct case above, the import commit sits as a child of the + *old* sync point, not of central's HEAD at the time - a separate line + of history, joined by the merge: *) + Central_test_helpers.print_graph ~vcs ~repo_root:central_root ~refs:[ "main" ]; + (* @mdexp.snapshot *) + [%expect + {| + * Merge widget import + |\ + | * Bring in upstream usage example + * | Add internal note + * | Add fake subrepo sprocket + * | Add fake subrepo gadget + |/ + * Add fake subrepo widget + * Initial commit + |}] +;; + +(* @mdexp + +## When it doesn't merge cleanly + +Falling back to a merge means it can behave like an ordinary `git merge` in +every other way too: if your own central changes happen to touch the exact +same lines the upstream commits did, `import` leaves you in the middle of a +real conflict, markers included: *) + +let%expect_test "conflict" = + let vcs = Volgo_git_unix.create () in + let fake_central = + Central_test_helpers.create ~vcs ~subrepos:[ widget; gadget; sprocket ] + in + let { Central_test_helpers.Fake_central.central_root; _ } = fake_central in + let fake_widget = + Central_test_helpers.Fake_central.find_exn fake_central ~subrepo:widget + in + let readme = Vcs.Path_in_repo.v "README.md" in + let central_readme = central_path widget ~subrepo_path:readme in + Central_test_helpers.write_file + ~repo_root:central_root + ~path_in_repo:central_readme + ~contents:"# widget, edited by central\n"; + Vcs.add vcs ~repo_root:central_root ~path:central_readme; + let (_ : Vcs.Rev.t) = + Central_test_helpers.commit + ~vcs + ~repo_root:central_root + ~commit_message:"Central retitles the README" + in + Vcs.git + vcs + ~repo_root:fake_widget.repo_root + ~args:[ "checkout"; "subrepo" ] + ~f:Vcs.Git.exit0; + Central_test_helpers.write_file + ~repo_root:fake_widget.repo_root + ~path_in_repo:readme + ~contents:"# widget, retitled upstream\n"; + Vcs.add vcs ~repo_root:fake_widget.repo_root ~path:readme; + let (_ : Vcs.Rev.t) = + Central_test_helpers.commit + ~vcs + ~repo_root:fake_widget.repo_root + ~commit_message:"Upstream retitles the README" + in + let harness = Central_test_harness.create ~repo_root:central_root in + let@ central = Central_test_harness.with_cli harness ~cwd:central_root in + central [ [ "import"; "widget" ]; [ "-m"; "Bring in upstream retitle" ] ]; + (* @mdexp.snapshot *) + [%expect + {| + $ central import widget -m "Bring in upstream retitle" + [ OK ] Built the import commit. + Auto-merging repo/widget/README.md + CONFLICT (content): Merge conflict in repo/widget/README.md + Automatic merge failed; fix conflicts and then commit the result. + Error: Merge conflict while importing - resolve the conflicts above in + [main], then [git add] the resolved files and [git commit] to finish the + merge. + Hint: .gitrepo has already been updated as part of the import commit being + merged - no further action needed there once the merge is complete. + [123] + |}]; + (* @mdexp + + Resolve it exactly like you would any git merge conflict - edit the + file, then `git add` and `git commit`. `.gitrepo` is already updated at + this point, so there's nothing else to do for the subrepo side of it: *) + let merge_head = + Vcs.git + vcs + ~repo_root:central_root + ~args:[ "rev-parse"; "MERGE_HEAD" ] + ~f:(fun output -> Vcs.Git.exit0_and_stdout output |> String.strip |> Vcs.Rev.v) + in + Central_test_harness.register_rev harness ~rev:merge_head; + print_string + (Central_test_harness.redact + harness + (String.trim + (Central_test_helpers.read_file + ~repo_root:central_root + ~path_in_repo:central_readme))); + (* @mdexp.snapshot { lang: "text" } *) + [%expect + {| + <<<<<<< HEAD + # widget, edited by central + ======= + # widget, retitled upstream + >>>>>>> 1185512b92d612b25613f2e5b473e5231185512b + |}]; + Central_test_helpers.write_file + ~repo_root:central_root + ~path_in_repo:central_readme + ~contents:"# widget, retitled (resolved)\n"; + Vcs.add vcs ~repo_root:central_root ~path:central_readme; + let (_ : Vcs.Rev.t) = + Central_test_helpers.commit + ~vcs + ~repo_root:central_root + ~commit_message:"Resolve README retitle conflict" + in + (* @mdexp + + With the merge committed, `export` is available again right away - it + carries your resolution out to `widget`, since that's what its own + history now disagrees with: *) + central [ [ "export"; "widget" ]; [ "-m"; "Resolve conflicting retitle" ] ]; + (* @mdexp.snapshot *) + [%expect + {| + $ central export widget -m "Resolve conflicting retitle" + ==================== widget ==================== + [ OK ] Applied patch in the subrepo. + [ OK ] Exported to [widget]. + |}]; + Central_test_helpers.print_log_subjects + ~vcs + ~repo_root:fake_widget.repo_root + ~ref_:"subrepo" + (); + (* @mdexp.snapshot *) + [%expect + {| + Resolve conflicting retitle + Upstream retitles the README + Initial commit + |}]; + (* @mdexp + + `central todo` shows the same pattern as after any `export`: `widget`'s + own `main` is left one `advance-main` behind its `subrepo` branch - + nothing to do with the conflict just resolved: *) + central [ [ "todo" ] ]; + (* @mdexp.snapshot *) + [%expect + {| + $ central todo + ┌──────────┬──────────────┬──────┐ + │ Repo │ Next step │ Diff │ + ├──────────┼──────────────┼──────┤ + │ central │ push │ 8 │ + │ widget │ advance-main │ │ + └──────────┴──────────────┴──────┘ + |}] +;; + +(* @mdexp + +Either way, once a change has landed in a subrepo's own history, the last +step is [pushing it out for real](push.md). *) diff --git a/doc/book/introduction-to-central-cli/import.mli b/doc/book/introduction-to-central-cli/import.mli new file mode 100644 index 0000000..bdaa586 --- /dev/null +++ b/doc/book/introduction-to-central-cli/import.mli @@ -0,0 +1,5 @@ +(*_********************************************************************************) +(*_ central - Manage history between sub-repos and their monorepo *) +(*_ SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(*_ SPDX-License-Identifier: MIT *) +(*_********************************************************************************) diff --git a/doc/book/introduction-to-central-cli/push.md b/doc/book/introduction-to-central-cli/push.md new file mode 100644 index 0000000..348d4a6 --- /dev/null +++ b/doc/book/introduction-to-central-cli/push.md @@ -0,0 +1,98 @@ +# Pushing your changes + +`export` (and `advance-main`, when needed) bring a change all the way to a +subrepo's own `main` branch - but only in your local checkout. The last +step is getting it out to the subrepo's real remote: + +``` +central push +``` + +`central` itself needs the same treatment: any local commit not yet on its +own remote (including, as you're about to see, the one `export` itself just +made to update `.gitrepo`). By default `push` opens `gitk` to show you what +you're about to push and asks for confirmation; pass `--yes` to skip both +and push right away. + +## Finishing the loop + +Picking up where [Exporting a change](export.md) left off: `widget`'s +README was edited in central and committed there. `central todo` is the +constant thread through all of this - it's what tells you export is next: + +```ansi +$ central todo +┌──────────┬───────────┬──────┐ +│ Repo │ Next step │ Diff │ +├──────────┼───────────┼──────┤ +│ central │ push │ 2 │ +│ widget │ export │ 2 │ +└──────────┴───────────┴──────┘ +``` + +Following it means exporting: + +```ansi +$ central export widget -m "Document installation" +==================== widget ==================== +[ OK ] Applied patch in the subrepo. +[ OK ] Exported to [widget]. +``` + +Checking back in, `widget`'s next step changed - `export` only moved +its `subrepo` branch, so `main` is now behind it: + +```ansi +$ central todo +┌──────────┬──────────────┬──────┐ +│ Repo │ Next step │ Diff │ +├──────────┼──────────────┼──────┤ +│ central │ push │ 6 │ +│ widget │ advance-main │ │ +└──────────┴──────────────┴──────┘ +``` + +`advance-main` catches it up: + +```ansi +$ central advance-main widget +==================== widget ==================== +Updating 1185512..f452a6f +Fast-forward + README.md | 2 ++ + 1 file changed, 2 insertions(+) +``` + +Now both central and `widget` have local commits their remotes don't +have yet - the dashboard agrees, with the same next step for both: + +```ansi +$ central todo +┌──────────┬───────────┬──────┐ +│ Repo │ Next step │ Diff │ +├──────────┼───────────┼──────┤ +│ central │ push │ 6 │ +│ widget │ push │ │ +└──────────┴───────────┴──────┘ +``` + +`push` sends them all in one go: + +```ansi +$ central push central widget --yes +==================== central ==================== +[ OK ] Pushed. +==================== widget ==================== +[ OK ] Pushed. +``` + +And the dashboard is clear - back where it started, the change now +genuinely out, all the way to both real remotes: + +```ansi +$ central todo +``` + +That's the full loop, start to finish: edit under `repo//`, `export` +it, `advance-main` if `main` is left behind, `push` - and `central todo` +tells you what's next at every step along the way. diff --git a/doc/book/introduction-to-central-cli/push.ml b/doc/book/introduction-to-central-cli/push.ml new file mode 100644 index 0000000..e09b853 --- /dev/null +++ b/doc/book/introduction-to-central-cli/push.ml @@ -0,0 +1,173 @@ +(*********************************************************************************) +(* central - Manage history between sub-repos and their monorepo *) +(* SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(* SPDX-License-Identifier: MIT *) +(*********************************************************************************) + +(* Like [export.ml] and [import.ml], this page runs the real [central] + executable against a throwaway fake repo - including a real, separate + bare remote (never a real, production one) for central and for `widget`, + so the pushes below are genuine. Built with a few subrepos, not just + `widget`, so [central todo]'s dashboard has more than one row to narrow + down. *) + +(* @mdexp.config { snapshot: { lang: "ansi" } } *) + +(* @mdexp + +# Pushing your changes + +`export` (and `advance-main`, when needed) bring a change all the way to a +subrepo's own `main` branch - but only in your local checkout. The last +step is getting it out to the subrepo's real remote: + +``` +central push +``` + +`central` itself needs the same treatment: any local commit not yet on its +own remote (including, as you're about to see, the one `export` itself just +made to update `.gitrepo`). By default `push` opens `gitk` to show you what +you're about to push and asks for confirmation; pass `--yes` to skip both +and push right away. + +## Finishing the loop + +Picking up where [Exporting a change](export.md) left off: `widget`'s +README was edited in central and committed there. `central todo` is the +constant thread through all of this - it's what tells you export is next: *) + +let widget = Central.Subrepo.v "widget" +let gadget = Central.Subrepo.v "gadget" +let sprocket = Central.Subrepo.v "sprocket" + +let central_path subrepo ~subrepo_path = + Vcs.Path_in_repo.v + (Filename.concat + (Vcs.Path_in_repo.to_string (Central.Subrepo.root subrepo)) + (Vcs.Path_in_repo.to_string subrepo_path)) +;; + +let%expect_test "push" = + let vcs = Volgo_git_unix.create () in + let fake_central = + Central_test_helpers.create ~vcs ~subrepos:[ widget; gadget; sprocket ] + in + let { Central_test_helpers.Fake_central.central_root; _ } = fake_central in + let fake_widget = + Central_test_helpers.Fake_central.find_exn fake_central ~subrepo:widget + in + let readme_path = central_path widget ~subrepo_path:(Vcs.Path_in_repo.v "README.md") in + Central_test_helpers.append_file + ~repo_root:central_root + ~path_in_repo:readme_path + ~text:"\nAdded a line about installation.\n"; + Vcs.add vcs ~repo_root:central_root ~path:readme_path; + let (_ : Vcs.Rev.t) = + Central_test_helpers.commit + ~vcs + ~repo_root:central_root + ~commit_message:"Document installation in widget's README" + in + let harness = Central_test_harness.create ~repo_root:central_root in + let@ central = Central_test_harness.with_cli harness ~cwd:central_root in + central [ [ "todo" ] ]; + (* @mdexp.snapshot *) + [%expect + {| + $ central todo + ┌──────────┬───────────┬──────┐ + │ Repo │ Next step │ Diff │ + ├──────────┼───────────┼──────┤ + │ central │ push │ 2 │ + │ widget │ export │ 2 │ + └──────────┴───────────┴──────┘ + |}]; + (* @mdexp Following it means exporting: *) + central [ [ "export"; "widget" ]; [ "-m"; "Document installation" ] ]; + (* @mdexp.snapshot *) + [%expect + {| + $ central export widget -m "Document installation" + ==================== widget ==================== + [ OK ] Applied patch in the subrepo. + [ OK ] Exported to [widget]. + |}]; + (* @mdexp + + Checking back in, `widget`'s next step changed - `export` only moved + its `subrepo` branch, so `main` is now behind it: *) + central [ [ "todo" ] ]; + (* @mdexp.snapshot *) + [%expect + {| + $ central todo + ┌──────────┬──────────────┬──────┐ + │ Repo │ Next step │ Diff │ + ├──────────┼──────────────┼──────┤ + │ central │ push │ 6 │ + │ widget │ advance-main │ │ + └──────────┴──────────────┴──────┘ + |}]; + (* @mdexp `advance-main` catches it up: *) + let rev_of ~ref_ = + Vcs.git + vcs + ~repo_root:fake_widget.repo_root + ~args:[ "rev-parse"; ref_ ] + ~f:(fun output -> Vcs.Git.exit0_and_stdout output |> String.strip |> Vcs.Rev.v) + in + Central_test_harness.register_rev harness ~rev:(rev_of ~ref_:"main"); + Central_test_harness.register_rev harness ~rev:(rev_of ~ref_:"subrepo"); + central [ [ "advance-main"; "widget" ] ]; + (* @mdexp.snapshot *) + [%expect + {| + $ central advance-main widget + ==================== widget ==================== + Updating 1185512..f452a6f + Fast-forward + README.md | 2 ++ + 1 file changed, 2 insertions(+) + |}]; + (* @mdexp + + Now both central and `widget` have local commits their remotes don't + have yet - the dashboard agrees, with the same next step for both: *) + central [ [ "todo" ] ]; + (* @mdexp.snapshot *) + [%expect + {| + $ central todo + ┌──────────┬───────────┬──────┐ + │ Repo │ Next step │ Diff │ + ├──────────┼───────────┼──────┤ + │ central │ push │ 6 │ + │ widget │ push │ │ + └──────────┴───────────┴──────┘ + |}]; + (* @mdexp `push` sends them all in one go: *) + central [ [ "push"; "central"; "widget" ]; [ "--yes" ] ]; + (* @mdexp.snapshot *) + [%expect + {| + $ central push central widget --yes + ==================== central ==================== + [ OK ] Pushed. + ==================== widget ==================== + [ OK ] Pushed. + |}]; + (* @mdexp + + And the dashboard is clear - back where it started, the change now + genuinely out, all the way to both real remotes: *) + central [ [ "todo" ] ]; + (* @mdexp.snapshot *) + [%expect {| $ central todo |}] +;; + +(* @mdexp + +That's the full loop, start to finish: edit under `repo//`, `export` +it, `advance-main` if `main` is left behind, `push` - and `central todo` +tells you what's next at every step along the way. *) diff --git a/doc/book/introduction-to-central-cli/push.mli b/doc/book/introduction-to-central-cli/push.mli new file mode 100644 index 0000000..bdaa586 --- /dev/null +++ b/doc/book/introduction-to-central-cli/push.mli @@ -0,0 +1,5 @@ +(*_********************************************************************************) +(*_ central - Manage history between sub-repos and their monorepo *) +(*_ SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(*_ SPDX-License-Identifier: MIT *) +(*_********************************************************************************) diff --git a/doc/book/introduction-to-central-cli/shared-theme/ansi-plugin.js b/doc/book/introduction-to-central-cli/shared-theme/ansi-plugin.js new file mode 100644 index 0000000..f7362bc --- /dev/null +++ b/doc/book/introduction-to-central-cli/shared-theme/ansi-plugin.js @@ -0,0 +1,123 @@ +// ANSI to HTML converter for terminal code blocks +// Converts ANSI escape sequences to colored HTML spans +(function () { + "use strict"; + + // Map ANSI codes to CSS classes/styles + var ansiStyles = { + // Reset + "0": null, + // Bold + "1": "font-weight:bold", + // Italic + "3": "font-style:italic", + // Underline + "4": "text-decoration:underline", + // Standard colors (foreground) + "30": "color:#073642", // black + "31": "color:#dc322f", // red + "32": "color:#859900", // green + "33": "color:#b58900", // yellow + "34": "color:#268bd2", // blue + "35": "color:#d33682", // magenta + "36": "color:#2aa198", // cyan + "37": "color:#eee8d5", // white + // Bright colors (foreground) + "90": "color:#586e75", // bright black (gray) + "91": "color:#cb4b16", // bright red + "92": "color:#586e75", // bright green + "93": "color:#657b83", // bright yellow + "94": "color:#839496", // bright blue + "95": "color:#6c71c4", // bright magenta + "96": "color:#93a1a1", // bright cyan + "97": "color:#fdf6e3", // bright white + }; + + function ansiToHtml(text) { + var result = ""; + var currentStyles = []; + var i = 0; + + while (i < text.length) { + // Check for ESC character (0x1b) + if (text.charCodeAt(i) === 0x1b && text[i + 1] === "[") { + // Find the end of the escape sequence (the 'm') + var j = i + 2; + while (j < text.length && text[j] !== "m") { + j++; + } + if (j < text.length) { + // Extract the codes (e.g., "1;31" from ESC[1;31m) + var codes = text.substring(i + 2, j).split(";"); + + // Close any open spans for reset + if (codes.indexOf("0") !== -1 || codes.length === 0) { + for (var k = 0; k < currentStyles.length; k++) { + result += ""; + } + currentStyles = []; + } + + // Apply new styles + var newStyles = []; + for (var c = 0; c < codes.length; c++) { + var code = codes[c]; + if (code === "0") continue; // reset handled above + + // Handle combined codes like "1;31" (bold red) + var style = ansiStyles[code]; + if (style) { + newStyles.push(style); + } + } + + if (newStyles.length > 0) { + result += ''; + currentStyles.push(newStyles.length); + } + + i = j + 1; // Skip past the 'm' + continue; + } + } + + // Escape HTML special characters + var char = text[i]; + if (char === "<") { + result += "<"; + } else if (char === ">") { + result += ">"; + } else if (char === "&") { + result += "&"; + } else { + result += char; + } + i++; + } + + // Close any remaining open spans + for (var s = 0; s < currentStyles.length; s++) { + result += ""; + } + + return result; + } + + // Process all terminal code blocks + function processTerminalBlocks() { + document + .querySelectorAll("code.language-terminal, code.language-ansi") + .forEach(function (block) { + var text = block.textContent; + block.innerHTML = ansiToHtml(text); + block.classList.add("hljs"); // Add hljs class for consistent styling + }); + } + + // Run when DOM is ready + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", processTerminalBlocks); + } else { + processTerminalBlocks(); + } +})(); diff --git a/doc/book/introduction-to-central-cli/shared-theme/highlight.js b/doc/book/introduction-to-central-cli/shared-theme/highlight.js new file mode 100644 index 0000000..6b6bf4a --- /dev/null +++ b/doc/book/introduction-to-central-cli/shared-theme/highlight.js @@ -0,0 +1,1226 @@ +/*! + Highlight.js v11.9.0 (git: f47103d4f1) + (c) 2006-2023 undefined and other contributors + License: BSD-3-Clause + */ +var hljs=function(){"use strict";function e(n){ +return n instanceof Map?n.clear=n.delete=n.set=()=>{ +throw Error("map is read-only")}:n instanceof Set&&(n.add=n.clear=n.delete=()=>{ +throw Error("set is read-only") +}),Object.freeze(n),Object.getOwnPropertyNames(n).forEach((t=>{ +const a=n[t],i=typeof a;"object"!==i&&"function"!==i||Object.isFrozen(a)||e(a) +})),n}class n{constructor(e){ +void 0===e.data&&(e.data={}),this.data=e.data,this.isMatchIgnored=!1} +ignoreMatch(){this.isMatchIgnored=!0}}function t(e){ +return e.replace(/&/g,"&").replace(//g,">").replace(/"/g,""").replace(/'/g,"'") +}function a(e,...n){const t=Object.create(null);for(const n in e)t[n]=e[n] +;return n.forEach((e=>{for(const n in e)t[n]=e[n]})),t}const i=e=>!!e.scope +;class r{constructor(e,n){ +this.buffer="",this.classPrefix=n.classPrefix,e.walk(this)}addText(e){ +this.buffer+=t(e)}openNode(e){if(!i(e))return;const n=((e,{prefix:n})=>{ +if(e.startsWith("language:"))return e.replace("language:","language-") +;if(e.includes(".")){const t=e.split(".") +;return[`${n}${t.shift()}`,...t.map(((e,n)=>`${e}${"_".repeat(n+1)}`))].join(" ") +}return`${n}${e}`})(e.scope,{prefix:this.classPrefix});this.span(n)} +closeNode(e){i(e)&&(this.buffer+="")}value(){return this.buffer}span(e){ +this.buffer+=``}}const s=(e={})=>{const n={children:[]} +;return Object.assign(n,e),n};class o{constructor(){ +this.rootNode=s(),this.stack=[this.rootNode]}get top(){ +return this.stack[this.stack.length-1]}get root(){return this.rootNode}add(e){ +this.top.children.push(e)}openNode(e){const n=s({scope:e}) +;this.add(n),this.stack.push(n)}closeNode(){ +if(this.stack.length>1)return this.stack.pop()}closeAllNodes(){ +for(;this.closeNode(););}toJSON(){return JSON.stringify(this.rootNode,null,4)} +walk(e){return this.constructor._walk(e,this.rootNode)}static _walk(e,n){ +return"string"==typeof n?e.addText(n):n.children&&(e.openNode(n), +n.children.forEach((n=>this._walk(e,n))),e.closeNode(n)),e}static _collapse(e){ +"string"!=typeof e&&e.children&&(e.children.every((e=>"string"==typeof e))?e.children=[e.children.join("")]:e.children.forEach((e=>{ +o._collapse(e)})))}}class l extends o{constructor(e){super(),this.options=e} +addText(e){""!==e&&this.add(e)}startScope(e){this.openNode(e)}endScope(){ +this.closeNode()}__addSublanguage(e,n){const t=e.root +;n&&(t.scope="language:"+n),this.add(t)}toHTML(){ +return new r(this,this.options).value()}finalize(){ +return this.closeAllNodes(),!0}}function c(e){ +return e?"string"==typeof e?e:e.source:null}function d(e){return b("(?=",e,")")} +function g(e){return b("(?:",e,")*")}function u(e){return b("(?:",e,")?")} +function b(...e){return e.map((e=>c(e))).join("")}function m(...e){const n=(e=>{ +const n=e[e.length-1] +;return"object"==typeof n&&n.constructor===Object?(e.splice(e.length-1,1),n):{} +})(e);return"("+(n.capture?"":"?:")+e.map((e=>c(e))).join("|")+")"} +function p(e){return RegExp(e.toString()+"|").exec("").length-1} +const _=/\[(?:[^\\\]]|\\.)*\]|\(\??|\\([1-9][0-9]*)|\\./ +;function h(e,{joinWith:n}){let t=0;return e.map((e=>{t+=1;const n=t +;let a=c(e),i="";for(;a.length>0;){const e=_.exec(a);if(!e){i+=a;break} +i+=a.substring(0,e.index), +a=a.substring(e.index+e[0].length),"\\"===e[0][0]&&e[1]?i+="\\"+(Number(e[1])+n):(i+=e[0], +"("===e[0]&&t++)}return i})).map((e=>`(${e})`)).join(n)} +const f="[a-zA-Z]\\w*",E="[a-zA-Z_]\\w*",y="\\b\\d+(\\.\\d+)?",N="(-?)(\\b0[xX][a-fA-F0-9]+|(\\b\\d+(\\.\\d*)?|\\.\\d+)([eE][-+]?\\d+)?)",w="\\b(0b[01]+)",v={ +begin:"\\\\[\\s\\S]",relevance:0},O={scope:"string",begin:"'",end:"'", +illegal:"\\n",contains:[v]},k={scope:"string",begin:'"',end:'"',illegal:"\\n", +contains:[v]},x=(e,n,t={})=>{const i=a({scope:"comment",begin:e,end:n, +contains:[]},t);i.contains.push({scope:"doctag", +begin:"[ ]*(?=(TODO|FIXME|NOTE|BUG|OPTIMIZE|HACK|XXX):)", +end:/(TODO|FIXME|NOTE|BUG|OPTIMIZE|HACK|XXX):/,excludeBegin:!0,relevance:0}) +;const r=m("I","a","is","so","us","to","at","if","in","it","on",/[A-Za-z]+['](d|ve|re|ll|t|s|n)/,/[A-Za-z]+[-][a-z]+/,/[A-Za-z][a-z]{2,}/) +;return i.contains.push({begin:b(/[ ]+/,"(",r,/[.]?[:]?([.][ ]|[ ])/,"){3}")}),i +},M=x("//","$"),S=x("/\\*","\\*/"),A=x("#","$");var C=Object.freeze({ +__proto__:null,APOS_STRING_MODE:O,BACKSLASH_ESCAPE:v,BINARY_NUMBER_MODE:{ +scope:"number",begin:w,relevance:0},BINARY_NUMBER_RE:w,COMMENT:x, +C_BLOCK_COMMENT_MODE:S,C_LINE_COMMENT_MODE:M,C_NUMBER_MODE:{scope:"number", +begin:N,relevance:0},C_NUMBER_RE:N,END_SAME_AS_BEGIN:e=>Object.assign(e,{ +"on:begin":(e,n)=>{n.data._beginMatch=e[1]},"on:end":(e,n)=>{ +n.data._beginMatch!==e[1]&&n.ignoreMatch()}}),HASH_COMMENT_MODE:A,IDENT_RE:f, +MATCH_NOTHING_RE:/\b\B/,METHOD_GUARD:{begin:"\\.\\s*"+E,relevance:0}, +NUMBER_MODE:{scope:"number",begin:y,relevance:0},NUMBER_RE:y, +PHRASAL_WORDS_MODE:{ +begin:/\b(a|an|the|are|I'm|isn't|don't|doesn't|won't|but|just|should|pretty|simply|enough|gonna|going|wtf|so|such|will|you|your|they|like|more)\b/ +},QUOTE_STRING_MODE:k,REGEXP_MODE:{scope:"regexp",begin:/\/(?=[^/\n]*\/)/, +end:/\/[gimuy]*/,contains:[v,{begin:/\[/,end:/\]/,relevance:0,contains:[v]}]}, +RE_STARTERS_RE:"!|!=|!==|%|%=|&|&&|&=|\\*|\\*=|\\+|\\+=|,|-|-=|/=|/|:|;|<<|<<=|<=|<|===|==|=|>>>=|>>=|>=|>>>|>>|>|\\?|\\[|\\{|\\(|\\^|\\^=|\\||\\|=|\\|\\||~", +SHEBANG:(e={})=>{const n=/^#![ ]*\// +;return e.binary&&(e.begin=b(n,/.*\b/,e.binary,/\b.*/)),a({scope:"meta",begin:n, +end:/$/,relevance:0,"on:begin":(e,n)=>{0!==e.index&&n.ignoreMatch()}},e)}, +TITLE_MODE:{scope:"title",begin:f,relevance:0},UNDERSCORE_IDENT_RE:E, +UNDERSCORE_TITLE_MODE:{scope:"title",begin:E,relevance:0}});function T(e,n){ +"."===e.input[e.index-1]&&n.ignoreMatch()}function R(e,n){ +void 0!==e.className&&(e.scope=e.className,delete e.className)}function D(e,n){ +n&&e.beginKeywords&&(e.begin="\\b("+e.beginKeywords.split(" ").join("|")+")(?!\\.)(?=\\b|\\s)", +e.__beforeBegin=T,e.keywords=e.keywords||e.beginKeywords,delete e.beginKeywords, +void 0===e.relevance&&(e.relevance=0))}function I(e,n){ +Array.isArray(e.illegal)&&(e.illegal=m(...e.illegal))}function L(e,n){ +if(e.match){ +if(e.begin||e.end)throw Error("begin & end are not supported with match") +;e.begin=e.match,delete e.match}}function B(e,n){ +void 0===e.relevance&&(e.relevance=1)}const $=(e,n)=>{if(!e.beforeMatch)return +;if(e.starts)throw Error("beforeMatch cannot be used with starts") +;const t=Object.assign({},e);Object.keys(e).forEach((n=>{delete e[n] +})),e.keywords=t.keywords,e.begin=b(t.beforeMatch,d(t.begin)),e.starts={ +relevance:0,contains:[Object.assign(t,{endsParent:!0})] +},e.relevance=0,delete t.beforeMatch +},z=["of","and","for","in","not","or","if","then","parent","list","value"],F="keyword" +;function U(e,n,t=F){const a=Object.create(null) +;return"string"==typeof e?i(t,e.split(" ")):Array.isArray(e)?i(t,e):Object.keys(e).forEach((t=>{ +Object.assign(a,U(e[t],n,t))})),a;function i(e,t){ +n&&(t=t.map((e=>e.toLowerCase()))),t.forEach((n=>{const t=n.split("|") +;a[t[0]]=[e,j(t[0],t[1])]}))}}function j(e,n){ +return n?Number(n):(e=>z.includes(e.toLowerCase()))(e)?0:1}const P={},K=e=>{ +console.error(e)},H=(e,...n)=>{console.log("WARN: "+e,...n)},q=(e,n)=>{ +P[`${e}/${n}`]||(console.log(`Deprecated as of ${e}. ${n}`),P[`${e}/${n}`]=!0) +},G=Error();function Z(e,n,{key:t}){let a=0;const i=e[t],r={},s={} +;for(let e=1;e<=n.length;e++)s[e+a]=i[e],r[e+a]=!0,a+=p(n[e-1]) +;e[t]=s,e[t]._emit=r,e[t]._multi=!0}function W(e){(e=>{ +e.scope&&"object"==typeof e.scope&&null!==e.scope&&(e.beginScope=e.scope, +delete e.scope)})(e),"string"==typeof e.beginScope&&(e.beginScope={ +_wrap:e.beginScope}),"string"==typeof e.endScope&&(e.endScope={_wrap:e.endScope +}),(e=>{if(Array.isArray(e.begin)){ +if(e.skip||e.excludeBegin||e.returnBegin)throw K("skip, excludeBegin, returnBegin not compatible with beginScope: {}"), +G +;if("object"!=typeof e.beginScope||null===e.beginScope)throw K("beginScope must be object"), +G;Z(e,e.begin,{key:"beginScope"}),e.begin=h(e.begin,{joinWith:""})}})(e),(e=>{ +if(Array.isArray(e.end)){ +if(e.skip||e.excludeEnd||e.returnEnd)throw K("skip, excludeEnd, returnEnd not compatible with endScope: {}"), +G +;if("object"!=typeof e.endScope||null===e.endScope)throw K("endScope must be object"), +G;Z(e,e.end,{key:"endScope"}),e.end=h(e.end,{joinWith:""})}})(e)}function Q(e){ +function n(n,t){ +return RegExp(c(n),"m"+(e.case_insensitive?"i":"")+(e.unicodeRegex?"u":"")+(t?"g":"")) +}class t{constructor(){ +this.matchIndexes={},this.regexes=[],this.matchAt=1,this.position=0} +addRule(e,n){ +n.position=this.position++,this.matchIndexes[this.matchAt]=n,this.regexes.push([n,e]), +this.matchAt+=p(e)+1}compile(){0===this.regexes.length&&(this.exec=()=>null) +;const e=this.regexes.map((e=>e[1]));this.matcherRe=n(h(e,{joinWith:"|" +}),!0),this.lastIndex=0}exec(e){this.matcherRe.lastIndex=this.lastIndex +;const n=this.matcherRe.exec(e);if(!n)return null +;const t=n.findIndex(((e,n)=>n>0&&void 0!==e)),a=this.matchIndexes[t] +;return n.splice(0,t),Object.assign(n,a)}}class i{constructor(){ +this.rules=[],this.multiRegexes=[], +this.count=0,this.lastIndex=0,this.regexIndex=0}getMatcher(e){ +if(this.multiRegexes[e])return this.multiRegexes[e];const n=new t +;return this.rules.slice(e).forEach((([e,t])=>n.addRule(e,t))), +n.compile(),this.multiRegexes[e]=n,n}resumingScanAtSamePosition(){ +return 0!==this.regexIndex}considerAll(){this.regexIndex=0}addRule(e,n){ +this.rules.push([e,n]),"begin"===n.type&&this.count++}exec(e){ +const n=this.getMatcher(this.regexIndex);n.lastIndex=this.lastIndex +;let t=n.exec(e) +;if(this.resumingScanAtSamePosition())if(t&&t.index===this.lastIndex);else{ +const n=this.getMatcher(0);n.lastIndex=this.lastIndex+1,t=n.exec(e)} +return t&&(this.regexIndex+=t.position+1, +this.regexIndex===this.count&&this.considerAll()),t}} +if(e.compilerExtensions||(e.compilerExtensions=[]), +e.contains&&e.contains.includes("self"))throw Error("ERR: contains `self` is not supported at the top-level of a language. See documentation.") +;return e.classNameAliases=a(e.classNameAliases||{}),function t(r,s){const o=r +;if(r.isCompiled)return o +;[R,L,W,$].forEach((e=>e(r,s))),e.compilerExtensions.forEach((e=>e(r,s))), +r.__beforeBegin=null,[D,I,B].forEach((e=>e(r,s))),r.isCompiled=!0;let l=null +;return"object"==typeof r.keywords&&r.keywords.$pattern&&(r.keywords=Object.assign({},r.keywords), +l=r.keywords.$pattern, +delete r.keywords.$pattern),l=l||/\w+/,r.keywords&&(r.keywords=U(r.keywords,e.case_insensitive)), +o.keywordPatternRe=n(l,!0), +s&&(r.begin||(r.begin=/\B|\b/),o.beginRe=n(o.begin),r.end||r.endsWithParent||(r.end=/\B|\b/), +r.end&&(o.endRe=n(o.end)), +o.terminatorEnd=c(o.end)||"",r.endsWithParent&&s.terminatorEnd&&(o.terminatorEnd+=(r.end?"|":"")+s.terminatorEnd)), +r.illegal&&(o.illegalRe=n(r.illegal)), +r.contains||(r.contains=[]),r.contains=[].concat(...r.contains.map((e=>(e=>(e.variants&&!e.cachedVariants&&(e.cachedVariants=e.variants.map((n=>a(e,{ +variants:null},n)))),e.cachedVariants?e.cachedVariants:X(e)?a(e,{ +starts:e.starts?a(e.starts):null +}):Object.isFrozen(e)?a(e):e))("self"===e?r:e)))),r.contains.forEach((e=>{t(e,o) +})),r.starts&&t(r.starts,s),o.matcher=(e=>{const n=new i +;return e.contains.forEach((e=>n.addRule(e.begin,{rule:e,type:"begin" +}))),e.terminatorEnd&&n.addRule(e.terminatorEnd,{type:"end" +}),e.illegal&&n.addRule(e.illegal,{type:"illegal"}),n})(o),o}(e)}function X(e){ +return!!e&&(e.endsWithParent||X(e.starts))}class V extends Error{ +constructor(e,n){super(e),this.name="HTMLInjectionError",this.html=n}} +const J=t,Y=a,ee=Symbol("nomatch"),ne=t=>{ +const a=Object.create(null),i=Object.create(null),r=[];let s=!0 +;const o="Could not find the language '{}', did you forget to load/include a language module?",c={ +disableAutodetect:!0,name:"Plain text",contains:[]};let p={ +ignoreUnescapedHTML:!1,throwUnescapedHTML:!1,noHighlightRe:/^(no-?highlight)$/i, +languageDetectRe:/\blang(?:uage)?-([\w-]+)\b/i,classPrefix:"hljs-", +cssSelector:"pre code",languages:null,__emitter:l};function _(e){ +return p.noHighlightRe.test(e)}function h(e,n,t){let a="",i="" +;"object"==typeof n?(a=e, +t=n.ignoreIllegals,i=n.language):(q("10.7.0","highlight(lang, code, ...args) has been deprecated."), +q("10.7.0","Please use highlight(code, options) instead.\nhttps://github.com/highlightjs/highlight.js/issues/2277"), +i=e,a=n),void 0===t&&(t=!0);const r={code:a,language:i};x("before:highlight",r) +;const s=r.result?r.result:f(r.language,r.code,t) +;return s.code=r.code,x("after:highlight",s),s}function f(e,t,i,r){ +const l=Object.create(null);function c(){if(!x.keywords)return void S.addText(A) +;let e=0;x.keywordPatternRe.lastIndex=0;let n=x.keywordPatternRe.exec(A),t="" +;for(;n;){t+=A.substring(e,n.index) +;const i=w.case_insensitive?n[0].toLowerCase():n[0],r=(a=i,x.keywords[a]);if(r){ +const[e,a]=r +;if(S.addText(t),t="",l[i]=(l[i]||0)+1,l[i]<=7&&(C+=a),e.startsWith("_"))t+=n[0];else{ +const t=w.classNameAliases[e]||e;g(n[0],t)}}else t+=n[0] +;e=x.keywordPatternRe.lastIndex,n=x.keywordPatternRe.exec(A)}var a +;t+=A.substring(e),S.addText(t)}function d(){null!=x.subLanguage?(()=>{ +if(""===A)return;let e=null;if("string"==typeof x.subLanguage){ +if(!a[x.subLanguage])return void S.addText(A) +;e=f(x.subLanguage,A,!0,M[x.subLanguage]),M[x.subLanguage]=e._top +}else e=E(A,x.subLanguage.length?x.subLanguage:null) +;x.relevance>0&&(C+=e.relevance),S.__addSublanguage(e._emitter,e.language) +})():c(),A=""}function g(e,n){ +""!==e&&(S.startScope(n),S.addText(e),S.endScope())}function u(e,n){let t=1 +;const a=n.length-1;for(;t<=a;){if(!e._emit[t]){t++;continue} +const a=w.classNameAliases[e[t]]||e[t],i=n[t];a?g(i,a):(A=i,c(),A=""),t++}} +function b(e,n){ +return e.scope&&"string"==typeof e.scope&&S.openNode(w.classNameAliases[e.scope]||e.scope), +e.beginScope&&(e.beginScope._wrap?(g(A,w.classNameAliases[e.beginScope._wrap]||e.beginScope._wrap), +A=""):e.beginScope._multi&&(u(e.beginScope,n),A="")),x=Object.create(e,{parent:{ +value:x}}),x}function m(e,t,a){let i=((e,n)=>{const t=e&&e.exec(n) +;return t&&0===t.index})(e.endRe,a);if(i){if(e["on:end"]){const a=new n(e) +;e["on:end"](t,a),a.isMatchIgnored&&(i=!1)}if(i){ +for(;e.endsParent&&e.parent;)e=e.parent;return e}} +if(e.endsWithParent)return m(e.parent,t,a)}function _(e){ +return 0===x.matcher.regexIndex?(A+=e[0],1):(D=!0,0)}function h(e){ +const n=e[0],a=t.substring(e.index),i=m(x,e,a);if(!i)return ee;const r=x +;x.endScope&&x.endScope._wrap?(d(), +g(n,x.endScope._wrap)):x.endScope&&x.endScope._multi?(d(), +u(x.endScope,e)):r.skip?A+=n:(r.returnEnd||r.excludeEnd||(A+=n), +d(),r.excludeEnd&&(A=n));do{ +x.scope&&S.closeNode(),x.skip||x.subLanguage||(C+=x.relevance),x=x.parent +}while(x!==i.parent);return i.starts&&b(i.starts,e),r.returnEnd?0:n.length} +let y={};function N(a,r){const o=r&&r[0];if(A+=a,null==o)return d(),0 +;if("begin"===y.type&&"end"===r.type&&y.index===r.index&&""===o){ +if(A+=t.slice(r.index,r.index+1),!s){const n=Error(`0 width match regex (${e})`) +;throw n.languageName=e,n.badRule=y.rule,n}return 1} +if(y=r,"begin"===r.type)return(e=>{ +const t=e[0],a=e.rule,i=new n(a),r=[a.__beforeBegin,a["on:begin"]] +;for(const n of r)if(n&&(n(e,i),i.isMatchIgnored))return _(t) +;return a.skip?A+=t:(a.excludeBegin&&(A+=t), +d(),a.returnBegin||a.excludeBegin||(A=t)),b(a,e),a.returnBegin?0:t.length})(r) +;if("illegal"===r.type&&!i){ +const e=Error('Illegal lexeme "'+o+'" for mode "'+(x.scope||"")+'"') +;throw e.mode=x,e}if("end"===r.type){const e=h(r);if(e!==ee)return e} +if("illegal"===r.type&&""===o)return 1 +;if(R>1e5&&R>3*r.index)throw Error("potential infinite loop, way more iterations than matches") +;return A+=o,o.length}const w=v(e) +;if(!w)throw K(o.replace("{}",e)),Error('Unknown language: "'+e+'"') +;const O=Q(w);let k="",x=r||O;const M={},S=new p.__emitter(p);(()=>{const e=[] +;for(let n=x;n!==w;n=n.parent)n.scope&&e.unshift(n.scope) +;e.forEach((e=>S.openNode(e)))})();let A="",C=0,T=0,R=0,D=!1;try{ +if(w.__emitTokens)w.__emitTokens(t,S);else{for(x.matcher.considerAll();;){ +R++,D?D=!1:x.matcher.considerAll(),x.matcher.lastIndex=T +;const e=x.matcher.exec(t);if(!e)break;const n=N(t.substring(T,e.index),e) +;T=e.index+n}N(t.substring(T))}return S.finalize(),k=S.toHTML(),{language:e, +value:k,relevance:C,illegal:!1,_emitter:S,_top:x}}catch(n){ +if(n.message&&n.message.includes("Illegal"))return{language:e,value:J(t), +illegal:!0,relevance:0,_illegalBy:{message:n.message,index:T, +context:t.slice(T-100,T+100),mode:n.mode,resultSoFar:k},_emitter:S};if(s)return{ +language:e,value:J(t),illegal:!1,relevance:0,errorRaised:n,_emitter:S,_top:x} +;throw n}}function E(e,n){n=n||p.languages||Object.keys(a);const t=(e=>{ +const n={value:J(e),illegal:!1,relevance:0,_top:c,_emitter:new p.__emitter(p)} +;return n._emitter.addText(e),n})(e),i=n.filter(v).filter(k).map((n=>f(n,e,!1))) +;i.unshift(t);const r=i.sort(((e,n)=>{ +if(e.relevance!==n.relevance)return n.relevance-e.relevance +;if(e.language&&n.language){if(v(e.language).supersetOf===n.language)return 1 +;if(v(n.language).supersetOf===e.language)return-1}return 0})),[s,o]=r,l=s +;return l.secondBest=o,l}function y(e){let n=null;const t=(e=>{ +let n=e.className+" ";n+=e.parentNode?e.parentNode.className:"" +;const t=p.languageDetectRe.exec(n);if(t){const n=v(t[1]) +;return n||(H(o.replace("{}",t[1])), +H("Falling back to no-highlight mode for this block.",e)),n?t[1]:"no-highlight"} +return n.split(/\s+/).find((e=>_(e)||v(e)))})(e);if(_(t))return +;if(x("before:highlightElement",{el:e,language:t +}),e.dataset.highlighted)return void console.log("Element previously highlighted. To highlight again, first unset `dataset.highlighted`.",e) +;if(e.children.length>0&&(p.ignoreUnescapedHTML||(console.warn("One of your code blocks includes unescaped HTML. This is a potentially serious security risk."), +console.warn("https://github.com/highlightjs/highlight.js/wiki/security"), +console.warn("The element with unescaped HTML:"), +console.warn(e)),p.throwUnescapedHTML))throw new V("One of your code blocks includes unescaped HTML.",e.innerHTML) +;n=e;const a=n.textContent,r=t?h(a,{language:t,ignoreIllegals:!0}):E(a) +;e.innerHTML=r.value,e.dataset.highlighted="yes",((e,n,t)=>{const a=n&&i[n]||t +;e.classList.add("hljs"),e.classList.add("language-"+a) +})(e,t,r.language),e.result={language:r.language,re:r.relevance, +relevance:r.relevance},r.secondBest&&(e.secondBest={ +language:r.secondBest.language,relevance:r.secondBest.relevance +}),x("after:highlightElement",{el:e,result:r,text:a})}let N=!1;function w(){ +"loading"!==document.readyState?document.querySelectorAll(p.cssSelector).forEach(y):N=!0 +}function v(e){return e=(e||"").toLowerCase(),a[e]||a[i[e]]} +function O(e,{languageName:n}){"string"==typeof e&&(e=[e]),e.forEach((e=>{ +i[e.toLowerCase()]=n}))}function k(e){const n=v(e) +;return n&&!n.disableAutodetect}function x(e,n){const t=e;r.forEach((e=>{ +e[t]&&e[t](n)}))} +"undefined"!=typeof window&&window.addEventListener&&window.addEventListener("DOMContentLoaded",(()=>{ +N&&w()}),!1),Object.assign(t,{highlight:h,highlightAuto:E,highlightAll:w, +highlightElement:y, +highlightBlock:e=>(q("10.7.0","highlightBlock will be removed entirely in v12.0"), +q("10.7.0","Please use highlightElement now."),y(e)),configure:e=>{p=Y(p,e)}, +initHighlighting:()=>{ +w(),q("10.6.0","initHighlighting() deprecated. Use highlightAll() now.")}, +initHighlightingOnLoad:()=>{ +w(),q("10.6.0","initHighlightingOnLoad() deprecated. Use highlightAll() now.") +},registerLanguage:(e,n)=>{let i=null;try{i=n(t)}catch(n){ +if(K("Language definition for '{}' could not be registered.".replace("{}",e)), +!s)throw n;K(n),i=c} +i.name||(i.name=e),a[e]=i,i.rawDefinition=n.bind(null,t),i.aliases&&O(i.aliases,{ +languageName:e})},unregisterLanguage:e=>{delete a[e] +;for(const n of Object.keys(i))i[n]===e&&delete i[n]}, +listLanguages:()=>Object.keys(a),getLanguage:v,registerAliases:O, +autoDetection:k,inherit:Y,addPlugin:e=>{(e=>{ +e["before:highlightBlock"]&&!e["before:highlightElement"]&&(e["before:highlightElement"]=n=>{ +e["before:highlightBlock"](Object.assign({block:n.el},n)) +}),e["after:highlightBlock"]&&!e["after:highlightElement"]&&(e["after:highlightElement"]=n=>{ +e["after:highlightBlock"](Object.assign({block:n.el},n))})})(e),r.push(e)}, +removePlugin:e=>{const n=r.indexOf(e);-1!==n&&r.splice(n,1)}}),t.debugMode=()=>{ +s=!1},t.safeMode=()=>{s=!0},t.versionString="11.9.0",t.regex={concat:b, +lookahead:d,either:m,optional:u,anyNumberOfTimes:g} +;for(const n in C)"object"==typeof C[n]&&e(C[n]);return Object.assign(t,C),t +},te=ne({});te.newInstance=()=>ne({});var ae=te;const ie=e=>({IMPORTANT:{ +scope:"meta",begin:"!important"},BLOCK_COMMENT:e.C_BLOCK_COMMENT_MODE,HEXCOLOR:{ +scope:"number",begin:/#(([0-9a-fA-F]{3,4})|(([0-9a-fA-F]{2}){3,4}))\b/}, +FUNCTION_DISPATCH:{className:"built_in",begin:/[\w-]+(?=\()/}, +ATTRIBUTE_SELECTOR_MODE:{scope:"selector-attr",begin:/\[/,end:/\]/,illegal:"$", +contains:[e.APOS_STRING_MODE,e.QUOTE_STRING_MODE]},CSS_NUMBER_MODE:{ +scope:"number", +begin:e.NUMBER_RE+"(%|em|ex|ch|rem|vw|vh|vmin|vmax|cm|mm|in|pt|pc|px|deg|grad|rad|turn|s|ms|Hz|kHz|dpi|dpcm|dppx)?", +relevance:0},CSS_VARIABLE:{className:"attr",begin:/--[A-Za-z_][A-Za-z0-9_-]*/} +}),re=["a","abbr","address","article","aside","audio","b","blockquote","body","button","canvas","caption","cite","code","dd","del","details","dfn","div","dl","dt","em","fieldset","figcaption","figure","footer","form","h1","h2","h3","h4","h5","h6","header","hgroup","html","i","iframe","img","input","ins","kbd","label","legend","li","main","mark","menu","nav","object","ol","p","q","quote","samp","section","span","strong","summary","sup","table","tbody","td","textarea","tfoot","th","thead","time","tr","ul","var","video"],se=["any-hover","any-pointer","aspect-ratio","color","color-gamut","color-index","device-aspect-ratio","device-height","device-width","display-mode","forced-colors","grid","height","hover","inverted-colors","monochrome","orientation","overflow-block","overflow-inline","pointer","prefers-color-scheme","prefers-contrast","prefers-reduced-motion","prefers-reduced-transparency","resolution","scan","scripting","update","width","min-width","max-width","min-height","max-height"],oe=["active","any-link","blank","checked","current","default","defined","dir","disabled","drop","empty","enabled","first","first-child","first-of-type","fullscreen","future","focus","focus-visible","focus-within","has","host","host-context","hover","indeterminate","in-range","invalid","is","lang","last-child","last-of-type","left","link","local-link","not","nth-child","nth-col","nth-last-child","nth-last-col","nth-last-of-type","nth-of-type","only-child","only-of-type","optional","out-of-range","past","placeholder-shown","read-only","read-write","required","right","root","scope","target","target-within","user-invalid","valid","visited","where"],le=["after","backdrop","before","cue","cue-region","first-letter","first-line","grammar-error","marker","part","placeholder","selection","slotted","spelling-error"],ce=["align-content","align-items","align-self","all","animation","animation-delay","animation-direction","animation-duration","animation-fill-mode","animation-iteration-count","animation-name","animation-play-state","animation-timing-function","backface-visibility","background","background-attachment","background-blend-mode","background-clip","background-color","background-image","background-origin","background-position","background-repeat","background-size","block-size","border","border-block","border-block-color","border-block-end","border-block-end-color","border-block-end-style","border-block-end-width","border-block-start","border-block-start-color","border-block-start-style","border-block-start-width","border-block-style","border-block-width","border-bottom","border-bottom-color","border-bottom-left-radius","border-bottom-right-radius","border-bottom-style","border-bottom-width","border-collapse","border-color","border-image","border-image-outset","border-image-repeat","border-image-slice","border-image-source","border-image-width","border-inline","border-inline-color","border-inline-end","border-inline-end-color","border-inline-end-style","border-inline-end-width","border-inline-start","border-inline-start-color","border-inline-start-style","border-inline-start-width","border-inline-style","border-inline-width","border-left","border-left-color","border-left-style","border-left-width","border-radius","border-right","border-right-color","border-right-style","border-right-width","border-spacing","border-style","border-top","border-top-color","border-top-left-radius","border-top-right-radius","border-top-style","border-top-width","border-width","bottom","box-decoration-break","box-shadow","box-sizing","break-after","break-before","break-inside","caption-side","caret-color","clear","clip","clip-path","clip-rule","color","column-count","column-fill","column-gap","column-rule","column-rule-color","column-rule-style","column-rule-width","column-span","column-width","columns","contain","content","content-visibility","counter-increment","counter-reset","cue","cue-after","cue-before","cursor","direction","display","empty-cells","filter","flex","flex-basis","flex-direction","flex-flow","flex-grow","flex-shrink","flex-wrap","float","flow","font","font-display","font-family","font-feature-settings","font-kerning","font-language-override","font-size","font-size-adjust","font-smoothing","font-stretch","font-style","font-synthesis","font-variant","font-variant-caps","font-variant-east-asian","font-variant-ligatures","font-variant-numeric","font-variant-position","font-variation-settings","font-weight","gap","glyph-orientation-vertical","grid","grid-area","grid-auto-columns","grid-auto-flow","grid-auto-rows","grid-column","grid-column-end","grid-column-start","grid-gap","grid-row","grid-row-end","grid-row-start","grid-template","grid-template-areas","grid-template-columns","grid-template-rows","hanging-punctuation","height","hyphens","icon","image-orientation","image-rendering","image-resolution","ime-mode","inline-size","isolation","justify-content","left","letter-spacing","line-break","line-height","list-style","list-style-image","list-style-position","list-style-type","margin","margin-block","margin-block-end","margin-block-start","margin-bottom","margin-inline","margin-inline-end","margin-inline-start","margin-left","margin-right","margin-top","marks","mask","mask-border","mask-border-mode","mask-border-outset","mask-border-repeat","mask-border-slice","mask-border-source","mask-border-width","mask-clip","mask-composite","mask-image","mask-mode","mask-origin","mask-position","mask-repeat","mask-size","mask-type","max-block-size","max-height","max-inline-size","max-width","min-block-size","min-height","min-inline-size","min-width","mix-blend-mode","nav-down","nav-index","nav-left","nav-right","nav-up","none","normal","object-fit","object-position","opacity","order","orphans","outline","outline-color","outline-offset","outline-style","outline-width","overflow","overflow-wrap","overflow-x","overflow-y","padding","padding-block","padding-block-end","padding-block-start","padding-bottom","padding-inline","padding-inline-end","padding-inline-start","padding-left","padding-right","padding-top","page-break-after","page-break-before","page-break-inside","pause","pause-after","pause-before","perspective","perspective-origin","pointer-events","position","quotes","resize","rest","rest-after","rest-before","right","row-gap","scroll-margin","scroll-margin-block","scroll-margin-block-end","scroll-margin-block-start","scroll-margin-bottom","scroll-margin-inline","scroll-margin-inline-end","scroll-margin-inline-start","scroll-margin-left","scroll-margin-right","scroll-margin-top","scroll-padding","scroll-padding-block","scroll-padding-block-end","scroll-padding-block-start","scroll-padding-bottom","scroll-padding-inline","scroll-padding-inline-end","scroll-padding-inline-start","scroll-padding-left","scroll-padding-right","scroll-padding-top","scroll-snap-align","scroll-snap-stop","scroll-snap-type","scrollbar-color","scrollbar-gutter","scrollbar-width","shape-image-threshold","shape-margin","shape-outside","speak","speak-as","src","tab-size","table-layout","text-align","text-align-all","text-align-last","text-combine-upright","text-decoration","text-decoration-color","text-decoration-line","text-decoration-style","text-emphasis","text-emphasis-color","text-emphasis-position","text-emphasis-style","text-indent","text-justify","text-orientation","text-overflow","text-rendering","text-shadow","text-transform","text-underline-position","top","transform","transform-box","transform-origin","transform-style","transition","transition-delay","transition-duration","transition-property","transition-timing-function","unicode-bidi","vertical-align","visibility","voice-balance","voice-duration","voice-family","voice-pitch","voice-range","voice-rate","voice-stress","voice-volume","white-space","widows","width","will-change","word-break","word-spacing","word-wrap","writing-mode","z-index"].reverse(),de=oe.concat(le) +;var ge="[0-9](_*[0-9])*",ue=`\\.(${ge})`,be="[0-9a-fA-F](_*[0-9a-fA-F])*",me={ +className:"number",variants:[{ +begin:`(\\b(${ge})((${ue})|\\.)?|(${ue}))[eE][+-]?(${ge})[fFdD]?\\b`},{ +begin:`\\b(${ge})((${ue})[fFdD]?\\b|\\.([fFdD]\\b)?)`},{ +begin:`(${ue})[fFdD]?\\b`},{begin:`\\b(${ge})[fFdD]\\b`},{ +begin:`\\b0[xX]((${be})\\.?|(${be})?\\.(${be}))[pP][+-]?(${ge})[fFdD]?\\b`},{ +begin:"\\b(0|[1-9](_*[0-9])*)[lL]?\\b"},{begin:`\\b0[xX](${be})[lL]?\\b`},{ +begin:"\\b0(_*[0-7])*[lL]?\\b"},{begin:"\\b0[bB][01](_*[01])*[lL]?\\b"}], +relevance:0};function pe(e,n,t){return-1===t?"":e.replace(n,(a=>pe(e,n,t-1)))} +const _e="[A-Za-z$_][0-9A-Za-z$_]*",he=["as","in","of","if","for","while","finally","var","new","function","do","return","void","else","break","catch","instanceof","with","throw","case","default","try","switch","continue","typeof","delete","let","yield","const","class","debugger","async","await","static","import","from","export","extends"],fe=["true","false","null","undefined","NaN","Infinity"],Ee=["Object","Function","Boolean","Symbol","Math","Date","Number","BigInt","String","RegExp","Array","Float32Array","Float64Array","Int8Array","Uint8Array","Uint8ClampedArray","Int16Array","Int32Array","Uint16Array","Uint32Array","BigInt64Array","BigUint64Array","Set","Map","WeakSet","WeakMap","ArrayBuffer","SharedArrayBuffer","Atomics","DataView","JSON","Promise","Generator","GeneratorFunction","AsyncFunction","Reflect","Proxy","Intl","WebAssembly"],ye=["Error","EvalError","InternalError","RangeError","ReferenceError","SyntaxError","TypeError","URIError"],Ne=["setInterval","setTimeout","clearInterval","clearTimeout","require","exports","eval","isFinite","isNaN","parseFloat","parseInt","decodeURI","decodeURIComponent","encodeURI","encodeURIComponent","escape","unescape"],we=["arguments","this","super","console","window","document","localStorage","sessionStorage","module","global"],ve=[].concat(Ne,Ee,ye) +;function Oe(e){const n=e.regex,t=_e,a={begin:/<[A-Za-z0-9\\._:-]+/, +end:/\/[A-Za-z0-9\\._:-]+>|\/>/,isTrulyOpeningTag:(e,n)=>{ +const t=e[0].length+e.index,a=e.input[t] +;if("<"===a||","===a)return void n.ignoreMatch();let i +;">"===a&&(((e,{after:n})=>{const t="",M={ +match:[/const|var|let/,/\s+/,t,/\s*/,/=\s*/,/(async\s*)?/,n.lookahead(x)], +keywords:"async",className:{1:"keyword",3:"title.function"},contains:[f]} +;return{name:"JavaScript",aliases:["js","jsx","mjs","cjs"],keywords:i,exports:{ +PARAMS_CONTAINS:h,CLASS_REFERENCE:y},illegal:/#(?![$_A-z])/, +contains:[e.SHEBANG({label:"shebang",binary:"node",relevance:5}),{ +label:"use_strict",className:"meta",relevance:10, +begin:/^\s*['"]use (strict|asm)['"]/ +},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,d,g,u,b,m,{match:/\$\d+/},l,y,{ +className:"attr",begin:t+n.lookahead(":"),relevance:0},M,{ +begin:"("+e.RE_STARTERS_RE+"|\\b(case|return|throw)\\b)\\s*", +keywords:"return throw case",relevance:0,contains:[m,e.REGEXP_MODE,{ +className:"function",begin:x,returnBegin:!0,end:"\\s*=>",contains:[{ +className:"params",variants:[{begin:e.UNDERSCORE_IDENT_RE,relevance:0},{ +className:null,begin:/\(\s*\)/,skip:!0},{begin:/\(/,end:/\)/,excludeBegin:!0, +excludeEnd:!0,keywords:i,contains:h}]}]},{begin:/,/,relevance:0},{match:/\s+/, +relevance:0},{variants:[{begin:"<>",end:""},{ +match:/<[A-Za-z0-9\\._:-]+\s*\/>/},{begin:a.begin, +"on:begin":a.isTrulyOpeningTag,end:a.end}],subLanguage:"xml",contains:[{ +begin:a.begin,end:a.end,skip:!0,contains:["self"]}]}]},N,{ +beginKeywords:"while if switch catch for"},{ +begin:"\\b(?!function)"+e.UNDERSCORE_IDENT_RE+"\\([^()]*(\\([^()]*(\\([^()]*\\)[^()]*)*\\)[^()]*)*\\)\\s*\\{", +returnBegin:!0,label:"func.def",contains:[f,e.inherit(e.TITLE_MODE,{begin:t, +className:"title.function"})]},{match:/\.\.\./,relevance:0},O,{match:"\\$"+t, +relevance:0},{match:[/\bconstructor(?=\s*\()/],className:{1:"title.function"}, +contains:[f]},w,{relevance:0,match:/\b[A-Z][A-Z_0-9]+\b/, +className:"variable.constant"},E,k,{match:/\$[(.]/}]}} +const ke=e=>b(/\b/,e,/\w$/.test(e)?/\b/:/\B/),xe=["Protocol","Type"].map(ke),Me=["init","self"].map(ke),Se=["Any","Self"],Ae=["actor","any","associatedtype","async","await",/as\?/,/as!/,"as","borrowing","break","case","catch","class","consume","consuming","continue","convenience","copy","default","defer","deinit","didSet","distributed","do","dynamic","each","else","enum","extension","fallthrough",/fileprivate\(set\)/,"fileprivate","final","for","func","get","guard","if","import","indirect","infix",/init\?/,/init!/,"inout",/internal\(set\)/,"internal","in","is","isolated","nonisolated","lazy","let","macro","mutating","nonmutating",/open\(set\)/,"open","operator","optional","override","postfix","precedencegroup","prefix",/private\(set\)/,"private","protocol",/public\(set\)/,"public","repeat","required","rethrows","return","set","some","static","struct","subscript","super","switch","throws","throw",/try\?/,/try!/,"try","typealias",/unowned\(safe\)/,/unowned\(unsafe\)/,"unowned","var","weak","where","while","willSet"],Ce=["false","nil","true"],Te=["assignment","associativity","higherThan","left","lowerThan","none","right"],Re=["#colorLiteral","#column","#dsohandle","#else","#elseif","#endif","#error","#file","#fileID","#fileLiteral","#filePath","#function","#if","#imageLiteral","#keyPath","#line","#selector","#sourceLocation","#warning"],De=["abs","all","any","assert","assertionFailure","debugPrint","dump","fatalError","getVaList","isKnownUniquelyReferenced","max","min","numericCast","pointwiseMax","pointwiseMin","precondition","preconditionFailure","print","readLine","repeatElement","sequence","stride","swap","swift_unboxFromSwiftValueWithType","transcode","type","unsafeBitCast","unsafeDowncast","withExtendedLifetime","withUnsafeMutablePointer","withUnsafePointer","withVaList","withoutActuallyEscaping","zip"],Ie=m(/[/=\-+!*%<>&|^~?]/,/[\u00A1-\u00A7]/,/[\u00A9\u00AB]/,/[\u00AC\u00AE]/,/[\u00B0\u00B1]/,/[\u00B6\u00BB\u00BF\u00D7\u00F7]/,/[\u2016-\u2017]/,/[\u2020-\u2027]/,/[\u2030-\u203E]/,/[\u2041-\u2053]/,/[\u2055-\u205E]/,/[\u2190-\u23FF]/,/[\u2500-\u2775]/,/[\u2794-\u2BFF]/,/[\u2E00-\u2E7F]/,/[\u3001-\u3003]/,/[\u3008-\u3020]/,/[\u3030]/),Le=m(Ie,/[\u0300-\u036F]/,/[\u1DC0-\u1DFF]/,/[\u20D0-\u20FF]/,/[\uFE00-\uFE0F]/,/[\uFE20-\uFE2F]/),Be=b(Ie,Le,"*"),$e=m(/[a-zA-Z_]/,/[\u00A8\u00AA\u00AD\u00AF\u00B2-\u00B5\u00B7-\u00BA]/,/[\u00BC-\u00BE\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u00FF]/,/[\u0100-\u02FF\u0370-\u167F\u1681-\u180D\u180F-\u1DBF]/,/[\u1E00-\u1FFF]/,/[\u200B-\u200D\u202A-\u202E\u203F-\u2040\u2054\u2060-\u206F]/,/[\u2070-\u20CF\u2100-\u218F\u2460-\u24FF\u2776-\u2793]/,/[\u2C00-\u2DFF\u2E80-\u2FFF]/,/[\u3004-\u3007\u3021-\u302F\u3031-\u303F\u3040-\uD7FF]/,/[\uF900-\uFD3D\uFD40-\uFDCF\uFDF0-\uFE1F\uFE30-\uFE44]/,/[\uFE47-\uFEFE\uFF00-\uFFFD]/),ze=m($e,/\d/,/[\u0300-\u036F\u1DC0-\u1DFF\u20D0-\u20FF\uFE20-\uFE2F]/),Fe=b($e,ze,"*"),Ue=b(/[A-Z]/,ze,"*"),je=["attached","autoclosure",b(/convention\(/,m("swift","block","c"),/\)/),"discardableResult","dynamicCallable","dynamicMemberLookup","escaping","freestanding","frozen","GKInspectable","IBAction","IBDesignable","IBInspectable","IBOutlet","IBSegueAction","inlinable","main","nonobjc","NSApplicationMain","NSCopying","NSManaged",b(/objc\(/,Fe,/\)/),"objc","objcMembers","propertyWrapper","requires_stored_property_inits","resultBuilder","Sendable","testable","UIApplicationMain","unchecked","unknown","usableFromInline","warn_unqualified_access"],Pe=["iOS","iOSApplicationExtension","macOS","macOSApplicationExtension","macCatalyst","macCatalystApplicationExtension","watchOS","watchOSApplicationExtension","tvOS","tvOSApplicationExtension","swift"] +;var Ke=Object.freeze({__proto__:null,grmr_bash:e=>{const n=e.regex,t={},a={ +begin:/\$\{/,end:/\}/,contains:["self",{begin:/:-/,contains:[t]}]} +;Object.assign(t,{className:"variable",variants:[{ +begin:n.concat(/\$[\w\d#@][\w\d_]*/,"(?![\\w\\d])(?![$])")},a]});const i={ +className:"subst",begin:/\$\(/,end:/\)/,contains:[e.BACKSLASH_ESCAPE]},r={ +begin:/<<-?\s*(?=\w+)/,starts:{contains:[e.END_SAME_AS_BEGIN({begin:/(\w+)/, +end:/(\w+)/,className:"string"})]}},s={className:"string",begin:/"/,end:/"/, +contains:[e.BACKSLASH_ESCAPE,t,i]};i.contains.push(s);const o={begin:/\$?\(\(/, +end:/\)\)/,contains:[{begin:/\d+#[0-9a-f]+/,className:"number"},e.NUMBER_MODE,t] +},l=e.SHEBANG({binary:"(fish|bash|zsh|sh|csh|ksh|tcsh|dash|scsh)",relevance:10 +}),c={className:"function",begin:/\w[\w\d_]*\s*\(\s*\)\s*\{/,returnBegin:!0, +contains:[e.inherit(e.TITLE_MODE,{begin:/\w[\w\d_]*/})],relevance:0};return{ +name:"Bash",aliases:["sh"],keywords:{$pattern:/\b[a-z][a-z0-9._-]+\b/, +keyword:["if","then","else","elif","fi","for","while","until","in","do","done","case","esac","function","select"], +literal:["true","false"], +built_in:["break","cd","continue","eval","exec","exit","export","getopts","hash","pwd","readonly","return","shift","test","times","trap","umask","unset","alias","bind","builtin","caller","command","declare","echo","enable","help","let","local","logout","mapfile","printf","read","readarray","source","type","typeset","ulimit","unalias","set","shopt","autoload","bg","bindkey","bye","cap","chdir","clone","comparguments","compcall","compctl","compdescribe","compfiles","compgroups","compquote","comptags","comptry","compvalues","dirs","disable","disown","echotc","echoti","emulate","fc","fg","float","functions","getcap","getln","history","integer","jobs","kill","limit","log","noglob","popd","print","pushd","pushln","rehash","sched","setcap","setopt","stat","suspend","ttyctl","unfunction","unhash","unlimit","unsetopt","vared","wait","whence","where","which","zcompile","zformat","zftp","zle","zmodload","zparseopts","zprof","zpty","zregexparse","zsocket","zstyle","ztcp","chcon","chgrp","chown","chmod","cp","dd","df","dir","dircolors","ln","ls","mkdir","mkfifo","mknod","mktemp","mv","realpath","rm","rmdir","shred","sync","touch","truncate","vdir","b2sum","base32","base64","cat","cksum","comm","csplit","cut","expand","fmt","fold","head","join","md5sum","nl","numfmt","od","paste","ptx","pr","sha1sum","sha224sum","sha256sum","sha384sum","sha512sum","shuf","sort","split","sum","tac","tail","tr","tsort","unexpand","uniq","wc","arch","basename","chroot","date","dirname","du","echo","env","expr","factor","groups","hostid","id","link","logname","nice","nohup","nproc","pathchk","pinky","printenv","printf","pwd","readlink","runcon","seq","sleep","stat","stdbuf","stty","tee","test","timeout","tty","uname","unlink","uptime","users","who","whoami","yes"] +},contains:[l,e.SHEBANG(),c,o,e.HASH_COMMENT_MODE,r,{match:/(\/[a-z._-]+)+/},s,{ +match:/\\"/},{className:"string",begin:/'/,end:/'/},{match:/\\'/},t]}}, +grmr_c:e=>{const n=e.regex,t=e.COMMENT("//","$",{contains:[{begin:/\\\n/}] +}),a="decltype\\(auto\\)",i="[a-zA-Z_]\\w*::",r="("+a+"|"+n.optional(i)+"[a-zA-Z_]\\w*"+n.optional("<[^<>]+>")+")",s={ +className:"type",variants:[{begin:"\\b[a-z\\d_]*_t\\b"},{ +match:/\batomic_[a-z]{3,6}\b/}]},o={className:"string",variants:[{ +begin:'(u8?|U|L)?"',end:'"',illegal:"\\n",contains:[e.BACKSLASH_ESCAPE]},{ +begin:"(u8?|U|L)?'(\\\\(x[0-9A-Fa-f]{2}|u[0-9A-Fa-f]{4,8}|[0-7]{3}|\\S)|.)", +end:"'",illegal:"."},e.END_SAME_AS_BEGIN({ +begin:/(?:u8?|U|L)?R"([^()\\ ]{0,16})\(/,end:/\)([^()\\ ]{0,16})"/})]},l={ +className:"number",variants:[{begin:"\\b(0b[01']+)"},{ +begin:"(-?)\\b([\\d']+(\\.[\\d']*)?|\\.[\\d']+)((ll|LL|l|L)(u|U)?|(u|U)(ll|LL|l|L)?|f|F|b|B)" +},{ +begin:"(-?)(\\b0[xX][a-fA-F0-9']+|(\\b[\\d']+(\\.[\\d']*)?|\\.[\\d']+)([eE][-+]?[\\d']+)?)" +}],relevance:0},c={className:"meta",begin:/#\s*[a-z]+\b/,end:/$/,keywords:{ +keyword:"if else elif endif define undef warning error line pragma _Pragma ifdef ifndef include" +},contains:[{begin:/\\\n/,relevance:0},e.inherit(o,{className:"string"}),{ +className:"string",begin:/<.*?>/},t,e.C_BLOCK_COMMENT_MODE]},d={ +className:"title",begin:n.optional(i)+e.IDENT_RE,relevance:0 +},g=n.optional(i)+e.IDENT_RE+"\\s*\\(",u={ +keyword:["asm","auto","break","case","continue","default","do","else","enum","extern","for","fortran","goto","if","inline","register","restrict","return","sizeof","struct","switch","typedef","union","volatile","while","_Alignas","_Alignof","_Atomic","_Generic","_Noreturn","_Static_assert","_Thread_local","alignas","alignof","noreturn","static_assert","thread_local","_Pragma"], +type:["float","double","signed","unsigned","int","short","long","char","void","_Bool","_Complex","_Imaginary","_Decimal32","_Decimal64","_Decimal128","const","static","complex","bool","imaginary"], +literal:"true false NULL", +built_in:"std string wstring cin cout cerr clog stdin stdout stderr stringstream istringstream ostringstream auto_ptr deque list queue stack vector map set pair bitset multiset multimap unordered_set unordered_map unordered_multiset unordered_multimap priority_queue make_pair array shared_ptr abort terminate abs acos asin atan2 atan calloc ceil cosh cos exit exp fabs floor fmod fprintf fputs free frexp fscanf future isalnum isalpha iscntrl isdigit isgraph islower isprint ispunct isspace isupper isxdigit tolower toupper labs ldexp log10 log malloc realloc memchr memcmp memcpy memset modf pow printf putchar puts scanf sinh sin snprintf sprintf sqrt sscanf strcat strchr strcmp strcpy strcspn strlen strncat strncmp strncpy strpbrk strrchr strspn strstr tanh tan vfprintf vprintf vsprintf endl initializer_list unique_ptr" +},b=[c,s,t,e.C_BLOCK_COMMENT_MODE,l,o],m={variants:[{begin:/=/,end:/;/},{ +begin:/\(/,end:/\)/},{beginKeywords:"new throw return else",end:/;/}], +keywords:u,contains:b.concat([{begin:/\(/,end:/\)/,keywords:u, +contains:b.concat(["self"]),relevance:0}]),relevance:0},p={ +begin:"("+r+"[\\*&\\s]+)+"+g,returnBegin:!0,end:/[{;=]/,excludeEnd:!0, +keywords:u,illegal:/[^\w\s\*&:<>.]/,contains:[{begin:a,keywords:u,relevance:0},{ +begin:g,returnBegin:!0,contains:[e.inherit(d,{className:"title.function"})], +relevance:0},{relevance:0,match:/,/},{className:"params",begin:/\(/,end:/\)/, +keywords:u,relevance:0,contains:[t,e.C_BLOCK_COMMENT_MODE,o,l,s,{begin:/\(/, +end:/\)/,keywords:u,relevance:0,contains:["self",t,e.C_BLOCK_COMMENT_MODE,o,l,s] +}]},s,t,e.C_BLOCK_COMMENT_MODE,c]};return{name:"C",aliases:["h"],keywords:u, +disableAutodetect:!0,illegal:"=]/,contains:[{ +beginKeywords:"final class struct"},e.TITLE_MODE]}]),exports:{preprocessor:c, +strings:o,keywords:u}}},grmr_cpp:e=>{const n=e.regex,t=e.COMMENT("//","$",{ +contains:[{begin:/\\\n/}] +}),a="decltype\\(auto\\)",i="[a-zA-Z_]\\w*::",r="(?!struct)("+a+"|"+n.optional(i)+"[a-zA-Z_]\\w*"+n.optional("<[^<>]+>")+")",s={ +className:"type",begin:"\\b[a-z\\d_]*_t\\b"},o={className:"string",variants:[{ +begin:'(u8?|U|L)?"',end:'"',illegal:"\\n",contains:[e.BACKSLASH_ESCAPE]},{ +begin:"(u8?|U|L)?'(\\\\(x[0-9A-Fa-f]{2}|u[0-9A-Fa-f]{4,8}|[0-7]{3}|\\S)|.)", +end:"'",illegal:"."},e.END_SAME_AS_BEGIN({ +begin:/(?:u8?|U|L)?R"([^()\\ ]{0,16})\(/,end:/\)([^()\\ ]{0,16})"/})]},l={ +className:"number",variants:[{begin:"\\b(0b[01']+)"},{ +begin:"(-?)\\b([\\d']+(\\.[\\d']*)?|\\.[\\d']+)((ll|LL|l|L)(u|U)?|(u|U)(ll|LL|l|L)?|f|F|b|B)" +},{ +begin:"(-?)(\\b0[xX][a-fA-F0-9']+|(\\b[\\d']+(\\.[\\d']*)?|\\.[\\d']+)([eE][-+]?[\\d']+)?)" +}],relevance:0},c={className:"meta",begin:/#\s*[a-z]+\b/,end:/$/,keywords:{ +keyword:"if else elif endif define undef warning error line pragma _Pragma ifdef ifndef include" +},contains:[{begin:/\\\n/,relevance:0},e.inherit(o,{className:"string"}),{ +className:"string",begin:/<.*?>/},t,e.C_BLOCK_COMMENT_MODE]},d={ +className:"title",begin:n.optional(i)+e.IDENT_RE,relevance:0 +},g=n.optional(i)+e.IDENT_RE+"\\s*\\(",u={ +type:["bool","char","char16_t","char32_t","char8_t","double","float","int","long","short","void","wchar_t","unsigned","signed","const","static"], +keyword:["alignas","alignof","and","and_eq","asm","atomic_cancel","atomic_commit","atomic_noexcept","auto","bitand","bitor","break","case","catch","class","co_await","co_return","co_yield","compl","concept","const_cast|10","consteval","constexpr","constinit","continue","decltype","default","delete","do","dynamic_cast|10","else","enum","explicit","export","extern","false","final","for","friend","goto","if","import","inline","module","mutable","namespace","new","noexcept","not","not_eq","nullptr","operator","or","or_eq","override","private","protected","public","reflexpr","register","reinterpret_cast|10","requires","return","sizeof","static_assert","static_cast|10","struct","switch","synchronized","template","this","thread_local","throw","transaction_safe","transaction_safe_dynamic","true","try","typedef","typeid","typename","union","using","virtual","volatile","while","xor","xor_eq"], +literal:["NULL","false","nullopt","nullptr","true"],built_in:["_Pragma"], +_type_hints:["any","auto_ptr","barrier","binary_semaphore","bitset","complex","condition_variable","condition_variable_any","counting_semaphore","deque","false_type","future","imaginary","initializer_list","istringstream","jthread","latch","lock_guard","multimap","multiset","mutex","optional","ostringstream","packaged_task","pair","promise","priority_queue","queue","recursive_mutex","recursive_timed_mutex","scoped_lock","set","shared_future","shared_lock","shared_mutex","shared_timed_mutex","shared_ptr","stack","string_view","stringstream","timed_mutex","thread","true_type","tuple","unique_lock","unique_ptr","unordered_map","unordered_multimap","unordered_multiset","unordered_set","variant","vector","weak_ptr","wstring","wstring_view"] +},b={className:"function.dispatch",relevance:0,keywords:{ +_hint:["abort","abs","acos","apply","as_const","asin","atan","atan2","calloc","ceil","cerr","cin","clog","cos","cosh","cout","declval","endl","exchange","exit","exp","fabs","floor","fmod","forward","fprintf","fputs","free","frexp","fscanf","future","invoke","isalnum","isalpha","iscntrl","isdigit","isgraph","islower","isprint","ispunct","isspace","isupper","isxdigit","labs","launder","ldexp","log","log10","make_pair","make_shared","make_shared_for_overwrite","make_tuple","make_unique","malloc","memchr","memcmp","memcpy","memset","modf","move","pow","printf","putchar","puts","realloc","scanf","sin","sinh","snprintf","sprintf","sqrt","sscanf","std","stderr","stdin","stdout","strcat","strchr","strcmp","strcpy","strcspn","strlen","strncat","strncmp","strncpy","strpbrk","strrchr","strspn","strstr","swap","tan","tanh","terminate","to_underlying","tolower","toupper","vfprintf","visit","vprintf","vsprintf"] +}, +begin:n.concat(/\b/,/(?!decltype)/,/(?!if)/,/(?!for)/,/(?!switch)/,/(?!while)/,e.IDENT_RE,n.lookahead(/(<[^<>]+>|)\s*\(/)) +},m=[b,c,s,t,e.C_BLOCK_COMMENT_MODE,l,o],p={variants:[{begin:/=/,end:/;/},{ +begin:/\(/,end:/\)/},{beginKeywords:"new throw return else",end:/;/}], +keywords:u,contains:m.concat([{begin:/\(/,end:/\)/,keywords:u, +contains:m.concat(["self"]),relevance:0}]),relevance:0},_={className:"function", +begin:"("+r+"[\\*&\\s]+)+"+g,returnBegin:!0,end:/[{;=]/,excludeEnd:!0, +keywords:u,illegal:/[^\w\s\*&:<>.]/,contains:[{begin:a,keywords:u,relevance:0},{ +begin:g,returnBegin:!0,contains:[d],relevance:0},{begin:/::/,relevance:0},{ +begin:/:/,endsWithParent:!0,contains:[o,l]},{relevance:0,match:/,/},{ +className:"params",begin:/\(/,end:/\)/,keywords:u,relevance:0, +contains:[t,e.C_BLOCK_COMMENT_MODE,o,l,s,{begin:/\(/,end:/\)/,keywords:u, +relevance:0,contains:["self",t,e.C_BLOCK_COMMENT_MODE,o,l,s]}] +},s,t,e.C_BLOCK_COMMENT_MODE,c]};return{name:"C++", +aliases:["cc","c++","h++","hpp","hh","hxx","cxx"],keywords:u,illegal:"",keywords:u,contains:["self",s]},{begin:e.IDENT_RE+"::",keywords:u},{ +match:[/\b(?:enum(?:\s+(?:class|struct))?|class|struct|union)/,/\s+/,/\w+/], +className:{1:"keyword",3:"title.class"}}])}},grmr_csharp:e=>{const n={ +keyword:["abstract","as","base","break","case","catch","class","const","continue","do","else","event","explicit","extern","finally","fixed","for","foreach","goto","if","implicit","in","interface","internal","is","lock","namespace","new","operator","out","override","params","private","protected","public","readonly","record","ref","return","scoped","sealed","sizeof","stackalloc","static","struct","switch","this","throw","try","typeof","unchecked","unsafe","using","virtual","void","volatile","while"].concat(["add","alias","and","ascending","async","await","by","descending","equals","from","get","global","group","init","into","join","let","nameof","not","notnull","on","or","orderby","partial","remove","select","set","unmanaged","value|0","var","when","where","with","yield"]), +built_in:["bool","byte","char","decimal","delegate","double","dynamic","enum","float","int","long","nint","nuint","object","sbyte","short","string","ulong","uint","ushort"], +literal:["default","false","null","true"]},t=e.inherit(e.TITLE_MODE,{ +begin:"[a-zA-Z](\\.?\\w)*"}),a={className:"number",variants:[{ +begin:"\\b(0b[01']+)"},{ +begin:"(-?)\\b([\\d']+(\\.[\\d']*)?|\\.[\\d']+)(u|U|l|L|ul|UL|f|F|b|B)"},{ +begin:"(-?)(\\b0[xX][a-fA-F0-9']+|(\\b[\\d']+(\\.[\\d']*)?|\\.[\\d']+)([eE][-+]?[\\d']+)?)" +}],relevance:0},i={className:"string",begin:'@"',end:'"',contains:[{begin:'""'}] +},r=e.inherit(i,{illegal:/\n/}),s={className:"subst",begin:/\{/,end:/\}/, +keywords:n},o=e.inherit(s,{illegal:/\n/}),l={className:"string",begin:/\$"/, +end:'"',illegal:/\n/,contains:[{begin:/\{\{/},{begin:/\}\}/ +},e.BACKSLASH_ESCAPE,o]},c={className:"string",begin:/\$@"/,end:'"',contains:[{ +begin:/\{\{/},{begin:/\}\}/},{begin:'""'},s]},d=e.inherit(c,{illegal:/\n/, +contains:[{begin:/\{\{/},{begin:/\}\}/},{begin:'""'},o]}) +;s.contains=[c,l,i,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,a,e.C_BLOCK_COMMENT_MODE], +o.contains=[d,l,r,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,a,e.inherit(e.C_BLOCK_COMMENT_MODE,{ +illegal:/\n/})];const g={variants:[c,l,i,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE] +},u={begin:"<",end:">",contains:[{beginKeywords:"in out"},t] +},b=e.IDENT_RE+"(<"+e.IDENT_RE+"(\\s*,\\s*"+e.IDENT_RE+")*>)?(\\[\\])?",m={ +begin:"@"+e.IDENT_RE,relevance:0};return{name:"C#",aliases:["cs","c#"], +keywords:n,illegal:/::/,contains:[e.COMMENT("///","$",{returnBegin:!0, +contains:[{className:"doctag",variants:[{begin:"///",relevance:0},{ +begin:"\x3c!--|--\x3e"},{begin:""}]}] +}),e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,{className:"meta",begin:"#", +end:"$",keywords:{ +keyword:"if else elif endif define undef warning error line region endregion pragma checksum" +}},g,a,{beginKeywords:"class interface",relevance:0,end:/[{;=]/, +illegal:/[^\s:,]/,contains:[{beginKeywords:"where class" +},t,u,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{beginKeywords:"namespace", +relevance:0,end:/[{;=]/,illegal:/[^\s:]/, +contains:[t,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{ +beginKeywords:"record",relevance:0,end:/[{;=]/,illegal:/[^\s:]/, +contains:[t,u,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{className:"meta", +begin:"^\\s*\\[(?=[\\w])",excludeBegin:!0,end:"\\]",excludeEnd:!0,contains:[{ +className:"string",begin:/"/,end:/"/}]},{ +beginKeywords:"new return throw await else",relevance:0},{className:"function", +begin:"("+b+"\\s+)+"+e.IDENT_RE+"\\s*(<[^=]+>\\s*)?\\(",returnBegin:!0, +end:/\s*[{;=]/,excludeEnd:!0,keywords:n,contains:[{ +beginKeywords:"public private protected static internal protected abstract async extern override unsafe virtual new sealed partial", +relevance:0},{begin:e.IDENT_RE+"\\s*(<[^=]+>\\s*)?\\(",returnBegin:!0, +contains:[e.TITLE_MODE,u],relevance:0},{match:/\(\)/},{className:"params", +begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:n,relevance:0, +contains:[g,a,e.C_BLOCK_COMMENT_MODE] +},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},m]}},grmr_css:e=>{ +const n=e.regex,t=ie(e),a=[e.APOS_STRING_MODE,e.QUOTE_STRING_MODE];return{ +name:"CSS",case_insensitive:!0,illegal:/[=|'\$]/,keywords:{ +keyframePosition:"from to"},classNameAliases:{keyframePosition:"selector-tag"}, +contains:[t.BLOCK_COMMENT,{begin:/-(webkit|moz|ms|o)-(?=[a-z])/ +},t.CSS_NUMBER_MODE,{className:"selector-id",begin:/#[A-Za-z0-9_-]+/,relevance:0 +},{className:"selector-class",begin:"\\.[a-zA-Z-][a-zA-Z0-9_-]*",relevance:0 +},t.ATTRIBUTE_SELECTOR_MODE,{className:"selector-pseudo",variants:[{ +begin:":("+oe.join("|")+")"},{begin:":(:)?("+le.join("|")+")"}] +},t.CSS_VARIABLE,{className:"attribute",begin:"\\b("+ce.join("|")+")\\b"},{ +begin:/:/,end:/[;}{]/, +contains:[t.BLOCK_COMMENT,t.HEXCOLOR,t.IMPORTANT,t.CSS_NUMBER_MODE,...a,{ +begin:/(url|data-uri)\(/,end:/\)/,relevance:0,keywords:{built_in:"url data-uri" +},contains:[...a,{className:"string",begin:/[^)]/,endsWithParent:!0, +excludeEnd:!0}]},t.FUNCTION_DISPATCH]},{begin:n.lookahead(/@/),end:"[{;]", +relevance:0,illegal:/:/,contains:[{className:"keyword",begin:/@-?\w[\w]*(-\w+)*/ +},{begin:/\s/,endsWithParent:!0,excludeEnd:!0,relevance:0,keywords:{ +$pattern:/[a-z-]+/,keyword:"and or not only",attribute:se.join(" ")},contains:[{ +begin:/[a-z-]+(?=:)/,className:"attribute"},...a,t.CSS_NUMBER_MODE]}]},{ +className:"selector-tag",begin:"\\b("+re.join("|")+")\\b"}]}},grmr_diff:e=>{ +const n=e.regex;return{name:"Diff",aliases:["patch"],contains:[{ +className:"meta",relevance:10, +match:n.either(/^@@ +-\d+,\d+ +\+\d+,\d+ +@@/,/^\*\*\* +\d+,\d+ +\*\*\*\*$/,/^--- +\d+,\d+ +----$/) +},{className:"comment",variants:[{ +begin:n.either(/Index: /,/^index/,/={3,}/,/^-{3}/,/^\*{3} /,/^\+{3}/,/^diff --git/), +end:/$/},{match:/^\*{15}$/}]},{className:"addition",begin:/^\+/,end:/$/},{ +className:"deletion",begin:/^-/,end:/$/},{className:"addition",begin:/^!/, +end:/$/}]}},grmr_go:e=>{const n={ +keyword:["break","case","chan","const","continue","default","defer","else","fallthrough","for","func","go","goto","if","import","interface","map","package","range","return","select","struct","switch","type","var"], +type:["bool","byte","complex64","complex128","error","float32","float64","int8","int16","int32","int64","string","uint8","uint16","uint32","uint64","int","uint","uintptr","rune"], +literal:["true","false","iota","nil"], +built_in:["append","cap","close","complex","copy","imag","len","make","new","panic","print","println","real","recover","delete"] +};return{name:"Go",aliases:["golang"],keywords:n,illegal:"{const n=e.regex;return{name:"GraphQL",aliases:["gql"], +case_insensitive:!0,disableAutodetect:!1,keywords:{ +keyword:["query","mutation","subscription","type","input","schema","directive","interface","union","scalar","fragment","enum","on"], +literal:["true","false","null"]}, +contains:[e.HASH_COMMENT_MODE,e.QUOTE_STRING_MODE,e.NUMBER_MODE,{ +scope:"punctuation",match:/[.]{3}/,relevance:0},{scope:"punctuation", +begin:/[\!\(\)\:\=\[\]\{\|\}]{1}/,relevance:0},{scope:"variable",begin:/\$/, +end:/\W/,excludeEnd:!0,relevance:0},{scope:"meta",match:/@\w+/,excludeEnd:!0},{ +scope:"symbol",begin:n.concat(/[_A-Za-z][_0-9A-Za-z]*/,n.lookahead(/\s*:/)), +relevance:0}],illegal:[/[;<']/,/BEGIN/]}},grmr_ini:e=>{const n=e.regex,t={ +className:"number",relevance:0,variants:[{begin:/([+-]+)?[\d]+_[\d_]+/},{ +begin:e.NUMBER_RE}]},a=e.COMMENT();a.variants=[{begin:/;/,end:/$/},{begin:/#/, +end:/$/}];const i={className:"variable",variants:[{begin:/\$[\w\d"][\w\d_]*/},{ +begin:/\$\{(.*?)\}/}]},r={className:"literal", +begin:/\bon|off|true|false|yes|no\b/},s={className:"string", +contains:[e.BACKSLASH_ESCAPE],variants:[{begin:"'''",end:"'''",relevance:10},{ +begin:'"""',end:'"""',relevance:10},{begin:'"',end:'"'},{begin:"'",end:"'"}] +},o={begin:/\[/,end:/\]/,contains:[a,r,i,s,t,"self"],relevance:0 +},l=n.either(/[A-Za-z0-9_-]+/,/"(\\"|[^"])*"/,/'[^']*'/);return{ +name:"TOML, also INI",aliases:["toml"],case_insensitive:!0,illegal:/\S/, +contains:[a,{className:"section",begin:/\[+/,end:/\]+/},{ +begin:n.concat(l,"(\\s*\\.\\s*",l,")*",n.lookahead(/\s*=\s*[^#\s]/)), +className:"attr",starts:{end:/$/,contains:[a,o,r,i,s,t]}}]}},grmr_java:e=>{ +const n=e.regex,t="[\xc0-\u02b8a-zA-Z_$][\xc0-\u02b8a-zA-Z_$0-9]*",a=t+pe("(?:<"+t+"~~~(?:\\s*,\\s*"+t+"~~~)*>)?",/~~~/g,2),i={ +keyword:["synchronized","abstract","private","var","static","if","const ","for","while","strictfp","finally","protected","import","native","final","void","enum","else","break","transient","catch","instanceof","volatile","case","assert","package","default","public","try","switch","continue","throws","protected","public","private","module","requires","exports","do","sealed","yield","permits"], +literal:["false","true","null"], +type:["char","boolean","long","float","int","byte","short","double"], +built_in:["super","this"]},r={className:"meta",begin:"@"+t,contains:[{ +begin:/\(/,end:/\)/,contains:["self"]}]},s={className:"params",begin:/\(/, +end:/\)/,keywords:i,relevance:0,contains:[e.C_BLOCK_COMMENT_MODE],endsParent:!0} +;return{name:"Java",aliases:["jsp"],keywords:i,illegal:/<\/|#/, +contains:[e.COMMENT("/\\*\\*","\\*/",{relevance:0,contains:[{begin:/\w+@/, +relevance:0},{className:"doctag",begin:"@[A-Za-z]+"}]}),{ +begin:/import java\.[a-z]+\./,keywords:"import",relevance:2 +},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,{begin:/"""/,end:/"""/, +className:"string",contains:[e.BACKSLASH_ESCAPE] +},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,{ +match:[/\b(?:class|interface|enum|extends|implements|new)/,/\s+/,t],className:{ +1:"keyword",3:"title.class"}},{match:/non-sealed/,scope:"keyword"},{ +begin:[n.concat(/(?!else)/,t),/\s+/,t,/\s+/,/=(?!=)/],className:{1:"type", +3:"variable",5:"operator"}},{begin:[/record/,/\s+/,t],className:{1:"keyword", +3:"title.class"},contains:[s,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{ +beginKeywords:"new throw return else",relevance:0},{ +begin:["(?:"+a+"\\s+)",e.UNDERSCORE_IDENT_RE,/\s*(?=\()/],className:{ +2:"title.function"},keywords:i,contains:[{className:"params",begin:/\(/, +end:/\)/,keywords:i,relevance:0, +contains:[r,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,me,e.C_BLOCK_COMMENT_MODE] +},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},me,r]}},grmr_javascript:Oe, +grmr_json:e=>{const n=["true","false","null"],t={scope:"literal", +beginKeywords:n.join(" ")};return{name:"JSON",keywords:{literal:n},contains:[{ +className:"attr",begin:/"(\\.|[^\\"\r\n])*"(?=\s*:)/,relevance:1.01},{ +match:/[{}[\],:]/,className:"punctuation",relevance:0 +},e.QUOTE_STRING_MODE,t,e.C_NUMBER_MODE,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE], +illegal:"\\S"}},grmr_kotlin:e=>{const n={ +keyword:"abstract as val var vararg get set class object open private protected public noinline crossinline dynamic final enum if else do while for when throw try catch finally import package is in fun override companion reified inline lateinit init interface annotation data sealed internal infix operator out by constructor super tailrec where const inner suspend typealias external expect actual", +built_in:"Byte Short Char Int Long Boolean Float Double Void Unit Nothing", +literal:"true false null"},t={className:"symbol",begin:e.UNDERSCORE_IDENT_RE+"@" +},a={className:"subst",begin:/\$\{/,end:/\}/,contains:[e.C_NUMBER_MODE]},i={ +className:"variable",begin:"\\$"+e.UNDERSCORE_IDENT_RE},r={className:"string", +variants:[{begin:'"""',end:'"""(?=[^"])',contains:[i,a]},{begin:"'",end:"'", +illegal:/\n/,contains:[e.BACKSLASH_ESCAPE]},{begin:'"',end:'"',illegal:/\n/, +contains:[e.BACKSLASH_ESCAPE,i,a]}]};a.contains.push(r);const s={ +className:"meta", +begin:"@(?:file|property|field|get|set|receiver|param|setparam|delegate)\\s*:(?:\\s*"+e.UNDERSCORE_IDENT_RE+")?" +},o={className:"meta",begin:"@"+e.UNDERSCORE_IDENT_RE,contains:[{begin:/\(/, +end:/\)/,contains:[e.inherit(r,{className:"string"}),"self"]}] +},l=me,c=e.COMMENT("/\\*","\\*/",{contains:[e.C_BLOCK_COMMENT_MODE]}),d={ +variants:[{className:"type",begin:e.UNDERSCORE_IDENT_RE},{begin:/\(/,end:/\)/, +contains:[]}]},g=d;return g.variants[1].contains=[d],d.variants[1].contains=[g], +{name:"Kotlin",aliases:["kt","kts"],keywords:n, +contains:[e.COMMENT("/\\*\\*","\\*/",{relevance:0,contains:[{className:"doctag", +begin:"@[A-Za-z]+"}]}),e.C_LINE_COMMENT_MODE,c,{className:"keyword", +begin:/\b(break|continue|return|this)\b/,starts:{contains:[{className:"symbol", +begin:/@\w+/}]}},t,s,o,{className:"function",beginKeywords:"fun",end:"[(]|$", +returnBegin:!0,excludeEnd:!0,keywords:n,relevance:5,contains:[{ +begin:e.UNDERSCORE_IDENT_RE+"\\s*\\(",returnBegin:!0,relevance:0, +contains:[e.UNDERSCORE_TITLE_MODE]},{className:"type",begin://, +keywords:"reified",relevance:0},{className:"params",begin:/\(/,end:/\)/, +endsParent:!0,keywords:n,relevance:0,contains:[{begin:/:/,end:/[=,\/]/, +endsWithParent:!0,contains:[d,e.C_LINE_COMMENT_MODE,c],relevance:0 +},e.C_LINE_COMMENT_MODE,c,s,o,r,e.C_NUMBER_MODE]},c]},{ +begin:[/class|interface|trait/,/\s+/,e.UNDERSCORE_IDENT_RE],beginScope:{ +3:"title.class"},keywords:"class interface trait",end:/[:\{(]|$/,excludeEnd:!0, +illegal:"extends implements",contains:[{ +beginKeywords:"public protected internal private constructor" +},e.UNDERSCORE_TITLE_MODE,{className:"type",begin://,excludeBegin:!0, +excludeEnd:!0,relevance:0},{className:"type",begin:/[,:]\s*/,end:/[<\(,){\s]|$/, +excludeBegin:!0,returnEnd:!0},s,o]},r,{className:"meta",begin:"^#!/usr/bin/env", +end:"$",illegal:"\n"},l]}},grmr_less:e=>{ +const n=ie(e),t=de,a="[\\w-]+",i="("+a+"|@\\{"+a+"\\})",r=[],s=[],o=e=>({ +className:"string",begin:"~?"+e+".*?"+e}),l=(e,n,t)=>({className:e,begin:n, +relevance:t}),c={$pattern:/[a-z-]+/,keyword:"and or not only", +attribute:se.join(" ")},d={begin:"\\(",end:"\\)",contains:s,keywords:c, +relevance:0} +;s.push(e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,o("'"),o('"'),n.CSS_NUMBER_MODE,{ +begin:"(url|data-uri)\\(",starts:{className:"string",end:"[\\)\\n]", +excludeEnd:!0} +},n.HEXCOLOR,d,l("variable","@@?"+a,10),l("variable","@\\{"+a+"\\}"),l("built_in","~?`[^`]*?`"),{ +className:"attribute",begin:a+"\\s*:",end:":",returnBegin:!0,excludeEnd:!0 +},n.IMPORTANT,{beginKeywords:"and not"},n.FUNCTION_DISPATCH);const g=s.concat({ +begin:/\{/,end:/\}/,contains:r}),u={beginKeywords:"when",endsWithParent:!0, +contains:[{beginKeywords:"and not"}].concat(s)},b={begin:i+"\\s*:", +returnBegin:!0,end:/[;}]/,relevance:0,contains:[{begin:/-(webkit|moz|ms|o)-/ +},n.CSS_VARIABLE,{className:"attribute",begin:"\\b("+ce.join("|")+")\\b", +end:/(?=:)/,starts:{endsWithParent:!0,illegal:"[<=$]",relevance:0,contains:s}}] +},m={className:"keyword", +begin:"@(import|media|charset|font-face|(-[a-z]+-)?keyframes|supports|document|namespace|page|viewport|host)\\b", +starts:{end:"[;{}]",keywords:c,returnEnd:!0,contains:s,relevance:0}},p={ +className:"variable",variants:[{begin:"@"+a+"\\s*:",relevance:15},{begin:"@"+a +}],starts:{end:"[;}]",returnEnd:!0,contains:g}},_={variants:[{ +begin:"[\\.#:&\\[>]",end:"[;{}]"},{begin:i,end:/\{/}],returnBegin:!0, +returnEnd:!0,illegal:"[<='$\"]",relevance:0, +contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,u,l("keyword","all\\b"),l("variable","@\\{"+a+"\\}"),{ +begin:"\\b("+re.join("|")+")\\b",className:"selector-tag" +},n.CSS_NUMBER_MODE,l("selector-tag",i,0),l("selector-id","#"+i),l("selector-class","\\."+i,0),l("selector-tag","&",0),n.ATTRIBUTE_SELECTOR_MODE,{ +className:"selector-pseudo",begin:":("+oe.join("|")+")"},{ +className:"selector-pseudo",begin:":(:)?("+le.join("|")+")"},{begin:/\(/, +end:/\)/,relevance:0,contains:g},{begin:"!important"},n.FUNCTION_DISPATCH]},h={ +begin:a+":(:)?"+`(${t.join("|")})`,returnBegin:!0,contains:[_]} +;return r.push(e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,m,p,h,b,_,u,n.FUNCTION_DISPATCH), +{name:"Less",case_insensitive:!0,illegal:"[=>'/<($\"]",contains:r}}, +grmr_lua:e=>{const n="\\[=*\\[",t="\\]=*\\]",a={begin:n,end:t,contains:["self"] +},i=[e.COMMENT("--(?!"+n+")","$"),e.COMMENT("--"+n,t,{contains:[a],relevance:10 +})];return{name:"Lua",keywords:{$pattern:e.UNDERSCORE_IDENT_RE, +literal:"true false nil", +keyword:"and break do else elseif end for goto if in local not or repeat return then until while", +built_in:"_G _ENV _VERSION __index __newindex __mode __call __metatable __tostring __len __gc __add __sub __mul __div __mod __pow __concat __unm __eq __lt __le assert collectgarbage dofile error getfenv getmetatable ipairs load loadfile loadstring module next pairs pcall print rawequal rawget rawset require select setfenv setmetatable tonumber tostring type unpack xpcall arg self coroutine resume yield status wrap create running debug getupvalue debug sethook getmetatable gethook setmetatable setlocal traceback setfenv getinfo setupvalue getlocal getregistry getfenv io lines write close flush open output type read stderr stdin input stdout popen tmpfile math log max acos huge ldexp pi cos tanh pow deg tan cosh sinh random randomseed frexp ceil floor rad abs sqrt modf asin min mod fmod log10 atan2 exp sin atan os exit setlocale date getenv difftime remove time clock tmpname rename execute package preload loadlib loaded loaders cpath config path seeall string sub upper len gfind rep find match char dump gmatch reverse byte format gsub lower table setn insert getn foreachi maxn foreach concat sort remove" +},contains:i.concat([{className:"function",beginKeywords:"function",end:"\\)", +contains:[e.inherit(e.TITLE_MODE,{ +begin:"([_a-zA-Z]\\w*\\.)*([_a-zA-Z]\\w*:)?[_a-zA-Z]\\w*"}),{className:"params", +begin:"\\(",endsWithParent:!0,contains:i}].concat(i) +},e.C_NUMBER_MODE,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,{className:"string", +begin:n,end:t,contains:[a],relevance:5}])}},grmr_makefile:e=>{const n={ +className:"variable",variants:[{begin:"\\$\\("+e.UNDERSCORE_IDENT_RE+"\\)", +contains:[e.BACKSLASH_ESCAPE]},{begin:/\$[@%{ +const n={begin:/<\/?[A-Za-z_]/,end:">",subLanguage:"xml",relevance:0},t={ +variants:[{begin:/\[.+?\]\[.*?\]/,relevance:0},{ +begin:/\[.+?\]\(((data|javascript|mailto):|(?:http|ftp)s?:\/\/).*?\)/, +relevance:2},{ +begin:e.regex.concat(/\[.+?\]\(/,/[A-Za-z][A-Za-z0-9+.-]*/,/:\/\/.*?\)/), +relevance:2},{begin:/\[.+?\]\([./?&#].*?\)/,relevance:1},{ +begin:/\[.*?\]\(.*?\)/,relevance:0}],returnBegin:!0,contains:[{match:/\[(?=\])/ +},{className:"string",relevance:0,begin:"\\[",end:"\\]",excludeBegin:!0, +returnEnd:!0},{className:"link",relevance:0,begin:"\\]\\(",end:"\\)", +excludeBegin:!0,excludeEnd:!0},{className:"symbol",relevance:0,begin:"\\]\\[", +end:"\\]",excludeBegin:!0,excludeEnd:!0}]},a={className:"strong",contains:[], +variants:[{begin:/_{2}(?!\s)/,end:/_{2}/},{begin:/\*{2}(?!\s)/,end:/\*{2}/}] +},i={className:"emphasis",contains:[],variants:[{begin:/\*(?![*\s])/,end:/\*/},{ +begin:/_(?![_\s])/,end:/_/,relevance:0}]},r=e.inherit(a,{contains:[] +}),s=e.inherit(i,{contains:[]});a.contains.push(s),i.contains.push(r) +;let o=[n,t];return[a,i,r,s].forEach((e=>{e.contains=e.contains.concat(o) +})),o=o.concat(a,i),{name:"Markdown",aliases:["md","mkdown","mkd"],contains:[{ +className:"section",variants:[{begin:"^#{1,6}",end:"$",contains:o},{ +begin:"(?=^.+?\\n[=-]{2,}$)",contains:[{begin:"^[=-]*$"},{begin:"^",end:"\\n", +contains:o}]}]},n,{className:"bullet",begin:"^[ \t]*([*+-]|(\\d+\\.))(?=\\s+)", +end:"\\s+",excludeEnd:!0},a,i,{className:"quote",begin:"^>\\s+",contains:o, +end:"$"},{className:"code",variants:[{begin:"(`{3,})[^`](.|\\n)*?\\1`*[ ]*"},{ +begin:"(~{3,})[^~](.|\\n)*?\\1~*[ ]*"},{begin:"```",end:"```+[ ]*$"},{ +begin:"~~~",end:"~~~+[ ]*$"},{begin:"`.+?`"},{begin:"(?=^( {4}|\\t))", +contains:[{begin:"^( {4}|\\t)",end:"(\\n)$"}],relevance:0}]},{ +begin:"^[-\\*]{3,}",end:"$"},t,{begin:/^\[[^\n]+\]:/,returnBegin:!0,contains:[{ +className:"symbol",begin:/\[/,end:/\]/,excludeBegin:!0,excludeEnd:!0},{ +className:"link",begin:/:\s*/,end:/$/,excludeBegin:!0}]}]}},grmr_objectivec:e=>{ +const n=/[a-zA-Z@][a-zA-Z0-9_]*/,t={$pattern:n, +keyword:["@interface","@class","@protocol","@implementation"]};return{ +name:"Objective-C",aliases:["mm","objc","obj-c","obj-c++","objective-c++"], +keywords:{"variable.language":["this","super"],$pattern:n, +keyword:["while","export","sizeof","typedef","const","struct","for","union","volatile","static","mutable","if","do","return","goto","enum","else","break","extern","asm","case","default","register","explicit","typename","switch","continue","inline","readonly","assign","readwrite","self","@synchronized","id","typeof","nonatomic","IBOutlet","IBAction","strong","weak","copy","in","out","inout","bycopy","byref","oneway","__strong","__weak","__block","__autoreleasing","@private","@protected","@public","@try","@property","@end","@throw","@catch","@finally","@autoreleasepool","@synthesize","@dynamic","@selector","@optional","@required","@encode","@package","@import","@defs","@compatibility_alias","__bridge","__bridge_transfer","__bridge_retained","__bridge_retain","__covariant","__contravariant","__kindof","_Nonnull","_Nullable","_Null_unspecified","__FUNCTION__","__PRETTY_FUNCTION__","__attribute__","getter","setter","retain","unsafe_unretained","nonnull","nullable","null_unspecified","null_resettable","class","instancetype","NS_DESIGNATED_INITIALIZER","NS_UNAVAILABLE","NS_REQUIRES_SUPER","NS_RETURNS_INNER_POINTER","NS_INLINE","NS_AVAILABLE","NS_DEPRECATED","NS_ENUM","NS_OPTIONS","NS_SWIFT_UNAVAILABLE","NS_ASSUME_NONNULL_BEGIN","NS_ASSUME_NONNULL_END","NS_REFINED_FOR_SWIFT","NS_SWIFT_NAME","NS_SWIFT_NOTHROW","NS_DURING","NS_HANDLER","NS_ENDHANDLER","NS_VALUERETURN","NS_VOIDRETURN"], +literal:["false","true","FALSE","TRUE","nil","YES","NO","NULL"], +built_in:["dispatch_once_t","dispatch_queue_t","dispatch_sync","dispatch_async","dispatch_once"], +type:["int","float","char","unsigned","signed","short","long","double","wchar_t","unichar","void","bool","BOOL","id|0","_Bool"] +},illegal:"/,end:/$/,illegal:"\\n" +},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{className:"class", +begin:"("+t.keyword.join("|")+")\\b",end:/(\{|$)/,excludeEnd:!0,keywords:t, +contains:[e.UNDERSCORE_TITLE_MODE]},{begin:"\\."+e.UNDERSCORE_IDENT_RE, +relevance:0}]}},grmr_perl:e=>{const n=e.regex,t=/[dualxmsipngr]{0,12}/,a={ +$pattern:/[\w.]+/, +keyword:"abs accept alarm and atan2 bind binmode bless break caller chdir chmod chomp chop chown chr chroot close closedir connect continue cos crypt dbmclose dbmopen defined delete die do dump each else elsif endgrent endhostent endnetent endprotoent endpwent endservent eof eval exec exists exit exp fcntl fileno flock for foreach fork format formline getc getgrent getgrgid getgrnam gethostbyaddr gethostbyname gethostent getlogin getnetbyaddr getnetbyname getnetent getpeername getpgrp getpriority getprotobyname getprotobynumber getprotoent getpwent getpwnam getpwuid getservbyname getservbyport getservent getsockname getsockopt given glob gmtime goto grep gt hex if index int ioctl join keys kill last lc lcfirst length link listen local localtime log lstat lt ma map mkdir msgctl msgget msgrcv msgsnd my ne next no not oct open opendir or ord our pack package pipe pop pos print printf prototype push q|0 qq quotemeta qw qx rand read readdir readline readlink readpipe recv redo ref rename require reset return reverse rewinddir rindex rmdir say scalar seek seekdir select semctl semget semop send setgrent sethostent setnetent setpgrp setpriority setprotoent setpwent setservent setsockopt shift shmctl shmget shmread shmwrite shutdown sin sleep socket socketpair sort splice split sprintf sqrt srand stat state study sub substr symlink syscall sysopen sysread sysseek system syswrite tell telldir tie tied time times tr truncate uc ucfirst umask undef unless unlink unpack unshift untie until use utime values vec wait waitpid wantarray warn when while write x|0 xor y|0" +},i={className:"subst",begin:"[$@]\\{",end:"\\}",keywords:a},r={begin:/->\{/, +end:/\}/},s={variants:[{begin:/\$\d/},{ +begin:n.concat(/[$%@](\^\w\b|#\w+(::\w+)*|\{\w+\}|\w+(::\w*)*)/,"(?![A-Za-z])(?![@$%])") +},{begin:/[$%@][^\s\w{]/,relevance:0}] +},o=[e.BACKSLASH_ESCAPE,i,s],l=[/!/,/\//,/\|/,/\?/,/'/,/"/,/#/],c=(e,a,i="\\1")=>{ +const r="\\1"===i?i:n.concat(i,a) +;return n.concat(n.concat("(?:",e,")"),a,/(?:\\.|[^\\\/])*?/,r,/(?:\\.|[^\\\/])*?/,i,t) +},d=(e,a,i)=>n.concat(n.concat("(?:",e,")"),a,/(?:\\.|[^\\\/])*?/,i,t),g=[s,e.HASH_COMMENT_MODE,e.COMMENT(/^=\w/,/=cut/,{ +endsWithParent:!0}),r,{className:"string",contains:o,variants:[{ +begin:"q[qwxr]?\\s*\\(",end:"\\)",relevance:5},{begin:"q[qwxr]?\\s*\\[", +end:"\\]",relevance:5},{begin:"q[qwxr]?\\s*\\{",end:"\\}",relevance:5},{ +begin:"q[qwxr]?\\s*\\|",end:"\\|",relevance:5},{begin:"q[qwxr]?\\s*<",end:">", +relevance:5},{begin:"qw\\s+q",end:"q",relevance:5},{begin:"'",end:"'", +contains:[e.BACKSLASH_ESCAPE]},{begin:'"',end:'"'},{begin:"`",end:"`", +contains:[e.BACKSLASH_ESCAPE]},{begin:/\{\w+\}/,relevance:0},{ +begin:"-?\\w+\\s*=>",relevance:0}]},{className:"number", +begin:"(\\b0[0-7_]+)|(\\b0x[0-9a-fA-F_]+)|(\\b[1-9][0-9_]*(\\.[0-9_]+)?)|[0_]\\b", +relevance:0},{ +begin:"(\\/\\/|"+e.RE_STARTERS_RE+"|\\b(split|return|print|reverse|grep)\\b)\\s*", +keywords:"split return print reverse grep",relevance:0, +contains:[e.HASH_COMMENT_MODE,{className:"regexp",variants:[{ +begin:c("s|tr|y",n.either(...l,{capture:!0}))},{begin:c("s|tr|y","\\(","\\)")},{ +begin:c("s|tr|y","\\[","\\]")},{begin:c("s|tr|y","\\{","\\}")}],relevance:2},{ +className:"regexp",variants:[{begin:/(m|qr)\/\//,relevance:0},{ +begin:d("(?:m|qr)?",/\//,/\//)},{begin:d("m|qr",n.either(...l,{capture:!0 +}),/\1/)},{begin:d("m|qr",/\(/,/\)/)},{begin:d("m|qr",/\[/,/\]/)},{ +begin:d("m|qr",/\{/,/\}/)}]}]},{className:"function",beginKeywords:"sub", +end:"(\\s*\\(.*?\\))?[;{]",excludeEnd:!0,relevance:5,contains:[e.TITLE_MODE]},{ +begin:"-\\w\\b",relevance:0},{begin:"^__DATA__$",end:"^__END__$", +subLanguage:"mojolicious",contains:[{begin:"^@@.*",end:"$",className:"comment"}] +}];return i.contains=g,r.contains=g,{name:"Perl",aliases:["pl","pm"],keywords:a, +contains:g}},grmr_php:e=>{ +const n=e.regex,t=/(?![A-Za-z0-9])(?![$])/,a=n.concat(/[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*/,t),i=n.concat(/(\\?[A-Z][a-z0-9_\x7f-\xff]+|\\?[A-Z]+(?=[A-Z][a-z0-9_\x7f-\xff])){1,}/,t),r={ +scope:"variable",match:"\\$+"+a},s={scope:"subst",variants:[{begin:/\$\w+/},{ +begin:/\{\$/,end:/\}/}]},o=e.inherit(e.APOS_STRING_MODE,{illegal:null +}),l="[ \t\n]",c={scope:"string",variants:[e.inherit(e.QUOTE_STRING_MODE,{ +illegal:null,contains:e.QUOTE_STRING_MODE.contains.concat(s)}),o,{ +begin:/<<<[ \t]*(?:(\w+)|"(\w+)")\n/,end:/[ \t]*(\w+)\b/, +contains:e.QUOTE_STRING_MODE.contains.concat(s),"on:begin":(e,n)=>{ +n.data._beginMatch=e[1]||e[2]},"on:end":(e,n)=>{ +n.data._beginMatch!==e[1]&&n.ignoreMatch()}},e.END_SAME_AS_BEGIN({ +begin:/<<<[ \t]*'(\w+)'\n/,end:/[ \t]*(\w+)\b/})]},d={scope:"number",variants:[{ +begin:"\\b0[bB][01]+(?:_[01]+)*\\b"},{begin:"\\b0[oO][0-7]+(?:_[0-7]+)*\\b"},{ +begin:"\\b0[xX][\\da-fA-F]+(?:_[\\da-fA-F]+)*\\b"},{ +begin:"(?:\\b\\d+(?:_\\d+)*(\\.(?:\\d+(?:_\\d+)*))?|\\B\\.\\d+)(?:[eE][+-]?\\d+)?" +}],relevance:0 +},g=["false","null","true"],u=["__CLASS__","__DIR__","__FILE__","__FUNCTION__","__COMPILER_HALT_OFFSET__","__LINE__","__METHOD__","__NAMESPACE__","__TRAIT__","die","echo","exit","include","include_once","print","require","require_once","array","abstract","and","as","binary","bool","boolean","break","callable","case","catch","class","clone","const","continue","declare","default","do","double","else","elseif","empty","enddeclare","endfor","endforeach","endif","endswitch","endwhile","enum","eval","extends","final","finally","float","for","foreach","from","global","goto","if","implements","instanceof","insteadof","int","integer","interface","isset","iterable","list","match|0","mixed","new","never","object","or","private","protected","public","readonly","real","return","string","switch","throw","trait","try","unset","use","var","void","while","xor","yield"],b=["Error|0","AppendIterator","ArgumentCountError","ArithmeticError","ArrayIterator","ArrayObject","AssertionError","BadFunctionCallException","BadMethodCallException","CachingIterator","CallbackFilterIterator","CompileError","Countable","DirectoryIterator","DivisionByZeroError","DomainException","EmptyIterator","ErrorException","Exception","FilesystemIterator","FilterIterator","GlobIterator","InfiniteIterator","InvalidArgumentException","IteratorIterator","LengthException","LimitIterator","LogicException","MultipleIterator","NoRewindIterator","OutOfBoundsException","OutOfRangeException","OuterIterator","OverflowException","ParentIterator","ParseError","RangeException","RecursiveArrayIterator","RecursiveCachingIterator","RecursiveCallbackFilterIterator","RecursiveDirectoryIterator","RecursiveFilterIterator","RecursiveIterator","RecursiveIteratorIterator","RecursiveRegexIterator","RecursiveTreeIterator","RegexIterator","RuntimeException","SeekableIterator","SplDoublyLinkedList","SplFileInfo","SplFileObject","SplFixedArray","SplHeap","SplMaxHeap","SplMinHeap","SplObjectStorage","SplObserver","SplPriorityQueue","SplQueue","SplStack","SplSubject","SplTempFileObject","TypeError","UnderflowException","UnexpectedValueException","UnhandledMatchError","ArrayAccess","BackedEnum","Closure","Fiber","Generator","Iterator","IteratorAggregate","Serializable","Stringable","Throwable","Traversable","UnitEnum","WeakReference","WeakMap","Directory","__PHP_Incomplete_Class","parent","php_user_filter","self","static","stdClass"],m={ +keyword:u,literal:(e=>{const n=[];return e.forEach((e=>{ +n.push(e),e.toLowerCase()===e?n.push(e.toUpperCase()):n.push(e.toLowerCase()) +})),n})(g),built_in:b},p=e=>e.map((e=>e.replace(/\|\d+$/,""))),_={variants:[{ +match:[/new/,n.concat(l,"+"),n.concat("(?!",p(b).join("\\b|"),"\\b)"),i],scope:{ +1:"keyword",4:"title.class"}}]},h=n.concat(a,"\\b(?!\\()"),f={variants:[{ +match:[n.concat(/::/,n.lookahead(/(?!class\b)/)),h],scope:{2:"variable.constant" +}},{match:[/::/,/class/],scope:{2:"variable.language"}},{ +match:[i,n.concat(/::/,n.lookahead(/(?!class\b)/)),h],scope:{1:"title.class", +3:"variable.constant"}},{match:[i,n.concat("::",n.lookahead(/(?!class\b)/))], +scope:{1:"title.class"}},{match:[i,/::/,/class/],scope:{1:"title.class", +3:"variable.language"}}]},E={scope:"attr", +match:n.concat(a,n.lookahead(":"),n.lookahead(/(?!::)/))},y={relevance:0, +begin:/\(/,end:/\)/,keywords:m,contains:[E,r,f,e.C_BLOCK_COMMENT_MODE,c,d,_] +},N={relevance:0, +match:[/\b/,n.concat("(?!fn\\b|function\\b|",p(u).join("\\b|"),"|",p(b).join("\\b|"),"\\b)"),a,n.concat(l,"*"),n.lookahead(/(?=\()/)], +scope:{3:"title.function.invoke"},contains:[y]};y.contains.push(N) +;const w=[E,f,e.C_BLOCK_COMMENT_MODE,c,d,_];return{case_insensitive:!1, +keywords:m,contains:[{begin:n.concat(/#\[\s*/,i),beginScope:"meta",end:/]/, +endScope:"meta",keywords:{literal:g,keyword:["new","array"]},contains:[{ +begin:/\[/,end:/]/,keywords:{literal:g,keyword:["new","array"]}, +contains:["self",...w]},...w,{scope:"meta",match:i}] +},e.HASH_COMMENT_MODE,e.COMMENT("//","$"),e.COMMENT("/\\*","\\*/",{contains:[{ +scope:"doctag",match:"@[A-Za-z]+"}]}),{match:/__halt_compiler\(\);/, +keywords:"__halt_compiler",starts:{scope:"comment",end:e.MATCH_NOTHING_RE, +contains:[{match:/\?>/,scope:"meta",endsParent:!0}]}},{scope:"meta",variants:[{ +begin:/<\?php/,relevance:10},{begin:/<\?=/},{begin:/<\?/,relevance:.1},{ +begin:/\?>/}]},{scope:"variable.language",match:/\$this\b/},r,N,f,{ +match:[/const/,/\s/,a],scope:{1:"keyword",3:"variable.constant"}},_,{ +scope:"function",relevance:0,beginKeywords:"fn function",end:/[;{]/, +excludeEnd:!0,illegal:"[$%\\[]",contains:[{beginKeywords:"use" +},e.UNDERSCORE_TITLE_MODE,{begin:"=>",endsParent:!0},{scope:"params", +begin:"\\(",end:"\\)",excludeBegin:!0,excludeEnd:!0,keywords:m, +contains:["self",r,f,e.C_BLOCK_COMMENT_MODE,c,d]}]},{scope:"class",variants:[{ +beginKeywords:"enum",illegal:/[($"]/},{beginKeywords:"class interface trait", +illegal:/[:($"]/}],relevance:0,end:/\{/,excludeEnd:!0,contains:[{ +beginKeywords:"extends implements"},e.UNDERSCORE_TITLE_MODE]},{ +beginKeywords:"namespace",relevance:0,end:";",illegal:/[.']/, +contains:[e.inherit(e.UNDERSCORE_TITLE_MODE,{scope:"title.class"})]},{ +beginKeywords:"use",relevance:0,end:";",contains:[{ +match:/\b(as|const|function)\b/,scope:"keyword"},e.UNDERSCORE_TITLE_MODE]},c,d]} +},grmr_php_template:e=>({name:"PHP template",subLanguage:"xml",contains:[{ +begin:/<\?(php|=)?/,end:/\?>/,subLanguage:"php",contains:[{begin:"/\\*", +end:"\\*/",skip:!0},{begin:'b"',end:'"',skip:!0},{begin:"b'",end:"'",skip:!0 +},e.inherit(e.APOS_STRING_MODE,{illegal:null,className:null,contains:null, +skip:!0}),e.inherit(e.QUOTE_STRING_MODE,{illegal:null,className:null, +contains:null,skip:!0})]}]}),grmr_plaintext:e=>({name:"Plain text", +aliases:["text","txt"],disableAutodetect:!0}),grmr_python:e=>{ +const n=e.regex,t=/[\p{XID_Start}_]\p{XID_Continue}*/u,a=["and","as","assert","async","await","break","case","class","continue","def","del","elif","else","except","finally","for","from","global","if","import","in","is","lambda","match","nonlocal|10","not","or","pass","raise","return","try","while","with","yield"],i={ +$pattern:/[A-Za-z]\w+|__\w+__/,keyword:a, +built_in:["__import__","abs","all","any","ascii","bin","bool","breakpoint","bytearray","bytes","callable","chr","classmethod","compile","complex","delattr","dict","dir","divmod","enumerate","eval","exec","filter","float","format","frozenset","getattr","globals","hasattr","hash","help","hex","id","input","int","isinstance","issubclass","iter","len","list","locals","map","max","memoryview","min","next","object","oct","open","ord","pow","print","property","range","repr","reversed","round","set","setattr","slice","sorted","staticmethod","str","sum","super","tuple","type","vars","zip"], +literal:["__debug__","Ellipsis","False","None","NotImplemented","True"], +type:["Any","Callable","Coroutine","Dict","List","Literal","Generic","Optional","Sequence","Set","Tuple","Type","Union"] +},r={className:"meta",begin:/^(>>>|\.\.\.) /},s={className:"subst",begin:/\{/, +end:/\}/,keywords:i,illegal:/#/},o={begin:/\{\{/,relevance:0},l={ +className:"string",contains:[e.BACKSLASH_ESCAPE],variants:[{ +begin:/([uU]|[bB]|[rR]|[bB][rR]|[rR][bB])?'''/,end:/'''/, +contains:[e.BACKSLASH_ESCAPE,r],relevance:10},{ +begin:/([uU]|[bB]|[rR]|[bB][rR]|[rR][bB])?"""/,end:/"""/, +contains:[e.BACKSLASH_ESCAPE,r],relevance:10},{ +begin:/([fF][rR]|[rR][fF]|[fF])'''/,end:/'''/, +contains:[e.BACKSLASH_ESCAPE,r,o,s]},{begin:/([fF][rR]|[rR][fF]|[fF])"""/, +end:/"""/,contains:[e.BACKSLASH_ESCAPE,r,o,s]},{begin:/([uU]|[rR])'/,end:/'/, +relevance:10},{begin:/([uU]|[rR])"/,end:/"/,relevance:10},{ +begin:/([bB]|[bB][rR]|[rR][bB])'/,end:/'/},{begin:/([bB]|[bB][rR]|[rR][bB])"/, +end:/"/},{begin:/([fF][rR]|[rR][fF]|[fF])'/,end:/'/, +contains:[e.BACKSLASH_ESCAPE,o,s]},{begin:/([fF][rR]|[rR][fF]|[fF])"/,end:/"/, +contains:[e.BACKSLASH_ESCAPE,o,s]},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE] +},c="[0-9](_?[0-9])*",d=`(\\b(${c}))?\\.(${c})|\\b(${c})\\.`,g="\\b|"+a.join("|"),u={ +className:"number",relevance:0,variants:[{ +begin:`(\\b(${c})|(${d}))[eE][+-]?(${c})[jJ]?(?=${g})`},{begin:`(${d})[jJ]?`},{ +begin:`\\b([1-9](_?[0-9])*|0+(_?0)*)[lLjJ]?(?=${g})`},{ +begin:`\\b0[bB](_?[01])+[lL]?(?=${g})`},{begin:`\\b0[oO](_?[0-7])+[lL]?(?=${g})` +},{begin:`\\b0[xX](_?[0-9a-fA-F])+[lL]?(?=${g})`},{begin:`\\b(${c})[jJ](?=${g})` +}]},b={className:"comment",begin:n.lookahead(/# type:/),end:/$/,keywords:i, +contains:[{begin:/# type:/},{begin:/#/,end:/\b\B/,endsWithParent:!0}]},m={ +className:"params",variants:[{className:"",begin:/\(\s*\)/,skip:!0},{begin:/\(/, +end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:i, +contains:["self",r,u,l,e.HASH_COMMENT_MODE]}]};return s.contains=[l,u,r],{ +name:"Python",aliases:["py","gyp","ipython"],unicodeRegex:!0,keywords:i, +illegal:/(<\/|\?)|=>/,contains:[r,u,{begin:/\bself\b/},{beginKeywords:"if", +relevance:0},l,b,e.HASH_COMMENT_MODE,{match:[/\bdef/,/\s+/,t],scope:{ +1:"keyword",3:"title.function"},contains:[m]},{variants:[{ +match:[/\bclass/,/\s+/,t,/\s*/,/\(\s*/,t,/\s*\)/]},{match:[/\bclass/,/\s+/,t]}], +scope:{1:"keyword",3:"title.class",6:"title.class.inherited"}},{ +className:"meta",begin:/^[\t ]*@/,end:/(?=#)|$/,contains:[u,m,l]}]}}, +grmr_python_repl:e=>({aliases:["pycon"],contains:[{className:"meta.prompt", +starts:{end:/ |$/,starts:{end:"$",subLanguage:"python"}},variants:[{ +begin:/^>>>(?=[ ]|$)/},{begin:/^\.\.\.(?=[ ]|$)/}]}]}),grmr_r:e=>{ +const n=e.regex,t=/(?:(?:[a-zA-Z]|\.[._a-zA-Z])[._a-zA-Z0-9]*)|\.(?!\d)/,a=n.either(/0[xX][0-9a-fA-F]+\.[0-9a-fA-F]*[pP][+-]?\d+i?/,/0[xX][0-9a-fA-F]+(?:[pP][+-]?\d+)?[Li]?/,/(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?[Li]?/),i=/[=!<>:]=|\|\||&&|:::?|<-|<<-|->>|->|\|>|[-+*\/?!$&|:<=>@^~]|\*\*/,r=n.either(/[()]/,/[{}]/,/\[\[/,/[[\]]/,/\\/,/,/) +;return{name:"R",keywords:{$pattern:t, +keyword:"function if in break next repeat else for while", +literal:"NULL NA TRUE FALSE Inf NaN NA_integer_|10 NA_real_|10 NA_character_|10 NA_complex_|10", +built_in:"LETTERS letters month.abb month.name pi T F abs acos acosh all any anyNA Arg as.call as.character as.complex as.double as.environment as.integer as.logical as.null.default as.numeric as.raw asin asinh atan atanh attr attributes baseenv browser c call ceiling class Conj cos cosh cospi cummax cummin cumprod cumsum digamma dim dimnames emptyenv exp expression floor forceAndCall gamma gc.time globalenv Im interactive invisible is.array is.atomic is.call is.character is.complex is.double is.environment is.expression is.finite is.function is.infinite is.integer is.language is.list is.logical is.matrix is.na is.name is.nan is.null is.numeric is.object is.pairlist is.raw is.recursive is.single is.symbol lazyLoadDBfetch length lgamma list log max min missing Mod names nargs nzchar oldClass on.exit pos.to.env proc.time prod quote range Re rep retracemem return round seq_along seq_len seq.int sign signif sin sinh sinpi sqrt standardGeneric substitute sum switch tan tanh tanpi tracemem trigamma trunc unclass untracemem UseMethod xtfrm" +},contains:[e.COMMENT(/#'/,/$/,{contains:[{scope:"doctag",match:/@examples/, +starts:{end:n.lookahead(n.either(/\n^#'\s*(?=@[a-zA-Z]+)/,/\n^(?!#')/)), +endsParent:!0}},{scope:"doctag",begin:"@param",end:/$/,contains:[{ +scope:"variable",variants:[{match:t},{match:/`(?:\\.|[^`\\])+`/}],endsParent:!0 +}]},{scope:"doctag",match:/@[a-zA-Z]+/},{scope:"keyword",match:/\\[a-zA-Z]+/}] +}),e.HASH_COMMENT_MODE,{scope:"string",contains:[e.BACKSLASH_ESCAPE], +variants:[e.END_SAME_AS_BEGIN({begin:/[rR]"(-*)\(/,end:/\)(-*)"/ +}),e.END_SAME_AS_BEGIN({begin:/[rR]"(-*)\{/,end:/\}(-*)"/ +}),e.END_SAME_AS_BEGIN({begin:/[rR]"(-*)\[/,end:/\](-*)"/ +}),e.END_SAME_AS_BEGIN({begin:/[rR]'(-*)\(/,end:/\)(-*)'/ +}),e.END_SAME_AS_BEGIN({begin:/[rR]'(-*)\{/,end:/\}(-*)'/ +}),e.END_SAME_AS_BEGIN({begin:/[rR]'(-*)\[/,end:/\](-*)'/}),{begin:'"',end:'"', +relevance:0},{begin:"'",end:"'",relevance:0}]},{relevance:0,variants:[{scope:{ +1:"operator",2:"number"},match:[i,a]},{scope:{1:"operator",2:"number"}, +match:[/%[^%]*%/,a]},{scope:{1:"punctuation",2:"number"},match:[r,a]},{scope:{ +2:"number"},match:[/[^a-zA-Z0-9._]|^/,a]}]},{scope:{3:"operator"}, +match:[t,/\s+/,/<-/,/\s+/]},{scope:"operator",relevance:0,variants:[{match:i},{ +match:/%[^%]*%/}]},{scope:"punctuation",relevance:0,match:r},{begin:"`",end:"`", +contains:[{begin:/\\./}]}]}},grmr_ruby:e=>{ +const n=e.regex,t="([a-zA-Z_]\\w*[!?=]?|[-+~]@|<<|>>|=~|===?|<=>|[<>]=?|\\*\\*|[-/+%^&*~`|]|\\[\\]=?)",a=n.either(/\b([A-Z]+[a-z0-9]+)+/,/\b([A-Z]+[a-z0-9]+)+[A-Z]+/),i=n.concat(a,/(::\w+)*/),r={ +"variable.constant":["__FILE__","__LINE__","__ENCODING__"], +"variable.language":["self","super"], +keyword:["alias","and","begin","BEGIN","break","case","class","defined","do","else","elsif","end","END","ensure","for","if","in","module","next","not","or","redo","require","rescue","retry","return","then","undef","unless","until","when","while","yield","include","extend","prepend","public","private","protected","raise","throw"], +built_in:["proc","lambda","attr_accessor","attr_reader","attr_writer","define_method","private_constant","module_function"], +literal:["true","false","nil"]},s={className:"doctag",begin:"@[A-Za-z]+"},o={ +begin:"#<",end:">"},l=[e.COMMENT("#","$",{contains:[s] +}),e.COMMENT("^=begin","^=end",{contains:[s],relevance:10 +}),e.COMMENT("^__END__",e.MATCH_NOTHING_RE)],c={className:"subst",begin:/#\{/, +end:/\}/,keywords:r},d={className:"string",contains:[e.BACKSLASH_ESCAPE,c], +variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/},{begin:/`/,end:/`/},{ +begin:/%[qQwWx]?\(/,end:/\)/},{begin:/%[qQwWx]?\[/,end:/\]/},{ +begin:/%[qQwWx]?\{/,end:/\}/},{begin:/%[qQwWx]?/},{begin:/%[qQwWx]?\//, +end:/\//},{begin:/%[qQwWx]?%/,end:/%/},{begin:/%[qQwWx]?-/,end:/-/},{ +begin:/%[qQwWx]?\|/,end:/\|/},{begin:/\B\?(\\\d{1,3})/},{ +begin:/\B\?(\\x[A-Fa-f0-9]{1,2})/},{begin:/\B\?(\\u\{?[A-Fa-f0-9]{1,6}\}?)/},{ +begin:/\B\?(\\M-\\C-|\\M-\\c|\\c\\M-|\\M-|\\C-\\M-)[\x20-\x7e]/},{ +begin:/\B\?\\(c|C-)[\x20-\x7e]/},{begin:/\B\?\\?\S/},{ +begin:n.concat(/<<[-~]?'?/,n.lookahead(/(\w+)(?=\W)[^\n]*\n(?:[^\n]*\n)*?\s*\1\b/)), +contains:[e.END_SAME_AS_BEGIN({begin:/(\w+)/,end:/(\w+)/, +contains:[e.BACKSLASH_ESCAPE,c]})]}]},g="[0-9](_?[0-9])*",u={className:"number", +relevance:0,variants:[{ +begin:`\\b([1-9](_?[0-9])*|0)(\\.(${g}))?([eE][+-]?(${g})|r)?i?\\b`},{ +begin:"\\b0[dD][0-9](_?[0-9])*r?i?\\b"},{begin:"\\b0[bB][0-1](_?[0-1])*r?i?\\b" +},{begin:"\\b0[oO][0-7](_?[0-7])*r?i?\\b"},{ +begin:"\\b0[xX][0-9a-fA-F](_?[0-9a-fA-F])*r?i?\\b"},{ +begin:"\\b0(_?[0-7])+r?i?\\b"}]},b={variants:[{match:/\(\)/},{ +className:"params",begin:/\(/,end:/(?=\))/,excludeBegin:!0,endsParent:!0, +keywords:r}]},m=[d,{variants:[{match:[/class\s+/,i,/\s+<\s+/,i]},{ +match:[/\b(class|module)\s+/,i]}],scope:{2:"title.class", +4:"title.class.inherited"},keywords:r},{match:[/(include|extend)\s+/,i],scope:{ +2:"title.class"},keywords:r},{relevance:0,match:[i,/\.new[. (]/],scope:{ +1:"title.class"}},{relevance:0,match:/\b[A-Z][A-Z_0-9]+\b/, +className:"variable.constant"},{relevance:0,match:a,scope:"title.class"},{ +match:[/def/,/\s+/,t],scope:{1:"keyword",3:"title.function"},contains:[b]},{ +begin:e.IDENT_RE+"::"},{className:"symbol", +begin:e.UNDERSCORE_IDENT_RE+"(!|\\?)?:",relevance:0},{className:"symbol", +begin:":(?!\\s)",contains:[d,{begin:t}],relevance:0},u,{className:"variable", +begin:"(\\$\\W)|((\\$|@@?)(\\w+))(?=[^@$?])(?![A-Za-z])(?![@$?'])"},{ +className:"params",begin:/\|/,end:/\|/,excludeBegin:!0,excludeEnd:!0, +relevance:0,keywords:r},{begin:"("+e.RE_STARTERS_RE+"|unless)\\s*", +keywords:"unless",contains:[{className:"regexp",contains:[e.BACKSLASH_ESCAPE,c], +illegal:/\n/,variants:[{begin:"/",end:"/[a-z]*"},{begin:/%r\{/,end:/\}[a-z]*/},{ +begin:"%r\\(",end:"\\)[a-z]*"},{begin:"%r!",end:"![a-z]*"},{begin:"%r\\[", +end:"\\][a-z]*"}]}].concat(o,l),relevance:0}].concat(o,l) +;c.contains=m,b.contains=m;const p=[{begin:/^\s*=>/,starts:{end:"$",contains:m} +},{className:"meta.prompt", +begin:"^([>?]>|[\\w#]+\\(\\w+\\):\\d+:\\d+[>*]|(\\w+-)?\\d+\\.\\d+\\.\\d+(p\\d+)?[^\\d][^>]+>)(?=[ ])", +starts:{end:"$",keywords:r,contains:m}}];return l.unshift(o),{name:"Ruby", +aliases:["rb","gemspec","podspec","thor","irb"],keywords:r,illegal:/\/\*/, +contains:[e.SHEBANG({binary:"ruby"})].concat(p).concat(l).concat(m)}}, +grmr_rust:e=>{const n=e.regex,t={className:"title.function.invoke",relevance:0, +begin:n.concat(/\b/,/(?!let|for|while|if|else|match\b)/,e.IDENT_RE,n.lookahead(/\s*\(/)) +},a="([ui](8|16|32|64|128|size)|f(32|64))?",i=["drop ","Copy","Send","Sized","Sync","Drop","Fn","FnMut","FnOnce","ToOwned","Clone","Debug","PartialEq","PartialOrd","Eq","Ord","AsRef","AsMut","Into","From","Default","Iterator","Extend","IntoIterator","DoubleEndedIterator","ExactSizeIterator","SliceConcatExt","ToString","assert!","assert_eq!","bitflags!","bytes!","cfg!","col!","concat!","concat_idents!","debug_assert!","debug_assert_eq!","env!","eprintln!","panic!","file!","format!","format_args!","include_bytes!","include_str!","line!","local_data_key!","module_path!","option_env!","print!","println!","select!","stringify!","try!","unimplemented!","unreachable!","vec!","write!","writeln!","macro_rules!","assert_ne!","debug_assert_ne!"],r=["i8","i16","i32","i64","i128","isize","u8","u16","u32","u64","u128","usize","f32","f64","str","char","bool","Box","Option","Result","String","Vec"] +;return{name:"Rust",aliases:["rs"],keywords:{$pattern:e.IDENT_RE+"!?",type:r, +keyword:["abstract","as","async","await","become","box","break","const","continue","crate","do","dyn","else","enum","extern","false","final","fn","for","if","impl","in","let","loop","macro","match","mod","move","mut","override","priv","pub","ref","return","self","Self","static","struct","super","trait","true","try","type","typeof","unsafe","unsized","use","virtual","where","while","yield"], +literal:["true","false","Some","None","Ok","Err"],built_in:i},illegal:""},t]}}, +grmr_scss:e=>{const n=ie(e),t=le,a=oe,i="@[a-z-]+",r={className:"variable", +begin:"(\\$[a-zA-Z-][a-zA-Z0-9_-]*)\\b",relevance:0};return{name:"SCSS", +case_insensitive:!0,illegal:"[=/|']", +contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,n.CSS_NUMBER_MODE,{ +className:"selector-id",begin:"#[A-Za-z0-9_-]+",relevance:0},{ +className:"selector-class",begin:"\\.[A-Za-z0-9_-]+",relevance:0 +},n.ATTRIBUTE_SELECTOR_MODE,{className:"selector-tag", +begin:"\\b("+re.join("|")+")\\b",relevance:0},{className:"selector-pseudo", +begin:":("+a.join("|")+")"},{className:"selector-pseudo", +begin:":(:)?("+t.join("|")+")"},r,{begin:/\(/,end:/\)/, +contains:[n.CSS_NUMBER_MODE]},n.CSS_VARIABLE,{className:"attribute", +begin:"\\b("+ce.join("|")+")\\b"},{ +begin:"\\b(whitespace|wait|w-resize|visible|vertical-text|vertical-ideographic|uppercase|upper-roman|upper-alpha|underline|transparent|top|thin|thick|text|text-top|text-bottom|tb-rl|table-header-group|table-footer-group|sw-resize|super|strict|static|square|solid|small-caps|separate|se-resize|scroll|s-resize|rtl|row-resize|ridge|right|repeat|repeat-y|repeat-x|relative|progress|pointer|overline|outside|outset|oblique|nowrap|not-allowed|normal|none|nw-resize|no-repeat|no-drop|newspaper|ne-resize|n-resize|move|middle|medium|ltr|lr-tb|lowercase|lower-roman|lower-alpha|loose|list-item|line|line-through|line-edge|lighter|left|keep-all|justify|italic|inter-word|inter-ideograph|inside|inset|inline|inline-block|inherit|inactive|ideograph-space|ideograph-parenthesis|ideograph-numeric|ideograph-alpha|horizontal|hidden|help|hand|groove|fixed|ellipsis|e-resize|double|dotted|distribute|distribute-space|distribute-letter|distribute-all-lines|disc|disabled|default|decimal|dashed|crosshair|collapse|col-resize|circle|char|center|capitalize|break-word|break-all|bottom|both|bolder|bold|block|bidi-override|below|baseline|auto|always|all-scroll|absolute|table|table-cell)\\b" +},{begin:/:/,end:/[;}{]/,relevance:0, +contains:[n.BLOCK_COMMENT,r,n.HEXCOLOR,n.CSS_NUMBER_MODE,e.QUOTE_STRING_MODE,e.APOS_STRING_MODE,n.IMPORTANT,n.FUNCTION_DISPATCH] +},{begin:"@(page|font-face)",keywords:{$pattern:i,keyword:"@page @font-face"}},{ +begin:"@",end:"[{;]",returnBegin:!0,keywords:{$pattern:/[a-z-]+/, +keyword:"and or not only",attribute:se.join(" ")},contains:[{begin:i, +className:"keyword"},{begin:/[a-z-]+(?=:)/,className:"attribute" +},r,e.QUOTE_STRING_MODE,e.APOS_STRING_MODE,n.HEXCOLOR,n.CSS_NUMBER_MODE] +},n.FUNCTION_DISPATCH]}},grmr_shell:e=>({name:"Shell Session", +aliases:["console","shellsession"],contains:[{className:"meta.prompt", +begin:/^\s{0,3}[/~\w\d[\]()@-]*[>%$#][ ]?/,starts:{end:/[^\\](?=\s*$)/, +subLanguage:"bash"}}]}),grmr_sql:e=>{ +const n=e.regex,t=e.COMMENT("--","$"),a=["true","false","unknown"],i=["bigint","binary","blob","boolean","char","character","clob","date","dec","decfloat","decimal","float","int","integer","interval","nchar","nclob","national","numeric","real","row","smallint","time","timestamp","varchar","varying","varbinary"],r=["abs","acos","array_agg","asin","atan","avg","cast","ceil","ceiling","coalesce","corr","cos","cosh","count","covar_pop","covar_samp","cume_dist","dense_rank","deref","element","exp","extract","first_value","floor","json_array","json_arrayagg","json_exists","json_object","json_objectagg","json_query","json_table","json_table_primitive","json_value","lag","last_value","lead","listagg","ln","log","log10","lower","max","min","mod","nth_value","ntile","nullif","percent_rank","percentile_cont","percentile_disc","position","position_regex","power","rank","regr_avgx","regr_avgy","regr_count","regr_intercept","regr_r2","regr_slope","regr_sxx","regr_sxy","regr_syy","row_number","sin","sinh","sqrt","stddev_pop","stddev_samp","substring","substring_regex","sum","tan","tanh","translate","translate_regex","treat","trim","trim_array","unnest","upper","value_of","var_pop","var_samp","width_bucket"],s=["create table","insert into","primary key","foreign key","not null","alter table","add constraint","grouping sets","on overflow","character set","respect nulls","ignore nulls","nulls first","nulls last","depth first","breadth first"],o=r,l=["abs","acos","all","allocate","alter","and","any","are","array","array_agg","array_max_cardinality","as","asensitive","asin","asymmetric","at","atan","atomic","authorization","avg","begin","begin_frame","begin_partition","between","bigint","binary","blob","boolean","both","by","call","called","cardinality","cascaded","case","cast","ceil","ceiling","char","char_length","character","character_length","check","classifier","clob","close","coalesce","collate","collect","column","commit","condition","connect","constraint","contains","convert","copy","corr","corresponding","cos","cosh","count","covar_pop","covar_samp","create","cross","cube","cume_dist","current","current_catalog","current_date","current_default_transform_group","current_path","current_role","current_row","current_schema","current_time","current_timestamp","current_path","current_role","current_transform_group_for_type","current_user","cursor","cycle","date","day","deallocate","dec","decimal","decfloat","declare","default","define","delete","dense_rank","deref","describe","deterministic","disconnect","distinct","double","drop","dynamic","each","element","else","empty","end","end_frame","end_partition","end-exec","equals","escape","every","except","exec","execute","exists","exp","external","extract","false","fetch","filter","first_value","float","floor","for","foreign","frame_row","free","from","full","function","fusion","get","global","grant","group","grouping","groups","having","hold","hour","identity","in","indicator","initial","inner","inout","insensitive","insert","int","integer","intersect","intersection","interval","into","is","join","json_array","json_arrayagg","json_exists","json_object","json_objectagg","json_query","json_table","json_table_primitive","json_value","lag","language","large","last_value","lateral","lead","leading","left","like","like_regex","listagg","ln","local","localtime","localtimestamp","log","log10","lower","match","match_number","match_recognize","matches","max","member","merge","method","min","minute","mod","modifies","module","month","multiset","national","natural","nchar","nclob","new","no","none","normalize","not","nth_value","ntile","null","nullif","numeric","octet_length","occurrences_regex","of","offset","old","omit","on","one","only","open","or","order","out","outer","over","overlaps","overlay","parameter","partition","pattern","per","percent","percent_rank","percentile_cont","percentile_disc","period","portion","position","position_regex","power","precedes","precision","prepare","primary","procedure","ptf","range","rank","reads","real","recursive","ref","references","referencing","regr_avgx","regr_avgy","regr_count","regr_intercept","regr_r2","regr_slope","regr_sxx","regr_sxy","regr_syy","release","result","return","returns","revoke","right","rollback","rollup","row","row_number","rows","running","savepoint","scope","scroll","search","second","seek","select","sensitive","session_user","set","show","similar","sin","sinh","skip","smallint","some","specific","specifictype","sql","sqlexception","sqlstate","sqlwarning","sqrt","start","static","stddev_pop","stddev_samp","submultiset","subset","substring","substring_regex","succeeds","sum","symmetric","system","system_time","system_user","table","tablesample","tan","tanh","then","time","timestamp","timezone_hour","timezone_minute","to","trailing","translate","translate_regex","translation","treat","trigger","trim","trim_array","true","truncate","uescape","union","unique","unknown","unnest","update","upper","user","using","value","values","value_of","var_pop","var_samp","varbinary","varchar","varying","versioning","when","whenever","where","width_bucket","window","with","within","without","year","add","asc","collation","desc","final","first","last","view"].filter((e=>!r.includes(e))),c={ +begin:n.concat(/\b/,n.either(...o),/\s*\(/),relevance:0,keywords:{built_in:o}} +;return{name:"SQL",case_insensitive:!0,illegal:/[{}]|<\//,keywords:{ +$pattern:/\b[\w\.]+/,keyword:((e,{exceptions:n,when:t}={})=>{const a=t +;return n=n||[],e.map((e=>e.match(/\|\d+$/)||n.includes(e)?e:a(e)?e+"|0":e)) +})(l,{when:e=>e.length<3}),literal:a,type:i, +built_in:["current_catalog","current_date","current_default_transform_group","current_path","current_role","current_schema","current_transform_group_for_type","current_user","session_user","system_time","system_user","current_time","localtime","current_timestamp","localtimestamp"] +},contains:[{begin:n.either(...s),relevance:0,keywords:{$pattern:/[\w\.]+/, +keyword:l.concat(s),literal:a,type:i}},{className:"type", +begin:n.either("double precision","large object","with timezone","without timezone") +},c,{className:"variable",begin:/@[a-z0-9][a-z0-9_]*/},{className:"string", +variants:[{begin:/'/,end:/'/,contains:[{begin:/''/}]}]},{begin:/"/,end:/"/, +contains:[{begin:/""/}]},e.C_NUMBER_MODE,e.C_BLOCK_COMMENT_MODE,t,{ +className:"operator",begin:/[-+*/=%^~]|&&?|\|\|?|!=?|<(?:=>?|<|>)?|>[>=]?/, +relevance:0}]}},grmr_swift:e=>{const n={match:/\s+/,relevance:0 +},t=e.COMMENT("/\\*","\\*/",{contains:["self"]}),a=[e.C_LINE_COMMENT_MODE,t],i={ +match:[/\./,m(...xe,...Me)],className:{2:"keyword"}},r={match:b(/\./,m(...Ae)), +relevance:0},s=Ae.filter((e=>"string"==typeof e)).concat(["_|0"]),o={variants:[{ +className:"keyword", +match:m(...Ae.filter((e=>"string"!=typeof e)).concat(Se).map(ke),...Me)}]},l={ +$pattern:m(/\b\w+/,/#\w+/),keyword:s.concat(Re),literal:Ce},c=[i,r,o],g=[{ +match:b(/\./,m(...De)),relevance:0},{className:"built_in", +match:b(/\b/,m(...De),/(?=\()/)}],u={match:/->/,relevance:0},p=[u,{ +className:"operator",relevance:0,variants:[{match:Be},{match:`\\.(\\.|${Le})+`}] +}],_="([0-9]_*)+",h="([0-9a-fA-F]_*)+",f={className:"number",relevance:0, +variants:[{match:`\\b(${_})(\\.(${_}))?([eE][+-]?(${_}))?\\b`},{ +match:`\\b0x(${h})(\\.(${h}))?([pP][+-]?(${_}))?\\b`},{match:/\b0o([0-7]_*)+\b/ +},{match:/\b0b([01]_*)+\b/}]},E=(e="")=>({className:"subst",variants:[{ +match:b(/\\/,e,/[0\\tnr"']/)},{match:b(/\\/,e,/u\{[0-9a-fA-F]{1,8}\}/)}] +}),y=(e="")=>({className:"subst",match:b(/\\/,e,/[\t ]*(?:[\r\n]|\r\n)/) +}),N=(e="")=>({className:"subst",label:"interpol",begin:b(/\\/,e,/\(/),end:/\)/ +}),w=(e="")=>({begin:b(e,/"""/),end:b(/"""/,e),contains:[E(e),y(e),N(e)] +}),v=(e="")=>({begin:b(e,/"/),end:b(/"/,e),contains:[E(e),N(e)]}),O={ +className:"string", +variants:[w(),w("#"),w("##"),w("###"),v(),v("#"),v("##"),v("###")] +},k=[e.BACKSLASH_ESCAPE,{begin:/\[/,end:/\]/,relevance:0, +contains:[e.BACKSLASH_ESCAPE]}],x={begin:/\/[^\s](?=[^/\n]*\/)/,end:/\//, +contains:k},M=e=>{const n=b(e,/\//),t=b(/\//,e);return{begin:n,end:t, +contains:[...k,{scope:"comment",begin:`#(?!.*${t})`,end:/$/}]}},S={ +scope:"regexp",variants:[M("###"),M("##"),M("#"),x]},A={match:b(/`/,Fe,/`/) +},C=[A,{className:"variable",match:/\$\d+/},{className:"variable", +match:`\\$${ze}+`}],T=[{match:/(@|#(un)?)available/,scope:"keyword",starts:{ +contains:[{begin:/\(/,end:/\)/,keywords:Pe,contains:[...p,f,O]}]}},{ +scope:"keyword",match:b(/@/,m(...je))},{scope:"meta",match:b(/@/,Fe)}],R={ +match:d(/\b[A-Z]/),relevance:0,contains:[{className:"type", +match:b(/(AV|CA|CF|CG|CI|CL|CM|CN|CT|MK|MP|MTK|MTL|NS|SCN|SK|UI|WK|XC)/,ze,"+") +},{className:"type",match:Ue,relevance:0},{match:/[?!]+/,relevance:0},{ +match:/\.\.\./,relevance:0},{match:b(/\s+&\s+/,d(Ue)),relevance:0}]},D={ +begin://,keywords:l,contains:[...a,...c,...T,u,R]};R.contains.push(D) +;const I={begin:/\(/,end:/\)/,relevance:0,keywords:l,contains:["self",{ +match:b(Fe,/\s*:/),keywords:"_|0",relevance:0 +},...a,S,...c,...g,...p,f,O,...C,...T,R]},L={begin://, +keywords:"repeat each",contains:[...a,R]},B={begin:/\(/,end:/\)/,keywords:l, +contains:[{begin:m(d(b(Fe,/\s*:/)),d(b(Fe,/\s+/,Fe,/\s*:/))),end:/:/, +relevance:0,contains:[{className:"keyword",match:/\b_\b/},{className:"params", +match:Fe}]},...a,...c,...p,f,O,...T,R,I],endsParent:!0,illegal:/["']/},$={ +match:[/(func|macro)/,/\s+/,m(A.match,Fe,Be)],className:{1:"keyword", +3:"title.function"},contains:[L,B,n],illegal:[/\[/,/%/]},z={ +match:[/\b(?:subscript|init[?!]?)/,/\s*(?=[<(])/],className:{1:"keyword"}, +contains:[L,B,n],illegal:/\[|%/},F={match:[/operator/,/\s+/,Be],className:{ +1:"keyword",3:"title"}},U={begin:[/precedencegroup/,/\s+/,Ue],className:{ +1:"keyword",3:"title"},contains:[R],keywords:[...Te,...Ce],end:/}/} +;for(const e of O.variants){const n=e.contains.find((e=>"interpol"===e.label)) +;n.keywords=l;const t=[...c,...g,...p,f,O,...C];n.contains=[...t,{begin:/\(/, +end:/\)/,contains:["self",...t]}]}return{name:"Swift",keywords:l, +contains:[...a,$,z,{beginKeywords:"struct protocol class extension enum actor", +end:"\\{",excludeEnd:!0,keywords:l,contains:[e.inherit(e.TITLE_MODE,{ +className:"title.class",begin:/[A-Za-z$_][\u00C0-\u02B80-9A-Za-z$_]*/}),...c] +},F,U,{beginKeywords:"import",end:/$/,contains:[...a],relevance:0 +},S,...c,...g,...p,f,O,...C,...T,R,I]}},grmr_typescript:e=>{ +const n=Oe(e),t=_e,a=["any","void","number","boolean","string","object","never","symbol","bigint","unknown"],i={ +beginKeywords:"namespace",end:/\{/,excludeEnd:!0, +contains:[n.exports.CLASS_REFERENCE]},r={beginKeywords:"interface",end:/\{/, +excludeEnd:!0,keywords:{keyword:"interface extends",built_in:a}, +contains:[n.exports.CLASS_REFERENCE]},s={$pattern:_e, +keyword:he.concat(["type","namespace","interface","public","private","protected","implements","declare","abstract","readonly","enum","override"]), +literal:fe,built_in:ve.concat(a),"variable.language":we},o={className:"meta", +begin:"@"+t},l=(e,n,t)=>{const a=e.contains.findIndex((e=>e.label===n)) +;if(-1===a)throw Error("can not find mode to replace");e.contains.splice(a,1,t)} +;return Object.assign(n.keywords,s), +n.exports.PARAMS_CONTAINS.push(o),n.contains=n.contains.concat([o,i,r]), +l(n,"shebang",e.SHEBANG()),l(n,"use_strict",{className:"meta",relevance:10, +begin:/^\s*['"]use strict['"]/ +}),n.contains.find((e=>"func.def"===e.label)).relevance=0,Object.assign(n,{ +name:"TypeScript",aliases:["ts","tsx","mts","cts"]}),n},grmr_vbnet:e=>{ +const n=e.regex,t=/\d{1,2}\/\d{1,2}\/\d{4}/,a=/\d{4}-\d{1,2}-\d{1,2}/,i=/(\d|1[012])(:\d+){0,2} *(AM|PM)/,r=/\d{1,2}(:\d{1,2}){1,2}/,s={ +className:"literal",variants:[{begin:n.concat(/# */,n.either(a,t),/ *#/)},{ +begin:n.concat(/# */,r,/ *#/)},{begin:n.concat(/# */,i,/ *#/)},{ +begin:n.concat(/# */,n.either(a,t),/ +/,n.either(i,r),/ *#/)}] +},o=e.COMMENT(/'''/,/$/,{contains:[{className:"doctag",begin:/<\/?/,end:/>/}] +}),l=e.COMMENT(null,/$/,{variants:[{begin:/'/},{begin:/([\t ]|^)REM(?=\s)/}]}) +;return{name:"Visual Basic .NET",aliases:["vb"],case_insensitive:!0, +classNameAliases:{label:"symbol"},keywords:{ +keyword:"addhandler alias aggregate ansi as async assembly auto binary by byref byval call case catch class compare const continue custom declare default delegate dim distinct do each equals else elseif end enum erase error event exit explicit finally for friend from function get global goto group handles if implements imports in inherits interface into iterator join key let lib loop me mid module mustinherit mustoverride mybase myclass namespace narrowing new next notinheritable notoverridable of off on operator option optional order overloads overridable overrides paramarray partial preserve private property protected public raiseevent readonly redim removehandler resume return select set shadows shared skip static step stop structure strict sub synclock take text then throw to try unicode until using when where while widening with withevents writeonly yield", +built_in:"addressof and andalso await directcast gettype getxmlnamespace is isfalse isnot istrue like mod nameof new not or orelse trycast typeof xor cbool cbyte cchar cdate cdbl cdec cint clng cobj csbyte cshort csng cstr cuint culng cushort", +type:"boolean byte char date decimal double integer long object sbyte short single string uinteger ulong ushort", +literal:"true false nothing"}, +illegal:"//|\\{|\\}|endif|gosub|variant|wend|^\\$ ",contains:[{ +className:"string",begin:/"(""|[^/n])"C\b/},{className:"string",begin:/"/, +end:/"/,illegal:/\n/,contains:[{begin:/""/}]},s,{className:"number",relevance:0, +variants:[{begin:/\b\d[\d_]*((\.[\d_]+(E[+-]?[\d_]+)?)|(E[+-]?[\d_]+))[RFD@!#]?/ +},{begin:/\b\d[\d_]*((U?[SIL])|[%&])?/},{begin:/&H[\dA-F_]+((U?[SIL])|[%&])?/},{ +begin:/&O[0-7_]+((U?[SIL])|[%&])?/},{begin:/&B[01_]+((U?[SIL])|[%&])?/}]},{ +className:"label",begin:/^\w+:/},o,l,{className:"meta", +begin:/[\t ]*#(const|disable|else|elseif|enable|end|externalsource|if|region)\b/, +end:/$/,keywords:{ +keyword:"const disable else elseif enable end externalsource if region then"}, +contains:[l]}]}},grmr_wasm:e=>{e.regex;const n=e.COMMENT(/\(;/,/;\)/) +;return n.contains.push("self"),{name:"WebAssembly",keywords:{$pattern:/[\w.]+/, +keyword:["anyfunc","block","br","br_if","br_table","call","call_indirect","data","drop","elem","else","end","export","func","global.get","global.set","local.get","local.set","local.tee","get_global","get_local","global","if","import","local","loop","memory","memory.grow","memory.size","module","mut","nop","offset","param","result","return","select","set_global","set_local","start","table","tee_local","then","type","unreachable"] +},contains:[e.COMMENT(/;;/,/$/),n,{match:[/(?:offset|align)/,/\s*/,/=/], +className:{1:"keyword",3:"operator"}},{className:"variable",begin:/\$[\w_]+/},{ +match:/(\((?!;)|\))+/,className:"punctuation",relevance:0},{ +begin:[/(?:func|call|call_indirect)/,/\s+/,/\$[^\s)]+/],className:{1:"keyword", +3:"title.function"}},e.QUOTE_STRING_MODE,{match:/(i32|i64|f32|f64)(?!\.)/, +className:"type"},{className:"keyword", +match:/\b(f32|f64|i32|i64)(?:\.(?:abs|add|and|ceil|clz|const|convert_[su]\/i(?:32|64)|copysign|ctz|demote\/f64|div(?:_[su])?|eqz?|extend_[su]\/i32|floor|ge(?:_[su])?|gt(?:_[su])?|le(?:_[su])?|load(?:(?:8|16|32)_[su])?|lt(?:_[su])?|max|min|mul|nearest|neg?|or|popcnt|promote\/f32|reinterpret\/[fi](?:32|64)|rem_[su]|rot[lr]|shl|shr_[su]|store(?:8|16|32)?|sqrt|sub|trunc(?:_[su]\/f(?:32|64))?|wrap\/i64|xor))\b/ +},{className:"number",relevance:0, +match:/[+-]?\b(?:\d(?:_?\d)*(?:\.\d(?:_?\d)*)?(?:[eE][+-]?\d(?:_?\d)*)?|0x[\da-fA-F](?:_?[\da-fA-F])*(?:\.[\da-fA-F](?:_?[\da-fA-D])*)?(?:[pP][+-]?\d(?:_?\d)*)?)\b|\binf\b|\bnan(?::0x[\da-fA-F](?:_?[\da-fA-D])*)?\b/ +}]}},grmr_xml:e=>{ +const n=e.regex,t=n.concat(/[\p{L}_]/u,n.optional(/[\p{L}0-9_.-]*:/u),/[\p{L}0-9_.-]*/u),a={ +className:"symbol",begin:/&[a-z]+;|&#[0-9]+;|&#x[a-f0-9]+;/},i={begin:/\s/, +contains:[{className:"keyword",begin:/#?[a-z_][a-z1-9_-]+/,illegal:/\n/}] +},r=e.inherit(i,{begin:/\(/,end:/\)/}),s=e.inherit(e.APOS_STRING_MODE,{ +className:"string"}),o=e.inherit(e.QUOTE_STRING_MODE,{className:"string"}),l={ +endsWithParent:!0,illegal:/`]+/}]}]}]};return{ +name:"HTML, XML", +aliases:["html","xhtml","rss","atom","xjb","xsd","xsl","plist","wsf","svg"], +case_insensitive:!0,unicodeRegex:!0,contains:[{className:"meta",begin://,relevance:10,contains:[i,o,s,r,{begin:/\[/,end:/\]/,contains:[{ +className:"meta",begin://,contains:[i,r,o,s]}]}] +},e.COMMENT(//,{relevance:10}),{begin://, +relevance:10},a,{className:"meta",end:/\?>/,variants:[{begin:/<\?xml/, +relevance:10,contains:[o]},{begin:/<\?[a-z][a-z0-9]+/}]},{className:"tag", +begin:/)/,end:/>/,keywords:{name:"style"},contains:[l],starts:{ +end:/<\/style>/,returnEnd:!0,subLanguage:["css","xml"]}},{className:"tag", +begin:/)/,end:/>/,keywords:{name:"script"},contains:[l],starts:{ +end:/<\/script>/,returnEnd:!0,subLanguage:["javascript","handlebars","xml"]}},{ +className:"tag",begin:/<>|<\/>/},{className:"tag", +begin:n.concat(//,/>/,/\s/)))), +end:/\/?>/,contains:[{className:"name",begin:t,relevance:0,starts:l}]},{ +className:"tag",begin:n.concat(/<\//,n.lookahead(n.concat(t,/>/))),contains:[{ +className:"name",begin:t,relevance:0},{begin:/>/,relevance:0,endsParent:!0}]}]} +},grmr_yaml:e=>{ +const n="true false yes no null",t="[\\w#;/?:@&=+$,.~*'()[\\]]+",a={ +className:"string",relevance:0,variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/ +},{begin:/\S+/}],contains:[e.BACKSLASH_ESCAPE,{className:"template-variable", +variants:[{begin:/\{\{/,end:/\}\}/},{begin:/%\{/,end:/\}/}]}]},i=e.inherit(a,{ +variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/},{begin:/[^\s,{}[\]]+/}]}),r={ +end:",",endsWithParent:!0,excludeEnd:!0,keywords:n,relevance:0},s={begin:/\{/, +end:/\}/,contains:[r],illegal:"\\n",relevance:0},o={begin:"\\[",end:"\\]", +contains:[r],illegal:"\\n",relevance:0},l=[{className:"attr",variants:[{ +begin:"\\w[\\w :\\/.-]*:(?=[ \t]|$)"},{begin:'"\\w[\\w :\\/.-]*":(?=[ \t]|$)'},{ +begin:"'\\w[\\w :\\/.-]*':(?=[ \t]|$)"}]},{className:"meta",begin:"^---\\s*$", +relevance:10},{className:"string", +begin:"[\\|>]([1-9]?[+-])?[ ]*\\n( +)[^ ][^\\n]*\\n(\\2[^\\n]+\\n?)*"},{ +begin:"<%[%=-]?",end:"[%-]?%>",subLanguage:"ruby",excludeBegin:!0,excludeEnd:!0, +relevance:0},{className:"type",begin:"!\\w+!"+t},{className:"type", +begin:"!<"+t+">"},{className:"type",begin:"!"+t},{className:"type",begin:"!!"+t +},{className:"meta",begin:"&"+e.UNDERSCORE_IDENT_RE+"$"},{className:"meta", +begin:"\\*"+e.UNDERSCORE_IDENT_RE+"$"},{className:"bullet",begin:"-(?=[ ]|$)", +relevance:0},e.HASH_COMMENT_MODE,{beginKeywords:n,keywords:{literal:n}},{ +className:"number", +begin:"\\b[0-9]{4}(-[0-9][0-9]){0,2}([Tt \\t][0-9][0-9]?(:[0-9][0-9]){2})?(\\.[0-9]*)?([ \\t])*(Z|[-+][0-9][0-9]?(:[0-9][0-9])?)?\\b" +},{className:"number",begin:e.C_NUMBER_RE+"\\b",relevance:0},s,o,a],c=[...l] +;return c.pop(),c.push(i),r.contains=c,{name:"YAML",case_insensitive:!0, +aliases:["yml"],contains:l}}});const He=ae;for(const e of Object.keys(Ke)){ +const n=e.replace("grmr_","").replace("_","-");He.registerLanguage(n,Ke[e])} +return He}() +;"object"==typeof exports&&"undefined"!=typeof module&&(module.exports=hljs);/*! `ocaml` grammar compiled for Highlight.js 11.9.0 */ +(()=>{var e=(()=>{"use strict";return e=>({name:"OCaml",aliases:["ml"], +keywords:{$pattern:"[a-z_]\\w*!?", +keyword:"and as assert asr begin class constraint do done downto else end exception external for fun function functor if in include inherit! inherit initializer land lazy let lor lsl lsr lxor match method!|10 method mod module mutable new object of open! open or private rec sig struct then to try type val! val virtual when while with parser value", +built_in:"array bool bytes char exn|5 float int int32 int64 list lazy_t|5 nativeint|5 string unit in_channel out_channel ref", +literal:"true false"},illegal:/\/\/|>>/,contains:[{className:"literal", +begin:"\\[(\\|\\|)?\\]|\\(\\)",relevance:0},e.COMMENT("\\(\\*","\\*\\)",{ +contains:["self"]}),{className:"symbol",begin:"'[A-Za-z_](?!')[\\w']*"},{ +className:"type",begin:"`[A-Z][\\w']*"},{className:"type", +begin:"\\b[A-Z][\\w']*",relevance:0},{begin:"[a-z_]\\w*'[\\w']*",relevance:0 +},e.inherit(e.APOS_STRING_MODE,{className:"string",relevance:0 +}),e.inherit(e.QUOTE_STRING_MODE,{illegal:null}),{className:"number", +begin:"\\b(0[xX][a-fA-F0-9_]+[Lln]?|0[oO][0-7_]+[Lln]?|0[bB][01_]+[Lln]?|[0-9][0-9_]*([Lln]|(\\.[0-9_]*)?([eE][-+]?[0-9_]+)?)?)", +relevance:0},{begin:/->/}]})})();hljs.registerLanguage("ocaml",e)})(); \ No newline at end of file diff --git a/doc/book/introduction-to-central-cli/stitch.md b/doc/book/introduction-to-central-cli/stitch.md new file mode 100644 index 0000000..f7856fc --- /dev/null +++ b/doc/book/introduction-to-central-cli/stitch.md @@ -0,0 +1,74 @@ +# Stitching a rewritten history + +A narrower situation than [importing a change](import.md): you `export`ed a +change, then reworked the commit(s) that just landed in the subrepo's own +history - splitting it into a nicer sequence, rewording, reordering - +without changing the content it arrives at. `repo//.gitrepo` in +central still names the pre-rewrite commit, which doesn't exist on the +subrepo's `subrepo` branch any more. + +`import` isn't the right tool here: there is nothing to actually bring in, +since the content hasn't changed - only the commit(s) carrying it have. The +fix is: + +``` +central stitch +``` + +which simply repoints `.gitrepo` at the subrepo's new tip and commits that +update itself, with an auto-generated message - central's copy of a subrepo +isn't public history, so unlike `export` there's nothing worth writing by +hand here. + +Say `widget`'s README got a couple of new paragraphs, exported as usual: + +```ansi +$ central export widget -m "Document feature A and B" +==================== widget ==================== +[ OK ] Applied patch in the subrepo. +[ OK ] Exported to [widget]. +``` + +Now imagine that squashed commit gets reworked directly in `widget`'s +own history into two smaller commits instead - reaching the exact same +final `README.md` either way: + +```ansi +Document feature B +Document feature A +Initial commit +``` + +`import` would refuse at this point - the commit `.gitrepo` names is +gone, so it can't tell this apart from history that was reset or +rewritten in some more troubling way. `stitch` recognizes it for what +it is and just catches `.gitrepo` up: + +```ansi +$ central stitch widget +==================== widget ==================== +[ OK ] Stitched [widget]. +``` + +`central todo` shows the same pattern as after any `export` - `widget`'s +own `main` is one `advance-main` behind, nothing to do with the rewrite +just stitched over: + +```ansi +$ central todo +┌──────────┬──────────────┬──────┐ +│ Repo │ Next step │ Diff │ +├──────────┼──────────────┼──────┤ +│ central │ push │ 8 │ +│ widget │ advance-main │ │ +└──────────┴──────────────┴──────┘ +``` + +`stitch` refuses if the subrepo's tip has any real content diff against what +`.gitrepo` records - that would mean actual changes, not just a rewrite, and +those need `import` instead - or if central has moved on with local changes +of its own under `repo//` in the meantime. + +That covers the two directions changes travel between central and a +subrepo. Either way, once a change has landed in a subrepo's own history, +the last step is [pushing it out for real](push.md). diff --git a/doc/book/introduction-to-central-cli/stitch.ml b/doc/book/introduction-to-central-cli/stitch.ml new file mode 100644 index 0000000..94b353e --- /dev/null +++ b/doc/book/introduction-to-central-cli/stitch.ml @@ -0,0 +1,170 @@ +(*********************************************************************************) +(* central - Manage history between sub-repos and their monorepo *) +(* SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(* SPDX-License-Identifier: MIT *) +(*********************************************************************************) + +(* Like [export.ml] and [import.ml], this page runs the real [central] + executable (see [Central_test_harness]), against a throwaway fake repo + (see [Central_test_helpers]). *) + +(* @mdexp.config { snapshot: { lang: "ansi" } } *) + +(* @mdexp + +# Stitching a rewritten history + +A narrower situation than [importing a change](import.md): you `export`ed a +change, then reworked the commit(s) that just landed in the subrepo's own +history - splitting it into a nicer sequence, rewording, reordering - +without changing the content it arrives at. `repo//.gitrepo` in +central still names the pre-rewrite commit, which doesn't exist on the +subrepo's `subrepo` branch any more. + +`import` isn't the right tool here: there is nothing to actually bring in, +since the content hasn't changed - only the commit(s) carrying it have. The +fix is: + +``` +central stitch +``` + +which simply repoints `.gitrepo` at the subrepo's new tip and commits that +update itself, with an auto-generated message - central's copy of a subrepo +isn't public history, so unlike `export` there's nothing worth writing by +hand here. + +Say `widget`'s README got a couple of new paragraphs, exported as usual: *) + +let widget = Central.Subrepo.v "widget" + +let central_path subrepo ~subrepo_path = + Vcs.Path_in_repo.v + (Filename.concat + (Vcs.Path_in_repo.to_string (Central.Subrepo.root subrepo)) + (Vcs.Path_in_repo.to_string subrepo_path)) +;; + +let%expect_test "stitch" = + let vcs = Volgo_git_unix.create () in + let fake_central = Central_test_helpers.create ~vcs ~subrepos:[ widget ] in + let { Central_test_helpers.Fake_central.central_root; _ } = fake_central in + let fake_widget = + Central_test_helpers.Fake_central.find_exn fake_central ~subrepo:widget + in + let readme_path = central_path widget ~subrepo_path:(Vcs.Path_in_repo.v "README.md") in + Central_test_helpers.append_file + ~repo_root:central_root + ~path_in_repo:readme_path + ~text:"\nDescribe feature A.\n\nDescribe feature B.\n"; + Vcs.add vcs ~repo_root:central_root ~path:readme_path; + let (_ : Vcs.Rev.t) = + Central_test_helpers.commit + ~vcs + ~repo_root:central_root + ~commit_message:"Document feature A and B" + in + let harness = Central_test_harness.create ~repo_root:central_root in + let@ central = Central_test_harness.with_cli harness ~cwd:central_root in + central [ [ "export"; "widget" ]; [ "-m"; "Document feature A and B" ] ]; + (* @mdexp.snapshot *) + [%expect + {| + $ central export widget -m "Document feature A and B" + ==================== widget ==================== + [ OK ] Applied patch in the subrepo. + [ OK ] Exported to [widget]. + |}]; + (* @mdexp + + Now imagine that squashed commit gets reworked directly in `widget`'s + own history into two smaller commits instead - reaching the exact same + final `README.md` either way: *) + let readme = Vcs.Path_in_repo.v "README.md" in + Vcs.git + vcs + ~repo_root:fake_widget.repo_root + ~args:[ "checkout"; "subrepo" ] + ~f:Vcs.Git.exit0; + Vcs.git + vcs + ~repo_root:fake_widget.repo_root + ~args:[ "reset"; "--hard"; "HEAD~1" ] + ~f:Vcs.Git.exit0; + Central_test_helpers.append_file + ~repo_root:fake_widget.repo_root + ~path_in_repo:readme + ~text:"\nDescribe feature A.\n"; + Vcs.add vcs ~repo_root:fake_widget.repo_root ~path:readme; + let (_ : Vcs.Rev.t) = + Central_test_helpers.commit + ~vcs + ~repo_root:fake_widget.repo_root + ~commit_message:"Document feature A" + in + Central_test_helpers.append_file + ~repo_root:fake_widget.repo_root + ~path_in_repo:readme + ~text:"\nDescribe feature B.\n"; + Vcs.add vcs ~repo_root:fake_widget.repo_root ~path:readme; + let (_ : Vcs.Rev.t) = + Central_test_helpers.commit + ~vcs + ~repo_root:fake_widget.repo_root + ~commit_message:"Document feature B" + in + Central_test_helpers.print_log_subjects + ~vcs + ~repo_root:fake_widget.repo_root + ~ref_:"subrepo" + (); + (* @mdexp.snapshot *) + [%expect + {| + Document feature B + Document feature A + Initial commit + |}]; + (* @mdexp + + `import` would refuse at this point - the commit `.gitrepo` names is + gone, so it can't tell this apart from history that was reset or + rewritten in some more troubling way. `stitch` recognizes it for what + it is and just catches `.gitrepo` up: *) + central [ [ "stitch"; "widget" ] ]; + (* @mdexp.snapshot *) + [%expect + {| + $ central stitch widget + ==================== widget ==================== + [ OK ] Stitched [widget]. + |}]; + (* @mdexp + + `central todo` shows the same pattern as after any `export` - `widget`'s + own `main` is one `advance-main` behind, nothing to do with the rewrite + just stitched over: *) + central [ [ "todo" ] ]; + (* @mdexp.snapshot *) + [%expect + {| + $ central todo + ┌──────────┬──────────────┬──────┐ + │ Repo │ Next step │ Diff │ + ├──────────┼──────────────┼──────┤ + │ central │ push │ 8 │ + │ widget │ advance-main │ │ + └──────────┴──────────────┴──────┘ + |}] +;; + +(* @mdexp + +`stitch` refuses if the subrepo's tip has any real content diff against what +`.gitrepo` records - that would mean actual changes, not just a rewrite, and +those need `import` instead - or if central has moved on with local changes +of its own under `repo//` in the meantime. + +That covers the two directions changes travel between central and a +subrepo. Either way, once a change has landed in a subrepo's own history, +the last step is [pushing it out for real](push.md). *) diff --git a/doc/book/introduction-to-central-cli/stitch.mli b/doc/book/introduction-to-central-cli/stitch.mli new file mode 100644 index 0000000..bdaa586 --- /dev/null +++ b/doc/book/introduction-to-central-cli/stitch.mli @@ -0,0 +1,5 @@ +(*_********************************************************************************) +(*_ central - Manage history between sub-repos and their monorepo *) +(*_ SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(*_ SPDX-License-Identifier: MIT *) +(*_********************************************************************************) diff --git a/doc/book/shared-theme/ansi-plugin.js b/doc/book/shared-theme/ansi-plugin.js new file mode 100644 index 0000000..f7362bc --- /dev/null +++ b/doc/book/shared-theme/ansi-plugin.js @@ -0,0 +1,123 @@ +// ANSI to HTML converter for terminal code blocks +// Converts ANSI escape sequences to colored HTML spans +(function () { + "use strict"; + + // Map ANSI codes to CSS classes/styles + var ansiStyles = { + // Reset + "0": null, + // Bold + "1": "font-weight:bold", + // Italic + "3": "font-style:italic", + // Underline + "4": "text-decoration:underline", + // Standard colors (foreground) + "30": "color:#073642", // black + "31": "color:#dc322f", // red + "32": "color:#859900", // green + "33": "color:#b58900", // yellow + "34": "color:#268bd2", // blue + "35": "color:#d33682", // magenta + "36": "color:#2aa198", // cyan + "37": "color:#eee8d5", // white + // Bright colors (foreground) + "90": "color:#586e75", // bright black (gray) + "91": "color:#cb4b16", // bright red + "92": "color:#586e75", // bright green + "93": "color:#657b83", // bright yellow + "94": "color:#839496", // bright blue + "95": "color:#6c71c4", // bright magenta + "96": "color:#93a1a1", // bright cyan + "97": "color:#fdf6e3", // bright white + }; + + function ansiToHtml(text) { + var result = ""; + var currentStyles = []; + var i = 0; + + while (i < text.length) { + // Check for ESC character (0x1b) + if (text.charCodeAt(i) === 0x1b && text[i + 1] === "[") { + // Find the end of the escape sequence (the 'm') + var j = i + 2; + while (j < text.length && text[j] !== "m") { + j++; + } + if (j < text.length) { + // Extract the codes (e.g., "1;31" from ESC[1;31m) + var codes = text.substring(i + 2, j).split(";"); + + // Close any open spans for reset + if (codes.indexOf("0") !== -1 || codes.length === 0) { + for (var k = 0; k < currentStyles.length; k++) { + result += ""; + } + currentStyles = []; + } + + // Apply new styles + var newStyles = []; + for (var c = 0; c < codes.length; c++) { + var code = codes[c]; + if (code === "0") continue; // reset handled above + + // Handle combined codes like "1;31" (bold red) + var style = ansiStyles[code]; + if (style) { + newStyles.push(style); + } + } + + if (newStyles.length > 0) { + result += ''; + currentStyles.push(newStyles.length); + } + + i = j + 1; // Skip past the 'm' + continue; + } + } + + // Escape HTML special characters + var char = text[i]; + if (char === "<") { + result += "<"; + } else if (char === ">") { + result += ">"; + } else if (char === "&") { + result += "&"; + } else { + result += char; + } + i++; + } + + // Close any remaining open spans + for (var s = 0; s < currentStyles.length; s++) { + result += ""; + } + + return result; + } + + // Process all terminal code blocks + function processTerminalBlocks() { + document + .querySelectorAll("code.language-terminal, code.language-ansi") + .forEach(function (block) { + var text = block.textContent; + block.innerHTML = ansiToHtml(text); + block.classList.add("hljs"); // Add hljs class for consistent styling + }); + } + + // Run when DOM is ready + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", processTerminalBlocks); + } else { + processTerminalBlocks(); + } +})(); diff --git a/doc/book/shared-theme/highlight.js b/doc/book/shared-theme/highlight.js new file mode 100644 index 0000000..6b6bf4a --- /dev/null +++ b/doc/book/shared-theme/highlight.js @@ -0,0 +1,1226 @@ +/*! + Highlight.js v11.9.0 (git: f47103d4f1) + (c) 2006-2023 undefined and other contributors + License: BSD-3-Clause + */ +var hljs=function(){"use strict";function e(n){ +return n instanceof Map?n.clear=n.delete=n.set=()=>{ +throw Error("map is read-only")}:n instanceof Set&&(n.add=n.clear=n.delete=()=>{ +throw Error("set is read-only") +}),Object.freeze(n),Object.getOwnPropertyNames(n).forEach((t=>{ +const a=n[t],i=typeof a;"object"!==i&&"function"!==i||Object.isFrozen(a)||e(a) +})),n}class n{constructor(e){ +void 0===e.data&&(e.data={}),this.data=e.data,this.isMatchIgnored=!1} +ignoreMatch(){this.isMatchIgnored=!0}}function t(e){ +return e.replace(/&/g,"&").replace(//g,">").replace(/"/g,""").replace(/'/g,"'") +}function a(e,...n){const t=Object.create(null);for(const n in e)t[n]=e[n] +;return n.forEach((e=>{for(const n in e)t[n]=e[n]})),t}const i=e=>!!e.scope +;class r{constructor(e,n){ +this.buffer="",this.classPrefix=n.classPrefix,e.walk(this)}addText(e){ +this.buffer+=t(e)}openNode(e){if(!i(e))return;const n=((e,{prefix:n})=>{ +if(e.startsWith("language:"))return e.replace("language:","language-") +;if(e.includes(".")){const t=e.split(".") +;return[`${n}${t.shift()}`,...t.map(((e,n)=>`${e}${"_".repeat(n+1)}`))].join(" ") +}return`${n}${e}`})(e.scope,{prefix:this.classPrefix});this.span(n)} +closeNode(e){i(e)&&(this.buffer+="")}value(){return this.buffer}span(e){ +this.buffer+=``}}const s=(e={})=>{const n={children:[]} +;return Object.assign(n,e),n};class o{constructor(){ +this.rootNode=s(),this.stack=[this.rootNode]}get top(){ +return this.stack[this.stack.length-1]}get root(){return this.rootNode}add(e){ +this.top.children.push(e)}openNode(e){const n=s({scope:e}) +;this.add(n),this.stack.push(n)}closeNode(){ +if(this.stack.length>1)return this.stack.pop()}closeAllNodes(){ +for(;this.closeNode(););}toJSON(){return JSON.stringify(this.rootNode,null,4)} +walk(e){return this.constructor._walk(e,this.rootNode)}static _walk(e,n){ +return"string"==typeof n?e.addText(n):n.children&&(e.openNode(n), +n.children.forEach((n=>this._walk(e,n))),e.closeNode(n)),e}static _collapse(e){ +"string"!=typeof e&&e.children&&(e.children.every((e=>"string"==typeof e))?e.children=[e.children.join("")]:e.children.forEach((e=>{ +o._collapse(e)})))}}class l extends o{constructor(e){super(),this.options=e} +addText(e){""!==e&&this.add(e)}startScope(e){this.openNode(e)}endScope(){ +this.closeNode()}__addSublanguage(e,n){const t=e.root +;n&&(t.scope="language:"+n),this.add(t)}toHTML(){ +return new r(this,this.options).value()}finalize(){ +return this.closeAllNodes(),!0}}function c(e){ +return e?"string"==typeof e?e:e.source:null}function d(e){return b("(?=",e,")")} +function g(e){return b("(?:",e,")*")}function u(e){return b("(?:",e,")?")} +function b(...e){return e.map((e=>c(e))).join("")}function m(...e){const n=(e=>{ +const n=e[e.length-1] +;return"object"==typeof n&&n.constructor===Object?(e.splice(e.length-1,1),n):{} +})(e);return"("+(n.capture?"":"?:")+e.map((e=>c(e))).join("|")+")"} +function p(e){return RegExp(e.toString()+"|").exec("").length-1} +const _=/\[(?:[^\\\]]|\\.)*\]|\(\??|\\([1-9][0-9]*)|\\./ +;function h(e,{joinWith:n}){let t=0;return e.map((e=>{t+=1;const n=t +;let a=c(e),i="";for(;a.length>0;){const e=_.exec(a);if(!e){i+=a;break} +i+=a.substring(0,e.index), +a=a.substring(e.index+e[0].length),"\\"===e[0][0]&&e[1]?i+="\\"+(Number(e[1])+n):(i+=e[0], +"("===e[0]&&t++)}return i})).map((e=>`(${e})`)).join(n)} +const f="[a-zA-Z]\\w*",E="[a-zA-Z_]\\w*",y="\\b\\d+(\\.\\d+)?",N="(-?)(\\b0[xX][a-fA-F0-9]+|(\\b\\d+(\\.\\d*)?|\\.\\d+)([eE][-+]?\\d+)?)",w="\\b(0b[01]+)",v={ +begin:"\\\\[\\s\\S]",relevance:0},O={scope:"string",begin:"'",end:"'", +illegal:"\\n",contains:[v]},k={scope:"string",begin:'"',end:'"',illegal:"\\n", +contains:[v]},x=(e,n,t={})=>{const i=a({scope:"comment",begin:e,end:n, +contains:[]},t);i.contains.push({scope:"doctag", +begin:"[ ]*(?=(TODO|FIXME|NOTE|BUG|OPTIMIZE|HACK|XXX):)", +end:/(TODO|FIXME|NOTE|BUG|OPTIMIZE|HACK|XXX):/,excludeBegin:!0,relevance:0}) +;const r=m("I","a","is","so","us","to","at","if","in","it","on",/[A-Za-z]+['](d|ve|re|ll|t|s|n)/,/[A-Za-z]+[-][a-z]+/,/[A-Za-z][a-z]{2,}/) +;return i.contains.push({begin:b(/[ ]+/,"(",r,/[.]?[:]?([.][ ]|[ ])/,"){3}")}),i +},M=x("//","$"),S=x("/\\*","\\*/"),A=x("#","$");var C=Object.freeze({ +__proto__:null,APOS_STRING_MODE:O,BACKSLASH_ESCAPE:v,BINARY_NUMBER_MODE:{ +scope:"number",begin:w,relevance:0},BINARY_NUMBER_RE:w,COMMENT:x, +C_BLOCK_COMMENT_MODE:S,C_LINE_COMMENT_MODE:M,C_NUMBER_MODE:{scope:"number", +begin:N,relevance:0},C_NUMBER_RE:N,END_SAME_AS_BEGIN:e=>Object.assign(e,{ +"on:begin":(e,n)=>{n.data._beginMatch=e[1]},"on:end":(e,n)=>{ +n.data._beginMatch!==e[1]&&n.ignoreMatch()}}),HASH_COMMENT_MODE:A,IDENT_RE:f, +MATCH_NOTHING_RE:/\b\B/,METHOD_GUARD:{begin:"\\.\\s*"+E,relevance:0}, +NUMBER_MODE:{scope:"number",begin:y,relevance:0},NUMBER_RE:y, +PHRASAL_WORDS_MODE:{ +begin:/\b(a|an|the|are|I'm|isn't|don't|doesn't|won't|but|just|should|pretty|simply|enough|gonna|going|wtf|so|such|will|you|your|they|like|more)\b/ +},QUOTE_STRING_MODE:k,REGEXP_MODE:{scope:"regexp",begin:/\/(?=[^/\n]*\/)/, +end:/\/[gimuy]*/,contains:[v,{begin:/\[/,end:/\]/,relevance:0,contains:[v]}]}, +RE_STARTERS_RE:"!|!=|!==|%|%=|&|&&|&=|\\*|\\*=|\\+|\\+=|,|-|-=|/=|/|:|;|<<|<<=|<=|<|===|==|=|>>>=|>>=|>=|>>>|>>|>|\\?|\\[|\\{|\\(|\\^|\\^=|\\||\\|=|\\|\\||~", +SHEBANG:(e={})=>{const n=/^#![ ]*\// +;return e.binary&&(e.begin=b(n,/.*\b/,e.binary,/\b.*/)),a({scope:"meta",begin:n, +end:/$/,relevance:0,"on:begin":(e,n)=>{0!==e.index&&n.ignoreMatch()}},e)}, +TITLE_MODE:{scope:"title",begin:f,relevance:0},UNDERSCORE_IDENT_RE:E, +UNDERSCORE_TITLE_MODE:{scope:"title",begin:E,relevance:0}});function T(e,n){ +"."===e.input[e.index-1]&&n.ignoreMatch()}function R(e,n){ +void 0!==e.className&&(e.scope=e.className,delete e.className)}function D(e,n){ +n&&e.beginKeywords&&(e.begin="\\b("+e.beginKeywords.split(" ").join("|")+")(?!\\.)(?=\\b|\\s)", +e.__beforeBegin=T,e.keywords=e.keywords||e.beginKeywords,delete e.beginKeywords, +void 0===e.relevance&&(e.relevance=0))}function I(e,n){ +Array.isArray(e.illegal)&&(e.illegal=m(...e.illegal))}function L(e,n){ +if(e.match){ +if(e.begin||e.end)throw Error("begin & end are not supported with match") +;e.begin=e.match,delete e.match}}function B(e,n){ +void 0===e.relevance&&(e.relevance=1)}const $=(e,n)=>{if(!e.beforeMatch)return +;if(e.starts)throw Error("beforeMatch cannot be used with starts") +;const t=Object.assign({},e);Object.keys(e).forEach((n=>{delete e[n] +})),e.keywords=t.keywords,e.begin=b(t.beforeMatch,d(t.begin)),e.starts={ +relevance:0,contains:[Object.assign(t,{endsParent:!0})] +},e.relevance=0,delete t.beforeMatch +},z=["of","and","for","in","not","or","if","then","parent","list","value"],F="keyword" +;function U(e,n,t=F){const a=Object.create(null) +;return"string"==typeof e?i(t,e.split(" ")):Array.isArray(e)?i(t,e):Object.keys(e).forEach((t=>{ +Object.assign(a,U(e[t],n,t))})),a;function i(e,t){ +n&&(t=t.map((e=>e.toLowerCase()))),t.forEach((n=>{const t=n.split("|") +;a[t[0]]=[e,j(t[0],t[1])]}))}}function j(e,n){ +return n?Number(n):(e=>z.includes(e.toLowerCase()))(e)?0:1}const P={},K=e=>{ +console.error(e)},H=(e,...n)=>{console.log("WARN: "+e,...n)},q=(e,n)=>{ +P[`${e}/${n}`]||(console.log(`Deprecated as of ${e}. ${n}`),P[`${e}/${n}`]=!0) +},G=Error();function Z(e,n,{key:t}){let a=0;const i=e[t],r={},s={} +;for(let e=1;e<=n.length;e++)s[e+a]=i[e],r[e+a]=!0,a+=p(n[e-1]) +;e[t]=s,e[t]._emit=r,e[t]._multi=!0}function W(e){(e=>{ +e.scope&&"object"==typeof e.scope&&null!==e.scope&&(e.beginScope=e.scope, +delete e.scope)})(e),"string"==typeof e.beginScope&&(e.beginScope={ +_wrap:e.beginScope}),"string"==typeof e.endScope&&(e.endScope={_wrap:e.endScope +}),(e=>{if(Array.isArray(e.begin)){ +if(e.skip||e.excludeBegin||e.returnBegin)throw K("skip, excludeBegin, returnBegin not compatible with beginScope: {}"), +G +;if("object"!=typeof e.beginScope||null===e.beginScope)throw K("beginScope must be object"), +G;Z(e,e.begin,{key:"beginScope"}),e.begin=h(e.begin,{joinWith:""})}})(e),(e=>{ +if(Array.isArray(e.end)){ +if(e.skip||e.excludeEnd||e.returnEnd)throw K("skip, excludeEnd, returnEnd not compatible with endScope: {}"), +G +;if("object"!=typeof e.endScope||null===e.endScope)throw K("endScope must be object"), +G;Z(e,e.end,{key:"endScope"}),e.end=h(e.end,{joinWith:""})}})(e)}function Q(e){ +function n(n,t){ +return RegExp(c(n),"m"+(e.case_insensitive?"i":"")+(e.unicodeRegex?"u":"")+(t?"g":"")) +}class t{constructor(){ +this.matchIndexes={},this.regexes=[],this.matchAt=1,this.position=0} +addRule(e,n){ +n.position=this.position++,this.matchIndexes[this.matchAt]=n,this.regexes.push([n,e]), +this.matchAt+=p(e)+1}compile(){0===this.regexes.length&&(this.exec=()=>null) +;const e=this.regexes.map((e=>e[1]));this.matcherRe=n(h(e,{joinWith:"|" +}),!0),this.lastIndex=0}exec(e){this.matcherRe.lastIndex=this.lastIndex +;const n=this.matcherRe.exec(e);if(!n)return null +;const t=n.findIndex(((e,n)=>n>0&&void 0!==e)),a=this.matchIndexes[t] +;return n.splice(0,t),Object.assign(n,a)}}class i{constructor(){ +this.rules=[],this.multiRegexes=[], +this.count=0,this.lastIndex=0,this.regexIndex=0}getMatcher(e){ +if(this.multiRegexes[e])return this.multiRegexes[e];const n=new t +;return this.rules.slice(e).forEach((([e,t])=>n.addRule(e,t))), +n.compile(),this.multiRegexes[e]=n,n}resumingScanAtSamePosition(){ +return 0!==this.regexIndex}considerAll(){this.regexIndex=0}addRule(e,n){ +this.rules.push([e,n]),"begin"===n.type&&this.count++}exec(e){ +const n=this.getMatcher(this.regexIndex);n.lastIndex=this.lastIndex +;let t=n.exec(e) +;if(this.resumingScanAtSamePosition())if(t&&t.index===this.lastIndex);else{ +const n=this.getMatcher(0);n.lastIndex=this.lastIndex+1,t=n.exec(e)} +return t&&(this.regexIndex+=t.position+1, +this.regexIndex===this.count&&this.considerAll()),t}} +if(e.compilerExtensions||(e.compilerExtensions=[]), +e.contains&&e.contains.includes("self"))throw Error("ERR: contains `self` is not supported at the top-level of a language. See documentation.") +;return e.classNameAliases=a(e.classNameAliases||{}),function t(r,s){const o=r +;if(r.isCompiled)return o +;[R,L,W,$].forEach((e=>e(r,s))),e.compilerExtensions.forEach((e=>e(r,s))), +r.__beforeBegin=null,[D,I,B].forEach((e=>e(r,s))),r.isCompiled=!0;let l=null +;return"object"==typeof r.keywords&&r.keywords.$pattern&&(r.keywords=Object.assign({},r.keywords), +l=r.keywords.$pattern, +delete r.keywords.$pattern),l=l||/\w+/,r.keywords&&(r.keywords=U(r.keywords,e.case_insensitive)), +o.keywordPatternRe=n(l,!0), +s&&(r.begin||(r.begin=/\B|\b/),o.beginRe=n(o.begin),r.end||r.endsWithParent||(r.end=/\B|\b/), +r.end&&(o.endRe=n(o.end)), +o.terminatorEnd=c(o.end)||"",r.endsWithParent&&s.terminatorEnd&&(o.terminatorEnd+=(r.end?"|":"")+s.terminatorEnd)), +r.illegal&&(o.illegalRe=n(r.illegal)), +r.contains||(r.contains=[]),r.contains=[].concat(...r.contains.map((e=>(e=>(e.variants&&!e.cachedVariants&&(e.cachedVariants=e.variants.map((n=>a(e,{ +variants:null},n)))),e.cachedVariants?e.cachedVariants:X(e)?a(e,{ +starts:e.starts?a(e.starts):null +}):Object.isFrozen(e)?a(e):e))("self"===e?r:e)))),r.contains.forEach((e=>{t(e,o) +})),r.starts&&t(r.starts,s),o.matcher=(e=>{const n=new i +;return e.contains.forEach((e=>n.addRule(e.begin,{rule:e,type:"begin" +}))),e.terminatorEnd&&n.addRule(e.terminatorEnd,{type:"end" +}),e.illegal&&n.addRule(e.illegal,{type:"illegal"}),n})(o),o}(e)}function X(e){ +return!!e&&(e.endsWithParent||X(e.starts))}class V extends Error{ +constructor(e,n){super(e),this.name="HTMLInjectionError",this.html=n}} +const J=t,Y=a,ee=Symbol("nomatch"),ne=t=>{ +const a=Object.create(null),i=Object.create(null),r=[];let s=!0 +;const o="Could not find the language '{}', did you forget to load/include a language module?",c={ +disableAutodetect:!0,name:"Plain text",contains:[]};let p={ +ignoreUnescapedHTML:!1,throwUnescapedHTML:!1,noHighlightRe:/^(no-?highlight)$/i, +languageDetectRe:/\blang(?:uage)?-([\w-]+)\b/i,classPrefix:"hljs-", +cssSelector:"pre code",languages:null,__emitter:l};function _(e){ +return p.noHighlightRe.test(e)}function h(e,n,t){let a="",i="" +;"object"==typeof n?(a=e, +t=n.ignoreIllegals,i=n.language):(q("10.7.0","highlight(lang, code, ...args) has been deprecated."), +q("10.7.0","Please use highlight(code, options) instead.\nhttps://github.com/highlightjs/highlight.js/issues/2277"), +i=e,a=n),void 0===t&&(t=!0);const r={code:a,language:i};x("before:highlight",r) +;const s=r.result?r.result:f(r.language,r.code,t) +;return s.code=r.code,x("after:highlight",s),s}function f(e,t,i,r){ +const l=Object.create(null);function c(){if(!x.keywords)return void S.addText(A) +;let e=0;x.keywordPatternRe.lastIndex=0;let n=x.keywordPatternRe.exec(A),t="" +;for(;n;){t+=A.substring(e,n.index) +;const i=w.case_insensitive?n[0].toLowerCase():n[0],r=(a=i,x.keywords[a]);if(r){ +const[e,a]=r +;if(S.addText(t),t="",l[i]=(l[i]||0)+1,l[i]<=7&&(C+=a),e.startsWith("_"))t+=n[0];else{ +const t=w.classNameAliases[e]||e;g(n[0],t)}}else t+=n[0] +;e=x.keywordPatternRe.lastIndex,n=x.keywordPatternRe.exec(A)}var a +;t+=A.substring(e),S.addText(t)}function d(){null!=x.subLanguage?(()=>{ +if(""===A)return;let e=null;if("string"==typeof x.subLanguage){ +if(!a[x.subLanguage])return void S.addText(A) +;e=f(x.subLanguage,A,!0,M[x.subLanguage]),M[x.subLanguage]=e._top +}else e=E(A,x.subLanguage.length?x.subLanguage:null) +;x.relevance>0&&(C+=e.relevance),S.__addSublanguage(e._emitter,e.language) +})():c(),A=""}function g(e,n){ +""!==e&&(S.startScope(n),S.addText(e),S.endScope())}function u(e,n){let t=1 +;const a=n.length-1;for(;t<=a;){if(!e._emit[t]){t++;continue} +const a=w.classNameAliases[e[t]]||e[t],i=n[t];a?g(i,a):(A=i,c(),A=""),t++}} +function b(e,n){ +return e.scope&&"string"==typeof e.scope&&S.openNode(w.classNameAliases[e.scope]||e.scope), +e.beginScope&&(e.beginScope._wrap?(g(A,w.classNameAliases[e.beginScope._wrap]||e.beginScope._wrap), +A=""):e.beginScope._multi&&(u(e.beginScope,n),A="")),x=Object.create(e,{parent:{ +value:x}}),x}function m(e,t,a){let i=((e,n)=>{const t=e&&e.exec(n) +;return t&&0===t.index})(e.endRe,a);if(i){if(e["on:end"]){const a=new n(e) +;e["on:end"](t,a),a.isMatchIgnored&&(i=!1)}if(i){ +for(;e.endsParent&&e.parent;)e=e.parent;return e}} +if(e.endsWithParent)return m(e.parent,t,a)}function _(e){ +return 0===x.matcher.regexIndex?(A+=e[0],1):(D=!0,0)}function h(e){ +const n=e[0],a=t.substring(e.index),i=m(x,e,a);if(!i)return ee;const r=x +;x.endScope&&x.endScope._wrap?(d(), +g(n,x.endScope._wrap)):x.endScope&&x.endScope._multi?(d(), +u(x.endScope,e)):r.skip?A+=n:(r.returnEnd||r.excludeEnd||(A+=n), +d(),r.excludeEnd&&(A=n));do{ +x.scope&&S.closeNode(),x.skip||x.subLanguage||(C+=x.relevance),x=x.parent +}while(x!==i.parent);return i.starts&&b(i.starts,e),r.returnEnd?0:n.length} +let y={};function N(a,r){const o=r&&r[0];if(A+=a,null==o)return d(),0 +;if("begin"===y.type&&"end"===r.type&&y.index===r.index&&""===o){ +if(A+=t.slice(r.index,r.index+1),!s){const n=Error(`0 width match regex (${e})`) +;throw n.languageName=e,n.badRule=y.rule,n}return 1} +if(y=r,"begin"===r.type)return(e=>{ +const t=e[0],a=e.rule,i=new n(a),r=[a.__beforeBegin,a["on:begin"]] +;for(const n of r)if(n&&(n(e,i),i.isMatchIgnored))return _(t) +;return a.skip?A+=t:(a.excludeBegin&&(A+=t), +d(),a.returnBegin||a.excludeBegin||(A=t)),b(a,e),a.returnBegin?0:t.length})(r) +;if("illegal"===r.type&&!i){ +const e=Error('Illegal lexeme "'+o+'" for mode "'+(x.scope||"")+'"') +;throw e.mode=x,e}if("end"===r.type){const e=h(r);if(e!==ee)return e} +if("illegal"===r.type&&""===o)return 1 +;if(R>1e5&&R>3*r.index)throw Error("potential infinite loop, way more iterations than matches") +;return A+=o,o.length}const w=v(e) +;if(!w)throw K(o.replace("{}",e)),Error('Unknown language: "'+e+'"') +;const O=Q(w);let k="",x=r||O;const M={},S=new p.__emitter(p);(()=>{const e=[] +;for(let n=x;n!==w;n=n.parent)n.scope&&e.unshift(n.scope) +;e.forEach((e=>S.openNode(e)))})();let A="",C=0,T=0,R=0,D=!1;try{ +if(w.__emitTokens)w.__emitTokens(t,S);else{for(x.matcher.considerAll();;){ +R++,D?D=!1:x.matcher.considerAll(),x.matcher.lastIndex=T +;const e=x.matcher.exec(t);if(!e)break;const n=N(t.substring(T,e.index),e) +;T=e.index+n}N(t.substring(T))}return S.finalize(),k=S.toHTML(),{language:e, +value:k,relevance:C,illegal:!1,_emitter:S,_top:x}}catch(n){ +if(n.message&&n.message.includes("Illegal"))return{language:e,value:J(t), +illegal:!0,relevance:0,_illegalBy:{message:n.message,index:T, +context:t.slice(T-100,T+100),mode:n.mode,resultSoFar:k},_emitter:S};if(s)return{ +language:e,value:J(t),illegal:!1,relevance:0,errorRaised:n,_emitter:S,_top:x} +;throw n}}function E(e,n){n=n||p.languages||Object.keys(a);const t=(e=>{ +const n={value:J(e),illegal:!1,relevance:0,_top:c,_emitter:new p.__emitter(p)} +;return n._emitter.addText(e),n})(e),i=n.filter(v).filter(k).map((n=>f(n,e,!1))) +;i.unshift(t);const r=i.sort(((e,n)=>{ +if(e.relevance!==n.relevance)return n.relevance-e.relevance +;if(e.language&&n.language){if(v(e.language).supersetOf===n.language)return 1 +;if(v(n.language).supersetOf===e.language)return-1}return 0})),[s,o]=r,l=s +;return l.secondBest=o,l}function y(e){let n=null;const t=(e=>{ +let n=e.className+" ";n+=e.parentNode?e.parentNode.className:"" +;const t=p.languageDetectRe.exec(n);if(t){const n=v(t[1]) +;return n||(H(o.replace("{}",t[1])), +H("Falling back to no-highlight mode for this block.",e)),n?t[1]:"no-highlight"} +return n.split(/\s+/).find((e=>_(e)||v(e)))})(e);if(_(t))return +;if(x("before:highlightElement",{el:e,language:t +}),e.dataset.highlighted)return void console.log("Element previously highlighted. To highlight again, first unset `dataset.highlighted`.",e) +;if(e.children.length>0&&(p.ignoreUnescapedHTML||(console.warn("One of your code blocks includes unescaped HTML. This is a potentially serious security risk."), +console.warn("https://github.com/highlightjs/highlight.js/wiki/security"), +console.warn("The element with unescaped HTML:"), +console.warn(e)),p.throwUnescapedHTML))throw new V("One of your code blocks includes unescaped HTML.",e.innerHTML) +;n=e;const a=n.textContent,r=t?h(a,{language:t,ignoreIllegals:!0}):E(a) +;e.innerHTML=r.value,e.dataset.highlighted="yes",((e,n,t)=>{const a=n&&i[n]||t +;e.classList.add("hljs"),e.classList.add("language-"+a) +})(e,t,r.language),e.result={language:r.language,re:r.relevance, +relevance:r.relevance},r.secondBest&&(e.secondBest={ +language:r.secondBest.language,relevance:r.secondBest.relevance +}),x("after:highlightElement",{el:e,result:r,text:a})}let N=!1;function w(){ +"loading"!==document.readyState?document.querySelectorAll(p.cssSelector).forEach(y):N=!0 +}function v(e){return e=(e||"").toLowerCase(),a[e]||a[i[e]]} +function O(e,{languageName:n}){"string"==typeof e&&(e=[e]),e.forEach((e=>{ +i[e.toLowerCase()]=n}))}function k(e){const n=v(e) +;return n&&!n.disableAutodetect}function x(e,n){const t=e;r.forEach((e=>{ +e[t]&&e[t](n)}))} +"undefined"!=typeof window&&window.addEventListener&&window.addEventListener("DOMContentLoaded",(()=>{ +N&&w()}),!1),Object.assign(t,{highlight:h,highlightAuto:E,highlightAll:w, +highlightElement:y, +highlightBlock:e=>(q("10.7.0","highlightBlock will be removed entirely in v12.0"), +q("10.7.0","Please use highlightElement now."),y(e)),configure:e=>{p=Y(p,e)}, +initHighlighting:()=>{ +w(),q("10.6.0","initHighlighting() deprecated. Use highlightAll() now.")}, +initHighlightingOnLoad:()=>{ +w(),q("10.6.0","initHighlightingOnLoad() deprecated. Use highlightAll() now.") +},registerLanguage:(e,n)=>{let i=null;try{i=n(t)}catch(n){ +if(K("Language definition for '{}' could not be registered.".replace("{}",e)), +!s)throw n;K(n),i=c} +i.name||(i.name=e),a[e]=i,i.rawDefinition=n.bind(null,t),i.aliases&&O(i.aliases,{ +languageName:e})},unregisterLanguage:e=>{delete a[e] +;for(const n of Object.keys(i))i[n]===e&&delete i[n]}, +listLanguages:()=>Object.keys(a),getLanguage:v,registerAliases:O, +autoDetection:k,inherit:Y,addPlugin:e=>{(e=>{ +e["before:highlightBlock"]&&!e["before:highlightElement"]&&(e["before:highlightElement"]=n=>{ +e["before:highlightBlock"](Object.assign({block:n.el},n)) +}),e["after:highlightBlock"]&&!e["after:highlightElement"]&&(e["after:highlightElement"]=n=>{ +e["after:highlightBlock"](Object.assign({block:n.el},n))})})(e),r.push(e)}, +removePlugin:e=>{const n=r.indexOf(e);-1!==n&&r.splice(n,1)}}),t.debugMode=()=>{ +s=!1},t.safeMode=()=>{s=!0},t.versionString="11.9.0",t.regex={concat:b, +lookahead:d,either:m,optional:u,anyNumberOfTimes:g} +;for(const n in C)"object"==typeof C[n]&&e(C[n]);return Object.assign(t,C),t +},te=ne({});te.newInstance=()=>ne({});var ae=te;const ie=e=>({IMPORTANT:{ +scope:"meta",begin:"!important"},BLOCK_COMMENT:e.C_BLOCK_COMMENT_MODE,HEXCOLOR:{ +scope:"number",begin:/#(([0-9a-fA-F]{3,4})|(([0-9a-fA-F]{2}){3,4}))\b/}, +FUNCTION_DISPATCH:{className:"built_in",begin:/[\w-]+(?=\()/}, +ATTRIBUTE_SELECTOR_MODE:{scope:"selector-attr",begin:/\[/,end:/\]/,illegal:"$", +contains:[e.APOS_STRING_MODE,e.QUOTE_STRING_MODE]},CSS_NUMBER_MODE:{ +scope:"number", +begin:e.NUMBER_RE+"(%|em|ex|ch|rem|vw|vh|vmin|vmax|cm|mm|in|pt|pc|px|deg|grad|rad|turn|s|ms|Hz|kHz|dpi|dpcm|dppx)?", +relevance:0},CSS_VARIABLE:{className:"attr",begin:/--[A-Za-z_][A-Za-z0-9_-]*/} +}),re=["a","abbr","address","article","aside","audio","b","blockquote","body","button","canvas","caption","cite","code","dd","del","details","dfn","div","dl","dt","em","fieldset","figcaption","figure","footer","form","h1","h2","h3","h4","h5","h6","header","hgroup","html","i","iframe","img","input","ins","kbd","label","legend","li","main","mark","menu","nav","object","ol","p","q","quote","samp","section","span","strong","summary","sup","table","tbody","td","textarea","tfoot","th","thead","time","tr","ul","var","video"],se=["any-hover","any-pointer","aspect-ratio","color","color-gamut","color-index","device-aspect-ratio","device-height","device-width","display-mode","forced-colors","grid","height","hover","inverted-colors","monochrome","orientation","overflow-block","overflow-inline","pointer","prefers-color-scheme","prefers-contrast","prefers-reduced-motion","prefers-reduced-transparency","resolution","scan","scripting","update","width","min-width","max-width","min-height","max-height"],oe=["active","any-link","blank","checked","current","default","defined","dir","disabled","drop","empty","enabled","first","first-child","first-of-type","fullscreen","future","focus","focus-visible","focus-within","has","host","host-context","hover","indeterminate","in-range","invalid","is","lang","last-child","last-of-type","left","link","local-link","not","nth-child","nth-col","nth-last-child","nth-last-col","nth-last-of-type","nth-of-type","only-child","only-of-type","optional","out-of-range","past","placeholder-shown","read-only","read-write","required","right","root","scope","target","target-within","user-invalid","valid","visited","where"],le=["after","backdrop","before","cue","cue-region","first-letter","first-line","grammar-error","marker","part","placeholder","selection","slotted","spelling-error"],ce=["align-content","align-items","align-self","all","animation","animation-delay","animation-direction","animation-duration","animation-fill-mode","animation-iteration-count","animation-name","animation-play-state","animation-timing-function","backface-visibility","background","background-attachment","background-blend-mode","background-clip","background-color","background-image","background-origin","background-position","background-repeat","background-size","block-size","border","border-block","border-block-color","border-block-end","border-block-end-color","border-block-end-style","border-block-end-width","border-block-start","border-block-start-color","border-block-start-style","border-block-start-width","border-block-style","border-block-width","border-bottom","border-bottom-color","border-bottom-left-radius","border-bottom-right-radius","border-bottom-style","border-bottom-width","border-collapse","border-color","border-image","border-image-outset","border-image-repeat","border-image-slice","border-image-source","border-image-width","border-inline","border-inline-color","border-inline-end","border-inline-end-color","border-inline-end-style","border-inline-end-width","border-inline-start","border-inline-start-color","border-inline-start-style","border-inline-start-width","border-inline-style","border-inline-width","border-left","border-left-color","border-left-style","border-left-width","border-radius","border-right","border-right-color","border-right-style","border-right-width","border-spacing","border-style","border-top","border-top-color","border-top-left-radius","border-top-right-radius","border-top-style","border-top-width","border-width","bottom","box-decoration-break","box-shadow","box-sizing","break-after","break-before","break-inside","caption-side","caret-color","clear","clip","clip-path","clip-rule","color","column-count","column-fill","column-gap","column-rule","column-rule-color","column-rule-style","column-rule-width","column-span","column-width","columns","contain","content","content-visibility","counter-increment","counter-reset","cue","cue-after","cue-before","cursor","direction","display","empty-cells","filter","flex","flex-basis","flex-direction","flex-flow","flex-grow","flex-shrink","flex-wrap","float","flow","font","font-display","font-family","font-feature-settings","font-kerning","font-language-override","font-size","font-size-adjust","font-smoothing","font-stretch","font-style","font-synthesis","font-variant","font-variant-caps","font-variant-east-asian","font-variant-ligatures","font-variant-numeric","font-variant-position","font-variation-settings","font-weight","gap","glyph-orientation-vertical","grid","grid-area","grid-auto-columns","grid-auto-flow","grid-auto-rows","grid-column","grid-column-end","grid-column-start","grid-gap","grid-row","grid-row-end","grid-row-start","grid-template","grid-template-areas","grid-template-columns","grid-template-rows","hanging-punctuation","height","hyphens","icon","image-orientation","image-rendering","image-resolution","ime-mode","inline-size","isolation","justify-content","left","letter-spacing","line-break","line-height","list-style","list-style-image","list-style-position","list-style-type","margin","margin-block","margin-block-end","margin-block-start","margin-bottom","margin-inline","margin-inline-end","margin-inline-start","margin-left","margin-right","margin-top","marks","mask","mask-border","mask-border-mode","mask-border-outset","mask-border-repeat","mask-border-slice","mask-border-source","mask-border-width","mask-clip","mask-composite","mask-image","mask-mode","mask-origin","mask-position","mask-repeat","mask-size","mask-type","max-block-size","max-height","max-inline-size","max-width","min-block-size","min-height","min-inline-size","min-width","mix-blend-mode","nav-down","nav-index","nav-left","nav-right","nav-up","none","normal","object-fit","object-position","opacity","order","orphans","outline","outline-color","outline-offset","outline-style","outline-width","overflow","overflow-wrap","overflow-x","overflow-y","padding","padding-block","padding-block-end","padding-block-start","padding-bottom","padding-inline","padding-inline-end","padding-inline-start","padding-left","padding-right","padding-top","page-break-after","page-break-before","page-break-inside","pause","pause-after","pause-before","perspective","perspective-origin","pointer-events","position","quotes","resize","rest","rest-after","rest-before","right","row-gap","scroll-margin","scroll-margin-block","scroll-margin-block-end","scroll-margin-block-start","scroll-margin-bottom","scroll-margin-inline","scroll-margin-inline-end","scroll-margin-inline-start","scroll-margin-left","scroll-margin-right","scroll-margin-top","scroll-padding","scroll-padding-block","scroll-padding-block-end","scroll-padding-block-start","scroll-padding-bottom","scroll-padding-inline","scroll-padding-inline-end","scroll-padding-inline-start","scroll-padding-left","scroll-padding-right","scroll-padding-top","scroll-snap-align","scroll-snap-stop","scroll-snap-type","scrollbar-color","scrollbar-gutter","scrollbar-width","shape-image-threshold","shape-margin","shape-outside","speak","speak-as","src","tab-size","table-layout","text-align","text-align-all","text-align-last","text-combine-upright","text-decoration","text-decoration-color","text-decoration-line","text-decoration-style","text-emphasis","text-emphasis-color","text-emphasis-position","text-emphasis-style","text-indent","text-justify","text-orientation","text-overflow","text-rendering","text-shadow","text-transform","text-underline-position","top","transform","transform-box","transform-origin","transform-style","transition","transition-delay","transition-duration","transition-property","transition-timing-function","unicode-bidi","vertical-align","visibility","voice-balance","voice-duration","voice-family","voice-pitch","voice-range","voice-rate","voice-stress","voice-volume","white-space","widows","width","will-change","word-break","word-spacing","word-wrap","writing-mode","z-index"].reverse(),de=oe.concat(le) +;var ge="[0-9](_*[0-9])*",ue=`\\.(${ge})`,be="[0-9a-fA-F](_*[0-9a-fA-F])*",me={ +className:"number",variants:[{ +begin:`(\\b(${ge})((${ue})|\\.)?|(${ue}))[eE][+-]?(${ge})[fFdD]?\\b`},{ +begin:`\\b(${ge})((${ue})[fFdD]?\\b|\\.([fFdD]\\b)?)`},{ +begin:`(${ue})[fFdD]?\\b`},{begin:`\\b(${ge})[fFdD]\\b`},{ +begin:`\\b0[xX]((${be})\\.?|(${be})?\\.(${be}))[pP][+-]?(${ge})[fFdD]?\\b`},{ +begin:"\\b(0|[1-9](_*[0-9])*)[lL]?\\b"},{begin:`\\b0[xX](${be})[lL]?\\b`},{ +begin:"\\b0(_*[0-7])*[lL]?\\b"},{begin:"\\b0[bB][01](_*[01])*[lL]?\\b"}], +relevance:0};function pe(e,n,t){return-1===t?"":e.replace(n,(a=>pe(e,n,t-1)))} +const _e="[A-Za-z$_][0-9A-Za-z$_]*",he=["as","in","of","if","for","while","finally","var","new","function","do","return","void","else","break","catch","instanceof","with","throw","case","default","try","switch","continue","typeof","delete","let","yield","const","class","debugger","async","await","static","import","from","export","extends"],fe=["true","false","null","undefined","NaN","Infinity"],Ee=["Object","Function","Boolean","Symbol","Math","Date","Number","BigInt","String","RegExp","Array","Float32Array","Float64Array","Int8Array","Uint8Array","Uint8ClampedArray","Int16Array","Int32Array","Uint16Array","Uint32Array","BigInt64Array","BigUint64Array","Set","Map","WeakSet","WeakMap","ArrayBuffer","SharedArrayBuffer","Atomics","DataView","JSON","Promise","Generator","GeneratorFunction","AsyncFunction","Reflect","Proxy","Intl","WebAssembly"],ye=["Error","EvalError","InternalError","RangeError","ReferenceError","SyntaxError","TypeError","URIError"],Ne=["setInterval","setTimeout","clearInterval","clearTimeout","require","exports","eval","isFinite","isNaN","parseFloat","parseInt","decodeURI","decodeURIComponent","encodeURI","encodeURIComponent","escape","unescape"],we=["arguments","this","super","console","window","document","localStorage","sessionStorage","module","global"],ve=[].concat(Ne,Ee,ye) +;function Oe(e){const n=e.regex,t=_e,a={begin:/<[A-Za-z0-9\\._:-]+/, +end:/\/[A-Za-z0-9\\._:-]+>|\/>/,isTrulyOpeningTag:(e,n)=>{ +const t=e[0].length+e.index,a=e.input[t] +;if("<"===a||","===a)return void n.ignoreMatch();let i +;">"===a&&(((e,{after:n})=>{const t="",M={ +match:[/const|var|let/,/\s+/,t,/\s*/,/=\s*/,/(async\s*)?/,n.lookahead(x)], +keywords:"async",className:{1:"keyword",3:"title.function"},contains:[f]} +;return{name:"JavaScript",aliases:["js","jsx","mjs","cjs"],keywords:i,exports:{ +PARAMS_CONTAINS:h,CLASS_REFERENCE:y},illegal:/#(?![$_A-z])/, +contains:[e.SHEBANG({label:"shebang",binary:"node",relevance:5}),{ +label:"use_strict",className:"meta",relevance:10, +begin:/^\s*['"]use (strict|asm)['"]/ +},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,d,g,u,b,m,{match:/\$\d+/},l,y,{ +className:"attr",begin:t+n.lookahead(":"),relevance:0},M,{ +begin:"("+e.RE_STARTERS_RE+"|\\b(case|return|throw)\\b)\\s*", +keywords:"return throw case",relevance:0,contains:[m,e.REGEXP_MODE,{ +className:"function",begin:x,returnBegin:!0,end:"\\s*=>",contains:[{ +className:"params",variants:[{begin:e.UNDERSCORE_IDENT_RE,relevance:0},{ +className:null,begin:/\(\s*\)/,skip:!0},{begin:/\(/,end:/\)/,excludeBegin:!0, +excludeEnd:!0,keywords:i,contains:h}]}]},{begin:/,/,relevance:0},{match:/\s+/, +relevance:0},{variants:[{begin:"<>",end:""},{ +match:/<[A-Za-z0-9\\._:-]+\s*\/>/},{begin:a.begin, +"on:begin":a.isTrulyOpeningTag,end:a.end}],subLanguage:"xml",contains:[{ +begin:a.begin,end:a.end,skip:!0,contains:["self"]}]}]},N,{ +beginKeywords:"while if switch catch for"},{ +begin:"\\b(?!function)"+e.UNDERSCORE_IDENT_RE+"\\([^()]*(\\([^()]*(\\([^()]*\\)[^()]*)*\\)[^()]*)*\\)\\s*\\{", +returnBegin:!0,label:"func.def",contains:[f,e.inherit(e.TITLE_MODE,{begin:t, +className:"title.function"})]},{match:/\.\.\./,relevance:0},O,{match:"\\$"+t, +relevance:0},{match:[/\bconstructor(?=\s*\()/],className:{1:"title.function"}, +contains:[f]},w,{relevance:0,match:/\b[A-Z][A-Z_0-9]+\b/, +className:"variable.constant"},E,k,{match:/\$[(.]/}]}} +const ke=e=>b(/\b/,e,/\w$/.test(e)?/\b/:/\B/),xe=["Protocol","Type"].map(ke),Me=["init","self"].map(ke),Se=["Any","Self"],Ae=["actor","any","associatedtype","async","await",/as\?/,/as!/,"as","borrowing","break","case","catch","class","consume","consuming","continue","convenience","copy","default","defer","deinit","didSet","distributed","do","dynamic","each","else","enum","extension","fallthrough",/fileprivate\(set\)/,"fileprivate","final","for","func","get","guard","if","import","indirect","infix",/init\?/,/init!/,"inout",/internal\(set\)/,"internal","in","is","isolated","nonisolated","lazy","let","macro","mutating","nonmutating",/open\(set\)/,"open","operator","optional","override","postfix","precedencegroup","prefix",/private\(set\)/,"private","protocol",/public\(set\)/,"public","repeat","required","rethrows","return","set","some","static","struct","subscript","super","switch","throws","throw",/try\?/,/try!/,"try","typealias",/unowned\(safe\)/,/unowned\(unsafe\)/,"unowned","var","weak","where","while","willSet"],Ce=["false","nil","true"],Te=["assignment","associativity","higherThan","left","lowerThan","none","right"],Re=["#colorLiteral","#column","#dsohandle","#else","#elseif","#endif","#error","#file","#fileID","#fileLiteral","#filePath","#function","#if","#imageLiteral","#keyPath","#line","#selector","#sourceLocation","#warning"],De=["abs","all","any","assert","assertionFailure","debugPrint","dump","fatalError","getVaList","isKnownUniquelyReferenced","max","min","numericCast","pointwiseMax","pointwiseMin","precondition","preconditionFailure","print","readLine","repeatElement","sequence","stride","swap","swift_unboxFromSwiftValueWithType","transcode","type","unsafeBitCast","unsafeDowncast","withExtendedLifetime","withUnsafeMutablePointer","withUnsafePointer","withVaList","withoutActuallyEscaping","zip"],Ie=m(/[/=\-+!*%<>&|^~?]/,/[\u00A1-\u00A7]/,/[\u00A9\u00AB]/,/[\u00AC\u00AE]/,/[\u00B0\u00B1]/,/[\u00B6\u00BB\u00BF\u00D7\u00F7]/,/[\u2016-\u2017]/,/[\u2020-\u2027]/,/[\u2030-\u203E]/,/[\u2041-\u2053]/,/[\u2055-\u205E]/,/[\u2190-\u23FF]/,/[\u2500-\u2775]/,/[\u2794-\u2BFF]/,/[\u2E00-\u2E7F]/,/[\u3001-\u3003]/,/[\u3008-\u3020]/,/[\u3030]/),Le=m(Ie,/[\u0300-\u036F]/,/[\u1DC0-\u1DFF]/,/[\u20D0-\u20FF]/,/[\uFE00-\uFE0F]/,/[\uFE20-\uFE2F]/),Be=b(Ie,Le,"*"),$e=m(/[a-zA-Z_]/,/[\u00A8\u00AA\u00AD\u00AF\u00B2-\u00B5\u00B7-\u00BA]/,/[\u00BC-\u00BE\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u00FF]/,/[\u0100-\u02FF\u0370-\u167F\u1681-\u180D\u180F-\u1DBF]/,/[\u1E00-\u1FFF]/,/[\u200B-\u200D\u202A-\u202E\u203F-\u2040\u2054\u2060-\u206F]/,/[\u2070-\u20CF\u2100-\u218F\u2460-\u24FF\u2776-\u2793]/,/[\u2C00-\u2DFF\u2E80-\u2FFF]/,/[\u3004-\u3007\u3021-\u302F\u3031-\u303F\u3040-\uD7FF]/,/[\uF900-\uFD3D\uFD40-\uFDCF\uFDF0-\uFE1F\uFE30-\uFE44]/,/[\uFE47-\uFEFE\uFF00-\uFFFD]/),ze=m($e,/\d/,/[\u0300-\u036F\u1DC0-\u1DFF\u20D0-\u20FF\uFE20-\uFE2F]/),Fe=b($e,ze,"*"),Ue=b(/[A-Z]/,ze,"*"),je=["attached","autoclosure",b(/convention\(/,m("swift","block","c"),/\)/),"discardableResult","dynamicCallable","dynamicMemberLookup","escaping","freestanding","frozen","GKInspectable","IBAction","IBDesignable","IBInspectable","IBOutlet","IBSegueAction","inlinable","main","nonobjc","NSApplicationMain","NSCopying","NSManaged",b(/objc\(/,Fe,/\)/),"objc","objcMembers","propertyWrapper","requires_stored_property_inits","resultBuilder","Sendable","testable","UIApplicationMain","unchecked","unknown","usableFromInline","warn_unqualified_access"],Pe=["iOS","iOSApplicationExtension","macOS","macOSApplicationExtension","macCatalyst","macCatalystApplicationExtension","watchOS","watchOSApplicationExtension","tvOS","tvOSApplicationExtension","swift"] +;var Ke=Object.freeze({__proto__:null,grmr_bash:e=>{const n=e.regex,t={},a={ +begin:/\$\{/,end:/\}/,contains:["self",{begin:/:-/,contains:[t]}]} +;Object.assign(t,{className:"variable",variants:[{ +begin:n.concat(/\$[\w\d#@][\w\d_]*/,"(?![\\w\\d])(?![$])")},a]});const i={ +className:"subst",begin:/\$\(/,end:/\)/,contains:[e.BACKSLASH_ESCAPE]},r={ +begin:/<<-?\s*(?=\w+)/,starts:{contains:[e.END_SAME_AS_BEGIN({begin:/(\w+)/, +end:/(\w+)/,className:"string"})]}},s={className:"string",begin:/"/,end:/"/, +contains:[e.BACKSLASH_ESCAPE,t,i]};i.contains.push(s);const o={begin:/\$?\(\(/, +end:/\)\)/,contains:[{begin:/\d+#[0-9a-f]+/,className:"number"},e.NUMBER_MODE,t] +},l=e.SHEBANG({binary:"(fish|bash|zsh|sh|csh|ksh|tcsh|dash|scsh)",relevance:10 +}),c={className:"function",begin:/\w[\w\d_]*\s*\(\s*\)\s*\{/,returnBegin:!0, +contains:[e.inherit(e.TITLE_MODE,{begin:/\w[\w\d_]*/})],relevance:0};return{ +name:"Bash",aliases:["sh"],keywords:{$pattern:/\b[a-z][a-z0-9._-]+\b/, +keyword:["if","then","else","elif","fi","for","while","until","in","do","done","case","esac","function","select"], +literal:["true","false"], +built_in:["break","cd","continue","eval","exec","exit","export","getopts","hash","pwd","readonly","return","shift","test","times","trap","umask","unset","alias","bind","builtin","caller","command","declare","echo","enable","help","let","local","logout","mapfile","printf","read","readarray","source","type","typeset","ulimit","unalias","set","shopt","autoload","bg","bindkey","bye","cap","chdir","clone","comparguments","compcall","compctl","compdescribe","compfiles","compgroups","compquote","comptags","comptry","compvalues","dirs","disable","disown","echotc","echoti","emulate","fc","fg","float","functions","getcap","getln","history","integer","jobs","kill","limit","log","noglob","popd","print","pushd","pushln","rehash","sched","setcap","setopt","stat","suspend","ttyctl","unfunction","unhash","unlimit","unsetopt","vared","wait","whence","where","which","zcompile","zformat","zftp","zle","zmodload","zparseopts","zprof","zpty","zregexparse","zsocket","zstyle","ztcp","chcon","chgrp","chown","chmod","cp","dd","df","dir","dircolors","ln","ls","mkdir","mkfifo","mknod","mktemp","mv","realpath","rm","rmdir","shred","sync","touch","truncate","vdir","b2sum","base32","base64","cat","cksum","comm","csplit","cut","expand","fmt","fold","head","join","md5sum","nl","numfmt","od","paste","ptx","pr","sha1sum","sha224sum","sha256sum","sha384sum","sha512sum","shuf","sort","split","sum","tac","tail","tr","tsort","unexpand","uniq","wc","arch","basename","chroot","date","dirname","du","echo","env","expr","factor","groups","hostid","id","link","logname","nice","nohup","nproc","pathchk","pinky","printenv","printf","pwd","readlink","runcon","seq","sleep","stat","stdbuf","stty","tee","test","timeout","tty","uname","unlink","uptime","users","who","whoami","yes"] +},contains:[l,e.SHEBANG(),c,o,e.HASH_COMMENT_MODE,r,{match:/(\/[a-z._-]+)+/},s,{ +match:/\\"/},{className:"string",begin:/'/,end:/'/},{match:/\\'/},t]}}, +grmr_c:e=>{const n=e.regex,t=e.COMMENT("//","$",{contains:[{begin:/\\\n/}] +}),a="decltype\\(auto\\)",i="[a-zA-Z_]\\w*::",r="("+a+"|"+n.optional(i)+"[a-zA-Z_]\\w*"+n.optional("<[^<>]+>")+")",s={ +className:"type",variants:[{begin:"\\b[a-z\\d_]*_t\\b"},{ +match:/\batomic_[a-z]{3,6}\b/}]},o={className:"string",variants:[{ +begin:'(u8?|U|L)?"',end:'"',illegal:"\\n",contains:[e.BACKSLASH_ESCAPE]},{ +begin:"(u8?|U|L)?'(\\\\(x[0-9A-Fa-f]{2}|u[0-9A-Fa-f]{4,8}|[0-7]{3}|\\S)|.)", +end:"'",illegal:"."},e.END_SAME_AS_BEGIN({ +begin:/(?:u8?|U|L)?R"([^()\\ ]{0,16})\(/,end:/\)([^()\\ ]{0,16})"/})]},l={ +className:"number",variants:[{begin:"\\b(0b[01']+)"},{ +begin:"(-?)\\b([\\d']+(\\.[\\d']*)?|\\.[\\d']+)((ll|LL|l|L)(u|U)?|(u|U)(ll|LL|l|L)?|f|F|b|B)" +},{ +begin:"(-?)(\\b0[xX][a-fA-F0-9']+|(\\b[\\d']+(\\.[\\d']*)?|\\.[\\d']+)([eE][-+]?[\\d']+)?)" +}],relevance:0},c={className:"meta",begin:/#\s*[a-z]+\b/,end:/$/,keywords:{ +keyword:"if else elif endif define undef warning error line pragma _Pragma ifdef ifndef include" +},contains:[{begin:/\\\n/,relevance:0},e.inherit(o,{className:"string"}),{ +className:"string",begin:/<.*?>/},t,e.C_BLOCK_COMMENT_MODE]},d={ +className:"title",begin:n.optional(i)+e.IDENT_RE,relevance:0 +},g=n.optional(i)+e.IDENT_RE+"\\s*\\(",u={ +keyword:["asm","auto","break","case","continue","default","do","else","enum","extern","for","fortran","goto","if","inline","register","restrict","return","sizeof","struct","switch","typedef","union","volatile","while","_Alignas","_Alignof","_Atomic","_Generic","_Noreturn","_Static_assert","_Thread_local","alignas","alignof","noreturn","static_assert","thread_local","_Pragma"], +type:["float","double","signed","unsigned","int","short","long","char","void","_Bool","_Complex","_Imaginary","_Decimal32","_Decimal64","_Decimal128","const","static","complex","bool","imaginary"], +literal:"true false NULL", +built_in:"std string wstring cin cout cerr clog stdin stdout stderr stringstream istringstream ostringstream auto_ptr deque list queue stack vector map set pair bitset multiset multimap unordered_set unordered_map unordered_multiset unordered_multimap priority_queue make_pair array shared_ptr abort terminate abs acos asin atan2 atan calloc ceil cosh cos exit exp fabs floor fmod fprintf fputs free frexp fscanf future isalnum isalpha iscntrl isdigit isgraph islower isprint ispunct isspace isupper isxdigit tolower toupper labs ldexp log10 log malloc realloc memchr memcmp memcpy memset modf pow printf putchar puts scanf sinh sin snprintf sprintf sqrt sscanf strcat strchr strcmp strcpy strcspn strlen strncat strncmp strncpy strpbrk strrchr strspn strstr tanh tan vfprintf vprintf vsprintf endl initializer_list unique_ptr" +},b=[c,s,t,e.C_BLOCK_COMMENT_MODE,l,o],m={variants:[{begin:/=/,end:/;/},{ +begin:/\(/,end:/\)/},{beginKeywords:"new throw return else",end:/;/}], +keywords:u,contains:b.concat([{begin:/\(/,end:/\)/,keywords:u, +contains:b.concat(["self"]),relevance:0}]),relevance:0},p={ +begin:"("+r+"[\\*&\\s]+)+"+g,returnBegin:!0,end:/[{;=]/,excludeEnd:!0, +keywords:u,illegal:/[^\w\s\*&:<>.]/,contains:[{begin:a,keywords:u,relevance:0},{ +begin:g,returnBegin:!0,contains:[e.inherit(d,{className:"title.function"})], +relevance:0},{relevance:0,match:/,/},{className:"params",begin:/\(/,end:/\)/, +keywords:u,relevance:0,contains:[t,e.C_BLOCK_COMMENT_MODE,o,l,s,{begin:/\(/, +end:/\)/,keywords:u,relevance:0,contains:["self",t,e.C_BLOCK_COMMENT_MODE,o,l,s] +}]},s,t,e.C_BLOCK_COMMENT_MODE,c]};return{name:"C",aliases:["h"],keywords:u, +disableAutodetect:!0,illegal:"=]/,contains:[{ +beginKeywords:"final class struct"},e.TITLE_MODE]}]),exports:{preprocessor:c, +strings:o,keywords:u}}},grmr_cpp:e=>{const n=e.regex,t=e.COMMENT("//","$",{ +contains:[{begin:/\\\n/}] +}),a="decltype\\(auto\\)",i="[a-zA-Z_]\\w*::",r="(?!struct)("+a+"|"+n.optional(i)+"[a-zA-Z_]\\w*"+n.optional("<[^<>]+>")+")",s={ +className:"type",begin:"\\b[a-z\\d_]*_t\\b"},o={className:"string",variants:[{ +begin:'(u8?|U|L)?"',end:'"',illegal:"\\n",contains:[e.BACKSLASH_ESCAPE]},{ +begin:"(u8?|U|L)?'(\\\\(x[0-9A-Fa-f]{2}|u[0-9A-Fa-f]{4,8}|[0-7]{3}|\\S)|.)", +end:"'",illegal:"."},e.END_SAME_AS_BEGIN({ +begin:/(?:u8?|U|L)?R"([^()\\ ]{0,16})\(/,end:/\)([^()\\ ]{0,16})"/})]},l={ +className:"number",variants:[{begin:"\\b(0b[01']+)"},{ +begin:"(-?)\\b([\\d']+(\\.[\\d']*)?|\\.[\\d']+)((ll|LL|l|L)(u|U)?|(u|U)(ll|LL|l|L)?|f|F|b|B)" +},{ +begin:"(-?)(\\b0[xX][a-fA-F0-9']+|(\\b[\\d']+(\\.[\\d']*)?|\\.[\\d']+)([eE][-+]?[\\d']+)?)" +}],relevance:0},c={className:"meta",begin:/#\s*[a-z]+\b/,end:/$/,keywords:{ +keyword:"if else elif endif define undef warning error line pragma _Pragma ifdef ifndef include" +},contains:[{begin:/\\\n/,relevance:0},e.inherit(o,{className:"string"}),{ +className:"string",begin:/<.*?>/},t,e.C_BLOCK_COMMENT_MODE]},d={ +className:"title",begin:n.optional(i)+e.IDENT_RE,relevance:0 +},g=n.optional(i)+e.IDENT_RE+"\\s*\\(",u={ +type:["bool","char","char16_t","char32_t","char8_t","double","float","int","long","short","void","wchar_t","unsigned","signed","const","static"], +keyword:["alignas","alignof","and","and_eq","asm","atomic_cancel","atomic_commit","atomic_noexcept","auto","bitand","bitor","break","case","catch","class","co_await","co_return","co_yield","compl","concept","const_cast|10","consteval","constexpr","constinit","continue","decltype","default","delete","do","dynamic_cast|10","else","enum","explicit","export","extern","false","final","for","friend","goto","if","import","inline","module","mutable","namespace","new","noexcept","not","not_eq","nullptr","operator","or","or_eq","override","private","protected","public","reflexpr","register","reinterpret_cast|10","requires","return","sizeof","static_assert","static_cast|10","struct","switch","synchronized","template","this","thread_local","throw","transaction_safe","transaction_safe_dynamic","true","try","typedef","typeid","typename","union","using","virtual","volatile","while","xor","xor_eq"], +literal:["NULL","false","nullopt","nullptr","true"],built_in:["_Pragma"], +_type_hints:["any","auto_ptr","barrier","binary_semaphore","bitset","complex","condition_variable","condition_variable_any","counting_semaphore","deque","false_type","future","imaginary","initializer_list","istringstream","jthread","latch","lock_guard","multimap","multiset","mutex","optional","ostringstream","packaged_task","pair","promise","priority_queue","queue","recursive_mutex","recursive_timed_mutex","scoped_lock","set","shared_future","shared_lock","shared_mutex","shared_timed_mutex","shared_ptr","stack","string_view","stringstream","timed_mutex","thread","true_type","tuple","unique_lock","unique_ptr","unordered_map","unordered_multimap","unordered_multiset","unordered_set","variant","vector","weak_ptr","wstring","wstring_view"] +},b={className:"function.dispatch",relevance:0,keywords:{ +_hint:["abort","abs","acos","apply","as_const","asin","atan","atan2","calloc","ceil","cerr","cin","clog","cos","cosh","cout","declval","endl","exchange","exit","exp","fabs","floor","fmod","forward","fprintf","fputs","free","frexp","fscanf","future","invoke","isalnum","isalpha","iscntrl","isdigit","isgraph","islower","isprint","ispunct","isspace","isupper","isxdigit","labs","launder","ldexp","log","log10","make_pair","make_shared","make_shared_for_overwrite","make_tuple","make_unique","malloc","memchr","memcmp","memcpy","memset","modf","move","pow","printf","putchar","puts","realloc","scanf","sin","sinh","snprintf","sprintf","sqrt","sscanf","std","stderr","stdin","stdout","strcat","strchr","strcmp","strcpy","strcspn","strlen","strncat","strncmp","strncpy","strpbrk","strrchr","strspn","strstr","swap","tan","tanh","terminate","to_underlying","tolower","toupper","vfprintf","visit","vprintf","vsprintf"] +}, +begin:n.concat(/\b/,/(?!decltype)/,/(?!if)/,/(?!for)/,/(?!switch)/,/(?!while)/,e.IDENT_RE,n.lookahead(/(<[^<>]+>|)\s*\(/)) +},m=[b,c,s,t,e.C_BLOCK_COMMENT_MODE,l,o],p={variants:[{begin:/=/,end:/;/},{ +begin:/\(/,end:/\)/},{beginKeywords:"new throw return else",end:/;/}], +keywords:u,contains:m.concat([{begin:/\(/,end:/\)/,keywords:u, +contains:m.concat(["self"]),relevance:0}]),relevance:0},_={className:"function", +begin:"("+r+"[\\*&\\s]+)+"+g,returnBegin:!0,end:/[{;=]/,excludeEnd:!0, +keywords:u,illegal:/[^\w\s\*&:<>.]/,contains:[{begin:a,keywords:u,relevance:0},{ +begin:g,returnBegin:!0,contains:[d],relevance:0},{begin:/::/,relevance:0},{ +begin:/:/,endsWithParent:!0,contains:[o,l]},{relevance:0,match:/,/},{ +className:"params",begin:/\(/,end:/\)/,keywords:u,relevance:0, +contains:[t,e.C_BLOCK_COMMENT_MODE,o,l,s,{begin:/\(/,end:/\)/,keywords:u, +relevance:0,contains:["self",t,e.C_BLOCK_COMMENT_MODE,o,l,s]}] +},s,t,e.C_BLOCK_COMMENT_MODE,c]};return{name:"C++", +aliases:["cc","c++","h++","hpp","hh","hxx","cxx"],keywords:u,illegal:"",keywords:u,contains:["self",s]},{begin:e.IDENT_RE+"::",keywords:u},{ +match:[/\b(?:enum(?:\s+(?:class|struct))?|class|struct|union)/,/\s+/,/\w+/], +className:{1:"keyword",3:"title.class"}}])}},grmr_csharp:e=>{const n={ +keyword:["abstract","as","base","break","case","catch","class","const","continue","do","else","event","explicit","extern","finally","fixed","for","foreach","goto","if","implicit","in","interface","internal","is","lock","namespace","new","operator","out","override","params","private","protected","public","readonly","record","ref","return","scoped","sealed","sizeof","stackalloc","static","struct","switch","this","throw","try","typeof","unchecked","unsafe","using","virtual","void","volatile","while"].concat(["add","alias","and","ascending","async","await","by","descending","equals","from","get","global","group","init","into","join","let","nameof","not","notnull","on","or","orderby","partial","remove","select","set","unmanaged","value|0","var","when","where","with","yield"]), +built_in:["bool","byte","char","decimal","delegate","double","dynamic","enum","float","int","long","nint","nuint","object","sbyte","short","string","ulong","uint","ushort"], +literal:["default","false","null","true"]},t=e.inherit(e.TITLE_MODE,{ +begin:"[a-zA-Z](\\.?\\w)*"}),a={className:"number",variants:[{ +begin:"\\b(0b[01']+)"},{ +begin:"(-?)\\b([\\d']+(\\.[\\d']*)?|\\.[\\d']+)(u|U|l|L|ul|UL|f|F|b|B)"},{ +begin:"(-?)(\\b0[xX][a-fA-F0-9']+|(\\b[\\d']+(\\.[\\d']*)?|\\.[\\d']+)([eE][-+]?[\\d']+)?)" +}],relevance:0},i={className:"string",begin:'@"',end:'"',contains:[{begin:'""'}] +},r=e.inherit(i,{illegal:/\n/}),s={className:"subst",begin:/\{/,end:/\}/, +keywords:n},o=e.inherit(s,{illegal:/\n/}),l={className:"string",begin:/\$"/, +end:'"',illegal:/\n/,contains:[{begin:/\{\{/},{begin:/\}\}/ +},e.BACKSLASH_ESCAPE,o]},c={className:"string",begin:/\$@"/,end:'"',contains:[{ +begin:/\{\{/},{begin:/\}\}/},{begin:'""'},s]},d=e.inherit(c,{illegal:/\n/, +contains:[{begin:/\{\{/},{begin:/\}\}/},{begin:'""'},o]}) +;s.contains=[c,l,i,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,a,e.C_BLOCK_COMMENT_MODE], +o.contains=[d,l,r,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,a,e.inherit(e.C_BLOCK_COMMENT_MODE,{ +illegal:/\n/})];const g={variants:[c,l,i,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE] +},u={begin:"<",end:">",contains:[{beginKeywords:"in out"},t] +},b=e.IDENT_RE+"(<"+e.IDENT_RE+"(\\s*,\\s*"+e.IDENT_RE+")*>)?(\\[\\])?",m={ +begin:"@"+e.IDENT_RE,relevance:0};return{name:"C#",aliases:["cs","c#"], +keywords:n,illegal:/::/,contains:[e.COMMENT("///","$",{returnBegin:!0, +contains:[{className:"doctag",variants:[{begin:"///",relevance:0},{ +begin:"\x3c!--|--\x3e"},{begin:""}]}] +}),e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,{className:"meta",begin:"#", +end:"$",keywords:{ +keyword:"if else elif endif define undef warning error line region endregion pragma checksum" +}},g,a,{beginKeywords:"class interface",relevance:0,end:/[{;=]/, +illegal:/[^\s:,]/,contains:[{beginKeywords:"where class" +},t,u,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{beginKeywords:"namespace", +relevance:0,end:/[{;=]/,illegal:/[^\s:]/, +contains:[t,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{ +beginKeywords:"record",relevance:0,end:/[{;=]/,illegal:/[^\s:]/, +contains:[t,u,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{className:"meta", +begin:"^\\s*\\[(?=[\\w])",excludeBegin:!0,end:"\\]",excludeEnd:!0,contains:[{ +className:"string",begin:/"/,end:/"/}]},{ +beginKeywords:"new return throw await else",relevance:0},{className:"function", +begin:"("+b+"\\s+)+"+e.IDENT_RE+"\\s*(<[^=]+>\\s*)?\\(",returnBegin:!0, +end:/\s*[{;=]/,excludeEnd:!0,keywords:n,contains:[{ +beginKeywords:"public private protected static internal protected abstract async extern override unsafe virtual new sealed partial", +relevance:0},{begin:e.IDENT_RE+"\\s*(<[^=]+>\\s*)?\\(",returnBegin:!0, +contains:[e.TITLE_MODE,u],relevance:0},{match:/\(\)/},{className:"params", +begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:n,relevance:0, +contains:[g,a,e.C_BLOCK_COMMENT_MODE] +},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},m]}},grmr_css:e=>{ +const n=e.regex,t=ie(e),a=[e.APOS_STRING_MODE,e.QUOTE_STRING_MODE];return{ +name:"CSS",case_insensitive:!0,illegal:/[=|'\$]/,keywords:{ +keyframePosition:"from to"},classNameAliases:{keyframePosition:"selector-tag"}, +contains:[t.BLOCK_COMMENT,{begin:/-(webkit|moz|ms|o)-(?=[a-z])/ +},t.CSS_NUMBER_MODE,{className:"selector-id",begin:/#[A-Za-z0-9_-]+/,relevance:0 +},{className:"selector-class",begin:"\\.[a-zA-Z-][a-zA-Z0-9_-]*",relevance:0 +},t.ATTRIBUTE_SELECTOR_MODE,{className:"selector-pseudo",variants:[{ +begin:":("+oe.join("|")+")"},{begin:":(:)?("+le.join("|")+")"}] +},t.CSS_VARIABLE,{className:"attribute",begin:"\\b("+ce.join("|")+")\\b"},{ +begin:/:/,end:/[;}{]/, +contains:[t.BLOCK_COMMENT,t.HEXCOLOR,t.IMPORTANT,t.CSS_NUMBER_MODE,...a,{ +begin:/(url|data-uri)\(/,end:/\)/,relevance:0,keywords:{built_in:"url data-uri" +},contains:[...a,{className:"string",begin:/[^)]/,endsWithParent:!0, +excludeEnd:!0}]},t.FUNCTION_DISPATCH]},{begin:n.lookahead(/@/),end:"[{;]", +relevance:0,illegal:/:/,contains:[{className:"keyword",begin:/@-?\w[\w]*(-\w+)*/ +},{begin:/\s/,endsWithParent:!0,excludeEnd:!0,relevance:0,keywords:{ +$pattern:/[a-z-]+/,keyword:"and or not only",attribute:se.join(" ")},contains:[{ +begin:/[a-z-]+(?=:)/,className:"attribute"},...a,t.CSS_NUMBER_MODE]}]},{ +className:"selector-tag",begin:"\\b("+re.join("|")+")\\b"}]}},grmr_diff:e=>{ +const n=e.regex;return{name:"Diff",aliases:["patch"],contains:[{ +className:"meta",relevance:10, +match:n.either(/^@@ +-\d+,\d+ +\+\d+,\d+ +@@/,/^\*\*\* +\d+,\d+ +\*\*\*\*$/,/^--- +\d+,\d+ +----$/) +},{className:"comment",variants:[{ +begin:n.either(/Index: /,/^index/,/={3,}/,/^-{3}/,/^\*{3} /,/^\+{3}/,/^diff --git/), +end:/$/},{match:/^\*{15}$/}]},{className:"addition",begin:/^\+/,end:/$/},{ +className:"deletion",begin:/^-/,end:/$/},{className:"addition",begin:/^!/, +end:/$/}]}},grmr_go:e=>{const n={ +keyword:["break","case","chan","const","continue","default","defer","else","fallthrough","for","func","go","goto","if","import","interface","map","package","range","return","select","struct","switch","type","var"], +type:["bool","byte","complex64","complex128","error","float32","float64","int8","int16","int32","int64","string","uint8","uint16","uint32","uint64","int","uint","uintptr","rune"], +literal:["true","false","iota","nil"], +built_in:["append","cap","close","complex","copy","imag","len","make","new","panic","print","println","real","recover","delete"] +};return{name:"Go",aliases:["golang"],keywords:n,illegal:"{const n=e.regex;return{name:"GraphQL",aliases:["gql"], +case_insensitive:!0,disableAutodetect:!1,keywords:{ +keyword:["query","mutation","subscription","type","input","schema","directive","interface","union","scalar","fragment","enum","on"], +literal:["true","false","null"]}, +contains:[e.HASH_COMMENT_MODE,e.QUOTE_STRING_MODE,e.NUMBER_MODE,{ +scope:"punctuation",match:/[.]{3}/,relevance:0},{scope:"punctuation", +begin:/[\!\(\)\:\=\[\]\{\|\}]{1}/,relevance:0},{scope:"variable",begin:/\$/, +end:/\W/,excludeEnd:!0,relevance:0},{scope:"meta",match:/@\w+/,excludeEnd:!0},{ +scope:"symbol",begin:n.concat(/[_A-Za-z][_0-9A-Za-z]*/,n.lookahead(/\s*:/)), +relevance:0}],illegal:[/[;<']/,/BEGIN/]}},grmr_ini:e=>{const n=e.regex,t={ +className:"number",relevance:0,variants:[{begin:/([+-]+)?[\d]+_[\d_]+/},{ +begin:e.NUMBER_RE}]},a=e.COMMENT();a.variants=[{begin:/;/,end:/$/},{begin:/#/, +end:/$/}];const i={className:"variable",variants:[{begin:/\$[\w\d"][\w\d_]*/},{ +begin:/\$\{(.*?)\}/}]},r={className:"literal", +begin:/\bon|off|true|false|yes|no\b/},s={className:"string", +contains:[e.BACKSLASH_ESCAPE],variants:[{begin:"'''",end:"'''",relevance:10},{ +begin:'"""',end:'"""',relevance:10},{begin:'"',end:'"'},{begin:"'",end:"'"}] +},o={begin:/\[/,end:/\]/,contains:[a,r,i,s,t,"self"],relevance:0 +},l=n.either(/[A-Za-z0-9_-]+/,/"(\\"|[^"])*"/,/'[^']*'/);return{ +name:"TOML, also INI",aliases:["toml"],case_insensitive:!0,illegal:/\S/, +contains:[a,{className:"section",begin:/\[+/,end:/\]+/},{ +begin:n.concat(l,"(\\s*\\.\\s*",l,")*",n.lookahead(/\s*=\s*[^#\s]/)), +className:"attr",starts:{end:/$/,contains:[a,o,r,i,s,t]}}]}},grmr_java:e=>{ +const n=e.regex,t="[\xc0-\u02b8a-zA-Z_$][\xc0-\u02b8a-zA-Z_$0-9]*",a=t+pe("(?:<"+t+"~~~(?:\\s*,\\s*"+t+"~~~)*>)?",/~~~/g,2),i={ +keyword:["synchronized","abstract","private","var","static","if","const ","for","while","strictfp","finally","protected","import","native","final","void","enum","else","break","transient","catch","instanceof","volatile","case","assert","package","default","public","try","switch","continue","throws","protected","public","private","module","requires","exports","do","sealed","yield","permits"], +literal:["false","true","null"], +type:["char","boolean","long","float","int","byte","short","double"], +built_in:["super","this"]},r={className:"meta",begin:"@"+t,contains:[{ +begin:/\(/,end:/\)/,contains:["self"]}]},s={className:"params",begin:/\(/, +end:/\)/,keywords:i,relevance:0,contains:[e.C_BLOCK_COMMENT_MODE],endsParent:!0} +;return{name:"Java",aliases:["jsp"],keywords:i,illegal:/<\/|#/, +contains:[e.COMMENT("/\\*\\*","\\*/",{relevance:0,contains:[{begin:/\w+@/, +relevance:0},{className:"doctag",begin:"@[A-Za-z]+"}]}),{ +begin:/import java\.[a-z]+\./,keywords:"import",relevance:2 +},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,{begin:/"""/,end:/"""/, +className:"string",contains:[e.BACKSLASH_ESCAPE] +},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,{ +match:[/\b(?:class|interface|enum|extends|implements|new)/,/\s+/,t],className:{ +1:"keyword",3:"title.class"}},{match:/non-sealed/,scope:"keyword"},{ +begin:[n.concat(/(?!else)/,t),/\s+/,t,/\s+/,/=(?!=)/],className:{1:"type", +3:"variable",5:"operator"}},{begin:[/record/,/\s+/,t],className:{1:"keyword", +3:"title.class"},contains:[s,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{ +beginKeywords:"new throw return else",relevance:0},{ +begin:["(?:"+a+"\\s+)",e.UNDERSCORE_IDENT_RE,/\s*(?=\()/],className:{ +2:"title.function"},keywords:i,contains:[{className:"params",begin:/\(/, +end:/\)/,keywords:i,relevance:0, +contains:[r,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,me,e.C_BLOCK_COMMENT_MODE] +},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},me,r]}},grmr_javascript:Oe, +grmr_json:e=>{const n=["true","false","null"],t={scope:"literal", +beginKeywords:n.join(" ")};return{name:"JSON",keywords:{literal:n},contains:[{ +className:"attr",begin:/"(\\.|[^\\"\r\n])*"(?=\s*:)/,relevance:1.01},{ +match:/[{}[\],:]/,className:"punctuation",relevance:0 +},e.QUOTE_STRING_MODE,t,e.C_NUMBER_MODE,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE], +illegal:"\\S"}},grmr_kotlin:e=>{const n={ +keyword:"abstract as val var vararg get set class object open private protected public noinline crossinline dynamic final enum if else do while for when throw try catch finally import package is in fun override companion reified inline lateinit init interface annotation data sealed internal infix operator out by constructor super tailrec where const inner suspend typealias external expect actual", +built_in:"Byte Short Char Int Long Boolean Float Double Void Unit Nothing", +literal:"true false null"},t={className:"symbol",begin:e.UNDERSCORE_IDENT_RE+"@" +},a={className:"subst",begin:/\$\{/,end:/\}/,contains:[e.C_NUMBER_MODE]},i={ +className:"variable",begin:"\\$"+e.UNDERSCORE_IDENT_RE},r={className:"string", +variants:[{begin:'"""',end:'"""(?=[^"])',contains:[i,a]},{begin:"'",end:"'", +illegal:/\n/,contains:[e.BACKSLASH_ESCAPE]},{begin:'"',end:'"',illegal:/\n/, +contains:[e.BACKSLASH_ESCAPE,i,a]}]};a.contains.push(r);const s={ +className:"meta", +begin:"@(?:file|property|field|get|set|receiver|param|setparam|delegate)\\s*:(?:\\s*"+e.UNDERSCORE_IDENT_RE+")?" +},o={className:"meta",begin:"@"+e.UNDERSCORE_IDENT_RE,contains:[{begin:/\(/, +end:/\)/,contains:[e.inherit(r,{className:"string"}),"self"]}] +},l=me,c=e.COMMENT("/\\*","\\*/",{contains:[e.C_BLOCK_COMMENT_MODE]}),d={ +variants:[{className:"type",begin:e.UNDERSCORE_IDENT_RE},{begin:/\(/,end:/\)/, +contains:[]}]},g=d;return g.variants[1].contains=[d],d.variants[1].contains=[g], +{name:"Kotlin",aliases:["kt","kts"],keywords:n, +contains:[e.COMMENT("/\\*\\*","\\*/",{relevance:0,contains:[{className:"doctag", +begin:"@[A-Za-z]+"}]}),e.C_LINE_COMMENT_MODE,c,{className:"keyword", +begin:/\b(break|continue|return|this)\b/,starts:{contains:[{className:"symbol", +begin:/@\w+/}]}},t,s,o,{className:"function",beginKeywords:"fun",end:"[(]|$", +returnBegin:!0,excludeEnd:!0,keywords:n,relevance:5,contains:[{ +begin:e.UNDERSCORE_IDENT_RE+"\\s*\\(",returnBegin:!0,relevance:0, +contains:[e.UNDERSCORE_TITLE_MODE]},{className:"type",begin://, +keywords:"reified",relevance:0},{className:"params",begin:/\(/,end:/\)/, +endsParent:!0,keywords:n,relevance:0,contains:[{begin:/:/,end:/[=,\/]/, +endsWithParent:!0,contains:[d,e.C_LINE_COMMENT_MODE,c],relevance:0 +},e.C_LINE_COMMENT_MODE,c,s,o,r,e.C_NUMBER_MODE]},c]},{ +begin:[/class|interface|trait/,/\s+/,e.UNDERSCORE_IDENT_RE],beginScope:{ +3:"title.class"},keywords:"class interface trait",end:/[:\{(]|$/,excludeEnd:!0, +illegal:"extends implements",contains:[{ +beginKeywords:"public protected internal private constructor" +},e.UNDERSCORE_TITLE_MODE,{className:"type",begin://,excludeBegin:!0, +excludeEnd:!0,relevance:0},{className:"type",begin:/[,:]\s*/,end:/[<\(,){\s]|$/, +excludeBegin:!0,returnEnd:!0},s,o]},r,{className:"meta",begin:"^#!/usr/bin/env", +end:"$",illegal:"\n"},l]}},grmr_less:e=>{ +const n=ie(e),t=de,a="[\\w-]+",i="("+a+"|@\\{"+a+"\\})",r=[],s=[],o=e=>({ +className:"string",begin:"~?"+e+".*?"+e}),l=(e,n,t)=>({className:e,begin:n, +relevance:t}),c={$pattern:/[a-z-]+/,keyword:"and or not only", +attribute:se.join(" ")},d={begin:"\\(",end:"\\)",contains:s,keywords:c, +relevance:0} +;s.push(e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,o("'"),o('"'),n.CSS_NUMBER_MODE,{ +begin:"(url|data-uri)\\(",starts:{className:"string",end:"[\\)\\n]", +excludeEnd:!0} +},n.HEXCOLOR,d,l("variable","@@?"+a,10),l("variable","@\\{"+a+"\\}"),l("built_in","~?`[^`]*?`"),{ +className:"attribute",begin:a+"\\s*:",end:":",returnBegin:!0,excludeEnd:!0 +},n.IMPORTANT,{beginKeywords:"and not"},n.FUNCTION_DISPATCH);const g=s.concat({ +begin:/\{/,end:/\}/,contains:r}),u={beginKeywords:"when",endsWithParent:!0, +contains:[{beginKeywords:"and not"}].concat(s)},b={begin:i+"\\s*:", +returnBegin:!0,end:/[;}]/,relevance:0,contains:[{begin:/-(webkit|moz|ms|o)-/ +},n.CSS_VARIABLE,{className:"attribute",begin:"\\b("+ce.join("|")+")\\b", +end:/(?=:)/,starts:{endsWithParent:!0,illegal:"[<=$]",relevance:0,contains:s}}] +},m={className:"keyword", +begin:"@(import|media|charset|font-face|(-[a-z]+-)?keyframes|supports|document|namespace|page|viewport|host)\\b", +starts:{end:"[;{}]",keywords:c,returnEnd:!0,contains:s,relevance:0}},p={ +className:"variable",variants:[{begin:"@"+a+"\\s*:",relevance:15},{begin:"@"+a +}],starts:{end:"[;}]",returnEnd:!0,contains:g}},_={variants:[{ +begin:"[\\.#:&\\[>]",end:"[;{}]"},{begin:i,end:/\{/}],returnBegin:!0, +returnEnd:!0,illegal:"[<='$\"]",relevance:0, +contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,u,l("keyword","all\\b"),l("variable","@\\{"+a+"\\}"),{ +begin:"\\b("+re.join("|")+")\\b",className:"selector-tag" +},n.CSS_NUMBER_MODE,l("selector-tag",i,0),l("selector-id","#"+i),l("selector-class","\\."+i,0),l("selector-tag","&",0),n.ATTRIBUTE_SELECTOR_MODE,{ +className:"selector-pseudo",begin:":("+oe.join("|")+")"},{ +className:"selector-pseudo",begin:":(:)?("+le.join("|")+")"},{begin:/\(/, +end:/\)/,relevance:0,contains:g},{begin:"!important"},n.FUNCTION_DISPATCH]},h={ +begin:a+":(:)?"+`(${t.join("|")})`,returnBegin:!0,contains:[_]} +;return r.push(e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,m,p,h,b,_,u,n.FUNCTION_DISPATCH), +{name:"Less",case_insensitive:!0,illegal:"[=>'/<($\"]",contains:r}}, +grmr_lua:e=>{const n="\\[=*\\[",t="\\]=*\\]",a={begin:n,end:t,contains:["self"] +},i=[e.COMMENT("--(?!"+n+")","$"),e.COMMENT("--"+n,t,{contains:[a],relevance:10 +})];return{name:"Lua",keywords:{$pattern:e.UNDERSCORE_IDENT_RE, +literal:"true false nil", +keyword:"and break do else elseif end for goto if in local not or repeat return then until while", +built_in:"_G _ENV _VERSION __index __newindex __mode __call __metatable __tostring __len __gc __add __sub __mul __div __mod __pow __concat __unm __eq __lt __le assert collectgarbage dofile error getfenv getmetatable ipairs load loadfile loadstring module next pairs pcall print rawequal rawget rawset require select setfenv setmetatable tonumber tostring type unpack xpcall arg self coroutine resume yield status wrap create running debug getupvalue debug sethook getmetatable gethook setmetatable setlocal traceback setfenv getinfo setupvalue getlocal getregistry getfenv io lines write close flush open output type read stderr stdin input stdout popen tmpfile math log max acos huge ldexp pi cos tanh pow deg tan cosh sinh random randomseed frexp ceil floor rad abs sqrt modf asin min mod fmod log10 atan2 exp sin atan os exit setlocale date getenv difftime remove time clock tmpname rename execute package preload loadlib loaded loaders cpath config path seeall string sub upper len gfind rep find match char dump gmatch reverse byte format gsub lower table setn insert getn foreachi maxn foreach concat sort remove" +},contains:i.concat([{className:"function",beginKeywords:"function",end:"\\)", +contains:[e.inherit(e.TITLE_MODE,{ +begin:"([_a-zA-Z]\\w*\\.)*([_a-zA-Z]\\w*:)?[_a-zA-Z]\\w*"}),{className:"params", +begin:"\\(",endsWithParent:!0,contains:i}].concat(i) +},e.C_NUMBER_MODE,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,{className:"string", +begin:n,end:t,contains:[a],relevance:5}])}},grmr_makefile:e=>{const n={ +className:"variable",variants:[{begin:"\\$\\("+e.UNDERSCORE_IDENT_RE+"\\)", +contains:[e.BACKSLASH_ESCAPE]},{begin:/\$[@%{ +const n={begin:/<\/?[A-Za-z_]/,end:">",subLanguage:"xml",relevance:0},t={ +variants:[{begin:/\[.+?\]\[.*?\]/,relevance:0},{ +begin:/\[.+?\]\(((data|javascript|mailto):|(?:http|ftp)s?:\/\/).*?\)/, +relevance:2},{ +begin:e.regex.concat(/\[.+?\]\(/,/[A-Za-z][A-Za-z0-9+.-]*/,/:\/\/.*?\)/), +relevance:2},{begin:/\[.+?\]\([./?&#].*?\)/,relevance:1},{ +begin:/\[.*?\]\(.*?\)/,relevance:0}],returnBegin:!0,contains:[{match:/\[(?=\])/ +},{className:"string",relevance:0,begin:"\\[",end:"\\]",excludeBegin:!0, +returnEnd:!0},{className:"link",relevance:0,begin:"\\]\\(",end:"\\)", +excludeBegin:!0,excludeEnd:!0},{className:"symbol",relevance:0,begin:"\\]\\[", +end:"\\]",excludeBegin:!0,excludeEnd:!0}]},a={className:"strong",contains:[], +variants:[{begin:/_{2}(?!\s)/,end:/_{2}/},{begin:/\*{2}(?!\s)/,end:/\*{2}/}] +},i={className:"emphasis",contains:[],variants:[{begin:/\*(?![*\s])/,end:/\*/},{ +begin:/_(?![_\s])/,end:/_/,relevance:0}]},r=e.inherit(a,{contains:[] +}),s=e.inherit(i,{contains:[]});a.contains.push(s),i.contains.push(r) +;let o=[n,t];return[a,i,r,s].forEach((e=>{e.contains=e.contains.concat(o) +})),o=o.concat(a,i),{name:"Markdown",aliases:["md","mkdown","mkd"],contains:[{ +className:"section",variants:[{begin:"^#{1,6}",end:"$",contains:o},{ +begin:"(?=^.+?\\n[=-]{2,}$)",contains:[{begin:"^[=-]*$"},{begin:"^",end:"\\n", +contains:o}]}]},n,{className:"bullet",begin:"^[ \t]*([*+-]|(\\d+\\.))(?=\\s+)", +end:"\\s+",excludeEnd:!0},a,i,{className:"quote",begin:"^>\\s+",contains:o, +end:"$"},{className:"code",variants:[{begin:"(`{3,})[^`](.|\\n)*?\\1`*[ ]*"},{ +begin:"(~{3,})[^~](.|\\n)*?\\1~*[ ]*"},{begin:"```",end:"```+[ ]*$"},{ +begin:"~~~",end:"~~~+[ ]*$"},{begin:"`.+?`"},{begin:"(?=^( {4}|\\t))", +contains:[{begin:"^( {4}|\\t)",end:"(\\n)$"}],relevance:0}]},{ +begin:"^[-\\*]{3,}",end:"$"},t,{begin:/^\[[^\n]+\]:/,returnBegin:!0,contains:[{ +className:"symbol",begin:/\[/,end:/\]/,excludeBegin:!0,excludeEnd:!0},{ +className:"link",begin:/:\s*/,end:/$/,excludeBegin:!0}]}]}},grmr_objectivec:e=>{ +const n=/[a-zA-Z@][a-zA-Z0-9_]*/,t={$pattern:n, +keyword:["@interface","@class","@protocol","@implementation"]};return{ +name:"Objective-C",aliases:["mm","objc","obj-c","obj-c++","objective-c++"], +keywords:{"variable.language":["this","super"],$pattern:n, +keyword:["while","export","sizeof","typedef","const","struct","for","union","volatile","static","mutable","if","do","return","goto","enum","else","break","extern","asm","case","default","register","explicit","typename","switch","continue","inline","readonly","assign","readwrite","self","@synchronized","id","typeof","nonatomic","IBOutlet","IBAction","strong","weak","copy","in","out","inout","bycopy","byref","oneway","__strong","__weak","__block","__autoreleasing","@private","@protected","@public","@try","@property","@end","@throw","@catch","@finally","@autoreleasepool","@synthesize","@dynamic","@selector","@optional","@required","@encode","@package","@import","@defs","@compatibility_alias","__bridge","__bridge_transfer","__bridge_retained","__bridge_retain","__covariant","__contravariant","__kindof","_Nonnull","_Nullable","_Null_unspecified","__FUNCTION__","__PRETTY_FUNCTION__","__attribute__","getter","setter","retain","unsafe_unretained","nonnull","nullable","null_unspecified","null_resettable","class","instancetype","NS_DESIGNATED_INITIALIZER","NS_UNAVAILABLE","NS_REQUIRES_SUPER","NS_RETURNS_INNER_POINTER","NS_INLINE","NS_AVAILABLE","NS_DEPRECATED","NS_ENUM","NS_OPTIONS","NS_SWIFT_UNAVAILABLE","NS_ASSUME_NONNULL_BEGIN","NS_ASSUME_NONNULL_END","NS_REFINED_FOR_SWIFT","NS_SWIFT_NAME","NS_SWIFT_NOTHROW","NS_DURING","NS_HANDLER","NS_ENDHANDLER","NS_VALUERETURN","NS_VOIDRETURN"], +literal:["false","true","FALSE","TRUE","nil","YES","NO","NULL"], +built_in:["dispatch_once_t","dispatch_queue_t","dispatch_sync","dispatch_async","dispatch_once"], +type:["int","float","char","unsigned","signed","short","long","double","wchar_t","unichar","void","bool","BOOL","id|0","_Bool"] +},illegal:"/,end:/$/,illegal:"\\n" +},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{className:"class", +begin:"("+t.keyword.join("|")+")\\b",end:/(\{|$)/,excludeEnd:!0,keywords:t, +contains:[e.UNDERSCORE_TITLE_MODE]},{begin:"\\."+e.UNDERSCORE_IDENT_RE, +relevance:0}]}},grmr_perl:e=>{const n=e.regex,t=/[dualxmsipngr]{0,12}/,a={ +$pattern:/[\w.]+/, +keyword:"abs accept alarm and atan2 bind binmode bless break caller chdir chmod chomp chop chown chr chroot close closedir connect continue cos crypt dbmclose dbmopen defined delete die do dump each else elsif endgrent endhostent endnetent endprotoent endpwent endservent eof eval exec exists exit exp fcntl fileno flock for foreach fork format formline getc getgrent getgrgid getgrnam gethostbyaddr gethostbyname gethostent getlogin getnetbyaddr getnetbyname getnetent getpeername getpgrp getpriority getprotobyname getprotobynumber getprotoent getpwent getpwnam getpwuid getservbyname getservbyport getservent getsockname getsockopt given glob gmtime goto grep gt hex if index int ioctl join keys kill last lc lcfirst length link listen local localtime log lstat lt ma map mkdir msgctl msgget msgrcv msgsnd my ne next no not oct open opendir or ord our pack package pipe pop pos print printf prototype push q|0 qq quotemeta qw qx rand read readdir readline readlink readpipe recv redo ref rename require reset return reverse rewinddir rindex rmdir say scalar seek seekdir select semctl semget semop send setgrent sethostent setnetent setpgrp setpriority setprotoent setpwent setservent setsockopt shift shmctl shmget shmread shmwrite shutdown sin sleep socket socketpair sort splice split sprintf sqrt srand stat state study sub substr symlink syscall sysopen sysread sysseek system syswrite tell telldir tie tied time times tr truncate uc ucfirst umask undef unless unlink unpack unshift untie until use utime values vec wait waitpid wantarray warn when while write x|0 xor y|0" +},i={className:"subst",begin:"[$@]\\{",end:"\\}",keywords:a},r={begin:/->\{/, +end:/\}/},s={variants:[{begin:/\$\d/},{ +begin:n.concat(/[$%@](\^\w\b|#\w+(::\w+)*|\{\w+\}|\w+(::\w*)*)/,"(?![A-Za-z])(?![@$%])") +},{begin:/[$%@][^\s\w{]/,relevance:0}] +},o=[e.BACKSLASH_ESCAPE,i,s],l=[/!/,/\//,/\|/,/\?/,/'/,/"/,/#/],c=(e,a,i="\\1")=>{ +const r="\\1"===i?i:n.concat(i,a) +;return n.concat(n.concat("(?:",e,")"),a,/(?:\\.|[^\\\/])*?/,r,/(?:\\.|[^\\\/])*?/,i,t) +},d=(e,a,i)=>n.concat(n.concat("(?:",e,")"),a,/(?:\\.|[^\\\/])*?/,i,t),g=[s,e.HASH_COMMENT_MODE,e.COMMENT(/^=\w/,/=cut/,{ +endsWithParent:!0}),r,{className:"string",contains:o,variants:[{ +begin:"q[qwxr]?\\s*\\(",end:"\\)",relevance:5},{begin:"q[qwxr]?\\s*\\[", +end:"\\]",relevance:5},{begin:"q[qwxr]?\\s*\\{",end:"\\}",relevance:5},{ +begin:"q[qwxr]?\\s*\\|",end:"\\|",relevance:5},{begin:"q[qwxr]?\\s*<",end:">", +relevance:5},{begin:"qw\\s+q",end:"q",relevance:5},{begin:"'",end:"'", +contains:[e.BACKSLASH_ESCAPE]},{begin:'"',end:'"'},{begin:"`",end:"`", +contains:[e.BACKSLASH_ESCAPE]},{begin:/\{\w+\}/,relevance:0},{ +begin:"-?\\w+\\s*=>",relevance:0}]},{className:"number", +begin:"(\\b0[0-7_]+)|(\\b0x[0-9a-fA-F_]+)|(\\b[1-9][0-9_]*(\\.[0-9_]+)?)|[0_]\\b", +relevance:0},{ +begin:"(\\/\\/|"+e.RE_STARTERS_RE+"|\\b(split|return|print|reverse|grep)\\b)\\s*", +keywords:"split return print reverse grep",relevance:0, +contains:[e.HASH_COMMENT_MODE,{className:"regexp",variants:[{ +begin:c("s|tr|y",n.either(...l,{capture:!0}))},{begin:c("s|tr|y","\\(","\\)")},{ +begin:c("s|tr|y","\\[","\\]")},{begin:c("s|tr|y","\\{","\\}")}],relevance:2},{ +className:"regexp",variants:[{begin:/(m|qr)\/\//,relevance:0},{ +begin:d("(?:m|qr)?",/\//,/\//)},{begin:d("m|qr",n.either(...l,{capture:!0 +}),/\1/)},{begin:d("m|qr",/\(/,/\)/)},{begin:d("m|qr",/\[/,/\]/)},{ +begin:d("m|qr",/\{/,/\}/)}]}]},{className:"function",beginKeywords:"sub", +end:"(\\s*\\(.*?\\))?[;{]",excludeEnd:!0,relevance:5,contains:[e.TITLE_MODE]},{ +begin:"-\\w\\b",relevance:0},{begin:"^__DATA__$",end:"^__END__$", +subLanguage:"mojolicious",contains:[{begin:"^@@.*",end:"$",className:"comment"}] +}];return i.contains=g,r.contains=g,{name:"Perl",aliases:["pl","pm"],keywords:a, +contains:g}},grmr_php:e=>{ +const n=e.regex,t=/(?![A-Za-z0-9])(?![$])/,a=n.concat(/[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*/,t),i=n.concat(/(\\?[A-Z][a-z0-9_\x7f-\xff]+|\\?[A-Z]+(?=[A-Z][a-z0-9_\x7f-\xff])){1,}/,t),r={ +scope:"variable",match:"\\$+"+a},s={scope:"subst",variants:[{begin:/\$\w+/},{ +begin:/\{\$/,end:/\}/}]},o=e.inherit(e.APOS_STRING_MODE,{illegal:null +}),l="[ \t\n]",c={scope:"string",variants:[e.inherit(e.QUOTE_STRING_MODE,{ +illegal:null,contains:e.QUOTE_STRING_MODE.contains.concat(s)}),o,{ +begin:/<<<[ \t]*(?:(\w+)|"(\w+)")\n/,end:/[ \t]*(\w+)\b/, +contains:e.QUOTE_STRING_MODE.contains.concat(s),"on:begin":(e,n)=>{ +n.data._beginMatch=e[1]||e[2]},"on:end":(e,n)=>{ +n.data._beginMatch!==e[1]&&n.ignoreMatch()}},e.END_SAME_AS_BEGIN({ +begin:/<<<[ \t]*'(\w+)'\n/,end:/[ \t]*(\w+)\b/})]},d={scope:"number",variants:[{ +begin:"\\b0[bB][01]+(?:_[01]+)*\\b"},{begin:"\\b0[oO][0-7]+(?:_[0-7]+)*\\b"},{ +begin:"\\b0[xX][\\da-fA-F]+(?:_[\\da-fA-F]+)*\\b"},{ +begin:"(?:\\b\\d+(?:_\\d+)*(\\.(?:\\d+(?:_\\d+)*))?|\\B\\.\\d+)(?:[eE][+-]?\\d+)?" +}],relevance:0 +},g=["false","null","true"],u=["__CLASS__","__DIR__","__FILE__","__FUNCTION__","__COMPILER_HALT_OFFSET__","__LINE__","__METHOD__","__NAMESPACE__","__TRAIT__","die","echo","exit","include","include_once","print","require","require_once","array","abstract","and","as","binary","bool","boolean","break","callable","case","catch","class","clone","const","continue","declare","default","do","double","else","elseif","empty","enddeclare","endfor","endforeach","endif","endswitch","endwhile","enum","eval","extends","final","finally","float","for","foreach","from","global","goto","if","implements","instanceof","insteadof","int","integer","interface","isset","iterable","list","match|0","mixed","new","never","object","or","private","protected","public","readonly","real","return","string","switch","throw","trait","try","unset","use","var","void","while","xor","yield"],b=["Error|0","AppendIterator","ArgumentCountError","ArithmeticError","ArrayIterator","ArrayObject","AssertionError","BadFunctionCallException","BadMethodCallException","CachingIterator","CallbackFilterIterator","CompileError","Countable","DirectoryIterator","DivisionByZeroError","DomainException","EmptyIterator","ErrorException","Exception","FilesystemIterator","FilterIterator","GlobIterator","InfiniteIterator","InvalidArgumentException","IteratorIterator","LengthException","LimitIterator","LogicException","MultipleIterator","NoRewindIterator","OutOfBoundsException","OutOfRangeException","OuterIterator","OverflowException","ParentIterator","ParseError","RangeException","RecursiveArrayIterator","RecursiveCachingIterator","RecursiveCallbackFilterIterator","RecursiveDirectoryIterator","RecursiveFilterIterator","RecursiveIterator","RecursiveIteratorIterator","RecursiveRegexIterator","RecursiveTreeIterator","RegexIterator","RuntimeException","SeekableIterator","SplDoublyLinkedList","SplFileInfo","SplFileObject","SplFixedArray","SplHeap","SplMaxHeap","SplMinHeap","SplObjectStorage","SplObserver","SplPriorityQueue","SplQueue","SplStack","SplSubject","SplTempFileObject","TypeError","UnderflowException","UnexpectedValueException","UnhandledMatchError","ArrayAccess","BackedEnum","Closure","Fiber","Generator","Iterator","IteratorAggregate","Serializable","Stringable","Throwable","Traversable","UnitEnum","WeakReference","WeakMap","Directory","__PHP_Incomplete_Class","parent","php_user_filter","self","static","stdClass"],m={ +keyword:u,literal:(e=>{const n=[];return e.forEach((e=>{ +n.push(e),e.toLowerCase()===e?n.push(e.toUpperCase()):n.push(e.toLowerCase()) +})),n})(g),built_in:b},p=e=>e.map((e=>e.replace(/\|\d+$/,""))),_={variants:[{ +match:[/new/,n.concat(l,"+"),n.concat("(?!",p(b).join("\\b|"),"\\b)"),i],scope:{ +1:"keyword",4:"title.class"}}]},h=n.concat(a,"\\b(?!\\()"),f={variants:[{ +match:[n.concat(/::/,n.lookahead(/(?!class\b)/)),h],scope:{2:"variable.constant" +}},{match:[/::/,/class/],scope:{2:"variable.language"}},{ +match:[i,n.concat(/::/,n.lookahead(/(?!class\b)/)),h],scope:{1:"title.class", +3:"variable.constant"}},{match:[i,n.concat("::",n.lookahead(/(?!class\b)/))], +scope:{1:"title.class"}},{match:[i,/::/,/class/],scope:{1:"title.class", +3:"variable.language"}}]},E={scope:"attr", +match:n.concat(a,n.lookahead(":"),n.lookahead(/(?!::)/))},y={relevance:0, +begin:/\(/,end:/\)/,keywords:m,contains:[E,r,f,e.C_BLOCK_COMMENT_MODE,c,d,_] +},N={relevance:0, +match:[/\b/,n.concat("(?!fn\\b|function\\b|",p(u).join("\\b|"),"|",p(b).join("\\b|"),"\\b)"),a,n.concat(l,"*"),n.lookahead(/(?=\()/)], +scope:{3:"title.function.invoke"},contains:[y]};y.contains.push(N) +;const w=[E,f,e.C_BLOCK_COMMENT_MODE,c,d,_];return{case_insensitive:!1, +keywords:m,contains:[{begin:n.concat(/#\[\s*/,i),beginScope:"meta",end:/]/, +endScope:"meta",keywords:{literal:g,keyword:["new","array"]},contains:[{ +begin:/\[/,end:/]/,keywords:{literal:g,keyword:["new","array"]}, +contains:["self",...w]},...w,{scope:"meta",match:i}] +},e.HASH_COMMENT_MODE,e.COMMENT("//","$"),e.COMMENT("/\\*","\\*/",{contains:[{ +scope:"doctag",match:"@[A-Za-z]+"}]}),{match:/__halt_compiler\(\);/, +keywords:"__halt_compiler",starts:{scope:"comment",end:e.MATCH_NOTHING_RE, +contains:[{match:/\?>/,scope:"meta",endsParent:!0}]}},{scope:"meta",variants:[{ +begin:/<\?php/,relevance:10},{begin:/<\?=/},{begin:/<\?/,relevance:.1},{ +begin:/\?>/}]},{scope:"variable.language",match:/\$this\b/},r,N,f,{ +match:[/const/,/\s/,a],scope:{1:"keyword",3:"variable.constant"}},_,{ +scope:"function",relevance:0,beginKeywords:"fn function",end:/[;{]/, +excludeEnd:!0,illegal:"[$%\\[]",contains:[{beginKeywords:"use" +},e.UNDERSCORE_TITLE_MODE,{begin:"=>",endsParent:!0},{scope:"params", +begin:"\\(",end:"\\)",excludeBegin:!0,excludeEnd:!0,keywords:m, +contains:["self",r,f,e.C_BLOCK_COMMENT_MODE,c,d]}]},{scope:"class",variants:[{ +beginKeywords:"enum",illegal:/[($"]/},{beginKeywords:"class interface trait", +illegal:/[:($"]/}],relevance:0,end:/\{/,excludeEnd:!0,contains:[{ +beginKeywords:"extends implements"},e.UNDERSCORE_TITLE_MODE]},{ +beginKeywords:"namespace",relevance:0,end:";",illegal:/[.']/, +contains:[e.inherit(e.UNDERSCORE_TITLE_MODE,{scope:"title.class"})]},{ +beginKeywords:"use",relevance:0,end:";",contains:[{ +match:/\b(as|const|function)\b/,scope:"keyword"},e.UNDERSCORE_TITLE_MODE]},c,d]} +},grmr_php_template:e=>({name:"PHP template",subLanguage:"xml",contains:[{ +begin:/<\?(php|=)?/,end:/\?>/,subLanguage:"php",contains:[{begin:"/\\*", +end:"\\*/",skip:!0},{begin:'b"',end:'"',skip:!0},{begin:"b'",end:"'",skip:!0 +},e.inherit(e.APOS_STRING_MODE,{illegal:null,className:null,contains:null, +skip:!0}),e.inherit(e.QUOTE_STRING_MODE,{illegal:null,className:null, +contains:null,skip:!0})]}]}),grmr_plaintext:e=>({name:"Plain text", +aliases:["text","txt"],disableAutodetect:!0}),grmr_python:e=>{ +const n=e.regex,t=/[\p{XID_Start}_]\p{XID_Continue}*/u,a=["and","as","assert","async","await","break","case","class","continue","def","del","elif","else","except","finally","for","from","global","if","import","in","is","lambda","match","nonlocal|10","not","or","pass","raise","return","try","while","with","yield"],i={ +$pattern:/[A-Za-z]\w+|__\w+__/,keyword:a, +built_in:["__import__","abs","all","any","ascii","bin","bool","breakpoint","bytearray","bytes","callable","chr","classmethod","compile","complex","delattr","dict","dir","divmod","enumerate","eval","exec","filter","float","format","frozenset","getattr","globals","hasattr","hash","help","hex","id","input","int","isinstance","issubclass","iter","len","list","locals","map","max","memoryview","min","next","object","oct","open","ord","pow","print","property","range","repr","reversed","round","set","setattr","slice","sorted","staticmethod","str","sum","super","tuple","type","vars","zip"], +literal:["__debug__","Ellipsis","False","None","NotImplemented","True"], +type:["Any","Callable","Coroutine","Dict","List","Literal","Generic","Optional","Sequence","Set","Tuple","Type","Union"] +},r={className:"meta",begin:/^(>>>|\.\.\.) /},s={className:"subst",begin:/\{/, +end:/\}/,keywords:i,illegal:/#/},o={begin:/\{\{/,relevance:0},l={ +className:"string",contains:[e.BACKSLASH_ESCAPE],variants:[{ +begin:/([uU]|[bB]|[rR]|[bB][rR]|[rR][bB])?'''/,end:/'''/, +contains:[e.BACKSLASH_ESCAPE,r],relevance:10},{ +begin:/([uU]|[bB]|[rR]|[bB][rR]|[rR][bB])?"""/,end:/"""/, +contains:[e.BACKSLASH_ESCAPE,r],relevance:10},{ +begin:/([fF][rR]|[rR][fF]|[fF])'''/,end:/'''/, +contains:[e.BACKSLASH_ESCAPE,r,o,s]},{begin:/([fF][rR]|[rR][fF]|[fF])"""/, +end:/"""/,contains:[e.BACKSLASH_ESCAPE,r,o,s]},{begin:/([uU]|[rR])'/,end:/'/, +relevance:10},{begin:/([uU]|[rR])"/,end:/"/,relevance:10},{ +begin:/([bB]|[bB][rR]|[rR][bB])'/,end:/'/},{begin:/([bB]|[bB][rR]|[rR][bB])"/, +end:/"/},{begin:/([fF][rR]|[rR][fF]|[fF])'/,end:/'/, +contains:[e.BACKSLASH_ESCAPE,o,s]},{begin:/([fF][rR]|[rR][fF]|[fF])"/,end:/"/, +contains:[e.BACKSLASH_ESCAPE,o,s]},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE] +},c="[0-9](_?[0-9])*",d=`(\\b(${c}))?\\.(${c})|\\b(${c})\\.`,g="\\b|"+a.join("|"),u={ +className:"number",relevance:0,variants:[{ +begin:`(\\b(${c})|(${d}))[eE][+-]?(${c})[jJ]?(?=${g})`},{begin:`(${d})[jJ]?`},{ +begin:`\\b([1-9](_?[0-9])*|0+(_?0)*)[lLjJ]?(?=${g})`},{ +begin:`\\b0[bB](_?[01])+[lL]?(?=${g})`},{begin:`\\b0[oO](_?[0-7])+[lL]?(?=${g})` +},{begin:`\\b0[xX](_?[0-9a-fA-F])+[lL]?(?=${g})`},{begin:`\\b(${c})[jJ](?=${g})` +}]},b={className:"comment",begin:n.lookahead(/# type:/),end:/$/,keywords:i, +contains:[{begin:/# type:/},{begin:/#/,end:/\b\B/,endsWithParent:!0}]},m={ +className:"params",variants:[{className:"",begin:/\(\s*\)/,skip:!0},{begin:/\(/, +end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:i, +contains:["self",r,u,l,e.HASH_COMMENT_MODE]}]};return s.contains=[l,u,r],{ +name:"Python",aliases:["py","gyp","ipython"],unicodeRegex:!0,keywords:i, +illegal:/(<\/|\?)|=>/,contains:[r,u,{begin:/\bself\b/},{beginKeywords:"if", +relevance:0},l,b,e.HASH_COMMENT_MODE,{match:[/\bdef/,/\s+/,t],scope:{ +1:"keyword",3:"title.function"},contains:[m]},{variants:[{ +match:[/\bclass/,/\s+/,t,/\s*/,/\(\s*/,t,/\s*\)/]},{match:[/\bclass/,/\s+/,t]}], +scope:{1:"keyword",3:"title.class",6:"title.class.inherited"}},{ +className:"meta",begin:/^[\t ]*@/,end:/(?=#)|$/,contains:[u,m,l]}]}}, +grmr_python_repl:e=>({aliases:["pycon"],contains:[{className:"meta.prompt", +starts:{end:/ |$/,starts:{end:"$",subLanguage:"python"}},variants:[{ +begin:/^>>>(?=[ ]|$)/},{begin:/^\.\.\.(?=[ ]|$)/}]}]}),grmr_r:e=>{ +const n=e.regex,t=/(?:(?:[a-zA-Z]|\.[._a-zA-Z])[._a-zA-Z0-9]*)|\.(?!\d)/,a=n.either(/0[xX][0-9a-fA-F]+\.[0-9a-fA-F]*[pP][+-]?\d+i?/,/0[xX][0-9a-fA-F]+(?:[pP][+-]?\d+)?[Li]?/,/(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?[Li]?/),i=/[=!<>:]=|\|\||&&|:::?|<-|<<-|->>|->|\|>|[-+*\/?!$&|:<=>@^~]|\*\*/,r=n.either(/[()]/,/[{}]/,/\[\[/,/[[\]]/,/\\/,/,/) +;return{name:"R",keywords:{$pattern:t, +keyword:"function if in break next repeat else for while", +literal:"NULL NA TRUE FALSE Inf NaN NA_integer_|10 NA_real_|10 NA_character_|10 NA_complex_|10", +built_in:"LETTERS letters month.abb month.name pi T F abs acos acosh all any anyNA Arg as.call as.character as.complex as.double as.environment as.integer as.logical as.null.default as.numeric as.raw asin asinh atan atanh attr attributes baseenv browser c call ceiling class Conj cos cosh cospi cummax cummin cumprod cumsum digamma dim dimnames emptyenv exp expression floor forceAndCall gamma gc.time globalenv Im interactive invisible is.array is.atomic is.call is.character is.complex is.double is.environment is.expression is.finite is.function is.infinite is.integer is.language is.list is.logical is.matrix is.na is.name is.nan is.null is.numeric is.object is.pairlist is.raw is.recursive is.single is.symbol lazyLoadDBfetch length lgamma list log max min missing Mod names nargs nzchar oldClass on.exit pos.to.env proc.time prod quote range Re rep retracemem return round seq_along seq_len seq.int sign signif sin sinh sinpi sqrt standardGeneric substitute sum switch tan tanh tanpi tracemem trigamma trunc unclass untracemem UseMethod xtfrm" +},contains:[e.COMMENT(/#'/,/$/,{contains:[{scope:"doctag",match:/@examples/, +starts:{end:n.lookahead(n.either(/\n^#'\s*(?=@[a-zA-Z]+)/,/\n^(?!#')/)), +endsParent:!0}},{scope:"doctag",begin:"@param",end:/$/,contains:[{ +scope:"variable",variants:[{match:t},{match:/`(?:\\.|[^`\\])+`/}],endsParent:!0 +}]},{scope:"doctag",match:/@[a-zA-Z]+/},{scope:"keyword",match:/\\[a-zA-Z]+/}] +}),e.HASH_COMMENT_MODE,{scope:"string",contains:[e.BACKSLASH_ESCAPE], +variants:[e.END_SAME_AS_BEGIN({begin:/[rR]"(-*)\(/,end:/\)(-*)"/ +}),e.END_SAME_AS_BEGIN({begin:/[rR]"(-*)\{/,end:/\}(-*)"/ +}),e.END_SAME_AS_BEGIN({begin:/[rR]"(-*)\[/,end:/\](-*)"/ +}),e.END_SAME_AS_BEGIN({begin:/[rR]'(-*)\(/,end:/\)(-*)'/ +}),e.END_SAME_AS_BEGIN({begin:/[rR]'(-*)\{/,end:/\}(-*)'/ +}),e.END_SAME_AS_BEGIN({begin:/[rR]'(-*)\[/,end:/\](-*)'/}),{begin:'"',end:'"', +relevance:0},{begin:"'",end:"'",relevance:0}]},{relevance:0,variants:[{scope:{ +1:"operator",2:"number"},match:[i,a]},{scope:{1:"operator",2:"number"}, +match:[/%[^%]*%/,a]},{scope:{1:"punctuation",2:"number"},match:[r,a]},{scope:{ +2:"number"},match:[/[^a-zA-Z0-9._]|^/,a]}]},{scope:{3:"operator"}, +match:[t,/\s+/,/<-/,/\s+/]},{scope:"operator",relevance:0,variants:[{match:i},{ +match:/%[^%]*%/}]},{scope:"punctuation",relevance:0,match:r},{begin:"`",end:"`", +contains:[{begin:/\\./}]}]}},grmr_ruby:e=>{ +const n=e.regex,t="([a-zA-Z_]\\w*[!?=]?|[-+~]@|<<|>>|=~|===?|<=>|[<>]=?|\\*\\*|[-/+%^&*~`|]|\\[\\]=?)",a=n.either(/\b([A-Z]+[a-z0-9]+)+/,/\b([A-Z]+[a-z0-9]+)+[A-Z]+/),i=n.concat(a,/(::\w+)*/),r={ +"variable.constant":["__FILE__","__LINE__","__ENCODING__"], +"variable.language":["self","super"], +keyword:["alias","and","begin","BEGIN","break","case","class","defined","do","else","elsif","end","END","ensure","for","if","in","module","next","not","or","redo","require","rescue","retry","return","then","undef","unless","until","when","while","yield","include","extend","prepend","public","private","protected","raise","throw"], +built_in:["proc","lambda","attr_accessor","attr_reader","attr_writer","define_method","private_constant","module_function"], +literal:["true","false","nil"]},s={className:"doctag",begin:"@[A-Za-z]+"},o={ +begin:"#<",end:">"},l=[e.COMMENT("#","$",{contains:[s] +}),e.COMMENT("^=begin","^=end",{contains:[s],relevance:10 +}),e.COMMENT("^__END__",e.MATCH_NOTHING_RE)],c={className:"subst",begin:/#\{/, +end:/\}/,keywords:r},d={className:"string",contains:[e.BACKSLASH_ESCAPE,c], +variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/},{begin:/`/,end:/`/},{ +begin:/%[qQwWx]?\(/,end:/\)/},{begin:/%[qQwWx]?\[/,end:/\]/},{ +begin:/%[qQwWx]?\{/,end:/\}/},{begin:/%[qQwWx]?/},{begin:/%[qQwWx]?\//, +end:/\//},{begin:/%[qQwWx]?%/,end:/%/},{begin:/%[qQwWx]?-/,end:/-/},{ +begin:/%[qQwWx]?\|/,end:/\|/},{begin:/\B\?(\\\d{1,3})/},{ +begin:/\B\?(\\x[A-Fa-f0-9]{1,2})/},{begin:/\B\?(\\u\{?[A-Fa-f0-9]{1,6}\}?)/},{ +begin:/\B\?(\\M-\\C-|\\M-\\c|\\c\\M-|\\M-|\\C-\\M-)[\x20-\x7e]/},{ +begin:/\B\?\\(c|C-)[\x20-\x7e]/},{begin:/\B\?\\?\S/},{ +begin:n.concat(/<<[-~]?'?/,n.lookahead(/(\w+)(?=\W)[^\n]*\n(?:[^\n]*\n)*?\s*\1\b/)), +contains:[e.END_SAME_AS_BEGIN({begin:/(\w+)/,end:/(\w+)/, +contains:[e.BACKSLASH_ESCAPE,c]})]}]},g="[0-9](_?[0-9])*",u={className:"number", +relevance:0,variants:[{ +begin:`\\b([1-9](_?[0-9])*|0)(\\.(${g}))?([eE][+-]?(${g})|r)?i?\\b`},{ +begin:"\\b0[dD][0-9](_?[0-9])*r?i?\\b"},{begin:"\\b0[bB][0-1](_?[0-1])*r?i?\\b" +},{begin:"\\b0[oO][0-7](_?[0-7])*r?i?\\b"},{ +begin:"\\b0[xX][0-9a-fA-F](_?[0-9a-fA-F])*r?i?\\b"},{ +begin:"\\b0(_?[0-7])+r?i?\\b"}]},b={variants:[{match:/\(\)/},{ +className:"params",begin:/\(/,end:/(?=\))/,excludeBegin:!0,endsParent:!0, +keywords:r}]},m=[d,{variants:[{match:[/class\s+/,i,/\s+<\s+/,i]},{ +match:[/\b(class|module)\s+/,i]}],scope:{2:"title.class", +4:"title.class.inherited"},keywords:r},{match:[/(include|extend)\s+/,i],scope:{ +2:"title.class"},keywords:r},{relevance:0,match:[i,/\.new[. (]/],scope:{ +1:"title.class"}},{relevance:0,match:/\b[A-Z][A-Z_0-9]+\b/, +className:"variable.constant"},{relevance:0,match:a,scope:"title.class"},{ +match:[/def/,/\s+/,t],scope:{1:"keyword",3:"title.function"},contains:[b]},{ +begin:e.IDENT_RE+"::"},{className:"symbol", +begin:e.UNDERSCORE_IDENT_RE+"(!|\\?)?:",relevance:0},{className:"symbol", +begin:":(?!\\s)",contains:[d,{begin:t}],relevance:0},u,{className:"variable", +begin:"(\\$\\W)|((\\$|@@?)(\\w+))(?=[^@$?])(?![A-Za-z])(?![@$?'])"},{ +className:"params",begin:/\|/,end:/\|/,excludeBegin:!0,excludeEnd:!0, +relevance:0,keywords:r},{begin:"("+e.RE_STARTERS_RE+"|unless)\\s*", +keywords:"unless",contains:[{className:"regexp",contains:[e.BACKSLASH_ESCAPE,c], +illegal:/\n/,variants:[{begin:"/",end:"/[a-z]*"},{begin:/%r\{/,end:/\}[a-z]*/},{ +begin:"%r\\(",end:"\\)[a-z]*"},{begin:"%r!",end:"![a-z]*"},{begin:"%r\\[", +end:"\\][a-z]*"}]}].concat(o,l),relevance:0}].concat(o,l) +;c.contains=m,b.contains=m;const p=[{begin:/^\s*=>/,starts:{end:"$",contains:m} +},{className:"meta.prompt", +begin:"^([>?]>|[\\w#]+\\(\\w+\\):\\d+:\\d+[>*]|(\\w+-)?\\d+\\.\\d+\\.\\d+(p\\d+)?[^\\d][^>]+>)(?=[ ])", +starts:{end:"$",keywords:r,contains:m}}];return l.unshift(o),{name:"Ruby", +aliases:["rb","gemspec","podspec","thor","irb"],keywords:r,illegal:/\/\*/, +contains:[e.SHEBANG({binary:"ruby"})].concat(p).concat(l).concat(m)}}, +grmr_rust:e=>{const n=e.regex,t={className:"title.function.invoke",relevance:0, +begin:n.concat(/\b/,/(?!let|for|while|if|else|match\b)/,e.IDENT_RE,n.lookahead(/\s*\(/)) +},a="([ui](8|16|32|64|128|size)|f(32|64))?",i=["drop ","Copy","Send","Sized","Sync","Drop","Fn","FnMut","FnOnce","ToOwned","Clone","Debug","PartialEq","PartialOrd","Eq","Ord","AsRef","AsMut","Into","From","Default","Iterator","Extend","IntoIterator","DoubleEndedIterator","ExactSizeIterator","SliceConcatExt","ToString","assert!","assert_eq!","bitflags!","bytes!","cfg!","col!","concat!","concat_idents!","debug_assert!","debug_assert_eq!","env!","eprintln!","panic!","file!","format!","format_args!","include_bytes!","include_str!","line!","local_data_key!","module_path!","option_env!","print!","println!","select!","stringify!","try!","unimplemented!","unreachable!","vec!","write!","writeln!","macro_rules!","assert_ne!","debug_assert_ne!"],r=["i8","i16","i32","i64","i128","isize","u8","u16","u32","u64","u128","usize","f32","f64","str","char","bool","Box","Option","Result","String","Vec"] +;return{name:"Rust",aliases:["rs"],keywords:{$pattern:e.IDENT_RE+"!?",type:r, +keyword:["abstract","as","async","await","become","box","break","const","continue","crate","do","dyn","else","enum","extern","false","final","fn","for","if","impl","in","let","loop","macro","match","mod","move","mut","override","priv","pub","ref","return","self","Self","static","struct","super","trait","true","try","type","typeof","unsafe","unsized","use","virtual","where","while","yield"], +literal:["true","false","Some","None","Ok","Err"],built_in:i},illegal:""},t]}}, +grmr_scss:e=>{const n=ie(e),t=le,a=oe,i="@[a-z-]+",r={className:"variable", +begin:"(\\$[a-zA-Z-][a-zA-Z0-9_-]*)\\b",relevance:0};return{name:"SCSS", +case_insensitive:!0,illegal:"[=/|']", +contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,n.CSS_NUMBER_MODE,{ +className:"selector-id",begin:"#[A-Za-z0-9_-]+",relevance:0},{ +className:"selector-class",begin:"\\.[A-Za-z0-9_-]+",relevance:0 +},n.ATTRIBUTE_SELECTOR_MODE,{className:"selector-tag", +begin:"\\b("+re.join("|")+")\\b",relevance:0},{className:"selector-pseudo", +begin:":("+a.join("|")+")"},{className:"selector-pseudo", +begin:":(:)?("+t.join("|")+")"},r,{begin:/\(/,end:/\)/, +contains:[n.CSS_NUMBER_MODE]},n.CSS_VARIABLE,{className:"attribute", +begin:"\\b("+ce.join("|")+")\\b"},{ +begin:"\\b(whitespace|wait|w-resize|visible|vertical-text|vertical-ideographic|uppercase|upper-roman|upper-alpha|underline|transparent|top|thin|thick|text|text-top|text-bottom|tb-rl|table-header-group|table-footer-group|sw-resize|super|strict|static|square|solid|small-caps|separate|se-resize|scroll|s-resize|rtl|row-resize|ridge|right|repeat|repeat-y|repeat-x|relative|progress|pointer|overline|outside|outset|oblique|nowrap|not-allowed|normal|none|nw-resize|no-repeat|no-drop|newspaper|ne-resize|n-resize|move|middle|medium|ltr|lr-tb|lowercase|lower-roman|lower-alpha|loose|list-item|line|line-through|line-edge|lighter|left|keep-all|justify|italic|inter-word|inter-ideograph|inside|inset|inline|inline-block|inherit|inactive|ideograph-space|ideograph-parenthesis|ideograph-numeric|ideograph-alpha|horizontal|hidden|help|hand|groove|fixed|ellipsis|e-resize|double|dotted|distribute|distribute-space|distribute-letter|distribute-all-lines|disc|disabled|default|decimal|dashed|crosshair|collapse|col-resize|circle|char|center|capitalize|break-word|break-all|bottom|both|bolder|bold|block|bidi-override|below|baseline|auto|always|all-scroll|absolute|table|table-cell)\\b" +},{begin:/:/,end:/[;}{]/,relevance:0, +contains:[n.BLOCK_COMMENT,r,n.HEXCOLOR,n.CSS_NUMBER_MODE,e.QUOTE_STRING_MODE,e.APOS_STRING_MODE,n.IMPORTANT,n.FUNCTION_DISPATCH] +},{begin:"@(page|font-face)",keywords:{$pattern:i,keyword:"@page @font-face"}},{ +begin:"@",end:"[{;]",returnBegin:!0,keywords:{$pattern:/[a-z-]+/, +keyword:"and or not only",attribute:se.join(" ")},contains:[{begin:i, +className:"keyword"},{begin:/[a-z-]+(?=:)/,className:"attribute" +},r,e.QUOTE_STRING_MODE,e.APOS_STRING_MODE,n.HEXCOLOR,n.CSS_NUMBER_MODE] +},n.FUNCTION_DISPATCH]}},grmr_shell:e=>({name:"Shell Session", +aliases:["console","shellsession"],contains:[{className:"meta.prompt", +begin:/^\s{0,3}[/~\w\d[\]()@-]*[>%$#][ ]?/,starts:{end:/[^\\](?=\s*$)/, +subLanguage:"bash"}}]}),grmr_sql:e=>{ +const n=e.regex,t=e.COMMENT("--","$"),a=["true","false","unknown"],i=["bigint","binary","blob","boolean","char","character","clob","date","dec","decfloat","decimal","float","int","integer","interval","nchar","nclob","national","numeric","real","row","smallint","time","timestamp","varchar","varying","varbinary"],r=["abs","acos","array_agg","asin","atan","avg","cast","ceil","ceiling","coalesce","corr","cos","cosh","count","covar_pop","covar_samp","cume_dist","dense_rank","deref","element","exp","extract","first_value","floor","json_array","json_arrayagg","json_exists","json_object","json_objectagg","json_query","json_table","json_table_primitive","json_value","lag","last_value","lead","listagg","ln","log","log10","lower","max","min","mod","nth_value","ntile","nullif","percent_rank","percentile_cont","percentile_disc","position","position_regex","power","rank","regr_avgx","regr_avgy","regr_count","regr_intercept","regr_r2","regr_slope","regr_sxx","regr_sxy","regr_syy","row_number","sin","sinh","sqrt","stddev_pop","stddev_samp","substring","substring_regex","sum","tan","tanh","translate","translate_regex","treat","trim","trim_array","unnest","upper","value_of","var_pop","var_samp","width_bucket"],s=["create table","insert into","primary key","foreign key","not null","alter table","add constraint","grouping sets","on overflow","character set","respect nulls","ignore nulls","nulls first","nulls last","depth first","breadth first"],o=r,l=["abs","acos","all","allocate","alter","and","any","are","array","array_agg","array_max_cardinality","as","asensitive","asin","asymmetric","at","atan","atomic","authorization","avg","begin","begin_frame","begin_partition","between","bigint","binary","blob","boolean","both","by","call","called","cardinality","cascaded","case","cast","ceil","ceiling","char","char_length","character","character_length","check","classifier","clob","close","coalesce","collate","collect","column","commit","condition","connect","constraint","contains","convert","copy","corr","corresponding","cos","cosh","count","covar_pop","covar_samp","create","cross","cube","cume_dist","current","current_catalog","current_date","current_default_transform_group","current_path","current_role","current_row","current_schema","current_time","current_timestamp","current_path","current_role","current_transform_group_for_type","current_user","cursor","cycle","date","day","deallocate","dec","decimal","decfloat","declare","default","define","delete","dense_rank","deref","describe","deterministic","disconnect","distinct","double","drop","dynamic","each","element","else","empty","end","end_frame","end_partition","end-exec","equals","escape","every","except","exec","execute","exists","exp","external","extract","false","fetch","filter","first_value","float","floor","for","foreign","frame_row","free","from","full","function","fusion","get","global","grant","group","grouping","groups","having","hold","hour","identity","in","indicator","initial","inner","inout","insensitive","insert","int","integer","intersect","intersection","interval","into","is","join","json_array","json_arrayagg","json_exists","json_object","json_objectagg","json_query","json_table","json_table_primitive","json_value","lag","language","large","last_value","lateral","lead","leading","left","like","like_regex","listagg","ln","local","localtime","localtimestamp","log","log10","lower","match","match_number","match_recognize","matches","max","member","merge","method","min","minute","mod","modifies","module","month","multiset","national","natural","nchar","nclob","new","no","none","normalize","not","nth_value","ntile","null","nullif","numeric","octet_length","occurrences_regex","of","offset","old","omit","on","one","only","open","or","order","out","outer","over","overlaps","overlay","parameter","partition","pattern","per","percent","percent_rank","percentile_cont","percentile_disc","period","portion","position","position_regex","power","precedes","precision","prepare","primary","procedure","ptf","range","rank","reads","real","recursive","ref","references","referencing","regr_avgx","regr_avgy","regr_count","regr_intercept","regr_r2","regr_slope","regr_sxx","regr_sxy","regr_syy","release","result","return","returns","revoke","right","rollback","rollup","row","row_number","rows","running","savepoint","scope","scroll","search","second","seek","select","sensitive","session_user","set","show","similar","sin","sinh","skip","smallint","some","specific","specifictype","sql","sqlexception","sqlstate","sqlwarning","sqrt","start","static","stddev_pop","stddev_samp","submultiset","subset","substring","substring_regex","succeeds","sum","symmetric","system","system_time","system_user","table","tablesample","tan","tanh","then","time","timestamp","timezone_hour","timezone_minute","to","trailing","translate","translate_regex","translation","treat","trigger","trim","trim_array","true","truncate","uescape","union","unique","unknown","unnest","update","upper","user","using","value","values","value_of","var_pop","var_samp","varbinary","varchar","varying","versioning","when","whenever","where","width_bucket","window","with","within","without","year","add","asc","collation","desc","final","first","last","view"].filter((e=>!r.includes(e))),c={ +begin:n.concat(/\b/,n.either(...o),/\s*\(/),relevance:0,keywords:{built_in:o}} +;return{name:"SQL",case_insensitive:!0,illegal:/[{}]|<\//,keywords:{ +$pattern:/\b[\w\.]+/,keyword:((e,{exceptions:n,when:t}={})=>{const a=t +;return n=n||[],e.map((e=>e.match(/\|\d+$/)||n.includes(e)?e:a(e)?e+"|0":e)) +})(l,{when:e=>e.length<3}),literal:a,type:i, +built_in:["current_catalog","current_date","current_default_transform_group","current_path","current_role","current_schema","current_transform_group_for_type","current_user","session_user","system_time","system_user","current_time","localtime","current_timestamp","localtimestamp"] +},contains:[{begin:n.either(...s),relevance:0,keywords:{$pattern:/[\w\.]+/, +keyword:l.concat(s),literal:a,type:i}},{className:"type", +begin:n.either("double precision","large object","with timezone","without timezone") +},c,{className:"variable",begin:/@[a-z0-9][a-z0-9_]*/},{className:"string", +variants:[{begin:/'/,end:/'/,contains:[{begin:/''/}]}]},{begin:/"/,end:/"/, +contains:[{begin:/""/}]},e.C_NUMBER_MODE,e.C_BLOCK_COMMENT_MODE,t,{ +className:"operator",begin:/[-+*/=%^~]|&&?|\|\|?|!=?|<(?:=>?|<|>)?|>[>=]?/, +relevance:0}]}},grmr_swift:e=>{const n={match:/\s+/,relevance:0 +},t=e.COMMENT("/\\*","\\*/",{contains:["self"]}),a=[e.C_LINE_COMMENT_MODE,t],i={ +match:[/\./,m(...xe,...Me)],className:{2:"keyword"}},r={match:b(/\./,m(...Ae)), +relevance:0},s=Ae.filter((e=>"string"==typeof e)).concat(["_|0"]),o={variants:[{ +className:"keyword", +match:m(...Ae.filter((e=>"string"!=typeof e)).concat(Se).map(ke),...Me)}]},l={ +$pattern:m(/\b\w+/,/#\w+/),keyword:s.concat(Re),literal:Ce},c=[i,r,o],g=[{ +match:b(/\./,m(...De)),relevance:0},{className:"built_in", +match:b(/\b/,m(...De),/(?=\()/)}],u={match:/->/,relevance:0},p=[u,{ +className:"operator",relevance:0,variants:[{match:Be},{match:`\\.(\\.|${Le})+`}] +}],_="([0-9]_*)+",h="([0-9a-fA-F]_*)+",f={className:"number",relevance:0, +variants:[{match:`\\b(${_})(\\.(${_}))?([eE][+-]?(${_}))?\\b`},{ +match:`\\b0x(${h})(\\.(${h}))?([pP][+-]?(${_}))?\\b`},{match:/\b0o([0-7]_*)+\b/ +},{match:/\b0b([01]_*)+\b/}]},E=(e="")=>({className:"subst",variants:[{ +match:b(/\\/,e,/[0\\tnr"']/)},{match:b(/\\/,e,/u\{[0-9a-fA-F]{1,8}\}/)}] +}),y=(e="")=>({className:"subst",match:b(/\\/,e,/[\t ]*(?:[\r\n]|\r\n)/) +}),N=(e="")=>({className:"subst",label:"interpol",begin:b(/\\/,e,/\(/),end:/\)/ +}),w=(e="")=>({begin:b(e,/"""/),end:b(/"""/,e),contains:[E(e),y(e),N(e)] +}),v=(e="")=>({begin:b(e,/"/),end:b(/"/,e),contains:[E(e),N(e)]}),O={ +className:"string", +variants:[w(),w("#"),w("##"),w("###"),v(),v("#"),v("##"),v("###")] +},k=[e.BACKSLASH_ESCAPE,{begin:/\[/,end:/\]/,relevance:0, +contains:[e.BACKSLASH_ESCAPE]}],x={begin:/\/[^\s](?=[^/\n]*\/)/,end:/\//, +contains:k},M=e=>{const n=b(e,/\//),t=b(/\//,e);return{begin:n,end:t, +contains:[...k,{scope:"comment",begin:`#(?!.*${t})`,end:/$/}]}},S={ +scope:"regexp",variants:[M("###"),M("##"),M("#"),x]},A={match:b(/`/,Fe,/`/) +},C=[A,{className:"variable",match:/\$\d+/},{className:"variable", +match:`\\$${ze}+`}],T=[{match:/(@|#(un)?)available/,scope:"keyword",starts:{ +contains:[{begin:/\(/,end:/\)/,keywords:Pe,contains:[...p,f,O]}]}},{ +scope:"keyword",match:b(/@/,m(...je))},{scope:"meta",match:b(/@/,Fe)}],R={ +match:d(/\b[A-Z]/),relevance:0,contains:[{className:"type", +match:b(/(AV|CA|CF|CG|CI|CL|CM|CN|CT|MK|MP|MTK|MTL|NS|SCN|SK|UI|WK|XC)/,ze,"+") +},{className:"type",match:Ue,relevance:0},{match:/[?!]+/,relevance:0},{ +match:/\.\.\./,relevance:0},{match:b(/\s+&\s+/,d(Ue)),relevance:0}]},D={ +begin://,keywords:l,contains:[...a,...c,...T,u,R]};R.contains.push(D) +;const I={begin:/\(/,end:/\)/,relevance:0,keywords:l,contains:["self",{ +match:b(Fe,/\s*:/),keywords:"_|0",relevance:0 +},...a,S,...c,...g,...p,f,O,...C,...T,R]},L={begin://, +keywords:"repeat each",contains:[...a,R]},B={begin:/\(/,end:/\)/,keywords:l, +contains:[{begin:m(d(b(Fe,/\s*:/)),d(b(Fe,/\s+/,Fe,/\s*:/))),end:/:/, +relevance:0,contains:[{className:"keyword",match:/\b_\b/},{className:"params", +match:Fe}]},...a,...c,...p,f,O,...T,R,I],endsParent:!0,illegal:/["']/},$={ +match:[/(func|macro)/,/\s+/,m(A.match,Fe,Be)],className:{1:"keyword", +3:"title.function"},contains:[L,B,n],illegal:[/\[/,/%/]},z={ +match:[/\b(?:subscript|init[?!]?)/,/\s*(?=[<(])/],className:{1:"keyword"}, +contains:[L,B,n],illegal:/\[|%/},F={match:[/operator/,/\s+/,Be],className:{ +1:"keyword",3:"title"}},U={begin:[/precedencegroup/,/\s+/,Ue],className:{ +1:"keyword",3:"title"},contains:[R],keywords:[...Te,...Ce],end:/}/} +;for(const e of O.variants){const n=e.contains.find((e=>"interpol"===e.label)) +;n.keywords=l;const t=[...c,...g,...p,f,O,...C];n.contains=[...t,{begin:/\(/, +end:/\)/,contains:["self",...t]}]}return{name:"Swift",keywords:l, +contains:[...a,$,z,{beginKeywords:"struct protocol class extension enum actor", +end:"\\{",excludeEnd:!0,keywords:l,contains:[e.inherit(e.TITLE_MODE,{ +className:"title.class",begin:/[A-Za-z$_][\u00C0-\u02B80-9A-Za-z$_]*/}),...c] +},F,U,{beginKeywords:"import",end:/$/,contains:[...a],relevance:0 +},S,...c,...g,...p,f,O,...C,...T,R,I]}},grmr_typescript:e=>{ +const n=Oe(e),t=_e,a=["any","void","number","boolean","string","object","never","symbol","bigint","unknown"],i={ +beginKeywords:"namespace",end:/\{/,excludeEnd:!0, +contains:[n.exports.CLASS_REFERENCE]},r={beginKeywords:"interface",end:/\{/, +excludeEnd:!0,keywords:{keyword:"interface extends",built_in:a}, +contains:[n.exports.CLASS_REFERENCE]},s={$pattern:_e, +keyword:he.concat(["type","namespace","interface","public","private","protected","implements","declare","abstract","readonly","enum","override"]), +literal:fe,built_in:ve.concat(a),"variable.language":we},o={className:"meta", +begin:"@"+t},l=(e,n,t)=>{const a=e.contains.findIndex((e=>e.label===n)) +;if(-1===a)throw Error("can not find mode to replace");e.contains.splice(a,1,t)} +;return Object.assign(n.keywords,s), +n.exports.PARAMS_CONTAINS.push(o),n.contains=n.contains.concat([o,i,r]), +l(n,"shebang",e.SHEBANG()),l(n,"use_strict",{className:"meta",relevance:10, +begin:/^\s*['"]use strict['"]/ +}),n.contains.find((e=>"func.def"===e.label)).relevance=0,Object.assign(n,{ +name:"TypeScript",aliases:["ts","tsx","mts","cts"]}),n},grmr_vbnet:e=>{ +const n=e.regex,t=/\d{1,2}\/\d{1,2}\/\d{4}/,a=/\d{4}-\d{1,2}-\d{1,2}/,i=/(\d|1[012])(:\d+){0,2} *(AM|PM)/,r=/\d{1,2}(:\d{1,2}){1,2}/,s={ +className:"literal",variants:[{begin:n.concat(/# */,n.either(a,t),/ *#/)},{ +begin:n.concat(/# */,r,/ *#/)},{begin:n.concat(/# */,i,/ *#/)},{ +begin:n.concat(/# */,n.either(a,t),/ +/,n.either(i,r),/ *#/)}] +},o=e.COMMENT(/'''/,/$/,{contains:[{className:"doctag",begin:/<\/?/,end:/>/}] +}),l=e.COMMENT(null,/$/,{variants:[{begin:/'/},{begin:/([\t ]|^)REM(?=\s)/}]}) +;return{name:"Visual Basic .NET",aliases:["vb"],case_insensitive:!0, +classNameAliases:{label:"symbol"},keywords:{ +keyword:"addhandler alias aggregate ansi as async assembly auto binary by byref byval call case catch class compare const continue custom declare default delegate dim distinct do each equals else elseif end enum erase error event exit explicit finally for friend from function get global goto group handles if implements imports in inherits interface into iterator join key let lib loop me mid module mustinherit mustoverride mybase myclass namespace narrowing new next notinheritable notoverridable of off on operator option optional order overloads overridable overrides paramarray partial preserve private property protected public raiseevent readonly redim removehandler resume return select set shadows shared skip static step stop structure strict sub synclock take text then throw to try unicode until using when where while widening with withevents writeonly yield", +built_in:"addressof and andalso await directcast gettype getxmlnamespace is isfalse isnot istrue like mod nameof new not or orelse trycast typeof xor cbool cbyte cchar cdate cdbl cdec cint clng cobj csbyte cshort csng cstr cuint culng cushort", +type:"boolean byte char date decimal double integer long object sbyte short single string uinteger ulong ushort", +literal:"true false nothing"}, +illegal:"//|\\{|\\}|endif|gosub|variant|wend|^\\$ ",contains:[{ +className:"string",begin:/"(""|[^/n])"C\b/},{className:"string",begin:/"/, +end:/"/,illegal:/\n/,contains:[{begin:/""/}]},s,{className:"number",relevance:0, +variants:[{begin:/\b\d[\d_]*((\.[\d_]+(E[+-]?[\d_]+)?)|(E[+-]?[\d_]+))[RFD@!#]?/ +},{begin:/\b\d[\d_]*((U?[SIL])|[%&])?/},{begin:/&H[\dA-F_]+((U?[SIL])|[%&])?/},{ +begin:/&O[0-7_]+((U?[SIL])|[%&])?/},{begin:/&B[01_]+((U?[SIL])|[%&])?/}]},{ +className:"label",begin:/^\w+:/},o,l,{className:"meta", +begin:/[\t ]*#(const|disable|else|elseif|enable|end|externalsource|if|region)\b/, +end:/$/,keywords:{ +keyword:"const disable else elseif enable end externalsource if region then"}, +contains:[l]}]}},grmr_wasm:e=>{e.regex;const n=e.COMMENT(/\(;/,/;\)/) +;return n.contains.push("self"),{name:"WebAssembly",keywords:{$pattern:/[\w.]+/, +keyword:["anyfunc","block","br","br_if","br_table","call","call_indirect","data","drop","elem","else","end","export","func","global.get","global.set","local.get","local.set","local.tee","get_global","get_local","global","if","import","local","loop","memory","memory.grow","memory.size","module","mut","nop","offset","param","result","return","select","set_global","set_local","start","table","tee_local","then","type","unreachable"] +},contains:[e.COMMENT(/;;/,/$/),n,{match:[/(?:offset|align)/,/\s*/,/=/], +className:{1:"keyword",3:"operator"}},{className:"variable",begin:/\$[\w_]+/},{ +match:/(\((?!;)|\))+/,className:"punctuation",relevance:0},{ +begin:[/(?:func|call|call_indirect)/,/\s+/,/\$[^\s)]+/],className:{1:"keyword", +3:"title.function"}},e.QUOTE_STRING_MODE,{match:/(i32|i64|f32|f64)(?!\.)/, +className:"type"},{className:"keyword", +match:/\b(f32|f64|i32|i64)(?:\.(?:abs|add|and|ceil|clz|const|convert_[su]\/i(?:32|64)|copysign|ctz|demote\/f64|div(?:_[su])?|eqz?|extend_[su]\/i32|floor|ge(?:_[su])?|gt(?:_[su])?|le(?:_[su])?|load(?:(?:8|16|32)_[su])?|lt(?:_[su])?|max|min|mul|nearest|neg?|or|popcnt|promote\/f32|reinterpret\/[fi](?:32|64)|rem_[su]|rot[lr]|shl|shr_[su]|store(?:8|16|32)?|sqrt|sub|trunc(?:_[su]\/f(?:32|64))?|wrap\/i64|xor))\b/ +},{className:"number",relevance:0, +match:/[+-]?\b(?:\d(?:_?\d)*(?:\.\d(?:_?\d)*)?(?:[eE][+-]?\d(?:_?\d)*)?|0x[\da-fA-F](?:_?[\da-fA-F])*(?:\.[\da-fA-F](?:_?[\da-fA-D])*)?(?:[pP][+-]?\d(?:_?\d)*)?)\b|\binf\b|\bnan(?::0x[\da-fA-F](?:_?[\da-fA-D])*)?\b/ +}]}},grmr_xml:e=>{ +const n=e.regex,t=n.concat(/[\p{L}_]/u,n.optional(/[\p{L}0-9_.-]*:/u),/[\p{L}0-9_.-]*/u),a={ +className:"symbol",begin:/&[a-z]+;|&#[0-9]+;|&#x[a-f0-9]+;/},i={begin:/\s/, +contains:[{className:"keyword",begin:/#?[a-z_][a-z1-9_-]+/,illegal:/\n/}] +},r=e.inherit(i,{begin:/\(/,end:/\)/}),s=e.inherit(e.APOS_STRING_MODE,{ +className:"string"}),o=e.inherit(e.QUOTE_STRING_MODE,{className:"string"}),l={ +endsWithParent:!0,illegal:/`]+/}]}]}]};return{ +name:"HTML, XML", +aliases:["html","xhtml","rss","atom","xjb","xsd","xsl","plist","wsf","svg"], +case_insensitive:!0,unicodeRegex:!0,contains:[{className:"meta",begin://,relevance:10,contains:[i,o,s,r,{begin:/\[/,end:/\]/,contains:[{ +className:"meta",begin://,contains:[i,r,o,s]}]}] +},e.COMMENT(//,{relevance:10}),{begin://, +relevance:10},a,{className:"meta",end:/\?>/,variants:[{begin:/<\?xml/, +relevance:10,contains:[o]},{begin:/<\?[a-z][a-z0-9]+/}]},{className:"tag", +begin:/)/,end:/>/,keywords:{name:"style"},contains:[l],starts:{ +end:/<\/style>/,returnEnd:!0,subLanguage:["css","xml"]}},{className:"tag", +begin:/)/,end:/>/,keywords:{name:"script"},contains:[l],starts:{ +end:/<\/script>/,returnEnd:!0,subLanguage:["javascript","handlebars","xml"]}},{ +className:"tag",begin:/<>|<\/>/},{className:"tag", +begin:n.concat(//,/>/,/\s/)))), +end:/\/?>/,contains:[{className:"name",begin:t,relevance:0,starts:l}]},{ +className:"tag",begin:n.concat(/<\//,n.lookahead(n.concat(t,/>/))),contains:[{ +className:"name",begin:t,relevance:0},{begin:/>/,relevance:0,endsParent:!0}]}]} +},grmr_yaml:e=>{ +const n="true false yes no null",t="[\\w#;/?:@&=+$,.~*'()[\\]]+",a={ +className:"string",relevance:0,variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/ +},{begin:/\S+/}],contains:[e.BACKSLASH_ESCAPE,{className:"template-variable", +variants:[{begin:/\{\{/,end:/\}\}/},{begin:/%\{/,end:/\}/}]}]},i=e.inherit(a,{ +variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/},{begin:/[^\s,{}[\]]+/}]}),r={ +end:",",endsWithParent:!0,excludeEnd:!0,keywords:n,relevance:0},s={begin:/\{/, +end:/\}/,contains:[r],illegal:"\\n",relevance:0},o={begin:"\\[",end:"\\]", +contains:[r],illegal:"\\n",relevance:0},l=[{className:"attr",variants:[{ +begin:"\\w[\\w :\\/.-]*:(?=[ \t]|$)"},{begin:'"\\w[\\w :\\/.-]*":(?=[ \t]|$)'},{ +begin:"'\\w[\\w :\\/.-]*':(?=[ \t]|$)"}]},{className:"meta",begin:"^---\\s*$", +relevance:10},{className:"string", +begin:"[\\|>]([1-9]?[+-])?[ ]*\\n( +)[^ ][^\\n]*\\n(\\2[^\\n]+\\n?)*"},{ +begin:"<%[%=-]?",end:"[%-]?%>",subLanguage:"ruby",excludeBegin:!0,excludeEnd:!0, +relevance:0},{className:"type",begin:"!\\w+!"+t},{className:"type", +begin:"!<"+t+">"},{className:"type",begin:"!"+t},{className:"type",begin:"!!"+t +},{className:"meta",begin:"&"+e.UNDERSCORE_IDENT_RE+"$"},{className:"meta", +begin:"\\*"+e.UNDERSCORE_IDENT_RE+"$"},{className:"bullet",begin:"-(?=[ ]|$)", +relevance:0},e.HASH_COMMENT_MODE,{beginKeywords:n,keywords:{literal:n}},{ +className:"number", +begin:"\\b[0-9]{4}(-[0-9][0-9]){0,2}([Tt \\t][0-9][0-9]?(:[0-9][0-9]){2})?(\\.[0-9]*)?([ \\t])*(Z|[-+][0-9][0-9]?(:[0-9][0-9])?)?\\b" +},{className:"number",begin:e.C_NUMBER_RE+"\\b",relevance:0},s,o,a],c=[...l] +;return c.pop(),c.push(i),r.contains=c,{name:"YAML",case_insensitive:!0, +aliases:["yml"],contains:l}}});const He=ae;for(const e of Object.keys(Ke)){ +const n=e.replace("grmr_","").replace("_","-");He.registerLanguage(n,Ke[e])} +return He}() +;"object"==typeof exports&&"undefined"!=typeof module&&(module.exports=hljs);/*! `ocaml` grammar compiled for Highlight.js 11.9.0 */ +(()=>{var e=(()=>{"use strict";return e=>({name:"OCaml",aliases:["ml"], +keywords:{$pattern:"[a-z_]\\w*!?", +keyword:"and as assert asr begin class constraint do done downto else end exception external for fun function functor if in include inherit! inherit initializer land lazy let lor lsl lsr lxor match method!|10 method mod module mutable new object of open! open or private rec sig struct then to try type val! val virtual when while with parser value", +built_in:"array bool bytes char exn|5 float int int32 int64 list lazy_t|5 nativeint|5 string unit in_channel out_channel ref", +literal:"true false"},illegal:/\/\/|>>/,contains:[{className:"literal", +begin:"\\[(\\|\\|)?\\]|\\(\\)",relevance:0},e.COMMENT("\\(\\*","\\*\\)",{ +contains:["self"]}),{className:"symbol",begin:"'[A-Za-z_](?!')[\\w']*"},{ +className:"type",begin:"`[A-Z][\\w']*"},{className:"type", +begin:"\\b[A-Z][\\w']*",relevance:0},{begin:"[a-z_]\\w*'[\\w']*",relevance:0 +},e.inherit(e.APOS_STRING_MODE,{className:"string",relevance:0 +}),e.inherit(e.QUOTE_STRING_MODE,{illegal:null}),{className:"number", +begin:"\\b(0[xX][a-fA-F0-9_]+[Lln]?|0[oO][0-7_]+[Lln]?|0[bB][01_]+[Lln]?|[0-9][0-9_]*([Lln]|(\\.[0-9_]*)?([eE][-+]?[0-9_]+)?)?)", +relevance:0},{begin:/->/}]})})();hljs.registerLanguage("ocaml",e)})(); \ No newline at end of file diff --git a/doc/config.toml b/doc/config.toml new file mode 100644 index 0000000..c6ce2a5 --- /dev/null +++ b/doc/config.toml @@ -0,0 +1,33 @@ +# The URL the site will be built for +base_url = "https://mbarbin.github.io/central-cli/" + +title = "central" +description = "Manage history between sub-repos and their monorepo" + +# The default language +default_language = "en" + +# Whether to automatically compile all Sass files in the sass directory +compile_sass = true + +# Whether to build a search index of the content +build_search_index = true + +# Whether to generate a feed +generate_feeds = true +feed_filenames = ["atom.xml"] + +[[taxonomies]] +name = "tags" + +[markdown] +# Whether to do syntax highlighting +highlighting = { theme = "one-dark-pro" } + +[extra] +# Put all your custom variables here +github_url = "https://github.com/mbarbin/central-cli" + +[extra.zolanight] +theme = "tokyonight" +home_list_latest_blog_posts = true diff --git a/doc/content/_index.md b/doc/content/_index.md new file mode 100644 index 0000000..ac12615 --- /dev/null +++ b/doc/content/_index.md @@ -0,0 +1,9 @@ ++++ +title = "Manage history between sub-repos and their monorepo" +template = "index.html" +sort_by = "weight" ++++ + +Welcome to **central**, a tool to help manage changes and git history +between individual sub-repos and a monorepo that aggregates them, allowing +changes to be promoted bidirectionally between the two. diff --git a/doc/content/blog/_index.md b/doc/content/blog/_index.md new file mode 100644 index 0000000..d6f7558 --- /dev/null +++ b/doc/content/blog/_index.md @@ -0,0 +1,5 @@ ++++ +title = "Blog" +sort_by = "date" +paginate_by = 10 ++++ diff --git a/doc/content/dune b/doc/content/dune new file mode 100644 index 0000000..d218096 --- /dev/null +++ b/doc/content/dune @@ -0,0 +1 @@ +(data_only_dirs blog explanation guides reference tutorials) diff --git a/doc/content/explanation/_index.md b/doc/content/explanation/_index.md new file mode 100644 index 0000000..0e0f45a --- /dev/null +++ b/doc/content/explanation/_index.md @@ -0,0 +1,5 @@ ++++ +title = "Explanation" +weight = 4 +sort_by = "weight" ++++ diff --git a/doc/content/guides/_index.md b/doc/content/guides/_index.md new file mode 100644 index 0000000..b677125 --- /dev/null +++ b/doc/content/guides/_index.md @@ -0,0 +1,5 @@ ++++ +title = "Guides" +weight = 2 +sort_by = "weight" ++++ diff --git a/doc/content/reference/_index.md b/doc/content/reference/_index.md new file mode 100644 index 0000000..3c26f6a --- /dev/null +++ b/doc/content/reference/_index.md @@ -0,0 +1,5 @@ ++++ +title = "Reference" +weight = 3 +sort_by = "weight" ++++ diff --git a/doc/content/resources/_index.md b/doc/content/resources/_index.md new file mode 100644 index 0000000..f139d55 --- /dev/null +++ b/doc/content/resources/_index.md @@ -0,0 +1,20 @@ ++++ +title = "Resources" ++++ + +Additional content meant to complement the documentation in different ways --- +from standalone documents to links showing how central is used in external +projects and beyond. + +## [Introduction to central](../book/introduction-to-central-cli/) + +A short, user-facing tour of the `central` CLI: what it's for, and how to +use it day to day. + +**Audience:** New users + +## [Test Suite](../book/test-suite/) + +The internal test suite for central, using mdexp of course. + +**Audience:** Central developers and contributors diff --git a/doc/content/tutorials/_index.md b/doc/content/tutorials/_index.md new file mode 100644 index 0000000..800f3bb --- /dev/null +++ b/doc/content/tutorials/_index.md @@ -0,0 +1,9 @@ ++++ +title = "Tutorials" +weight = 1 +sort_by = "weight" ++++ + +Tutorials are hands-on, learning-oriented guides that walk you through a +complete workflow from start to finish. See the +[Introduction to central](/book/introduction-to-central-cli/) book for now. diff --git a/doc/dune b/doc/dune new file mode 100644 index 0000000..29ae476 --- /dev/null +++ b/doc/dune @@ -0,0 +1 @@ +(data_only_dirs static public sass templates themes) diff --git a/doc/sass/_colors.scss b/doc/sass/_colors.scss new file mode 100644 index 0000000..4e6252f --- /dev/null +++ b/doc/sass/_colors.scss @@ -0,0 +1,51 @@ +// Color palettes +$palettes: ( + tokyonight: ( + bg: #1a1b26, + fg: #a9b1d6, + comment: #565f89, + blue: #7aa2f7, + cyan: #7dcfff, + green: #9ece6a, + orange: #ff9e64, + red: #f7768e, + yellow: #e0af68, + magenta: #bb9af7, + ), + tokyostorm: ( + bg: #24283b, + fg: #a9b1d6, + comment: #565f89, + blue: #7aa2f7, + cyan: #7dcfff, + green: #9ece6a, + orange: #ff9e64, + red: #f7768e, + yellow: #e0af68, + magenta: #bb9af7, + ), + tokyomoon: ( + bg: #222436, + fg: #c8d3f5, + comment: #636da6, + blue: #82aaff, + cyan: #86e1fc, + green: #c3e88d, + orange: #ff966c, + red: #ff757f, + yellow: #ffc777, + magenta: #c099ff, + ), + tokyoday: ( + bg: #e6e7ed, + fg: #343b58, + comment: #6c6e75, + blue: #2959aa, + cyan: #0f4b6e, + green: #385f0d, + orange: #965027, + red: #8c4351, + yellow: #8f5e15, + magenta: #5a3e8e, + ), +); diff --git a/doc/sass/_reset.scss b/doc/sass/_reset.scss new file mode 100644 index 0000000..e33253b --- /dev/null +++ b/doc/sass/_reset.scss @@ -0,0 +1,53 @@ +/* A modern CSS reset */ +*, +*::before, +*::after { + box-sizing: border-box; +} + +/* Remove default margin */ +body, +h1, +h2, +h3, +h4, +p, +figure, +blockquote, +dl, +dd { + margin: 0; +} + +/* Remove list styles on ul, ol elements with a list role, which suggests default styling will be removed */ +ul[role='list'], +ol[role='list'] { + list-style: none; +} + +/* Set core root defaults */ +html:focus-within { + scroll-behavior: smooth; +} + +/* Set core body defaults */ +body { + min-height: 100vh; + text-rendering: optimizeSpeed; + line-height: 1.5; +} + +/* Make images and media easier to work with */ +img, +picture { + max-width: 100%; + display: block; +} + +/* Inherit fonts for inputs and buttons */ +input, +button, +textarea, +select { + font: inherit; +} diff --git a/doc/sass/style.scss b/doc/sass/style.scss new file mode 100644 index 0000000..e624951 --- /dev/null +++ b/doc/sass/style.scss @@ -0,0 +1,382 @@ +// Import zolanight base styles +@import 'reset'; +@import 'colors'; + +// Generate CSS variables from zolanight palettes +@mixin theme-vars($palette) { + --bg-color: #{map-get($palette, bg)}; + --fg-color: #{map-get($palette, fg)}; + --comment-color: #{map-get($palette, comment)}; + --blue-color: #{map-get($palette, blue)}; + --cyan-color: #{map-get($palette, cyan)}; + --green-color: #{map-get($palette, green)}; + --orange-color: #{map-get($palette, orange)}; + --red-color: #{map-get($palette, red)}; + --yellow-color: #{map-get($palette, yellow)}; + --magenta-color: #{map-get($palette, magenta)}; +} + +@each $name, $palette in $palettes { + .theme-#{$name} { + @include theme-vars($palette); + } +} + +// Base styles (from zolanight, adapted for docs layout) +body { + font-family: + 'Hack', 'Source Code Pro', Menlo, Monaco, Consolas, 'Courier New', monospace; + font-size: 1rem; + line-height: 1.5; + margin: 0; + padding: 0; + background-color: var(--bg-color); + color: var(--fg-color); +} + +a { + color: var(--orange-color); + text-decoration: underline; +} + +p { + margin-bottom: 1em; +} + +h1, h2, h3, h4, h5, h6 { + color: var(--blue-color); + line-height: 1.25; + margin-bottom: 1rem; +} + +h1:before { content: '# '; color: var(--cyan-color); } +h2:before { content: '## '; color: var(--cyan-color); } +h3:before { content: '### '; color: var(--cyan-color); } + +h1 { font-size: 2rem; } +h2 { font-size: 1.5rem; } +h3 { font-size: 1.25rem; } +h4 { font-size: 1rem; } + +code { + padding: 0.1em 0.4em; + border-radius: 3px; + background-color: rgba(255, 255, 255, 0.08); + color: var(--cyan-color); +} + +pre { + padding: 0.5em 0.75em; + border-radius: 5px; + background-color: rgba(255, 255, 255, 0.05); + border: 1px solid var(--cyan-color); + overflow: auto; +} + +pre > code { + background-color: transparent; + color: var(--fg-color); + padding: 0; + + span { + background-color: transparent !important; + } +} + +hr { + border: 0; + height: 1px; + background: var(--cyan-color); + margin: 0.25em 0; +} + +blockquote { + border-left: 4px solid var(--blue-color); + padding: 1em; + margin: 1em 0; + background-color: rgba(255, 255, 255, 0.05); + + > *:last-child { + margin-bottom: 0; + } +} + +img { + border: 1px solid var(--magenta-color); + border-radius: 5px; + max-width: 100%; + height: auto; +} + +table { + margin: 1em auto; + border-collapse: collapse; + width: auto; + + thead { + background-color: rgba(255, 255, 255, 0.1); + + th { + padding: 0.4em 1.25em; + font-weight: 700; + border-bottom: 2px solid var(--cyan-color); + } + } + + td { + padding: 0.4em 1.25em; + border: 1px solid rgba(255, 255, 255, 0.1); + } + + tbody tr:nth-child(2n) { + background-color: rgba(255, 255, 255, 0.04); + } +} + +details { + margin-bottom: 1em; +} + +// Header: hide on scroll down, show on scroll up +header { + position: sticky; + top: 0; + z-index: 100; + background-color: var(--bg-color); + transition: transform 0.3s ease; + + &.header-hidden { + transform: translateY(-100%); + } +} + +// Top navigation +.top-nav { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 1.5rem; + + .site-title { + font-weight: bold; + font-size: 1.1rem; + color: var(--blue-color); + } + + .nav-links-left, .nav-links-right { + display: flex; + gap: 1.5rem; + font-size: 0.9rem; + + a { + color: var(--orange-color); + } + } + + .nav-links-left { + margin-right: auto; + margin-left: 2rem; + } +} + +// Two-column page wrapper: sidebar + content +.page-wrapper { + margin: 0 auto; + padding: 1rem 1.5rem 1rem calc(220px + 3.5rem); + + main { + max-width: 960px; + padding: 0 1.5rem; + } + + &.no-sidebar { + padding: 1rem 1.5rem; + + main { + margin: 0 auto; + } + } +} + +// Left sidebar +.sidebar { + width: 220px; + font-size: 0.85rem; + position: fixed; + left: 1.5rem; + top: 4.5rem; + max-height: calc(100vh - 4.5rem); + overflow-y: auto; + + .sidebar-section { + margin-bottom: 1.2rem; + + h4 { + margin: 0 0 0.3rem 0; + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--comment-color); + + a { + color: var(--comment-color); + text-decoration: none; + + &:hover { + color: var(--blue-color); + } + } + } + + // No # prefix on sidebar headings + h4:before { content: none; } + + ul { + list-style: none; + padding: 0; + margin: 0; + + li { + padding: 0.15rem 0; + + a { + display: block; + padding: 0.15rem 0.5rem; + border-radius: 3px; + color: var(--fg-color); + text-decoration: none; + + &:hover { + color: var(--orange-color); + background-color: rgba(255, 255, 255, 0.05); + } + + &.active { + color: var(--orange-color); + font-weight: bold; + } + } + } + + ul { + padding-left: 0.75rem; + } + } + } +} + + +// Landing page +.hero { + text-align: center; + padding: 2rem 0; +} + +.sections-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 1rem; + margin: 2rem 0; + text-align: left; + + .section-card { + padding: 1rem 1.5rem; + border: 1px solid var(--cyan-color); + border-radius: 6px; + display: block; + text-decoration: none; + + &:hover { + background-color: rgba(255, 255, 255, 0.05); + text-decoration: none; + } + + h3 { + margin: 0 0 0.3rem 0; + color: var(--blue-color); + } + + h3:before { content: none; } + + p { + margin: 0; + font-size: 0.9rem; + color: var(--comment-color); + } + } +} + +// Article content +article { + h1:first-child { + margin-top: 0; + } + + ul { + padding-left: 1.5rem; + } +} + +.meta { + font-size: 0.9rem; + color: var(--comment-color); +} + +.tags a { + color: var(--green-color); +} + +ul.blog { + list-style: none; + padding-left: 0; +} + +footer { + text-align: center; + padding: 2rem 1.5rem; + font-size: 0.85rem; + color: var(--comment-color); + + a { + color: var(--orange-color); + } +} + +// Responsive +@media (max-width: 1200px) { + body { + font-size: 0.85rem; + } +} + +@media (max-width: 900px) { + .page-wrapper { + padding-left: 1.5rem; + } + + .sidebar { + width: 100%; + position: static; + left: auto; + max-height: none; + } + + .top-nav { + flex-direction: column; + gap: 0.5rem; + + .nav-links-left, .nav-links-right { + flex-wrap: wrap; + justify-content: center; + } + + .nav-links-left { + margin-right: 0; + margin-left: 0; + } + } + + .sections-grid { + grid-template-columns: 1fr; + } +} diff --git a/doc/static/.gitignore b/doc/static/.gitignore new file mode 100644 index 0000000..3006b27 --- /dev/null +++ b/doc/static/.gitignore @@ -0,0 +1 @@ +book/ diff --git a/doc/static/.nojekyll b/doc/static/.nojekyll new file mode 100644 index 0000000..e69de29 diff --git a/doc/templates/404.html b/doc/templates/404.html new file mode 100644 index 0000000..da83883 --- /dev/null +++ b/doc/templates/404.html @@ -0,0 +1,11 @@ +{% extends "base.html" %} + +{% block title %}404 - Page Not Found{% endblock title %} + +{% block content %} +
+

404 - Page Not Found

+

The page you're looking for doesn't exist.

+

Navigate back to the homepage and try again.

+
+{% endblock content %} diff --git a/doc/templates/base.html b/doc/templates/base.html new file mode 100644 index 0000000..ce7130d --- /dev/null +++ b/doc/templates/base.html @@ -0,0 +1,101 @@ + + + + + + {% block title %}{{ config.title }}{% endblock title %} + {% if page %} + {% if page.description %} + + {% endif %} + {% elif section %} + {% if section.description %} + + {% endif %} + {% endif %} + {% if page %} + {% elif section %} + {% else %} + {% endif %} + + + +
+ +
+
+
+ {% block sidebar %}{% endblock sidebar %} +
+ {% block content %}{% endblock content %} +
+
+
+
+

© {{ now() | date(format="%Y") }} Mathieu Barbin. Powered by Zola and ZolaNight. Docs organized following Diátaxis.

+
+ + + diff --git a/doc/templates/index.html b/doc/templates/index.html new file mode 100644 index 0000000..19350d9 --- /dev/null +++ b/doc/templates/index.html @@ -0,0 +1,40 @@ +{% extends "base.html" %} + +{% block title %}{{ config.title }}{% endblock title %} + +{% block wrapper_class %} no-sidebar{% endblock wrapper_class %} + +{% block content %} +
+

{{ section.title }}

+ {{ section.content | safe }} + + +
+ +
+

Latest Additions

+ +
+{% endblock content %} diff --git a/doc/templates/page.html b/doc/templates/page.html new file mode 100644 index 0000000..fd0b5db --- /dev/null +++ b/doc/templates/page.html @@ -0,0 +1,54 @@ +{% extends "base.html" %} + +{% block title %}{{ page.title }} - {{ config.title }}{% endblock title %} + +{% block sidebar %} +{% if page.toc | length > 0 %} + +{% endif %} +{% endblock sidebar %} + +{% block content %} +
+

{{ page.title }}

+ {% if page.date %} +

+ + {% if page.extra.author %} — {{ page.extra.author }}{% endif %} +

+ {% endif %} + {% if page.taxonomies.tags %} +

+ {% for tag in page.taxonomies.tags %} + #{{ tag }}{% if not loop.last %} {% endif %} + {% endfor %} +

+ {% endif %} + {{ page.content | safe }} +
+{% endblock content %} diff --git a/doc/templates/section.html b/doc/templates/section.html new file mode 100644 index 0000000..5b4ed4b --- /dev/null +++ b/doc/templates/section.html @@ -0,0 +1,64 @@ +{% extends "base.html" %} + +{% block title %}{{ section.title }} - {{ config.title }}{% endblock title %} + +{% block sidebar %} +{% if section.toc | length > 0 %} + +{% endif %} +{% endblock sidebar %} + +{% block content %} +
+

{{ section.title }}

+ {{ section.content | safe }} + + {% if paginator %} + {# Paginated section (blog) #} +
    + {% for pg in paginator.pages %} +
  • + {% if pg.date %} + — + {% endif %} + {{ pg.title }} + {% if pg.taxonomies.tags %} + {% for tag in pg.taxonomies.tags %} + #{{ tag }} + {% endfor %} + {% endif %} +
  • + {% endfor %} +
+ {% if paginator.number_pagers > 1 %} + + {% endif %} + + {% endif %} +
+{% endblock content %} diff --git a/doc/templates/taxonomy_list.html b/doc/templates/taxonomy_list.html new file mode 100644 index 0000000..a708e01 --- /dev/null +++ b/doc/templates/taxonomy_list.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} + +{% block title %}{{ taxonomy.name | title }} | {{ config.title }}{% endblock title %} + +{% block content %} + +{% endblock content %} diff --git a/doc/templates/taxonomy_single.html b/doc/templates/taxonomy_single.html new file mode 100644 index 0000000..88edbcb --- /dev/null +++ b/doc/templates/taxonomy_single.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} + +{% block title %}{{ term.name }} | {{ config.title }}{% endblock title %} + +{% block content %} +
+

{{ term.name }}

+
    + {% for page in term.pages %} +
  • {{ page.title }} — {{ page.date | date(format="%B %d, %Y") }}
  • + {% endfor %} +
+
+{% endblock content %} diff --git a/schema/central-repo-config.schema.json b/schema/central-repo-config.schema.json new file mode 100644 index 0000000..21fe1de --- /dev/null +++ b/schema/central-repo-config.schema.json @@ -0,0 +1,20 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://github.com/mbarbin/central-cli/releases/download/%%VERSION%%/central-repo-config.schema.json", + "title": "Central Repo Config", + "description": "Configuration file for a monorepo using central, optionally read from .central/repo-config.json at the root of the repository.", + "type": "object", + "additionalProperties": false, + "properties": { + "$schema": { + "type": "string", + "description": "The JSON schema for this configuration file" + }, + "rootRepoName": { + "type": "string", + "description": "The name of the monorepo itself, as shown e.g. in `central todo`'s table, and used to resolve `central` as a \"which repos\" selector on the command line.", + "default": "central", + "examples": ["central"] + } + } +} diff --git a/schema/central-user-config.schema.json b/schema/central-user-config.schema.json new file mode 100644 index 0000000..511dfd5 --- /dev/null +++ b/schema/central-user-config.schema.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://github.com/mbarbin/central-cli/releases/download/%%VERSION%%/central-user-config.schema.json", + "title": "Central User Config", + "description": "Per-user configuration for central, read from the XDG config directory (typically ~/.config/central/user-config.json). Currently empty - fields will be added as the CLI grows.", + "type": "object", + "additionalProperties": false, + "properties": { + "$schema": { + "type": "string", + "description": "The JSON schema for this configuration file" + } + } +} From 0a27618618f7162b9a9a98f9a7006697141d391f Mon Sep 17 00:00:00 2001 From: Mathieu Barbin Date: Mon, 17 Aug 2026 22:22:12 +0200 Subject: [PATCH 13/26] Initiate CIs --- .github/crs-config.json | 9 +++ .github/workflows/ci.yml | 61 +++++++++++++++ .github/workflows/crs.yml | 28 +++++++ .github/workflows/deploy-doc.yml | 86 +++++++++++++++++++++ .github/workflows/dune-pkg-more-ci.yml | 72 ++++++++++++++++++ .github/workflows/dunolint.yml | 28 +++++++ .github/workflows/test-deploy-doc.yml | 58 ++++++++++++++ actions/install-mdbook.sh | 100 +++++++++++++++++++++++++ actions/install-zola.sh | 100 +++++++++++++++++++++++++ actions/test-install-mdbook.sh | 30 ++++++++ actions/test-install-zola.sh | 30 ++++++++ 11 files changed, 602 insertions(+) create mode 100644 .github/crs-config.json create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/crs.yml create mode 100644 .github/workflows/deploy-doc.yml create mode 100644 .github/workflows/dune-pkg-more-ci.yml create mode 100644 .github/workflows/dunolint.yml create mode 100644 .github/workflows/test-deploy-doc.yml create mode 100755 actions/install-mdbook.sh create mode 100755 actions/install-zola.sh create mode 100755 actions/test-install-mdbook.sh create mode 100755 actions/test-install-zola.sh diff --git a/.github/crs-config.json b/.github/crs-config.json new file mode 100644 index 0000000..8802d4d --- /dev/null +++ b/.github/crs-config.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://github.com/mbarbin/crs/releases/download/0.0.20260307/crs-config.schema.json", + "default_repo_owner": "mbarbin", + "user_mentions_allowlist": [ + "mbarbin" + ], + "invalid_crs_annotation_severity": "Error", + "crs_due_now_annotation_severity": "Info" +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..84bb28d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,61 @@ +name: ci + +on: + push: + branches: + - main + pull_request: + branches: + - "**" # This will match pull requests targeting any branch + +permissions: + contents: read + +jobs: + build: + name: CI + runs-on: ubuntu-latest + env: + OCAML_VERSION: "5.5" + DUNE_VERSION: "3.24.2" + DUNE_DIGEST: "sha256:3a9cac891f0b6086bfbf58d16c6c17ddfd1d493e031b6b43901f585a7f6899c7" + defaults: + run: + shell: bash + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Environment setup + run: | + echo "DUNE_WORKSPACE=$PWD/dune-workspace.${{ env.OCAML_VERSION }}" >> "$GITHUB_ENV" + + - name: Setup Dune + uses: mbarbin/setup-dune@03ede0220d3fe665f250727d8e21b7c0f9517f3b # v2.0.0+patch-7 + env: + GH_TOKEN: ${{ github.token }} + with: + version: "${{ env.DUNE_VERSION }}" + dune-digest: "${{ env.DUNE_DIGEST }}" + workspace: "${{ env.DUNE_WORKSPACE }}" + cache-prefix: "main-ci-${{ env.OCAML_VERSION }}" + cache-readonly: ${{ github.ref != 'refs/heads/main' }} + steps: install-dune enable-pkg lazy-update-depexts install-gpatch install-depexts + + - name: Build and Run tests + run: | + mkdir $BISECT_DIR + dune build @all @runtest + dune build @runtest --force --instrument-with bisect_ppx + env: + BISECT_DIR: ${{ runner.temp }}/_bisect_ppx_data + BISECT_FILE: ${{ runner.temp }}/_bisect_ppx_data/data + + - name: Lint + run: dune build @lint @fmt @unused-libs + + - name: Build Doc + run: dune build @doc + + - name: Check for uncommitted changes + run: git diff --exit-code diff --git a/.github/workflows/crs.yml b/.github/workflows/crs.yml new file mode 100644 index 0000000..e6d98f9 --- /dev/null +++ b/.github/workflows/crs.yml @@ -0,0 +1,28 @@ +name: CRs Workflows + +on: + pull_request: + branches: + - "**" # This will match pull requests targeting any branch + +permissions: + contents: read + pull-requests: write + +jobs: + crs-workflows: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Install crs + uses: mbarbin/crs-actions/setup-crs@1496bc422fe27b357d6354ab494e922f6a2b061f # v1.0.0-alpha.13 + with: + crs-version: "0.0.20260307" + crs-digest: "sha256:5097e709386d8d41351a87f86c8ad374db72aabe4ddc2a8ff2d58faebb1b889f" + + - name: Summarize CRs in PR + uses: mbarbin/crs-actions/summarize-crs-in-pr@1496bc422fe27b357d6354ab494e922f6a2b061f # v1.0.0-alpha.13 + with: + crs-config: .github/crs-config.json diff --git a/.github/workflows/deploy-doc.yml b/.github/workflows/deploy-doc.yml new file mode 100644 index 0000000..18d541c --- /dev/null +++ b/.github/workflows/deploy-doc.yml @@ -0,0 +1,86 @@ +name: deploy-doc + +on: + push: + branches: + - main + +permissions: + contents: read + +jobs: + build: + name: Build documentation + runs-on: ubuntu-latest + + env: + MDBOOK_VERSION: "0.5.2" + MDBOOK_DIGEST: "sha256:b7ab218618bb3c1715e3e1759cfa0abc56dcd7ab5d90f60dcd86d708c87b715d" + ZOLA_VERSION: "0.22.1" + ZOLA_DIGEST: "sha256:45de6b2559aba4df42199dc6b0161acb914d37be4ccaa03297cd4a26c8e14042" + + defaults: + run: + shell: bash + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Cache mdbook binary + uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 + id: mdbook-cache + with: + path: ~/.local/bin/mdbook + key: "mdbook-${{ env.MDBOOK_VERSION }}-${{ runner.os }}-${{ runner.arch }}-${{ env.MDBOOK_DIGEST }}" + + - name: Install mdbook + env: + BINARY_CACHE_HIT: ${{ steps.mdbook-cache.outputs.cache-hit }} + run: | + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + bash actions/install-mdbook.sh + + - name: Cache zola binary + uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 + id: zola-cache + with: + path: ~/.local/bin/zola + key: "zola-${{ env.ZOLA_VERSION }}-${{ runner.os }}-${{ runner.arch }}-${{ env.ZOLA_DIGEST }}" + + - name: Install zola + env: + BINARY_CACHE_HIT: ${{ steps.zola-cache.outputs.cache-hit }} + run: | + bash actions/install-zola.sh + + - name: Build documentation + run: make -C doc build + + - name: Upload Build Artifact + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 + with: + path: doc/public + + deploy: + name: Deploy to GitHub Pages + needs: build + + permissions: + pages: write + id-token: write + + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + + runs-on: ubuntu-latest + + defaults: + run: + shell: bash + + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0 diff --git a/.github/workflows/dune-pkg-more-ci.yml b/.github/workflows/dune-pkg-more-ci.yml new file mode 100644 index 0000000..d3d97bb --- /dev/null +++ b/.github/workflows/dune-pkg-more-ci.yml @@ -0,0 +1,72 @@ +# Additional CI workflow using setup-dune (dune package management). +# +# This tests across multiple operating systems and OCaml versions, but skips +# steps not necessary for every combination (linting, coverage, etc.). + +name: dune-pkg-more-ci + +on: + push: + branches: + - main + pull_request: + branches: + - "**" + +permissions: + contents: read + +jobs: + build: + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + - macos-latest + ocaml-version: + - "5.5" + - "5.4" + - "5.3" + include: + - os: ubuntu-latest + dune-version: "3.24.2" + dune-digest: "sha256:3a9cac891f0b6086bfbf58d16c6c17ddfd1d493e031b6b43901f585a7f6899c7" + - os: macos-latest + dune-version: "3.24.2" + dune-digest: "sha256:d4aa0d58370bb86e1f3f74635d9f7b69fd635fefe159173add82e851e85d12b2" + exclude: + # Exclude the combination already tested in the main ci workflow. + - os: ubuntu-latest + ocaml-version: "5.5" + + runs-on: ${{ matrix.os }} + + defaults: + run: + shell: bash + + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Environment setup + run: | + echo "DUNE_WORKSPACE=$PWD/dune-workspace.${{ matrix.ocaml-version }}" >> "$GITHUB_ENV" + echo "PACKAGES=central,central-tests" >> "$GITHUB_ENV" + + - name: Setup Dune + uses: mbarbin/setup-dune@03ede0220d3fe665f250727d8e21b7c0f9517f3b # v2.0.0+patch-7 + env: + GH_TOKEN: ${{ github.token }} + with: + version: "${{ matrix.dune-version }}" + dune-digest: "${{ matrix.dune-digest }}" + workspace: "${{ env.DUNE_WORKSPACE }}" + cache-prefix: "${{ matrix.ocaml-version }}" + cache-readonly: ${{ github.ref != 'refs/heads/main' }} + only-packages: ${{ env.PACKAGES }} + steps: install-dune enable-pkg lazy-update-depexts install-gpatch install-depexts + + - name: Build & Run tests + run: dune build @all @runtest --only-packages=${{ env.PACKAGES }} diff --git a/.github/workflows/dunolint.yml b/.github/workflows/dunolint.yml new file mode 100644 index 0000000..d473a60 --- /dev/null +++ b/.github/workflows/dunolint.yml @@ -0,0 +1,28 @@ +name: Dunolint Workflows + +on: + push: + branches: + - main + pull_request: + branches: + - "**" # This will match pull requests targeting any branch + +permissions: + contents: read + +jobs: + dunolint-workflows: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Install dunolint + uses: mbarbin/dunolint-actions/setup-dunolint@ddc2b3ee5e8e5558d74c68643c63d37c11eb7279 # v1.0.0-alpha.6 + with: + dunolint-version: "0.0.20260306" + dunolint-digest: "sha256:b83c07dd352cd4bec36b872ac593f299972710baff70a62e7a4650e80d2460d4" + + - name: Lint Check + uses: mbarbin/dunolint-actions/lint-check@ddc2b3ee5e8e5558d74c68643c63d37c11eb7279 # v1.0.0-alpha.6 diff --git a/.github/workflows/test-deploy-doc.yml b/.github/workflows/test-deploy-doc.yml new file mode 100644 index 0000000..c9b1c3f --- /dev/null +++ b/.github/workflows/test-deploy-doc.yml @@ -0,0 +1,58 @@ +name: test-deploy-doc + +on: + pull_request: + branches: + - main + +permissions: + contents: read + +jobs: + test-deploy: + name: Test deployment + runs-on: ubuntu-latest + + env: + MDBOOK_VERSION: "0.5.2" + MDBOOK_DIGEST: "sha256:b7ab218618bb3c1715e3e1759cfa0abc56dcd7ab5d90f60dcd86d708c87b715d" + ZOLA_VERSION: "0.22.1" + ZOLA_DIGEST: "sha256:45de6b2559aba4df42199dc6b0161acb914d37be4ccaa03297cd4a26c8e14042" + + defaults: + run: + shell: bash + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Cache mdbook binary + uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 + id: mdbook-cache + with: + path: ~/.local/bin/mdbook + key: "mdbook-${{ env.MDBOOK_VERSION }}-${{ runner.os }}-${{ runner.arch }}-${{ env.MDBOOK_DIGEST }}" + + - name: Install mdbook + env: + BINARY_CACHE_HIT: ${{ steps.mdbook-cache.outputs.cache-hit }} + run: | + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + bash actions/install-mdbook.sh + + - name: Cache zola binary + uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 + id: zola-cache + with: + path: ~/.local/bin/zola + key: "zola-${{ env.ZOLA_VERSION }}-${{ runner.os }}-${{ runner.arch }}-${{ env.ZOLA_DIGEST }}" + + - name: Install zola + env: + BINARY_CACHE_HIT: ${{ steps.zola-cache.outputs.cache-hit }} + run: | + bash actions/install-zola.sh + + - name: Build documentation + run: make -C doc build diff --git a/actions/install-mdbook.sh b/actions/install-mdbook.sh new file mode 100755 index 0000000..d4bdbe2 --- /dev/null +++ b/actions/install-mdbook.sh @@ -0,0 +1,100 @@ +#!/bin/bash +# SPDX-FileCopyrightText: 2026 Mathieu Barbin +# SPDX-License-Identifier: MIT +# Install mdbook binary from GitHub releases. +# +# Environment variables: +# MDBOOK_VERSION - required, e.g. "0.5.2" +# MDBOOK_DIGEST - optional, e.g. "sha256:abc123..." +# INSTALL_DIR - optional, defaults to "$HOME/.local/bin" +# BINARY_CACHE_HIT - optional, set to "true" to skip download (restored from cache) + +set -euo pipefail + +: "${ERRORPREFIX:="::error::Fatal error: "}" + +abort() { + printf '%s%s\n' "$ERRORPREFIX" "$1" + exit 2 +} + +install_mdbook() { + BIN_DIR="${INSTALL_DIR:-$HOME/.local/bin}" + mkdir -p "$BIN_DIR" + + if [ "${BINARY_CACHE_HIT:-}" = "true" ]; then + echo "mdbook binary restored from cache" + (set -x; "${BIN_DIR}/mdbook" --version) + return + fi + + case "$(uname -ms)" in + 'Linux x86_64') + target=x86_64-unknown-linux-gnu + ;; + 'Linux aarch64') + target=aarch64-unknown-linux-gnu + ;; + 'Darwin x86_64') + target=x86_64-apple-darwin + ;; + 'Darwin arm64') + target=aarch64-apple-darwin + ;; + *) + abort "Unsupported platform: $(uname -ms)" + ;; + esac + + local url="https://github.com/rust-lang/mdBook/releases/download/v${MDBOOK_VERSION}/mdbook-v${MDBOOK_VERSION}-${target}.tar.gz" + local tmp_dir + tmp_dir="$(mktemp -d)" + + (set -x; + curl -fsSL "$url" | tar -xzf - -C "$tmp_dir" + mv "$tmp_dir/mdbook" "$BIN_DIR/" + "${BIN_DIR}/mdbook" --version) + rm -rf "$tmp_dir" +} + +verify_digest() { + local binary="$1" + local digest="$2" + local algorithm="${digest%%:*}" + local expected_hash="${digest#*:}" + + case "${algorithm}" in + sha256) + local actual_hash + if command -v sha256sum >/dev/null 2>&1; then + actual_hash=$(sha256sum "${binary}" | cut -d ' ' -f 1) + elif command -v shasum >/dev/null 2>&1; then + actual_hash=$(shasum -a 256 "${binary}" | cut -d ' ' -f 1) + else + abort "sha256sum or shasum is required to verify the binary digest" + fi + ;; + *) + abort "Digest algorithm '${algorithm}' is not supported. Supported: sha256" + ;; + esac + + if [ "${actual_hash}" != "${expected_hash}" ]; then + abort "${algorithm}: expected ${expected_hash} but got ${actual_hash} for ${binary}" + fi + echo "Digest verified: ${algorithm}:${actual_hash}" +} + +main() { + if [ -z "${MDBOOK_VERSION:-}" ]; then + abort "MDBOOK_VERSION is required" + fi + + install_mdbook + + if [ -n "${MDBOOK_DIGEST:-}" ]; then + verify_digest "${BIN_DIR}/mdbook" "$MDBOOK_DIGEST" + fi +} + +main diff --git a/actions/install-zola.sh b/actions/install-zola.sh new file mode 100755 index 0000000..1edbd61 --- /dev/null +++ b/actions/install-zola.sh @@ -0,0 +1,100 @@ +#!/bin/bash +# SPDX-FileCopyrightText: 2026 Mathieu Barbin +# SPDX-License-Identifier: MIT +# Install zola binary from GitHub releases. +# +# Environment variables: +# ZOLA_VERSION - required, e.g. "0.22.1" +# ZOLA_DIGEST - optional, e.g. "sha256:abc123..." +# INSTALL_DIR - optional, defaults to "$HOME/.local/bin" +# BINARY_CACHE_HIT - optional, set to "true" to skip download (restored from cache) + +set -euo pipefail + +: "${ERRORPREFIX:="::error::Fatal error: "}" + +abort() { + printf '%s%s\n' "$ERRORPREFIX" "$1" + exit 2 +} + +install_zola() { + BIN_DIR="${INSTALL_DIR:-$HOME/.local/bin}" + mkdir -p "$BIN_DIR" + + if [ "${BINARY_CACHE_HIT:-}" = "true" ]; then + echo "zola binary restored from cache" + (set -x; "${BIN_DIR}/zola" --version) + return + fi + + case "$(uname -ms)" in + 'Linux x86_64') + target=x86_64-unknown-linux-gnu + ;; + 'Linux aarch64') + target=aarch64-unknown-linux-gnu + ;; + 'Darwin x86_64') + target=x86_64-apple-darwin + ;; + 'Darwin arm64') + target=aarch64-apple-darwin + ;; + *) + abort "Unsupported platform: $(uname -ms)" + ;; + esac + + local url="https://github.com/getzola/zola/releases/download/v${ZOLA_VERSION}/zola-v${ZOLA_VERSION}-${target}.tar.gz" + local tmp_dir + tmp_dir="$(mktemp -d)" + + (set -x; + curl -fsSL "$url" | tar -xzf - -C "$tmp_dir" + mv "$tmp_dir/zola" "$BIN_DIR/" + "${BIN_DIR}/zola" --version) + rm -rf "$tmp_dir" +} + +verify_digest() { + local binary="$1" + local digest="$2" + local algorithm="${digest%%:*}" + local expected_hash="${digest#*:}" + + case "${algorithm}" in + sha256) + local actual_hash + if command -v sha256sum >/dev/null 2>&1; then + actual_hash=$(sha256sum "${binary}" | cut -d ' ' -f 1) + elif command -v shasum >/dev/null 2>&1; then + actual_hash=$(shasum -a 256 "${binary}" | cut -d ' ' -f 1) + else + abort "sha256sum or shasum is required to verify the binary digest" + fi + ;; + *) + abort "Digest algorithm '${algorithm}' is not supported. Supported: sha256" + ;; + esac + + if [ "${actual_hash}" != "${expected_hash}" ]; then + abort "${algorithm}: expected ${expected_hash} but got ${actual_hash} for ${binary}" + fi + echo "Digest verified: ${algorithm}:${actual_hash}" +} + +main() { + if [ -z "${ZOLA_VERSION:-}" ]; then + abort "ZOLA_VERSION is required" + fi + + install_zola + + if [ -n "${ZOLA_DIGEST:-}" ]; then + verify_digest "${BIN_DIR}/zola" "$ZOLA_DIGEST" + fi +} + +main diff --git a/actions/test-install-mdbook.sh b/actions/test-install-mdbook.sh new file mode 100755 index 0000000..c947309 --- /dev/null +++ b/actions/test-install-mdbook.sh @@ -0,0 +1,30 @@ +#!/bin/sh +# SPDX-FileCopyrightText: 2026 Mathieu Barbin +# SPDX-License-Identifier: MIT +# Test the mdbook install script locally. +# Usage: ./test-install-mdbook.sh +set -eu + +if [ $# -ne 2 ]; then + echo "Usage: $0 " >&2 + exit 1 +fi + +BINARY="mdbook" +FAKE_TMPDIR="$(mktemp -d)" +trap 'rm -rf "${FAKE_TMPDIR}"' EXIT + +export MDBOOK_VERSION="$1" +export MDBOOK_DIGEST="$2" +export INSTALL_DIR="${FAKE_TMPDIR}/bin" + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +bash "${SCRIPT_DIR}/install-mdbook.sh" + +if [ -x "${INSTALL_DIR}/${BINARY}" ]; then + echo "${BINARY} binary installed successfully at ${INSTALL_DIR}/${BINARY}" + "${INSTALL_DIR}/${BINARY}" --version +else + echo "Error: ${BINARY} binary was not installed in ${INSTALL_DIR}/" >&2 + exit 1 +fi diff --git a/actions/test-install-zola.sh b/actions/test-install-zola.sh new file mode 100755 index 0000000..3e04a4c --- /dev/null +++ b/actions/test-install-zola.sh @@ -0,0 +1,30 @@ +#!/bin/sh +# SPDX-FileCopyrightText: 2026 Mathieu Barbin +# SPDX-License-Identifier: MIT +# Test the zola install script locally. +# Usage: ./test-install-zola.sh +set -eu + +if [ $# -ne 2 ]; then + echo "Usage: $0 " >&2 + exit 1 +fi + +BINARY="zola" +FAKE_TMPDIR="$(mktemp -d)" +trap 'rm -rf "${FAKE_TMPDIR}"' EXIT + +export ZOLA_VERSION="$1" +export ZOLA_DIGEST="$2" +export INSTALL_DIR="${FAKE_TMPDIR}/bin" + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +bash "${SCRIPT_DIR}/install-zola.sh" + +if [ -x "${INSTALL_DIR}/${BINARY}" ]; then + echo "${BINARY} binary installed successfully at ${INSTALL_DIR}/${BINARY}" + "${INSTALL_DIR}/${BINARY}" --version +else + echo "Error: ${BINARY} binary was not installed in ${INSTALL_DIR}/" >&2 + exit 1 +fi From 3bc9f0d2687a5ad20c578203c46cfd18de46216f Mon Sep 17 00:00:00 2001 From: Mathieu Barbin Date: Mon, 17 Aug 2026 22:22:46 +0200 Subject: [PATCH 14/26] Initiate third party licenses --- NOTICE.md | 80 +++++++++++++++++++ .../gazagnaire/ocaml-merge3/LICENSE.md | 15 ++++ .../highlightjs/highlight.js/LICENSE | 29 +++++++ .../invariant-hq/windtrap/LICENSE | 15 ++++ .../janestreet/base/LICENSE.md | 21 +++++ .../mbarbin/parsing-utils/LICENSE | 21 +++++ 6 files changed, 181 insertions(+) create mode 100644 NOTICE.md create mode 100644 third-party-license/gazagnaire/ocaml-merge3/LICENSE.md create mode 100644 third-party-license/highlightjs/highlight.js/LICENSE create mode 100644 third-party-license/invariant-hq/windtrap/LICENSE create mode 100644 third-party-license/janestreet/base/LICENSE.md create mode 100644 third-party-license/mbarbin/parsing-utils/LICENSE diff --git a/NOTICE.md b/NOTICE.md new file mode 100644 index 0000000..301b895 --- /dev/null +++ b/NOTICE.md @@ -0,0 +1,80 @@ +# License + +This project is released under the terms of the `MIT` license. + +This notice file documents the organization of files and headers that relate to licenses, and the third-party code vendored into this repository. + +## License, copyright & notices + +- **COPYING.HEADER** contains the copyright and license notice. It is added as a header to every file in the project. + +- **LICENSE** contains a copy of the full MIT license. + +- **NOTICE.md** (this file) documents the project licensing and third-party vendored code. + +## Third party licenses + +Under `third-party-license/` we include the license of software used as vendored code. The vendored code retains its original upstream license; only that vendored code is so licensed, not this project as a whole. + +## mbarbin/parsing-utils + +The library in `src/parsing-utils/` vendors +[parsing-utils](https://github.com/mbarbin/parsing-utils), released under +`MIT` by the same author as this project, unchanged from upstream. Its dune +library is named `central_parsing_utils` (rather than `parsing_utils`) so +that apps that end up linking both the original package and this vendored +copy do not hit a library name conflict; since the library name differs +from the file name, dune itself exposes the vendored module as +`Central_parsing_utils.Parsing_utils` - no wrapper file is needed. See +`src/parsing-utils/parsing_utils.ml`. + +A copy of the license file for parsing-utils is located under +`third-party-license/mbarbin/parsing-utils/LICENSE`. + +## Gazagnaire ocaml-merge3 (Myers diff) + +The Myers shortest-edit-script computation in `src/merge3/merge3.ml` is +vendored from [ocaml-merge3](https://tangled.sh/@gazagnaire.org/monopampam) +by Thomas Gazagnaire (released under `ISC`). Only the pure diff computation +is vendored; the parts unused by this project are not included. The exact +provenance and list of changes are documented at the top of +`src/merge3/merge3.ml` and in `src/merge3/vendor.json`. + +A copy of the license file for ocaml-merge3 is located under +`third-party-license/gazagnaire/ocaml-merge3/LICENSE.md`. + +## Windtrap (unified-diff renderer) + +The unified-diff renderer in `src/myers/myers.ml` is vendored from +[windtrap](https://github.com/invariant-hq/windtrap) by Invariant Systems +(released under `ISC`). The exact provenance and list of changes are +documented at the top of `src/myers/myers.ml` and in `src/myers/vendor.json`. + +A copy of the license file for windtrap is located under +`third-party-license/invariant-hq/windtrap/LICENSE`. + +## A note about Base + +A few helpers from the [Base](https://github.com/janestreet/base) project (released under `MIT`) are reproduced in our local `Stdlib` extensions, to avoid taking on `base` as a direct dependency. + +The relevant file is `src/stdlib/string0.ml`. It carries a notice at the top of the file, and the copied functions are clearly indicated next to the code. + +A copy of the license file for Base is located under +`third-party-license/janestreet/base/LICENSE.md`. + +## Highlight.js + +The syntax highlighter used by the doc book pages, +`doc/book/shared-theme/highlight.js` (and its copy under +`doc/book/introduction-to-central-cli/shared-theme/`), is +[Highlight.js](https://highlightjs.org), released under `BSD-3-Clause`. +The file carries its own license header. + +A copy of the license file for Highlight.js is located under +`third-party-license/highlightjs/highlight.js/LICENSE`. + +## A note about ZolaNight + +The templates and styles under `doc/templates/` and `doc/sass/` are derived +from the [ZolaNight](https://github.com/mxaddict/zolanight) theme by +mxaddict, released under `MIT`. See `doc/NOTICE-zolanight`. diff --git a/third-party-license/gazagnaire/ocaml-merge3/LICENSE.md b/third-party-license/gazagnaire/ocaml-merge3/LICENSE.md new file mode 100644 index 0000000..a8e7df4 --- /dev/null +++ b/third-party-license/gazagnaire/ocaml-merge3/LICENSE.md @@ -0,0 +1,15 @@ +ISC License + +Copyright (c) 2024-2026 Thomas Gazagnaire + +Permission to use, copy, modify, and distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/third-party-license/highlightjs/highlight.js/LICENSE b/third-party-license/highlightjs/highlight.js/LICENSE new file mode 100644 index 0000000..2250cc7 --- /dev/null +++ b/third-party-license/highlightjs/highlight.js/LICENSE @@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) 2006, Ivan Sagalaev. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third-party-license/invariant-hq/windtrap/LICENSE b/third-party-license/invariant-hq/windtrap/LICENSE new file mode 100644 index 0000000..b1efea4 --- /dev/null +++ b/third-party-license/invariant-hq/windtrap/LICENSE @@ -0,0 +1,15 @@ +ISC License + +Copyright (c) 2026 Invariant Systems. All rights reserved. + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/third-party-license/janestreet/base/LICENSE.md b/third-party-license/janestreet/base/LICENSE.md new file mode 100644 index 0000000..0d0dcb7 --- /dev/null +++ b/third-party-license/janestreet/base/LICENSE.md @@ -0,0 +1,21 @@ +The MIT License + +Copyright (c) 2016--2025 Jane Street Group, LLC + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third-party-license/mbarbin/parsing-utils/LICENSE b/third-party-license/mbarbin/parsing-utils/LICENSE new file mode 100644 index 0000000..21e7be4 --- /dev/null +++ b/third-party-license/mbarbin/parsing-utils/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Mathieu Barbin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. From 4667dbc8d67f9c41af49bd3df9b09a702d18a36d Mon Sep 17 00:00:00 2001 From: Mathieu Barbin Date: Mon, 17 Aug 2026 22:23:31 +0200 Subject: [PATCH 15/26] Adopt OCaml code of conduct --- CODE_OF_CONDUCT.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 CODE_OF_CONDUCT.md diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..5a915b8 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,7 @@ +# Code of Conduct + +This project has adopted the [OCaml Code of Conduct](https://github.com/ocaml/code-of-conduct/blob/main/CODE_OF_CONDUCT.md). + +# Enforcement + +This project follows the OCaml Code of Conduct [enforcement policy](https://github.com/ocaml/code-of-conduct/blob/main/CODE_OF_CONDUCT.md#enforcement). From ac1d9df0094d0b92743d549c51e2e9310be3130e Mon Sep 17 00:00:00 2001 From: Mathieu Barbin Date: Mon, 17 Aug 2026 22:23:57 +0200 Subject: [PATCH 16/26] Add code owners file --- CODEOWNERS | 1 + 1 file changed, 1 insertion(+) create mode 100644 CODEOWNERS diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 0000000..8724ab6 --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1 @@ +* @mbarbin From baddef9e608ed25838a0795c15414c91a32ac76d Mon Sep 17 00:00:00 2001 From: Mathieu Barbin Date: Mon, 17 Aug 2026 22:24:09 +0200 Subject: [PATCH 17/26] Configure root dune file --- dune | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 dune diff --git a/dune b/dune new file mode 100644 index 0000000..92e4d5b --- /dev/null +++ b/dune @@ -0,0 +1,4 @@ +(env + (dev + (odoc + (warnings fatal)))) From 57358666335a8985daaf1b533f0af094d3063d12 Mon Sep 17 00:00:00 2001 From: Mathieu Barbin Date: Mon, 17 Aug 2026 22:24:30 +0200 Subject: [PATCH 18/26] Add basic README - wip --- README.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..4665469 --- /dev/null +++ b/README.md @@ -0,0 +1,27 @@ +# central-cli + +A tool to help manage changes and git history between individual sub-repos +and a monorepo that aggregates them. + +## Why + +Monorepos are convenient, but publishing projects separately - sometimes +under different visibility levels (public vs private) - has real benefits. +`central` supports a workflow that combines both, allowing changes to be +promoted bidirectionally between the monorepo and each sub-repo's own +published history. + +This workflow used to rely on +[git-subrepo](https://github.com/ingydotnet/git-subrepo). `.gitrepo` files +written by `central` follow the same conventions as git-subrepo's, so that +the two tools' workflows stay compatible. However, `central` does not aim +to be a full reimplementation of git-subrepo: it only ports, to OCaml, the +handful of workflows we actually rely on day to day. Exact compatibility +with git-subrepo is not thoroughly tested - the goal is to reduce our +overall dependency footprint, and eventually remove git-subrepo from our +critical path. + +## Status + +This repository is an early, evolving skeleton. Expect the shape of the +library, CLI, and commands to change as functionality is added. From 500241fda3020642bc38286258550c2da053d3ef Mon Sep 17 00:00:00 2001 From: Mathieu Barbin Date: Mon, 17 Aug 2026 22:39:16 +0200 Subject: [PATCH 19/26] Fix odoc comment --- src/cli/central_cli.mli | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli/central_cli.mli b/src/cli/central_cli.mli index 24797a6..a0130b6 100644 --- a/src/cli/central_cli.mli +++ b/src/cli/central_cli.mli @@ -11,8 +11,8 @@ val main : unit Command.t tools built on top of the same conventions (a monorepo with subrepos vendored under [repo//]) can embed these commands directly, without going through command-line parsing or a subprocess. *) -module Cmd__advance_main = Cmd__advance_main +module Cmd__advance_main = Cmd__advance_main module Cmd__advance_subrepo = Cmd__advance_subrepo module Cmd__export = Cmd__export module Cmd__import = Cmd__import From ccd899e82c2bced322384c966103f30c1105ddeb Mon Sep 17 00:00:00 2001 From: Mathieu Barbin Date: Tue, 18 Aug 2026 07:17:38 +0200 Subject: [PATCH 20/26] Add string utils for older versions --- src/stdlib/string0.ml | 1 + src/stdlib/string0.mli | 2 ++ 2 files changed, 3 insertions(+) diff --git a/src/stdlib/string0.ml b/src/stdlib/string0.ml index c8c164d..c8ee32d 100644 --- a/src/stdlib/string0.ml +++ b/src/stdlib/string0.ml @@ -41,6 +41,7 @@ include Stdlib.StringLabels let to_string t = t +let is_empty t = length t = 0 let prefix t len = let len = if len < 0 then 0 else if len > length t then length t else len in diff --git a/src/stdlib/string0.mli b/src/stdlib/string0.mli index 95ebe1d..abe5bf9 100644 --- a/src/stdlib/string0.mli +++ b/src/stdlib/string0.mli @@ -10,6 +10,8 @@ include module type of Stdlib.StringLabels [Stringable]-like interface is expected (e.g. [Pp_tty.kwd]). *) val to_string : t -> t +val is_empty : t -> bool + (** [prefix t len] returns the first [len] characters of [t], clamped to [t]'s own length if [len] exceeds it (or to [""] if [len] is negative). *) From b2cf5b666820e64c70534b139bec462d9a83354b Mon Sep 17 00:00:00 2001 From: Mathieu Barbin Date: Tue, 18 Aug 2026 07:40:37 +0200 Subject: [PATCH 21/26] Waround for MacOs+relocatable+menhir build issue --- src/gitrepo-file-parser/dune | 15 ++++++++++++++- src/gitrepo-file-parser/parser.mly | 10 ++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/gitrepo-file-parser/dune b/src/gitrepo-file-parser/dune index 27db71e..5fcf196 100644 --- a/src/gitrepo-file-parser/dune +++ b/src/gitrepo-file-parser/dune @@ -1,7 +1,20 @@ (ocamllex lexer) +; [infer false] disables Menhir/Dune's [--infer] step, which would otherwise +; shell out to [ocamlc -i] on a generated mock module to derive [parser.mli]. +; That step is broken by Dune's package-managed macOS relocatable compiler +; (as of dune 3.24.1+ for OCaml >= 5.5): the compiler can't locate its own +; [Stdlib] when invoked through the build sandbox, and fails with "Unbound +; module Stdlib". See https://github.com/ocaml/dune/issues/15642 (fix +; drafted in https://github.com/ocaml/dune/pull/15679, not yet merged). +; +; TODO: once that's fixed upstream and released, revert this and the +; accompanying [%type] annotations in [parser.mly] added for the same +; reason. + (menhir - (modules parser)) + (modules parser) + (infer false)) (library (name gitrepo_file_parser) diff --git a/src/gitrepo-file-parser/parser.mly b/src/gitrepo-file-parser/parser.mly index cb134c5..a919e56 100644 --- a/src/gitrepo-file-parser/parser.mly +++ b/src/gitrepo-file-parser/parser.mly @@ -23,6 +23,16 @@ %type file +/* The 3 annotations below make every nonterminal's type explicit so that + Menhir doesn't need Dune's [--infer] step (disabled in the [dune] file + next to this one). See the comment there and + https://github.com/ocaml/dune/issues/15642 for why: this, and the + [(infer false)] in [dune], should be reverted once that issue is fixed + upstream and released. */ +%type field +%type nonempty_list(field) +%type list(COMMENT) + %start file %% From 6fabfa3c3d78c345a2edc932deaaf1a1c6972478 Mon Sep 17 00:00:00 2001 From: Mathieu Barbin Date: Tue, 18 Aug 2026 07:40:54 +0200 Subject: [PATCH 22/26] Fix for temp_dir in MacOS runners --- src/test-helpers/central_test_helpers.ml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/test-helpers/central_test_helpers.ml b/src/test-helpers/central_test_helpers.ml index 6d646ff..a56ca1b 100644 --- a/src/test-helpers/central_test_helpers.ml +++ b/src/test-helpers/central_test_helpers.ml @@ -102,7 +102,15 @@ let fake_readme_contents subrepo = (Central.Subrepo.to_string subrepo) ;; -let temp_dir prefix = Filename.temp_dir prefix "" |> Absolute_path.v +(* [Filename.temp_dir] honors [$TMPDIR], which on macOS is itself a symlink + (e.g. into [/var/folders/...], with [/var] a symlink to [/private/var]). + [Unix.realpath] resolves that once, up front, so that this directory's + path already matches what [Unix.getcwd ()] reports once something [chdir] + s into it and calls back into it - macOS's [getcwd] returns the fully + resolved path, so without this the same directory would print under two + different-looking absolute paths depending on which of the two functions + produced the string. *) +let temp_dir prefix = Filename.temp_dir prefix "" |> Unix.realpath |> Absolute_path.v (* This takes care of setting the user config with dummy values, so that [Vcs.commit] can be used without depending on the ambient user config - From b003b638c9ff7dc23dde1955b880013e104e43f7 Mon Sep 17 00:00:00 2001 From: Mathieu Barbin Date: Tue, 18 Aug 2026 08:09:51 +0200 Subject: [PATCH 23/26] Fix unstable dirty-working-tree error wrapping on macOS macOS runners use a much longer $TMPDIR than Linux, so the repo root path pushed the error message past Format's line margin before it got sanitized to $CENTRAL_ROOT in expect tests, causing the message to wrap unpredictably (splitting even "Repo" from the quoted path). Wrap the path-bearing sentence in an hbox so it's never auto-wrapped, move "commit or stash them first" into a dedicated hint. --- src/cli/common_helpers.ml | 13 ++++++++----- test/expect/export.md | 6 +++--- test/expect/export.ml | 6 +++--- test/expect/import.md | 6 +++--- test/expect/import.ml | 6 +++--- test/expect/stitch.md | 6 +++--- test/expect/stitch.ml | 6 +++--- 7 files changed, 26 insertions(+), 23 deletions(-) diff --git a/src/cli/common_helpers.ml b/src/cli/common_helpers.ml index e705718..b31894b 100644 --- a/src/cli/common_helpers.ml +++ b/src/cli/common_helpers.ml @@ -37,14 +37,17 @@ let resolve_in_path ~prog = let ensure_clean_working_tree ~vcs ~repo_root = let status = Vcs.git vcs ~repo_root ~args:[ "status"; "--porcelain" ] ~f:Vcs.Git.exit0_and_stdout + |> String.strip in - if not (String.equal (String.strip status) "") + if not (String.equal status "") then Err.raise Pp.O. - [ Pp.text "Repo " - ++ Pp_tty.path (module String) (Vcs.Repo_root.to_string repo_root) - ++ Pp.text " has uncommitted changes - commit or stash them first." + [ Pp.hbox + (Pp.text "Repo " + ++ Pp_tty.path (module String) (Vcs.Repo_root.to_string repo_root) + ++ Pp.text " has uncommitted changes.") + ; Pp.verbatim status ] - ~hints:[ Pp.verbatim (String.strip status) ] + ~hints:[ Pp.text "Commit or stash them first." ] ;; diff --git a/test/expect/export.md b/test/expect/export.md index cc85650..cdedff4 100644 --- a/test/expect/export.md +++ b/test/expect/export.md @@ -100,9 +100,9 @@ is even looked at: ```ansi $ central export widget -m "Should not apply" ==================== widget ==================== -Error: Repo "$CENTRAL_ROOT" has uncommitted changes - -commit or stash them first. -Hint: M repo/widget/README.md +Error: Repo "$CENTRAL_ROOT" has uncommitted changes. +M repo/widget/README.md +Hint: Commit or stash them first. [123] ``` diff --git a/test/expect/export.ml b/test/expect/export.ml index 7297cde..ad74947 100644 --- a/test/expect/export.ml +++ b/test/expect/export.ml @@ -229,9 +229,9 @@ let%expect_test "dirty working tree" = {| $ central export widget -m "Should not apply" ==================== widget ==================== - Error: Repo "$CENTRAL_ROOT" has uncommitted changes - - commit or stash them first. - Hint: M repo/widget/README.md + Error: Repo "$CENTRAL_ROOT" has uncommitted changes. + M repo/widget/README.md + Hint: Commit or stash them first. [123] |}] ;; diff --git a/test/expect/import.md b/test/expect/import.md index df5868f..04f6209 100644 --- a/test/expect/import.md +++ b/test/expect/import.md @@ -76,9 +76,9 @@ tree is clean - an unstaged edit is rejected outright: ```ansi $ central import widget -Error: Repo "$CENTRAL_ROOT" has uncommitted changes - -commit or stash them first. -Hint: M README.md +Error: Repo "$CENTRAL_ROOT" has uncommitted changes. +M README.md +Hint: Commit or stash them first. [123] ``` diff --git a/test/expect/import.ml b/test/expect/import.ml index b82b307..2b025da 100644 --- a/test/expect/import.ml +++ b/test/expect/import.ml @@ -159,9 +159,9 @@ let%expect_test "dirty working tree" = [%expect {| $ central import widget - Error: Repo "$CENTRAL_ROOT" has uncommitted changes - - commit or stash them first. - Hint: M README.md + Error: Repo "$CENTRAL_ROOT" has uncommitted changes. + M README.md + Hint: Commit or stash them first. [123] |}] ;; diff --git a/test/expect/stitch.md b/test/expect/stitch.md index 0f3ceb5..5673af8 100644 --- a/test/expect/stitch.md +++ b/test/expect/stitch.md @@ -140,8 +140,8 @@ is even looked at: ```ansi $ central stitch widget -Error: Repo "$CENTRAL_ROOT" has uncommitted changes - -commit or stash them first. -Hint: M README.md +Error: Repo "$CENTRAL_ROOT" has uncommitted changes. +M README.md +Hint: Commit or stash them first. [123] ``` diff --git a/test/expect/stitch.ml b/test/expect/stitch.ml index 8cc7277..a908e94 100644 --- a/test/expect/stitch.ml +++ b/test/expect/stitch.ml @@ -344,9 +344,9 @@ let%expect_test "dirty working tree" = [%expect {| $ central stitch widget - Error: Repo "$CENTRAL_ROOT" has uncommitted changes - - commit or stash them first. - Hint: M README.md + Error: Repo "$CENTRAL_ROOT" has uncommitted changes. + M README.md + Hint: Commit or stash them first. [123] |}] ;; From cdc5c63301c333961b06a733031a6abbfc904a54 Mon Sep 17 00:00:00 2001 From: Mathieu Barbin Date: Tue, 18 Aug 2026 08:30:31 +0200 Subject: [PATCH 24/26] Stabilize dirty-working-tree error message across platforms The earlier hbox fix wasn't enough: on macOS CI, the real repo_root path (under a much longer $TMPDIR than Linux) can be wide enough that the whole atomic hbox no longer fits on the line, so Format still breaks - just before "Repo" instead of mid-sentence. No box discipline can make an oversized real path fit. Instead, teach the CLI to print a short, fixed placeholder for repo_root when a CENTRAL_TEST_REPO_ROOT env var is set, and have the test harness set it (to the same "$CENTRAL_ROOT" placeholder it already substitutes post hoc) around the central subprocess it spawns. This keeps the formatted message short and platform-independent before Format ever makes a wrapping decision, so the hbox is no longer needed. --- src/cli/common_helpers.ml | 24 ++++++++++++++++++++---- src/test-harness/central_test_harness.ml | 14 +++++++++++++- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/src/cli/common_helpers.ml b/src/cli/common_helpers.ml index b31894b..41b6993 100644 --- a/src/cli/common_helpers.ml +++ b/src/cli/common_helpers.ml @@ -34,6 +34,23 @@ let resolve_in_path ~prog = else prog ;; +(* [Central_test_harness] sets this env var, around the [central] subprocess + it spawns for expect tests, to the same placeholder it otherwise + substitutes into raw output after the fact (e.g. ["$CENTRAL_ROOT"]). + Substituting it here, before formatting, rather than relying solely on + that post-hoc raw-text substitution, keeps messages built from + [repo_root] from word-wrapping differently depending on the real (and + platform-dependent - e.g. macOS's much longer [$TMPDIR]) length of the + path: the placeholder is always short, so the surrounding sentence always + fits on one line regardless of where the test happens to run. *) +let repo_root_env_var_for_test = "CENTRAL_TEST_REPO_ROOT" + +let repo_root_for_display repo_root = + match Sys.getenv_opt repo_root_env_var_for_test with + | Some placeholder -> placeholder + | None -> Vcs.Repo_root.to_string repo_root +;; + let ensure_clean_working_tree ~vcs ~repo_root = let status = Vcs.git vcs ~repo_root ~args:[ "status"; "--porcelain" ] ~f:Vcs.Git.exit0_and_stdout @@ -43,10 +60,9 @@ let ensure_clean_working_tree ~vcs ~repo_root = then Err.raise Pp.O. - [ Pp.hbox - (Pp.text "Repo " - ++ Pp_tty.path (module String) (Vcs.Repo_root.to_string repo_root) - ++ Pp.text " has uncommitted changes.") + [ Pp.text "Repo " + ++ Pp_tty.path (module String) (repo_root_for_display repo_root) + ++ Pp.text " has uncommitted changes." ; Pp.verbatim status ] ~hints:[ Pp.text "Commit or stash them first." ] diff --git a/src/test-harness/central_test_harness.ml b/src/test-harness/central_test_harness.ml index 552407f..99e31ef 100644 --- a/src/test-harness/central_test_harness.ml +++ b/src/test-harness/central_test_harness.ml @@ -61,6 +61,17 @@ let replace_all text ~pattern ~with_ = Buffer.contents buf) ;; +let repo_root_placeholder = "$CENTRAL_ROOT" + +(* Read by [Common_helpers.repo_root_for_display] in the spawned [central] + subprocess, so it can print [repo_root_placeholder] directly instead of + the real, platform-dependent absolute path. This keeps messages that + embed [repo_root] from word-wrapping differently depending on the length + of the real path (e.g. macOS's much longer [$TMPDIR]) - see there for + details - rather than relying solely on the raw-text substitution + [redact] does below after the fact. *) +let repo_root_env_var_for_test = "CENTRAL_TEST_REPO_ROOT" + type t = { mock_revs : Vcs.Mock_revs.t ; repo_root : string * string (* (absolute path, "$CENTRAL_ROOT") *) @@ -68,8 +79,9 @@ type t = } let create ~repo_root = + Unix.putenv repo_root_env_var_for_test repo_root_placeholder; { mock_revs = Vcs.Mock_revs.create () - ; repo_root = Vcs.Repo_root.to_string repo_root, "$CENTRAL_ROOT" + ; repo_root = Vcs.Repo_root.to_string repo_root, repo_root_placeholder ; registered_revs = [] } ;; From 3d10c6ee5c31d041fb529d51338761abc39bd3a4 Mon Sep 17 00:00:00 2001 From: Mathieu Barbin Date: Tue, 18 Aug 2026 09:13:26 +0200 Subject: [PATCH 25/26] Add a regression test for redact corrupting a mock rev Central_test_harness.redact rewrites every registered revision it finds in a piece of text by folding replace_all over the progressively rewritten accumulator, once per registered pattern (full shas and every abbreviated prefix, longest first). A mock rev is just a 40-hex-char digest, indistinguishable from a real one, so it can itself contain a short run that coincidentally matches some other, unrelated commit's abbreviated real-sha prefix registered later - corrupting an already-correct mock rev in place once that later pattern is applied. This is what produced a flaky CI failure: a conflict marker's trailing revision came out as ">>>>>>> 11855121185612b25613f2e5b473e5231185512b" instead of the correct ">>>>>>> 1185512b92d612b25613f2e5b473e5231185512b". Add a deterministic, git-independent regression test that reproduces the same collision directly against redact/register_rev, as a new page in the mdexp book, "Deterministic Revisions": it narrates why the test harness redacts revisions at all (so snapshots stay stable across runs) and then the substitution-order edge case this test pins down. Wired into SUMMARY.md and cross-linked from the mock rev already visible in the Import chapter's conflict example. This currently fails (red): -| [%expect {| 1185512b92d612b25613f2e5b473e5231185512b |}] +| [%expect {| 1185512f452612b25613f2e5b473e5231185512b |}] confirming the test actually exercises the bug before the next commit fixes it. --- test/README.md | 7 +++--- test/SUMMARY.md | 1 + test/expect/dune | 15 +++++++++++++ test/expect/import.md | 3 ++- test/expect/import.ml | 3 ++- test/expect/redact.md | 28 +++++++++++++++++++++++ test/expect/redact.ml | 51 ++++++++++++++++++++++++++++++++++++++++++ test/expect/redact.mli | 5 +++++ 8 files changed, 107 insertions(+), 6 deletions(-) create mode 100644 test/expect/redact.md create mode 100644 test/expect/redact.ml create mode 100644 test/expect/redact.mli diff --git a/test/README.md b/test/README.md index 76e0165..a644e17 100644 --- a/test/README.md +++ b/test/README.md @@ -9,10 +9,9 @@ drift from what actually happens when the code runs. ## Layout - `expect/` holds every test file, both the literate, book-generating ones - (`workflow.ml`, `export.ml`, `import.ml`, `stitch.ml`, `push.ml`, - `advance.ml`, `todo.ml`, `config.ml` - each carrying `@mdexp` directives - and a generated `.md` counterpart, checked in next to the `.ml`) and the - plain ones (`test__central.ml`). + (each carrying `@mdexp` directives and a generated `.md` counterpart, + checked in next to the `.ml` - see `SUMMARY.md` for the current list of + pages) and the plain ones (`test__central.ml`). - `gitrepo/` and `gitrepo-file-parser/` test the `.gitrepo` file parser in isolation. diff --git a/test/SUMMARY.md b/test/SUMMARY.md index 22eb6f0..dd22f41 100644 --- a/test/SUMMARY.md +++ b/test/SUMMARY.md @@ -10,3 +10,4 @@ - [Advance Main, Advance Subrepo](expect/advance.md) - [Todo](expect/todo.md) - [Config](expect/config.md) +- [Deterministic Revisions](expect/redact.md) diff --git a/test/expect/dune b/test/expect/dune index 68e6994..38cab79 100644 --- a/test/expect/dune +++ b/test/expect/dune @@ -154,3 +154,18 @@ (alias runtest) (action (diff todo.md todo.md.gen))) + +(rule + (enabled_if %{bin-available:mdexp}) + (target redact.md.gen) + (deps redact.ml) + (action + (with-stdout-to + %{target} + (run mdexp pp %{deps})))) + +(rule + (enabled_if %{bin-available:mdexp}) + (alias runtest) + (action + (diff redact.md redact.md.gen))) diff --git a/test/expect/import.md b/test/expect/import.md index 04f6209..1e9f163 100644 --- a/test/expect/import.md +++ b/test/expect/import.md @@ -134,7 +134,8 @@ merged - no further action needed there once the merge is complete. Central is left in the middle of the merge, exactly as an ordinary `git merge` would - conflict markers included, with the trailing revision on `>>>>>>>` naming the import commit, the side being merged -in: +in. That revision is a deterministic mock rather than the real sha - +see [Deterministic Revisions](redact.md) for why: ```text <<<<<<< HEAD diff --git a/test/expect/import.ml b/test/expect/import.ml index 2b025da..8a15a9b 100644 --- a/test/expect/import.ml +++ b/test/expect/import.ml @@ -323,7 +323,8 @@ let%expect_test "conflict" = Central is left in the middle of the merge, exactly as an ordinary `git merge` would - conflict markers included, with the trailing revision on `>>>>>>>` naming the import commit, the side being merged - in: *) + in. That revision is a deterministic mock rather than the real sha - + see [Deterministic Revisions](redact.md) for why: *) let merge_head = Vcs.git vcs diff --git a/test/expect/redact.md b/test/expect/redact.md new file mode 100644 index 0000000..07a2fc3 --- /dev/null +++ b/test/expect/redact.md @@ -0,0 +1,28 @@ +# Deterministic Revisions + +Every command that touches git prints real revisions - commit hashes, +`MERGE_HEAD`, the trailing `>>>>>>> ` on a conflict marker - and a real +revision is different every time a test runs, since it's derived from tree +content, parents, and commit timestamps, none of which stay fixed between +runs. Left alone, that would make every snapshot in this book flaky. + +`Central_test_harness` fixes this by redacting: every real revision it sees +is auto-detected and rewritten to a deterministic mock counterpart (see the +conflict example in [Import](import.md) for one in the wild), so the same +command run today or a year from now prints the exact same snapshot. +`to_mock_rev`/`register_rev` map a real revision to its mock; `redact` +applies that mapping - and every abbreviated prefix git might plausibly +print for it - to a piece of text. + +A mock revision is itself just a 40-character hex string, indistinguishable +from a real one. That has one sharp edge worth pinning down directly: a +mock revision can coincidentally *contain* a run of characters equal to +some other, unrelated revision's abbreviated prefix. `redact` has to +substitute every registered revision starting from the *original* text in a +single pass, so that a mock revision it has already written out is never +handed back for re-examination - otherwise a later, shorter pattern could +match inside it and corrupt it: + +```text +1185512b92d612b25613f2e5b473e5231185512b +``` diff --git a/test/expect/redact.ml b/test/expect/redact.ml new file mode 100644 index 0000000..3503678 --- /dev/null +++ b/test/expect/redact.ml @@ -0,0 +1,51 @@ +(*********************************************************************************) +(* central - Manage history between sub-repos and their monorepo *) +(* SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(* SPDX-License-Identifier: MIT *) +(*********************************************************************************) + +(* @mdexp + +# Deterministic Revisions + +Every command that touches git prints real revisions - commit hashes, +`MERGE_HEAD`, the trailing `>>>>>>> ` on a conflict marker - and a real +revision is different every time a test runs, since it's derived from tree +content, parents, and commit timestamps, none of which stay fixed between +runs. Left alone, that would make every snapshot in this book flaky. + +`Central_test_harness` fixes this by redacting: every real revision it sees +is auto-detected and rewritten to a deterministic mock counterpart (see the +conflict example in [Import](import.md) for one in the wild), so the same +command run today or a year from now prints the exact same snapshot. +`to_mock_rev`/`register_rev` map a real revision to its mock; `redact` +applies that mapping - and every abbreviated prefix git might plausibly +print for it - to a piece of text. + +A mock revision is itself just a 40-character hex string, indistinguishable +from a real one. That has one sharp edge worth pinning down directly: a +mock revision can coincidentally *contain* a run of characters equal to +some other, unrelated revision's abbreviated prefix. `redact` has to +substitute every registered revision starting from the *original* text in a +single pass, so that a mock revision it has already written out is never +handed back for re-examination - otherwise a later, shorter pattern could +match inside it and corrupt it: *) + +let%expect_test + "redact doesn't let one rev's abbreviated prefix corrupt another rev's \ + already-substituted mock" + = + let t = Central_test_harness.create ~repo_root:(Vcs.Repo_root.v "/tmp/repo") in + (* Registered first, so it lands on mock rev counter 0 - deterministically + ["1185512b92d612b25613f2e5b473e5231185512b"], regardless of + [first_rev]'s own (arbitrary) value. *) + let first_rev = Vcs.Rev.v (String.make 40 'a') in + Central_test_harness.register_rev t ~rev:first_rev; + (* Crafted so its abbreviated prefix, ["b92d"], is exactly the substring + sitting at offset 7 of [first_rev]'s mock counterpart above. *) + let second_rev = Vcs.Rev.v ("b92d" ^ String.make 36 '0') in + Central_test_harness.register_rev t ~rev:second_rev; + print_string (Central_test_harness.redact t (Vcs.Rev.to_string first_rev)); + (* @mdexp.snapshot { lang: "text" } *) + [%expect {| 1185512b92d612b25613f2e5b473e5231185512b |}] +;; diff --git a/test/expect/redact.mli b/test/expect/redact.mli new file mode 100644 index 0000000..bdaa586 --- /dev/null +++ b/test/expect/redact.mli @@ -0,0 +1,5 @@ +(*_********************************************************************************) +(*_ central - Manage history between sub-repos and their monorepo *) +(*_ SPDX-FileCopyrightText: 2024-2026 Mathieu Barbin *) +(*_ SPDX-License-Identifier: MIT *) +(*_********************************************************************************) From 86e9dc5ac92bfe34b7cff18cf1cc215d1af2f44e Mon Sep 17 00:00:00 2001 From: Mathieu Barbin Date: Tue, 18 Aug 2026 09:13:32 +0200 Subject: [PATCH 26/26] Fix redaction corrupting already-substituted mock revs redact rewrote the text once per registered pattern (full shas and every abbreviated prefix), folding each replace_all pass over the progressively rewritten accumulator - so a later, shorter pattern could match and corrupt a mock rev an earlier pass had already substituted in, whenever that mock rev's hex text coincidentally contained the later pattern. Fix: scan the original text once, left to right, trying all patterns (longest first) at each position, instead of re-scanning growing substituted output once per pattern. Same asymptotic cost, but already-written output is never re-matched. Turns the regression test added in the previous commit green: -| [%expect {| 1185512f452612b25613f2e5b473e5231185512b |}] +| [%expect {| 1185512b92d612b25613f2e5b473e5231185512b |}] --- src/test-harness/central_test_harness.ml | 54 ++++++++++++++---------- 1 file changed, 32 insertions(+), 22 deletions(-) diff --git a/src/test-harness/central_test_harness.ml b/src/test-harness/central_test_harness.ml index 99e31ef..608ab4a 100644 --- a/src/test-harness/central_test_harness.ml +++ b/src/test-harness/central_test_harness.ml @@ -40,25 +40,38 @@ let find_shas text = List.rev !results ;; -let replace_all text ~pattern ~with_ = - let plen = String.length pattern in +(* [text] is scanned once, left to right, trying every [pattern] (longest + first, per [patterns]) at the current position. Doing this as a single + pass over the *original* [text] - rather than a fold that runs each + pattern over the progressively-rewritten accumulator - matters: a mock + rev is just a 40-character hex string, indistinguishable from a real one, + so it can itself contain a 4-character run that matches some other, + unrelated real rev's abbreviated prefix. Re-scanning already-substituted + output for later patterns would occasionally let that coincidence + corrupt a mock rev that had already been written out correctly. *) +let replace_all text ~patterns = let tlen = String.length text in - if plen = 0 || plen > tlen - then text - else ( - let buf = Buffer.create tlen in - let i = ref 0 in - while !i <= tlen - plen do - if String.equal (String.sub text ~pos:!i ~len:plen) pattern - then ( - Buffer.add_string buf with_; - i := !i + plen) - else ( - Buffer.add_char buf text.[!i]; - incr i) - done; - if !i < tlen then Buffer.add_string buf (String.sub text ~pos:!i ~len:(tlen - !i)); - Buffer.contents buf) + let buf = Buffer.create tlen in + let i = ref 0 in + while !i < tlen do + match + List.find_map patterns ~f:(fun (pattern, with_) -> + let plen = String.length pattern in + if + plen > 0 + && !i + plen <= tlen + && String.equal (String.sub text ~pos:!i ~len:plen) pattern + then Some (plen, with_) + else None) + with + | Some (plen, with_) -> + Buffer.add_string buf with_; + i := !i + plen + | None -> + Buffer.add_char buf text.[!i]; + incr i + done; + Buffer.contents buf ;; let repo_root_placeholder = "$CENTRAL_ROOT" @@ -135,10 +148,7 @@ let replacements t text = sorted_by_length_desc (t.repo_root :: rev_replacements) ;; -let redact t text = - List.fold_left (replacements t text) ~init:text ~f:(fun acc (pattern, with_) -> - replace_all acc ~pattern ~with_) -;; +let redact t text = replace_all text ~patterns:(replacements t text) (* Keep the first group attached to the program name when it doesn't start with a flag (so e.g. [$ central export foo] reads on one line rather than