@shortlink-org/portolan 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +69 -205
- package/cli/init.mjs +285 -0
- package/cli/init.test.mjs +158 -0
- package/cli/portolan.mjs +37 -73
- package/index.html +19 -0
- package/package.json +5 -10
- package/plugins/README.md +66 -27
- package/plugins/portolan-go.wasm +0 -0
- package/portolan.json +52 -73
- package/public/landing/cats/bug-hunter.webp +0 -0
- package/public/landing/cats/compass-nap.webp +0 -0
- package/public/landing/cats/diagrammer.webp +0 -0
- package/public/landing/cats/docs-reader.webp +0 -0
- package/public/landing/cats/laptop.webp +0 -0
- package/public/landing/cats/map-inspector.webp +0 -0
- package/public/landing/cats/server-break.webp +0 -0
- package/public/landing/cats/star-mapper.webp +0 -0
- package/public/landing/cats/system-builder.webp +0 -0
- package/public/landing/cats/thread-tangle.webp +0 -0
- package/public/og.png +0 -0
- package/public/readme-header.webp +0 -0
- package/schema/portolan.schema.json +180 -0
- package/scripts/builtin-plugins.mjs +14 -0
- package/scripts/delivery-presets.mjs +353 -0
- package/scripts/gen.mjs +37 -4
- package/scripts/history.mjs +126 -0
- package/scripts/history.test.mjs +110 -0
- package/scripts/host-plugins/fetch-bsr.mjs +351 -0
- package/{plugins/fetch-bsr/options.schema.json → scripts/host-plugins/fetch-bsr.options.json} +4 -0
- package/scripts/host-plugins/fetch-bsr.test.mjs +212 -0
- package/scripts/host-plugins/fetch-csr.mjs +386 -0
- package/scripts/host-plugins/fetch-csr.test.mjs +178 -0
- package/scripts/host-plugins/fetch-git.mjs +379 -0
- package/scripts/host-plugins/fetch-git.test.mjs +210 -0
- package/scripts/local-api.mjs +22 -4
- package/scripts/local-api.test.mjs +84 -2
- package/scripts/manifest.mjs +3 -2
- package/scripts/package-smoke.mjs +6 -2
- package/scripts/plugin-host.mjs +77 -18
- package/scripts/plugin-host.test.mjs +50 -1
- package/scripts/plugin-wasm-worker.mjs +6 -3
- package/scripts/run-builtin.mjs +6 -26
- package/scripts/schema.mjs +6 -1
- package/src/app/App.tsx +24 -429
- package/src/app/Breadcrumbs.test.ts +7 -0
- package/src/app/Breadcrumbs.tsx +15 -2
- package/src/app/CatalogApp.tsx +436 -0
- package/src/app/Sidebar.tsx +0 -1
- package/src/chat/ChatPanel.tsx +98 -41
- package/src/chat/Composer.tsx +4 -2
- package/src/chat/Conversation.tsx +79 -15
- package/src/chat/Header.tsx +43 -9
- package/src/chat/Starter.tsx +64 -3
- package/src/chat/Steps.tsx +1 -1
- package/src/chat/flags.test.ts +51 -14
- package/src/chat/flags.ts +8 -2
- package/src/chat/page-context.test.ts +51 -0
- package/src/chat/page-context.ts +128 -0
- package/src/chat/prompt.test.ts +49 -1
- package/src/chat/prompt.ts +42 -1
- package/src/chat/tools.ts +7 -2
- package/src/chat/transport.ts +16 -4
- package/src/components/ProblemRow.tsx +182 -0
- package/src/data.ts +20 -5
- package/src/er/ErCanvas.tsx +1 -1
- package/src/graph/DependencyGraph.tsx +10 -1
- package/src/graph/FocusedEventGraph.tsx +1 -1
- package/src/graph/GraphToolbar.tsx +2 -4
- package/src/index.css +220 -2
- package/src/landing/DraggableReveal.tsx +168 -0
- package/src/landing/EstateGraph.tsx +28 -0
- package/src/landing/FlowPlayback.tsx +282 -0
- package/src/landing/HeroMap.tsx +128 -0
- package/src/landing/LandingPage.tsx +757 -0
- package/src/landing/ProductFrame.tsx +68 -0
- package/src/landing/ProductTour.tsx +371 -0
- package/src/landing/catalog.ts +25 -0
- package/src/landing/motion.tsx +52 -0
- package/src/lib/all-problems.ts +28 -0
- package/src/lib/local-api.ts +35 -1
- package/src/lib/setup-info.test.ts +8 -0
- package/src/lib/setup-info.ts +3 -2
- package/src/lib/source-code.ts +2 -1
- package/src/likec4/C4View.tsx +5 -0
- package/src/likec4/InteractiveView.tsx +5 -1
- package/src/map/ContextMapGraph.tsx +47 -20
- package/src/pages/ContextMap.tsx +5 -1
- package/src/pages/FlowDetail.tsx +24 -7
- package/src/pages/Overview.tsx +68 -12
- package/src/pages/Problems.tsx +6 -193
- package/src/pages/Settings.tsx +133 -108
- package/src/pages/settings/DeliverySettings.tsx +229 -0
- package/src/pages/settings/PreferencesSettings.tsx +101 -0
- package/src/routes.test.ts +20 -0
- package/src/routes.ts +13 -1
- package/vite.config.ts +12 -8
- package/catalog/enum_test.go +0 -46
- package/catalog/model.go +0 -932
- package/catalog/roundtrip_test.go +0 -185
- package/catalog/via_test.go +0 -38
- package/go.mod +0 -14
- package/go.sum +0 -14
- package/internal/commands/cargo.go +0 -154
- package/internal/commands/commands.go +0 -143
- package/internal/commands/commands_test.go +0 -323
- package/internal/commands/gradle.go +0 -77
- package/internal/commands/justfile.go +0 -82
- package/internal/commands/makefile.go +0 -106
- package/internal/commands/maven.go +0 -93
- package/internal/commands/packagejson.go +0 -159
- package/internal/commands/pyproject.go +0 -169
- package/internal/commands/taskfile.go +0 -101
- package/internal/commands/testdata/estate/.cargo/config.toml +0 -7
- package/internal/commands/testdata/estate/Makefile +0 -33
- package/internal/commands/testdata/estate/Taskfile.yml +0 -21
- package/internal/commands/testdata/estate/build.gradle.kts +0 -21
- package/internal/commands/testdata/estate/justfile +0 -20
- package/internal/commands/testdata/estate/package.json +0 -13
- package/internal/commands/testdata/estate/pom.xml +0 -29
- package/internal/commands/testdata/estate/pyproject.toml +0 -27
- package/internal/commands/testdata/estate/xtask/src/main.rs +0 -27
- package/internal/commands/testdata/golden/commands.json +0 -305
- package/internal/gohttp/analyze.go +0 -2500
- package/internal/gohttp/endpoints.go +0 -1067
- package/internal/gohttp/roots.go +0 -320
- package/internal/gohttp/typed.go +0 -143
- package/internal/goscan/constants.go +0 -85
- package/internal/goscan/goscan_test.go +0 -227
- package/internal/goscan/names.go +0 -52
- package/internal/goscan/parse_test.go +0 -11
- package/internal/goscan/source.go +0 -36
- package/internal/goscan/tree.go +0 -155
- package/internal/goscan/types.go +0 -99
- package/internal/wsdl/ids.go +0 -127
- package/internal/wsdl/ids_test.go +0 -21
- package/internal/wsdl/model.go +0 -62
- package/internal/wsdl/parse.go +0 -920
- package/internal/wsdl/parse_test.go +0 -133
- package/plugin/describe.go +0 -107
- package/plugin/describe_test.go +0 -114
- package/plugin/protocol.go +0 -117
- package/plugin/schematest/schematest.go +0 -126
- package/plugins/extract-adr/describe.go +0 -19
- package/plugins/extract-adr/describe_test.go +0 -11
- package/plugins/extract-adr/extract.go +0 -153
- package/plugins/extract-adr/extract_test.go +0 -350
- package/plugins/extract-adr/history.go +0 -99
- package/plugins/extract-adr/main.go +0 -65
- package/plugins/extract-adr/parse.go +0 -642
- package/plugins/extract-adr/parse_test.go +0 -405
- package/plugins/extract-asyncapi/describe.go +0 -19
- package/plugins/extract-asyncapi/describe_test.go +0 -11
- package/plugins/extract-asyncapi/extract.go +0 -315
- package/plugins/extract-asyncapi/extract_test.go +0 -197
- package/plugins/extract-asyncapi/main.go +0 -48
- package/plugins/extract-asyncapi/spec.go +0 -151
- package/plugins/extract-commands/describe.go +0 -19
- package/plugins/extract-commands/describe_test.go +0 -11
- package/plugins/extract-commands/extract.go +0 -79
- package/plugins/extract-commands/extract_test.go +0 -92
- package/plugins/extract-commands/main.go +0 -55
- package/plugins/extract-csr/avro.go +0 -258
- package/plugins/extract-csr/describe.go +0 -19
- package/plugins/extract-csr/describe_test.go +0 -11
- package/plugins/extract-csr/extract.go +0 -338
- package/plugins/extract-csr/extract_test.go +0 -374
- package/plugins/extract-csr/jsonschema.go +0 -343
- package/plugins/extract-csr/lock.go +0 -25
- package/plugins/extract-csr/main.go +0 -84
- package/plugins/extract-csr/subject.go +0 -102
- package/plugins/extract-flows/describe.go +0 -19
- package/plugins/extract-flows/describe_test.go +0 -11
- package/plugins/extract-flows/extract.go +0 -91
- package/plugins/extract-flows/main.go +0 -45
- package/plugins/extract-flows/parse.go +0 -593
- package/plugins/extract-flows/parse_test.go +0 -204
- package/plugins/extract-glossary/describe.go +0 -19
- package/plugins/extract-glossary/describe_test.go +0 -11
- package/plugins/extract-glossary/extract.go +0 -115
- package/plugins/extract-glossary/extract_test.go +0 -220
- package/plugins/extract-glossary/main.go +0 -59
- package/plugins/extract-glossary/parse.go +0 -214
- package/plugins/extract-glossary/parse_test.go +0 -203
- package/plugins/extract-go/aggregate.go +0 -214
- package/plugins/extract-go/client.go +0 -409
- package/plugins/extract-go/client_test.go +0 -266
- package/plugins/extract-go/describe.go +0 -19
- package/plugins/extract-go/describe_test.go +0 -11
- package/plugins/extract-go/enum.go +0 -195
- package/plugins/extract-go/enum_test.go +0 -82
- package/plugins/extract-go/event.go +0 -99
- package/plugins/extract-go/extract.go +0 -191
- package/plugins/extract-go/extract_test.go +0 -261
- package/plugins/extract-go/flow.go +0 -1441
- package/plugins/extract-go/flow_test.go +0 -609
- package/plugins/extract-go/httpclient.go +0 -174
- package/plugins/extract-go/httpclient_test.go +0 -296
- package/plugins/extract-go/ids.go +0 -92
- package/plugins/extract-go/layout.go +0 -225
- package/plugins/extract-go/layout_test.go +0 -125
- package/plugins/extract-go/lifecycle.go +0 -324
- package/plugins/extract-go/lifecycle_test.go +0 -108
- package/plugins/extract-go/main.go +0 -79
- package/plugins/extract-go/operation.go +0 -157
- package/plugins/extract-go/source.go +0 -316
- package/plugins/extract-go/transport.go +0 -321
- package/plugins/extract-go/transport_test.go +0 -145
- package/plugins/extract-go/wiring.go +0 -434
- package/plugins/extract-go-nats/describe.go +0 -19
- package/plugins/extract-go-nats/describe_test.go +0 -11
- package/plugins/extract-go-nats/extract.go +0 -177
- package/plugins/extract-go-nats/extract_test.go +0 -389
- package/plugins/extract-go-nats/index.go +0 -379
- package/plugins/extract-go-nats/main.go +0 -42
- package/plugins/extract-go-nats/resolve.go +0 -161
- package/plugins/extract-go-nats/sites.go +0 -224
- package/plugins/extract-graphql/describe.go +0 -19
- package/plugins/extract-graphql/describe_test.go +0 -11
- package/plugins/extract-graphql/extract.go +0 -433
- package/plugins/extract-graphql/extract_test.go +0 -256
- package/plugins/extract-graphql/ids.go +0 -49
- package/plugins/extract-graphql/lex.go +0 -237
- package/plugins/extract-graphql/main.go +0 -51
- package/plugins/extract-graphql/parse.go +0 -621
- package/plugins/extract-graphql/parse_test.go +0 -122
- package/plugins/extract-http-clients/describe.go +0 -19
- package/plugins/extract-http-clients/describe_test.go +0 -11
- package/plugins/extract-http-clients/extract.go +0 -705
- package/plugins/extract-http-clients/extract_test.go +0 -1263
- package/plugins/extract-http-clients/main.go +0 -42
- package/plugins/extract-openapi/describe.go +0 -19
- package/plugins/extract-openapi/describe_test.go +0 -11
- package/plugins/extract-openapi/discover.go +0 -243
- package/plugins/extract-openapi/extract.go +0 -526
- package/plugins/extract-openapi/extract_test.go +0 -545
- package/plugins/extract-openapi/main.go +0 -75
- package/plugins/extract-openapi/spec.go +0 -350
- package/plugins/extract-project/describe.go +0 -19
- package/plugins/extract-project/describe_test.go +0 -11
- package/plugins/extract-project/extract.go +0 -221
- package/plugins/extract-project/extract_test.go +0 -109
- package/plugins/extract-project/main.go +0 -41
- package/plugins/extract-proto/ast.go +0 -125
- package/plugins/extract-proto/consumes.go +0 -77
- package/plugins/extract-proto/describe.go +0 -19
- package/plugins/extract-proto/extract.go +0 -293
- package/plugins/extract-proto/extract_test.go +0 -459
- package/plugins/extract-proto/ids.go +0 -89
- package/plugins/extract-proto/ids_test.go +0 -57
- package/plugins/extract-proto/lex.go +0 -285
- package/plugins/extract-proto/main.go +0 -100
- package/plugins/extract-proto/module.go +0 -120
- package/plugins/extract-proto/parse.go +0 -720
- package/plugins/extract-proto/parse_test.go +0 -307
- package/plugins/extract-proto/provides.go +0 -236
- package/plugins/extract-proto/resolve.go +0 -222
- package/plugins/extract-proto/resolve_test.go +0 -119
- package/plugins/extract-redis/describe.go +0 -19
- package/plugins/extract-redis/describe_test.go +0 -11
- package/plugins/extract-redis/extract.go +0 -183
- package/plugins/extract-redis/extract_test.go +0 -168
- package/plugins/extract-redis/keyspaces.go +0 -469
- package/plugins/extract-redis/main.go +0 -44
- package/plugins/extract-river/describe.go +0 -19
- package/plugins/extract-river/describe_test.go +0 -11
- package/plugins/extract-river/extract.go +0 -507
- package/plugins/extract-river/extract_test.go +0 -132
- package/plugins/extract-river/main.go +0 -42
- package/plugins/extract-sql/ddl.go +0 -893
- package/plugins/extract-sql/ddl_test.go +0 -401
- package/plugins/extract-sql/describe.go +0 -19
- package/plugins/extract-sql/describe_test.go +0 -11
- package/plugins/extract-sql/layout.go +0 -221
- package/plugins/extract-sql/layout_test.go +0 -55
- package/plugins/extract-sql/lineage.go +0 -107
- package/plugins/extract-sql/main.go +0 -141
- package/plugins/extract-sql/maps.go +0 -564
- package/plugins/extract-sql/maps_java.go +0 -117
- package/plugins/extract-sql/maps_rust.go +0 -333
- package/plugins/extract-sql/maps_rust_test.go +0 -70
- package/plugins/extract-sql/maps_test.go +0 -204
- package/plugins/extract-sql/maps_ts.go +0 -398
- package/plugins/extract-sql/maps_ts_test.go +0 -136
- package/plugins/extract-sql/projection.go +0 -70
- package/plugins/extract-sql/projection_test.go +0 -50
- package/plugins/extract-sql/store.go +0 -420
- package/plugins/extract-sql/store_test.go +0 -233
- package/plugins/extract-sql/view.go +0 -295
- package/plugins/extract-watermill/describe.go +0 -19
- package/plugins/extract-watermill/describe_test.go +0 -11
- package/plugins/extract-watermill/extract.go +0 -1228
- package/plugins/extract-watermill/extract_test.go +0 -234
- package/plugins/extract-watermill/main.go +0 -41
- package/plugins/extract-wsdl/describe.go +0 -19
- package/plugins/extract-wsdl/describe_test.go +0 -11
- package/plugins/extract-wsdl/extract.go +0 -148
- package/plugins/extract-wsdl/extract_test.go +0 -45
- package/plugins/extract-wsdl/main.go +0 -53
- package/plugins/fetch-bsr/auth.go +0 -114
- package/plugins/fetch-bsr/bsr.go +0 -240
- package/plugins/fetch-bsr/cache.go +0 -66
- package/plugins/fetch-bsr/describe.go +0 -19
- package/plugins/fetch-bsr/fetch.go +0 -192
- package/plugins/fetch-bsr/fetch_test.go +0 -465
- package/plugins/fetch-bsr/lock.go +0 -71
- package/plugins/fetch-bsr/main.go +0 -117
- package/plugins/fetch-csr/auth.go +0 -95
- package/plugins/fetch-csr/cache.go +0 -69
- package/plugins/fetch-csr/describe.go +0 -19
- package/plugins/fetch-csr/describe_test.go +0 -11
- package/plugins/fetch-csr/fetch.go +0 -254
- package/plugins/fetch-csr/fetch_test.go +0 -494
- package/plugins/fetch-csr/lock.go +0 -95
- package/plugins/fetch-csr/main.go +0 -122
- package/plugins/fetch-csr/registry.go +0 -209
- package/plugins/fetch-git/cache.go +0 -65
- package/plugins/fetch-git/describe.go +0 -19
- package/plugins/fetch-git/describe_test.go +0 -11
- package/plugins/fetch-git/fetch.go +0 -181
- package/plugins/fetch-git/fetch_test.go +0 -332
- package/plugins/fetch-git/git.go +0 -169
- package/plugins/fetch-git/lock.go +0 -72
- package/plugins/fetch-git/main.go +0 -127
- package/plugins/fetch-git/offline.go +0 -39
- package/plugins/fetch-git/pin.go +0 -90
- package/plugins/fetch-git/pin_test.go +0 -74
- package/plugins/gen-backstage/describe.go +0 -17
- package/plugins/gen-backstage/main.go +0 -22
- package/plugins/gen-backstage/plugin.go +0 -473
- package/plugins/gen-backstage/plugin_test.go +0 -145
- package/plugins/gen-backstage.wasm +0 -0
- package/plugins/gen-markdown/adr.go +0 -162
- package/plugins/gen-markdown/aggregate.go +0 -362
- package/plugins/gen-markdown/canonical.go +0 -93
- package/plugins/gen-markdown/context.go +0 -90
- package/plugins/gen-markdown/coverage_test.go +0 -89
- package/plugins/gen-markdown/describe.go +0 -19
- package/plugins/gen-markdown/describe_test.go +0 -11
- package/plugins/gen-markdown/external.go +0 -71
- package/plugins/gen-markdown/flow.go +0 -277
- package/plugins/gen-markdown/glossary.go +0 -76
- package/plugins/gen-markdown/glossary_test.go +0 -148
- package/plugins/gen-markdown/llms.go +0 -302
- package/plugins/gen-markdown/main.go +0 -22
- package/plugins/gen-markdown/markdown.go +0 -254
- package/plugins/gen-markdown/markdown_test.go +0 -100
- package/plugins/gen-markdown/module.go +0 -107
- package/plugins/gen-markdown/plugin.go +0 -39
- package/plugins/gen-markdown/quality_test.go +0 -176
- package/plugins/gen-markdown/redis_test.go +0 -38
- package/plugins/gen-markdown/render.go +0 -344
- package/plugins/gen-markdown/render_test.go +0 -186
- package/plugins/gen-markdown/service.go +0 -456
- package/plugins/gen-markdown/source.go +0 -177
- package/plugins/gen-markdown/store.go +0 -201
- package/plugins/gen-markdown.wasm +0 -0
- package/plugins/gen-mermaid/describe.go +0 -17
- package/plugins/gen-mermaid/main.go +0 -22
- package/plugins/gen-mermaid/plugin.go +0 -104
- package/plugins/gen-mermaid/plugin_test.go +0 -33
- package/plugins/gen-mermaid.wasm +0 -0
- package/plugins/openapi/ids.go +0 -261
- package/plugins/openapi/ids_test.go +0 -98
- package/plugins/verify-codeowners/describe.go +0 -19
- package/plugins/verify-codeowners/describe_test.go +0 -11
- package/plugins/verify-codeowners/main.go +0 -75
- package/plugins/verify-codeowners/match.go +0 -85
- package/plugins/verify-codeowners/match_test.go +0 -47
- package/plugins/verify-codeowners/owners.go +0 -164
- package/plugins/verify-codeowners/owners_test.go +0 -225
- package/plugins/verify-codeowners/parse.go +0 -90
- package/plugins/verify-codeowners/parse_test.go +0 -62
- package/plugins/verify-otel/describe.go +0 -19
- package/plugins/verify-otel/describe_test.go +0 -11
- package/plugins/verify-otel/main.go +0 -53
- package/plugins/verify-otel/match.go +0 -336
- package/plugins/verify-otel/otlp.go +0 -200
- package/plugins/verify-otel/verify.go +0 -734
- package/plugins/verify-otel/verify_test.go +0 -460
- /package/{plugins/fetch-csr/options.schema.json → scripts/host-plugins/fetch-csr.options.json} +0 -0
- /package/{plugins/fetch-git/options.schema.json → scripts/host-plugins/fetch-git.options.json} +0 -0
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
// CI and publication recipes installed from Settings while Portolan is running
|
|
2
|
+
// locally. Every generated region identifies itself, so a later Portolan
|
|
3
|
+
// version can update its own work without taking ownership of the user's CI.
|
|
4
|
+
|
|
5
|
+
import { execFileSync } from "node:child_process";
|
|
6
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
7
|
+
import {
|
|
8
|
+
lstatSync,
|
|
9
|
+
mkdirSync,
|
|
10
|
+
mkdtempSync,
|
|
11
|
+
readFileSync,
|
|
12
|
+
renameSync,
|
|
13
|
+
rmSync,
|
|
14
|
+
writeFileSync,
|
|
15
|
+
} from "node:fs";
|
|
16
|
+
import { tmpdir } from "node:os";
|
|
17
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
18
|
+
|
|
19
|
+
const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
20
|
+
const VERSION = packageJson.version;
|
|
21
|
+
const GENERATED = "# Generated by Portolan. Preview changes in Settings before updating.";
|
|
22
|
+
const GITLAB_START = "# >>> Portolan delivery preset >>>";
|
|
23
|
+
const GITLAB_END = "# <<< Portolan delivery preset <<<";
|
|
24
|
+
const PROVIDERS = new Set(["github", "gitlab"]);
|
|
25
|
+
|
|
26
|
+
function exists(path) {
|
|
27
|
+
try { lstatSync(path); return true; } catch { return false; }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function git(workspace, args) {
|
|
31
|
+
try {
|
|
32
|
+
return execFileSync("git", args, {
|
|
33
|
+
cwd: workspace,
|
|
34
|
+
encoding: "utf8",
|
|
35
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
36
|
+
timeout: 5_000,
|
|
37
|
+
}).trim();
|
|
38
|
+
} catch {
|
|
39
|
+
return "";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function providerFromRemote(remote) {
|
|
44
|
+
const value = String(remote ?? "").trim().toLowerCase();
|
|
45
|
+
if (!value) return null;
|
|
46
|
+
const host = /^(?:https?|ssh):\/\/(?:[^@/]+@)?([^/:]+)/.exec(value)?.[1]
|
|
47
|
+
?? /^[^@]+@([^:]+):/.exec(value)?.[1]
|
|
48
|
+
?? "";
|
|
49
|
+
if (host === "github.com" || host.endsWith(".github.com")) return "github";
|
|
50
|
+
if (host === "gitlab.com" || host.includes("gitlab")) return "gitlab";
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function repositoryName(remote, workspace) {
|
|
55
|
+
const clean = String(remote ?? "").replace(/\/+$/, "").replace(/\.git$/, "");
|
|
56
|
+
return clean.split(/[/:]/).filter(Boolean).pop() || relative(dirname(workspace), workspace) || "repository";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function defaultBranch(workspace) {
|
|
60
|
+
const remoteHead = git(workspace, ["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"]);
|
|
61
|
+
if (remoteHead.startsWith("origin/")) return remoteHead.slice("origin/".length);
|
|
62
|
+
for (const candidate of ["main", "master"]) {
|
|
63
|
+
if (git(workspace, ["rev-parse", "--verify", `refs/heads/${candidate}`])) return candidate;
|
|
64
|
+
}
|
|
65
|
+
return git(workspace, ["branch", "--show-current"]) || "main";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function detectDeliveryForge(workspace) {
|
|
69
|
+
const remote = git(workspace, ["config", "--get", "remote.origin.url"]);
|
|
70
|
+
return {
|
|
71
|
+
provider: providerFromRemote(remote),
|
|
72
|
+
remote: remote || null,
|
|
73
|
+
repository: repositoryName(remote, workspace),
|
|
74
|
+
defaultBranch: defaultBranch(workspace),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function githubCheck(branch) {
|
|
79
|
+
return `${GENERATED}
|
|
80
|
+
name: Portolan architecture check
|
|
81
|
+
|
|
82
|
+
on:
|
|
83
|
+
pull_request:
|
|
84
|
+
push:
|
|
85
|
+
branches: [${branch}]
|
|
86
|
+
|
|
87
|
+
permissions:
|
|
88
|
+
contents: read
|
|
89
|
+
|
|
90
|
+
concurrency:
|
|
91
|
+
group: portolan-check-\${{ github.ref }}
|
|
92
|
+
cancel-in-progress: true
|
|
93
|
+
|
|
94
|
+
jobs:
|
|
95
|
+
check:
|
|
96
|
+
runs-on: ubuntu-latest
|
|
97
|
+
steps:
|
|
98
|
+
- uses: actions/checkout@v7
|
|
99
|
+
with:
|
|
100
|
+
fetch-depth: 0
|
|
101
|
+
- uses: shortlink-org/portolan@${VERSION}
|
|
102
|
+
with:
|
|
103
|
+
command: check
|
|
104
|
+
version: ${VERSION}
|
|
105
|
+
`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function githubPages(branch) {
|
|
109
|
+
return `${GENERATED}
|
|
110
|
+
name: Publish Portolan catalog
|
|
111
|
+
|
|
112
|
+
on:
|
|
113
|
+
push:
|
|
114
|
+
branches: [${branch}]
|
|
115
|
+
workflow_dispatch:
|
|
116
|
+
|
|
117
|
+
permissions:
|
|
118
|
+
contents: read
|
|
119
|
+
pages: write
|
|
120
|
+
id-token: write
|
|
121
|
+
|
|
122
|
+
concurrency:
|
|
123
|
+
group: portolan-pages
|
|
124
|
+
cancel-in-progress: false
|
|
125
|
+
|
|
126
|
+
jobs:
|
|
127
|
+
build:
|
|
128
|
+
runs-on: ubuntu-latest
|
|
129
|
+
steps:
|
|
130
|
+
- uses: actions/checkout@v7
|
|
131
|
+
with:
|
|
132
|
+
fetch-depth: 0
|
|
133
|
+
- uses: shortlink-org/portolan@${VERSION}
|
|
134
|
+
with:
|
|
135
|
+
command: build
|
|
136
|
+
version: ${VERSION}
|
|
137
|
+
output: dist
|
|
138
|
+
base: /\${{ github.event.repository.name }}/
|
|
139
|
+
- uses: actions/configure-pages@v6
|
|
140
|
+
- uses: actions/upload-pages-artifact@v5
|
|
141
|
+
with:
|
|
142
|
+
path: dist
|
|
143
|
+
|
|
144
|
+
deploy:
|
|
145
|
+
needs: build
|
|
146
|
+
runs-on: ubuntu-latest
|
|
147
|
+
environment:
|
|
148
|
+
name: github-pages
|
|
149
|
+
url: \${{ steps.deployment.outputs.page_url }}
|
|
150
|
+
steps:
|
|
151
|
+
- id: deployment
|
|
152
|
+
uses: actions/deploy-pages@v5
|
|
153
|
+
`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function gitlabBlock() {
|
|
157
|
+
return `${GITLAB_START}
|
|
158
|
+
"portolan:check":
|
|
159
|
+
stage: .pre
|
|
160
|
+
image:
|
|
161
|
+
name: ghcr.io/shortlink-org/portolan:${VERSION}
|
|
162
|
+
entrypoint: [""]
|
|
163
|
+
variables:
|
|
164
|
+
GIT_DEPTH: "0"
|
|
165
|
+
script:
|
|
166
|
+
- portolan check
|
|
167
|
+
rules:
|
|
168
|
+
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
|
|
169
|
+
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
|
|
170
|
+
|
|
171
|
+
"portolan:pages":
|
|
172
|
+
stage: .post
|
|
173
|
+
image:
|
|
174
|
+
name: ghcr.io/shortlink-org/portolan:${VERSION}
|
|
175
|
+
entrypoint: [""]
|
|
176
|
+
variables:
|
|
177
|
+
GIT_DEPTH: "0"
|
|
178
|
+
script:
|
|
179
|
+
- BASE_PATH="$(node -p 'new URL(process.env.CI_PAGES_URL).pathname.replace(/\\/?$/, "/") || "/"')"
|
|
180
|
+
- portolan build --output dist --base "$BASE_PATH"
|
|
181
|
+
pages:
|
|
182
|
+
publish: dist
|
|
183
|
+
rules:
|
|
184
|
+
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
|
|
185
|
+
${GITLAB_END}
|
|
186
|
+
`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function mergeGitlab(existing) {
|
|
190
|
+
const start = existing.indexOf(GITLAB_START);
|
|
191
|
+
const end = existing.indexOf(GITLAB_END);
|
|
192
|
+
if ((start === -1) !== (end === -1) || (start !== -1 && end < start)) {
|
|
193
|
+
return { conflict: "The existing Portolan managed region is incomplete." };
|
|
194
|
+
}
|
|
195
|
+
if (start !== -1) {
|
|
196
|
+
const after = end + GITLAB_END.length;
|
|
197
|
+
return { content: `${existing.slice(0, start)}${gitlabBlock().trimEnd()}${existing.slice(after)}`.replace(/\n*$/, "\n") };
|
|
198
|
+
}
|
|
199
|
+
if (/^[ \t]*["']?portolan:(?:check|pages)["']?\s*:/m.test(existing)) {
|
|
200
|
+
return { conflict: "This pipeline already declares a Portolan job outside the managed region." };
|
|
201
|
+
}
|
|
202
|
+
return { content: `${existing.trimEnd()}${existing.trim() ? "\n\n" : ""}${gitlabBlock()}` };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function textDiff(path, before, after) {
|
|
206
|
+
if (before === after) return "";
|
|
207
|
+
const holder = mkdtempSync(join(tmpdir(), "portolan-preset-diff-"));
|
|
208
|
+
const left = join(holder, "before");
|
|
209
|
+
const right = join(holder, "after");
|
|
210
|
+
try {
|
|
211
|
+
writeFileSync(left, before);
|
|
212
|
+
writeFileSync(right, after);
|
|
213
|
+
let output = "";
|
|
214
|
+
try {
|
|
215
|
+
output = execFileSync("git", ["diff", "--no-index", "--no-ext-diff", "--unified=3", "--", left, right], {
|
|
216
|
+
encoding: "utf8",
|
|
217
|
+
maxBuffer: 2 * 1024 * 1024,
|
|
218
|
+
});
|
|
219
|
+
} catch (cause) {
|
|
220
|
+
if (cause?.status !== 1) throw cause;
|
|
221
|
+
output = String(cause.stdout ?? "");
|
|
222
|
+
}
|
|
223
|
+
return output
|
|
224
|
+
.replaceAll(`a${left}`, `a/${path}`)
|
|
225
|
+
.replaceAll(`b${right}`, `b/${path}`)
|
|
226
|
+
.replaceAll(left, path)
|
|
227
|
+
.replaceAll(right, path);
|
|
228
|
+
} finally {
|
|
229
|
+
rmSync(holder, { recursive: true, force: true });
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function readTarget(workspace, path) {
|
|
234
|
+
const target = resolve(workspace, path);
|
|
235
|
+
const root = resolve(workspace);
|
|
236
|
+
if (target !== root && !target.startsWith(`${root}${sep}`)) throw new Error("Preset target must stay inside this repository.");
|
|
237
|
+
let cursor = root;
|
|
238
|
+
for (const segment of relative(root, target).split(sep).filter(Boolean)) {
|
|
239
|
+
cursor = join(cursor, segment);
|
|
240
|
+
if (exists(cursor) && lstatSync(cursor).isSymbolicLink()) return { exists: true, content: "", safe: false };
|
|
241
|
+
}
|
|
242
|
+
if (!exists(target)) return { exists: false, content: "", safe: true };
|
|
243
|
+
const stat = lstatSync(target);
|
|
244
|
+
if (!stat.isFile() || stat.isSymbolicLink()) return { exists: true, content: "", safe: false };
|
|
245
|
+
if (stat.size > 512_000) return { exists: true, content: "", safe: false };
|
|
246
|
+
const bytes = readFileSync(target);
|
|
247
|
+
if (bytes.includes(0)) return { exists: true, content: "", safe: false };
|
|
248
|
+
return { exists: true, content: bytes.toString("utf8"), safe: true };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function githubFile(workspace, path, content) {
|
|
252
|
+
const current = readTarget(workspace, path);
|
|
253
|
+
const managed = current.safe && current.content.startsWith(GENERATED);
|
|
254
|
+
const status = !current.exists ? "added" : !current.safe || !managed ? "conflict" : current.content === content ? "unchanged" : "changed";
|
|
255
|
+
return {
|
|
256
|
+
path,
|
|
257
|
+
status,
|
|
258
|
+
message: status === "conflict" ? "A file already exists here and was not created by Portolan." : undefined,
|
|
259
|
+
diff: current.safe ? textDiff(path, current.content, content) : "Existing file is not a safe UTF-8 file.",
|
|
260
|
+
current: current.content,
|
|
261
|
+
next: content,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function gitlabFile(workspace) {
|
|
266
|
+
const path = ".gitlab-ci.yml";
|
|
267
|
+
const current = readTarget(workspace, path);
|
|
268
|
+
if (!current.safe) return { path, status: "conflict", message: "The existing pipeline is not a safe UTF-8 file.", diff: "", current: "", next: "" };
|
|
269
|
+
const merged = mergeGitlab(current.content);
|
|
270
|
+
if (merged.conflict) return { path, status: "conflict", message: merged.conflict, diff: "", current: current.content, next: "" };
|
|
271
|
+
const status = !current.exists ? "added" : current.content === merged.content ? "unchanged" : "changed";
|
|
272
|
+
return { path, status, diff: textDiff(path, current.content, merged.content), current: current.content, next: merged.content };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function revisionFor(provider, files) {
|
|
276
|
+
const hash = createHash("sha256").update(provider);
|
|
277
|
+
for (const file of files) {
|
|
278
|
+
hash.update("\0"); hash.update(file.path); hash.update("\0"); hash.update(file.current); hash.update("\0"); hash.update(file.next);
|
|
279
|
+
}
|
|
280
|
+
return hash.digest("hex");
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export function planDeliveryPreset(workspace, requestedProvider) {
|
|
284
|
+
const detected = detectDeliveryForge(workspace);
|
|
285
|
+
const provider = requestedProvider || detected.provider || "github";
|
|
286
|
+
if (!PROVIDERS.has(provider)) throw new Error("Delivery preset must be github or gitlab.");
|
|
287
|
+
const files = provider === "github"
|
|
288
|
+
? [
|
|
289
|
+
githubFile(workspace, ".github/workflows/portolan-check.yml", githubCheck(detected.defaultBranch)),
|
|
290
|
+
githubFile(workspace, ".github/workflows/portolan-pages.yml", githubPages(detected.defaultBranch)),
|
|
291
|
+
]
|
|
292
|
+
: [gitlabFile(workspace)];
|
|
293
|
+
const status = files.some((file) => file.status === "conflict")
|
|
294
|
+
? "conflict"
|
|
295
|
+
: files.every((file) => file.status === "unchanged")
|
|
296
|
+
? "installed"
|
|
297
|
+
: files.some((file) => file.status === "changed") && (provider !== "gitlab" || files[0].current.includes(GITLAB_START))
|
|
298
|
+
? "update"
|
|
299
|
+
: "available";
|
|
300
|
+
return {
|
|
301
|
+
provider,
|
|
302
|
+
detectedProvider: detected.provider,
|
|
303
|
+
remote: detected.remote,
|
|
304
|
+
repository: detected.repository,
|
|
305
|
+
defaultBranch: detected.defaultBranch,
|
|
306
|
+
status,
|
|
307
|
+
revision: revisionFor(provider, files),
|
|
308
|
+
features: ["Validate generated architecture in reviews", "Publish the static catalog"],
|
|
309
|
+
files,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export function publicDeliveryPreset(plan) {
|
|
314
|
+
return { ...plan, files: plan.files.map(({ current: _current, next: _next, ...file }) => file) };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export function installDeliveryPreset(workspace, request) {
|
|
318
|
+
const plan = planDeliveryPreset(workspace, request.provider);
|
|
319
|
+
if (!request.revision || request.revision !== plan.revision) {
|
|
320
|
+
const error = new Error("Files changed after this preset preview. Preview it again.");
|
|
321
|
+
error.status = 409;
|
|
322
|
+
throw error;
|
|
323
|
+
}
|
|
324
|
+
const conflict = plan.files.find((file) => file.status === "conflict");
|
|
325
|
+
if (conflict) {
|
|
326
|
+
const error = new Error(`${conflict.path}: ${conflict.message}`);
|
|
327
|
+
error.status = 409;
|
|
328
|
+
throw error;
|
|
329
|
+
}
|
|
330
|
+
for (const file of plan.files) {
|
|
331
|
+
const current = readTarget(workspace, file.path);
|
|
332
|
+
if (!current.safe || current.content !== file.current) {
|
|
333
|
+
const error = new Error("Files changed after this preset preview. Preview it again.");
|
|
334
|
+
error.status = 409;
|
|
335
|
+
throw error;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
const written = [];
|
|
339
|
+
for (const file of plan.files) {
|
|
340
|
+
if (file.status === "unchanged") continue;
|
|
341
|
+
const target = resolve(workspace, file.path);
|
|
342
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
343
|
+
const temporary = join(dirname(target), `.portolan-${randomUUID()}.tmp`);
|
|
344
|
+
try {
|
|
345
|
+
writeFileSync(temporary, file.next, { flag: "wx" });
|
|
346
|
+
renameSync(temporary, target);
|
|
347
|
+
} finally {
|
|
348
|
+
if (exists(temporary)) rmSync(temporary, { force: true });
|
|
349
|
+
}
|
|
350
|
+
written.push(file.path);
|
|
351
|
+
}
|
|
352
|
+
return { ...publicDeliveryPreset(planDeliveryPreset(workspace, request.provider)), written };
|
|
353
|
+
}
|
package/scripts/gen.mjs
CHANGED
|
@@ -26,8 +26,9 @@ import {
|
|
|
26
26
|
writeBuildReport,
|
|
27
27
|
} from "./build-report.mjs";
|
|
28
28
|
import { loadCatalog } from "./catalog-sources.mjs";
|
|
29
|
+
import { historyFor } from "./history.mjs";
|
|
29
30
|
import { loadManifest, stepKeys } from "./manifest.mjs";
|
|
30
|
-
import { runPlugin } from "./plugin-host.mjs";
|
|
31
|
+
import { describePlugin, runPlugin } from "./plugin-host.mjs";
|
|
31
32
|
import { vendoredCommit } from "./vendor-lock.mjs";
|
|
32
33
|
import {
|
|
33
34
|
removeOutputFile,
|
|
@@ -73,6 +74,8 @@ const report = createBuildReport({
|
|
|
73
74
|
let manifest = {};
|
|
74
75
|
let keys = stepKeys({});
|
|
75
76
|
let drifted = false;
|
|
77
|
+
// What each plugin said it needs from the host (portolan.0007), by name.
|
|
78
|
+
const pluginNeeds = new Map();
|
|
76
79
|
|
|
77
80
|
// What each step wrote into each directory on this run, by key. The listing
|
|
78
81
|
// on disk says what was written last time; in check mode it is never updated,
|
|
@@ -122,12 +125,22 @@ async function generate() {
|
|
|
122
125
|
for (const step of manifest.extract ?? []) {
|
|
123
126
|
const plugin = pluginNamed(step.plugin);
|
|
124
127
|
const stamp = stampFor(step.in, step.out);
|
|
128
|
+
// A plugin that asks for history gets the root's, read once per checkout
|
|
129
|
+
// (portolan.0007); left out when the root is not in a checkout, which the
|
|
130
|
+
// plugin reports in its own words.
|
|
131
|
+
const history = (await needsOf(plugin)).has("history") ? historyFor(process.cwd(), step.in) : undefined;
|
|
125
132
|
await executeStep("extract", step, `${step.plugin} ← ${step.in}`, async () =>
|
|
126
133
|
runPlugin(plugin, {
|
|
127
134
|
portolanVersion: PORTOLAN_VERSION,
|
|
128
|
-
input: {
|
|
135
|
+
input: {
|
|
136
|
+
root: step.in,
|
|
137
|
+
output: step.out,
|
|
138
|
+
commit: stamp.commit,
|
|
139
|
+
generatedAt: stamp.generatedAt,
|
|
140
|
+
...(history ? { history } : {}),
|
|
141
|
+
},
|
|
129
142
|
options: step.options ?? {},
|
|
130
|
-
}),
|
|
143
|
+
}, {}, { workspace: process.cwd() }),
|
|
131
144
|
);
|
|
132
145
|
}
|
|
133
146
|
|
|
@@ -148,7 +161,7 @@ async function generate() {
|
|
|
148
161
|
input: { root: step.in, output: step.out, commit: stamp.commit, generatedAt: stamp.generatedAt },
|
|
149
162
|
catalog,
|
|
150
163
|
options: step.options ?? {},
|
|
151
|
-
}),
|
|
164
|
+
}, {}, { workspace: process.cwd() }),
|
|
152
165
|
);
|
|
153
166
|
}
|
|
154
167
|
|
|
@@ -301,6 +314,26 @@ function pluginNamed(name) {
|
|
|
301
314
|
return plugin ?? shipped;
|
|
302
315
|
}
|
|
303
316
|
|
|
317
|
+
/**
|
|
318
|
+
* What a plugin's descriptor says it needs from the host, asked once per
|
|
319
|
+
* plugin per run. A plugin that does not describe itself needs nothing it
|
|
320
|
+
* could be given.
|
|
321
|
+
*/
|
|
322
|
+
|
|
323
|
+
async function needsOf(plugin) {
|
|
324
|
+
if (!pluginNeeds.has(plugin.name)) {
|
|
325
|
+
let descriptor = null;
|
|
326
|
+
try {
|
|
327
|
+
descriptor = await describePlugin(plugin);
|
|
328
|
+
} catch {
|
|
329
|
+
// A plugin that cannot answer describe cannot ask for anything either;
|
|
330
|
+
// whatever is wrong with it, the step itself will say.
|
|
331
|
+
}
|
|
332
|
+
pluginNeeds.set(plugin.name, new Set(descriptor?.needs ?? []));
|
|
333
|
+
}
|
|
334
|
+
return pluginNeeds.get(plugin.name);
|
|
335
|
+
}
|
|
336
|
+
|
|
304
337
|
/** Prints what a step did, and says whether it left the tree out of date. */
|
|
305
338
|
function summarise(label, files, changes, warnings = []) {
|
|
306
339
|
const said = warnings.length > 0 ? `, ${warnings.length} warning${warnings.length === 1 ? "" : "s"}` : "";
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// When each file was first committed and last changed, read by the host for a
|
|
2
|
+
// plugin that asks (portolan.0007).
|
|
3
|
+
//
|
|
4
|
+
// One `git log` over the whole checkout, walked oldest first with renames
|
|
5
|
+
// followed, gives every path its first commit and its last. A plugin used to
|
|
6
|
+
// ask git this per file with `--follow`; a wasm module cannot run git, and
|
|
7
|
+
// the answer is a fact about the checkout the host already holds, so the host
|
|
8
|
+
// reads it once and hands each step the part under its root.
|
|
9
|
+
|
|
10
|
+
import { execFileSync } from "node:child_process";
|
|
11
|
+
import { realpathSync } from "node:fs";
|
|
12
|
+
import { relative, resolve, sep } from "node:path";
|
|
13
|
+
|
|
14
|
+
const RECORD = "\x1e";
|
|
15
|
+
const FIELD = "\x1f";
|
|
16
|
+
|
|
17
|
+
/** Per repository, per process: the history does not change during a run. */
|
|
18
|
+
const cache = new Map();
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* History for every file under `root`, keyed by the path a plugin would name
|
|
22
|
+
* it - relative to `workspace`, forward slashes. `undefined` when the root is
|
|
23
|
+
* not inside a git checkout, which is different from an empty object: a
|
|
24
|
+
* checkout with nothing committed under the root.
|
|
25
|
+
*
|
|
26
|
+
* @param {string} workspace
|
|
27
|
+
* @param {string} root
|
|
28
|
+
* @returns {Record<string, {created: Commit, revised?: Commit}> | undefined}
|
|
29
|
+
*/
|
|
30
|
+
export function historyFor(workspace, root) {
|
|
31
|
+
// Resolved through symlinks, as git reports its top level: on macOS a
|
|
32
|
+
// temporary directory is one and the two would never compare equal.
|
|
33
|
+
const workspaceReal = realpathSync(resolve(workspace));
|
|
34
|
+
const absoluteRoot = resolve(workspaceReal, root);
|
|
35
|
+
const repo = repositoryOf(absoluteRoot);
|
|
36
|
+
if (!repo) return undefined;
|
|
37
|
+
|
|
38
|
+
const rootKey = toPosix(relative(workspaceReal, absoluteRoot));
|
|
39
|
+
const under = (key) => rootKey === "" || rootKey === "." || key === rootKey || key.startsWith(`${rootKey}/`);
|
|
40
|
+
const history = {};
|
|
41
|
+
for (const [pathInRepo, entry] of fileHistory(repo)) {
|
|
42
|
+
const key = toPosix(relative(workspaceReal, resolve(repo, pathInRepo)));
|
|
43
|
+
if (!key || key.startsWith("..") || !under(key)) continue;
|
|
44
|
+
history[key] = entry.revised ? { created: entry.created, revised: entry.revised } : { created: entry.created };
|
|
45
|
+
}
|
|
46
|
+
return history;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Every path in the checkout with its first and last commit, renames
|
|
51
|
+
* followed: a file moved keeps the commit that first added it under its old
|
|
52
|
+
* name, and the move counts as a revision. Paths are relative to the
|
|
53
|
+
* repository, forward slashes.
|
|
54
|
+
*
|
|
55
|
+
* @param {string} repo
|
|
56
|
+
* @returns {Map<string, {created: Commit, revised: Commit | null}>}
|
|
57
|
+
*/
|
|
58
|
+
export function fileHistory(repo) {
|
|
59
|
+
const key = resolve(repo);
|
|
60
|
+
if (cache.has(key)) return cache.get(key);
|
|
61
|
+
const log = git(key, [
|
|
62
|
+
"log", "--reverse", "--name-status", "-M", "--no-color",
|
|
63
|
+
`--format=${RECORD}%H${FIELD}%an${FIELD}%cI`,
|
|
64
|
+
]);
|
|
65
|
+
const history = parseLog(log);
|
|
66
|
+
cache.set(key, history);
|
|
67
|
+
return history;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** @param {string} log */
|
|
71
|
+
export function parseLog(log) {
|
|
72
|
+
const history = new Map();
|
|
73
|
+
for (const record of log.split(RECORD)) {
|
|
74
|
+
if (!record.trim()) continue;
|
|
75
|
+
const [header, ...lines] = record.split("\n");
|
|
76
|
+
const [sha, author, date] = header.split(FIELD);
|
|
77
|
+
if (!sha) continue;
|
|
78
|
+
const commit = { commit: sha, author, date };
|
|
79
|
+
for (const line of lines) {
|
|
80
|
+
const match = /^([A-Z])\d*\t([^\t]+)(?:\t([^\t]+))?$/.exec(line);
|
|
81
|
+
if (!match) continue;
|
|
82
|
+
const [, status, first, second] = match;
|
|
83
|
+
if (status === "D") {
|
|
84
|
+
history.delete(first);
|
|
85
|
+
} else if (status === "R" && second) {
|
|
86
|
+
const moved = history.get(first) ?? { created: commit, revised: null };
|
|
87
|
+
history.delete(first);
|
|
88
|
+
history.set(second, { created: moved.created, revised: commit });
|
|
89
|
+
} else {
|
|
90
|
+
const target = status === "C" && second ? second : first;
|
|
91
|
+
const known = history.get(target);
|
|
92
|
+
if (known) history.set(target, { created: known.created, revised: commit });
|
|
93
|
+
else history.set(target, { created: commit, revised: null });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return history;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Forget what was read, for a test that commits between two reads. */
|
|
101
|
+
export function forgetHistory() {
|
|
102
|
+
cache.clear();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The working tree a directory belongs to, or "" when no repository holds it. */
|
|
106
|
+
export function repositoryOf(dir) {
|
|
107
|
+
return git(dir, ["rev-parse", "--show-toplevel"]);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function git(dir, args) {
|
|
111
|
+
try {
|
|
112
|
+
return execFileSync("git", ["-C", dir, ...args], {
|
|
113
|
+
encoding: "utf8",
|
|
114
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
115
|
+
maxBuffer: 256 * 1024 * 1024,
|
|
116
|
+
}).trim();
|
|
117
|
+
} catch {
|
|
118
|
+
return "";
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function toPosix(path) {
|
|
123
|
+
return path.split(sep).join("/");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** @typedef {{commit: string, author: string, date: string}} Commit */
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
6
|
+
|
|
7
|
+
import { fileHistory, forgetHistory, historyFor, parseLog } from "./history.mjs";
|
|
8
|
+
|
|
9
|
+
const created = [];
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
forgetHistory();
|
|
12
|
+
for (const path of created.splice(0)) rmSync(path, { recursive: true, force: true });
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const ADA = { GIT_AUTHOR_NAME: "Ada Lovelace", GIT_AUTHOR_EMAIL: "ada@example.com", GIT_COMMITTER_NAME: "Ada Lovelace", GIT_COMMITTER_EMAIL: "ada@example.com" };
|
|
16
|
+
const GRACE = { GIT_AUTHOR_NAME: "Grace Hopper", GIT_AUTHOR_EMAIL: "grace@example.com", GIT_COMMITTER_NAME: "Grace Hopper", GIT_COMMITTER_EMAIL: "grace@example.com" };
|
|
17
|
+
|
|
18
|
+
function repository() {
|
|
19
|
+
const root = mkdtempSync(join(tmpdir(), "portolan-history-"));
|
|
20
|
+
created.push(root);
|
|
21
|
+
const git = (args, who = ADA, date = "2026-01-01T09:00:00Z") =>
|
|
22
|
+
execFileSync("git", ["-C", root, ...args], {
|
|
23
|
+
encoding: "utf8",
|
|
24
|
+
env: { ...process.env, ...who, GIT_AUTHOR_DATE: date, GIT_COMMITTER_DATE: date },
|
|
25
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
26
|
+
}).trim();
|
|
27
|
+
const write = (name, contents) => {
|
|
28
|
+
mkdirSync(join(root, name, ".."), { recursive: true });
|
|
29
|
+
writeFileSync(join(root, name), contents);
|
|
30
|
+
};
|
|
31
|
+
git(["init", "-q"]);
|
|
32
|
+
return { root, git, write };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe("historyFor", () => {
|
|
36
|
+
it("gives every file its first commit, its last, and follows a move", () => {
|
|
37
|
+
const { root, git, write } = repository();
|
|
38
|
+
write("docs/adr/0001-first.md", "# 1. First\n");
|
|
39
|
+
write("docs/adr/0002-second.md", "# 2. Second\n");
|
|
40
|
+
git(["add", "."]);
|
|
41
|
+
git(["commit", "-q", "-m", "two records"]);
|
|
42
|
+
write("docs/adr/0002-second.md", "# 2. Second\n\nReworded.\n");
|
|
43
|
+
git(["commit", "-q", "-am", "reword"], GRACE, "2026-01-03T17:30:00Z");
|
|
44
|
+
mkdirSync(join(root, "adr"));
|
|
45
|
+
git(["mv", "docs/adr/0001-first.md", "adr/0001-first.md"]);
|
|
46
|
+
git(["commit", "-q", "-m", "move"], GRACE, "2026-01-05T08:00:00Z");
|
|
47
|
+
|
|
48
|
+
const history = historyFor(root, ".");
|
|
49
|
+
expect(Object.keys(history).sort()).toEqual(["adr/0001-first.md", "docs/adr/0002-second.md"]);
|
|
50
|
+
|
|
51
|
+
const second = history["docs/adr/0002-second.md"];
|
|
52
|
+
expect(second.created).toMatchObject({ author: "Ada Lovelace", date: "2026-01-01T09:00:00Z" });
|
|
53
|
+
expect(second.created.commit).toHaveLength(40);
|
|
54
|
+
expect(second.revised).toMatchObject({ author: "Grace Hopper", date: "2026-01-03T17:30:00Z" });
|
|
55
|
+
expect(second.revised.commit).not.toBe(second.created.commit);
|
|
56
|
+
|
|
57
|
+
const moved = history["adr/0001-first.md"];
|
|
58
|
+
expect(moved.created).toMatchObject({ author: "Ada Lovelace", date: "2026-01-01T09:00:00Z" });
|
|
59
|
+
expect(moved.revised).toMatchObject({ author: "Grace Hopper", date: "2026-01-05T08:00:00Z" });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("keeps only the root's files, and no revision for a file committed once", () => {
|
|
63
|
+
const { root, git, write } = repository();
|
|
64
|
+
write("docs/adr/0001-first.md", "# 1. First\n");
|
|
65
|
+
write("README.md", "# Repo\n");
|
|
66
|
+
git(["add", "."]);
|
|
67
|
+
git(["commit", "-q", "-m", "one"]);
|
|
68
|
+
|
|
69
|
+
expect(Object.keys(historyFor(root, "docs/adr"))).toEqual(["docs/adr/0001-first.md"]);
|
|
70
|
+
expect(historyFor(root, "docs/adr")["docs/adr/0001-first.md"]).not.toHaveProperty("revised");
|
|
71
|
+
expect(historyFor(root, "docs")).toHaveProperty("docs/adr/0001-first.md");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("names files as a manifest below the repository root would", () => {
|
|
75
|
+
const { root, git, write } = repository();
|
|
76
|
+
write("services/auth/docs/adr/0001-first.md", "# 1. First\n");
|
|
77
|
+
git(["add", "."]);
|
|
78
|
+
git(["commit", "-q", "-m", "one"]);
|
|
79
|
+
|
|
80
|
+
expect(Object.keys(historyFor(join(root, "services/auth"), "docs/adr"))).toEqual(["docs/adr/0001-first.md"]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("answers undefined outside a checkout, and nothing for a deleted file", () => {
|
|
84
|
+
const bare = mkdtempSync(join(tmpdir(), "portolan-history-bare-"));
|
|
85
|
+
created.push(bare);
|
|
86
|
+
expect(historyFor(bare, ".")).toBeUndefined();
|
|
87
|
+
|
|
88
|
+
const { root, git, write } = repository();
|
|
89
|
+
write("gone.md", "x\n");
|
|
90
|
+
git(["add", "."]);
|
|
91
|
+
git(["commit", "-q", "-m", "add"]);
|
|
92
|
+
git(["rm", "-q", "gone.md"]);
|
|
93
|
+
git(["commit", "-q", "-m", "remove"]);
|
|
94
|
+
expect(fileHistory(root).size).toBe(0);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("parseLog", () => {
|
|
99
|
+
it("reads git's record, field and name-status lines", () => {
|
|
100
|
+
const log = [
|
|
101
|
+
"\x1eaaa\x1fAda\x1f2026-01-01T09:00:00Z\n\nA\tdocs/a.md\nA\tdocs/b.md\n",
|
|
102
|
+
"\x1ebbb\x1fGrace\x1f2026-01-02T09:00:00Z\n\nM\tdocs/b.md\nR100\tdocs/a.md\tadr/a.md\nC075\tdocs/b.md\tdocs/c.md\n",
|
|
103
|
+
"\x1eccc\x1fGrace\x1f2026-01-03T09:00:00Z\n\nD\tdocs/c.md\n",
|
|
104
|
+
].join("");
|
|
105
|
+
const history = parseLog(log);
|
|
106
|
+
expect([...history.keys()].sort()).toEqual(["adr/a.md", "docs/b.md"]);
|
|
107
|
+
expect(history.get("adr/a.md")).toEqual({ created: { commit: "aaa", author: "Ada", date: "2026-01-01T09:00:00Z" }, revised: { commit: "bbb", author: "Grace", date: "2026-01-02T09:00:00Z" } });
|
|
108
|
+
expect(history.get("docs/b.md").revised.commit).toBe("bbb");
|
|
109
|
+
});
|
|
110
|
+
});
|