diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..585ad76 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,79 @@ +# +# @Project: @cldmv/.github +# @Filename: /examples/individual-repo-workflows/automation/dependabot.yml +# @Date: 2026-05-26 00:00:00 -07:00 (1782460800) +# @Author: Nate Corcoran +# @Email: +# @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. +# + +# Individual repo: .github/dependabot.yml +# +# Dependabot configuration tuned for the v4 staging-branch release flow. +# +# How it fits with v4: +# - All Dependabot PRs target `next` so they pool with every other +# contributor change and batch into the next release. +# - `dependabot-auto-merge.yml` (in this same folder) auto-merges +# patch/minor bumps into `next` after CI passes β€” zero-touch pooling. +# Delete that workflow if you'd rather review each bump by hand. +# - Security updates are detected by `hotfix-redirector.yml` +# (release-flow-v4/) by GHSA references in the PR body and auto-promoted +# from `next` β†’ `hotfixes` so they ship via the hotfix lane, not the +# next-batch release. No special routing needed in this config. +# +# Customize per repo: +# - Add or remove `package-ecosystem` blocks for your stack (gomod, pip, +# bundler, gradle, maven, cargo, docker, etc.). +# - Adjust `directory` if your manifests don't live at the repo root. +# - Tighten `open-pull-requests-limit` if Dependabot's noise is too much. +# - Add `allow` / `ignore` rules for specific packages. +# - Add `groups` to bundle related bumps into a single PR. + +version: 2 +updates: + # GitHub Actions: keep pinned action SHAs / version tags fresh. + - package-ecosystem: "github-actions" + directory: "/" + target-branch: "next" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 + commit-message: + prefix: "deps" + # Grouped PRs cut noise: one PR per (security | patch | minor) bundle + # per week instead of N separate PRs. Security PRs still get retargeted + # to `hotfixes` by hotfix-redirector.yml when GHSA refs appear in the + # body β€” bundling N GHSA fixes into one PR is fine, the redirector + # only needs one match to retarget. + groups: + security: + applies-to: security-updates + patterns: ["*"] + patch: + applies-to: version-updates + update-types: ["patch"] + minor: + applies-to: version-updates + update-types: ["minor"] + + # NPM: package.json + package-lock.json updates. + # Delete this block if your repo isn't a Node project. + - package-ecosystem: "npm" + directory: "/" + target-branch: "next" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 + commit-message: + prefix: "deps" + groups: + security: + applies-to: security-updates + patterns: ["*"] + patch: + applies-to: version-updates + update-types: ["patch"] + minor: + applies-to: version-updates + update-types: ["minor"] diff --git a/.github/workflows/branch-retention.yml b/.github/workflows/branch-retention.yml new file mode 100644 index 0000000..14df9e4 --- /dev/null +++ b/.github/workflows/branch-retention.yml @@ -0,0 +1,38 @@ +# +# @Project: @cldmv/.github +# @Filename: /examples/individual-repo-workflows/automation/branch-retention.yml +# @Date: 2026-05-20 00:00:00 -07:00 (1779606000) +# @Author: Nate Corcoran +# @Email: +# @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. +# + +# Individual repo: .github/workflows/branch-retention.yml +# +# On PR merge: most branches deleted immediately; release/* keeps last 5, +# hotfix/* keeps last 3. master/main/badges/gh-pages never touched. +# +# v4 flow: feature PRs merge into `next` and hotfix PRs into `hotfixes` +# (not directly into master). next/hotfixes are in the branches: filter +# below so this workflow fires on those PR closures too β€” otherwise +# feat/* / fix/* / chore/* etc. would pile up on origin indefinitely. +# (Repos that haven't adopted v4 just won't see those branches; the +# extra entries in the filter are harmless.) +name: 🌿 Branch Retention + +on: + pull_request: + types: [closed] + branches: [master, main, next, hotfixes] + +permissions: + contents: write + pull-requests: read + +jobs: + retain: + if: github.event.pull_request.merged == true + uses: CLDMV/.github/.github/workflows/reusable-branch-retention.yml@v4 + secrets: + BOT_APP_CLIENT_ID: ${{ secrets.CLDMV_BOT_APP_CLIENT_ID }} + BOT_APP_PRIVATE_KEY: ${{ secrets.CLDMV_BOT_APP_PRIVATE_KEY }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..51c66eb --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,306 @@ +# +# @Project: @cldmv/.github +# @Filename: /examples/individual-repo-workflows/core-cicd/ci.yml +# @Date: 2026-05-20 00:00:00 -07:00 (1779606000) +# @Author: Nate Corcoran +# @Email: +# @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. +# + +# Individual repo: .github/workflows/ci.yml +name: πŸ§ͺ CI Tests & Build + +on: + # Note: do NOT add `paths:` / `paths-ignore:` at the trigger level. Doing + # that makes GitHub skip the workflow entirely for docs-only changes, which + # means `Required PR Check` never posts and the ruleset blocks the merge. + # The reusable workflow's `paths-gate` job does the same job from inside, + # and exposes a `docs_only` output so this workflow can still green-light + # the required check for docs-only PRs (see `required-check` below). The + # ignore globs themselves are passed via the `paths_ignore:` input below + # β€” override there if your repo needs different rules. + # + # `push` fires for branches in this repo only (forks push to their own remote, + # not ours). Branch protection on the PR reads the status check from the + # commit SHA, so this single trigger covers both pre-PR pushes and PR head + # updates without duplicating runs. + push: + # Bot-managed branches (badges, gh-pages) carry no source to test. + branches-ignore: [badges, gh-pages] + # `pull_request` covers two cases: + # - Fork PRs (push doesn't fire upstream for fork commits). + # - Release PRs from `next` / `hotfixes` β†’ `master`. Their head SHA is + # a bot `chore: bump version` commit that workflow-ci.yml's + # `commit-gate` job filters out on the push path, so without the + # pull_request fallback the release PR's `Required PR Check` + # status never gets posted and the ruleset blocks the merge. + # `branches:` includes the v4 integration branches so PRs targeting + # `next` / `hotfixes` get CI too β€” feature PRs from forks would + # otherwise get nothing. Non-fork feature PRs still skip the + # pull_request `ci` job (push covers them); see the `if:` on the job. + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + branches: [master, main, next, hotfixes] + workflow_dispatch: + inputs: + debug: + description: "Enable debug logging for troubleshooting" + type: boolean + required: false + default: false + node_version: + description: "Node.js version to use (default: lts/*)" + type: string + required: false + default: "lts/*" + min_node_version: + description: "Minimum Node.js version for matrix testing (default: 20, oldest non-EOL)" + type: string + required: false + default: "20" + max_node_major: + description: "Override max Node.js major version (default: 22)" + type: string + required: false + default: "22" + lts_only_matrix: + description: "Only include even-numbered (LTS) Node.js major versions in the test matrix" + type: boolean + required: false + default: true + package_manager: + description: "Package manager (npm or yarn)" + type: string + required: false + default: "npm" + test_environment: + description: "Environment for tests (affects NODE_ENV and NODE_OPTIONS --conditions flag)" + type: string + required: false + default: "development" + # ── Coverage badge ─────────────────────────────────────────────── + enable_coverage_badge: + description: "Run the coverage + badge-push job after CI passes" + type: boolean + required: false + default: true + coverage_command: + description: "Command to run tests and generate coverage data" + type: string + required: false + default: "npm run ci:coverage" + coverage_summary_path: + description: "Path to the coverage-summary.json produced by Jest / c8" + type: string + required: false + default: "coverage/coverage-summary.json" + badges_branch: + description: "Branch where the badge JSON is published" + type: string + required: false + default: "badges" + badge_filename: + description: "Filename for the badge JSON committed to the badges branch" + type: string + required: false + default: "coverage.json" + upload_coverage_artifact: + description: "Upload the full coverage/ directory as a workflow artifact" + type: boolean + required: false + default: true + # ── Type check ────────────────────────────────────────────────── + type_check_command: + description: "Command to run type checking" + type: string + required: false + default: "npm run test:types" + skip_type_check: + description: "Skip the type-check step in the coverage-badge job" + type: boolean + required: false + default: false + default_branch: + description: "Default branch name β€” badge is only pushed on pushes to this branch" + type: string + required: false + default: "master" + enable_coverage_pr_comment: + description: "Inject a coverage badge into the PR description on pull request events" + type: boolean + required: false + default: true + +# Cancel superseded runs on feature branches; keep every master/main run as the +# permanent green record. Keyed on github.ref so push and pull_request events +# for the same branch share a group (the `if:` on the ci job already prevents +# non-fork PR sync from running, but the shared group guards against edge +# cases). +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/master' && github.ref != 'refs/heads/main' }} + +# Workflow-level: matches the broadest write surface the called +# `workflow-ci.yml` reaches across its branches: +# - coverage-badge: contents:write (push to `badges` branch) +# - coverage-pr-comment: pull-requests:write (edit PR description body) +# Jobs that don't need write (CI matrix, commit-gate, the mirror below) +# inherit but never exercise the surface. The mirror job overrides to +# `permissions: {}` since it's pure shell. +permissions: + contents: write + pull-requests: write + +jobs: + ci: + name: πŸ—οΈ Continuous Integration + # Run on pull_request when: + # - The PR is from a fork (push doesn't fire upstream for fork commits). + # - The PR is a v4 release PR β€” head ref is `next` or `hotfixes` + # targeting `master`/`main`. Push-event CI on the head SHA is + # unreliable for these because workflow-ci.yml's `commit-gate` + # filters out the bot's `chore: bump version` commit, so without + # this fallback the release PR's `Required PR Check` never posts. + # Other (in-repo, non-release) PRs skip β€” the push event on the head + # branch already ran CI and posted status to the SHA. + if: | + github.event_name != 'pull_request' || + github.event.pull_request.head.repo.fork == true || + github.event.pull_request.head.ref == 'next' || + github.event.pull_request.head.ref == 'hotfixes' + uses: CLDMV/.github/.github/workflows/workflow-ci.yml@v4 + with: + package_name: "@cldmv/git-embedded" # Required: replace with your NPM package name + # Globs that should NOT trigger the heavy CI matrix. When every changed + # file matches one of these, `docs_only=true` flows out of the reusable + # and `required-check` below posts a green Required PR Check without + # running CI. The default in the reusable matches these β€” override only + # if your repo needs different rules. + paths_ignore: | + **.md + docs/** + *.md + LICENSE + .gitignore + debug: ${{ github.event.inputs.debug == 'true' }} + node_version: ${{ github.event.inputs.node_version || 'lts/*' }} + min_node_version: ${{ github.event.inputs.min_node_version || '20' }} + max_node_major: ${{ github.event.inputs.max_node_major || '22' }} + # LTS-only matrix (even majors: 20, 22, 24, …) on every event. Odd majors + # (21, 23, …) are non-LTS interim releases, and the native-binding test + # toolchain (vitest 4 / rolldown / vite 8) excludes them via `engines` + # (`^20.19.0 || >=22.12.0`), so a "full matrix" on them only re-discovers a + # known toolchain gap ("Cannot find native binding") rather than a real + # per-version regression. workflow_dispatch can still opt out (set false). + lts_only_matrix: ${{ github.event.inputs.lts_only_matrix != 'false' }} + package_manager: ${{ github.event.inputs.package_manager || 'npm' }} + test_command: "npm test" # Use defaults: NODE_ENV=development, NODE_OPTIONS=--conditions=development + # test_command: "NODE_OPTIONS='--conditions=slothlet-dev' npm test" # Override NODE_OPTIONS only + # test_command: "NODE_ENV=test npm test" # Override NODE_ENV only + # test_command: "NODE_ENV=test NODE_OPTIONS='--conditions=slothlet-dev' npm test" # Override both + test_environment: ${{ github.event.inputs.test_environment || 'development' }} # Alternative to setting in test_command + build_command: "echo 'βœ“ no build step'" + skip_performance_tests: false + skip_matrix_tests: false + + # ── Coverage badge ───────────────────────────────────────────────────── + # Runs after a successful CI build; pushes a Shields.io-compatible badge + # JSON to the `badges` branch (signed commit via bot GPG). + # Only runs on direct pushes to default_branch β€” PRs and feature branches + # are automatically skipped so coverage always reflects merged master code. + # Requires: the coverage_command produces coverage/coverage-summary.json + enable_coverage_badge: ${{ github.event.inputs.enable_coverage_badge != 'false' }} + default_branch: ${{ github.event.inputs.default_branch || 'master' }} # Badge only pushed when a push lands on this branch + coverage_command: ${{ github.event.inputs.coverage_command || 'npm run ci:coverage' }} + coverage_summary_path: ${{ github.event.inputs.coverage_summary_path || 'coverage/coverage-summary.json' }} + badges_branch: ${{ github.event.inputs.badges_branch || 'badges' }} + badge_filename: ${{ github.event.inputs.badge_filename || 'coverage.json' }} + upload_coverage_artifact: ${{ github.event.inputs.upload_coverage_artifact != 'false' }} + + # ── Type check (runs inside the coverage-badge job) ──────────────────── + type_check_command: ${{ github.event.inputs.type_check_command || 'npm run test:types' }} + skip_type_check: ${{ github.event.inputs.skip_type_check == 'true' }} + + # ── PR coverage badge ───────────────────────────────────────────────── + # Injects a Shields.io badge + breakdown table directly into the PR body + # on every push to the PR branch. Only fires on pull_request events; + # skipped automatically on push and workflow_dispatch. No files committed. + enable_coverage_pr_comment: ${{ github.event.inputs.enable_coverage_pr_comment != 'false' }} + + # Authentication & Bot Configuration + # The workflow supports automatic App token detection for enhanced permissions and proper attribution: + # - WITH App secrets: Operations attributed to CLDMV bot, enhanced permissions for workflow repositories + # - WITHOUT App secrets: Falls back to GitHub Actions bot with standard permissions + # Note: CI workflow currently only runs build/test jobs, but App secrets are included for consistency + # To set up App authentication, add these secrets to your repository settings: + secrets: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + # Optional: CLDMV Bot credentials for enhanced permissions and proper attribution + # If not provided, will use default GITHUB_TOKEN with GitHub Actions bot attribution + BOT_APP_CLIENT_ID: ${{ secrets.CLDMV_BOT_APP_CLIENT_ID }} + BOT_APP_PRIVATE_KEY: ${{ secrets.CLDMV_BOT_APP_PRIVATE_KEY }} + # Required when enable_coverage_badge: true + BOT_NAME: ${{ secrets.CLDMV_BOT_NAME }} + BOT_EMAIL: ${{ secrets.CLDMV_BOT_EMAIL }} + BOT_GPG_PRIVATE_KEY: ${{ secrets.CLDMV_BOT_GPG_PRIVATE_KEY }} + BOT_GPG_PASSPHRASE: ${{ secrets.CLDMV_BOT_GPG_PASSPHRASE }} + + # βœ… Stable check that mirrors the `ci` result so branch protection has a + # single, predictable status name to require. The push event runs on the + # same SHA that becomes the PR head, so the status attaches to the PR + # automatically β€” no `pull_request` round-trip needed for non-fork + # non-release PRs. + required-check: + name: βœ… Required PR Check + needs: ci + # Mirror the `ci` job's gating exactly. The four cases that run: + # 1. push events (job needs CI run) + # 2. fork PRs (push doesn't cover forks) + # 3. release PRs from `next` β†’ master/main (push covers SHA but commit-gate skips chore-bump) + # 4. release PRs from `hotfixes` β†’ master/main (same reason) + # In-repo feature PRs targeting `next` / `hotfixes` skip on + # pull_request β€” push on the head branch already posted the status + # on the SHA, and mirroring here would overwrite it. + if: | + always() && ( + github.event_name != 'pull_request' || + github.event.pull_request.head.repo.fork == true || + github.event.pull_request.head.ref == 'next' || + github.event.pull_request.head.ref == 'hotfixes' + ) + runs-on: ubuntu-latest + # Pure shell mirror β€” no GitHub API access. Strip the workflow's + # write defaults to zero for this job. + permissions: {} + steps: + - name: Mirror reusable result + env: + IS_MASTER_SYNC: ${{ needs.ci.outputs.is_master_sync }} + DOCS_ONLY: ${{ needs.ci.outputs.docs_only }} + CI_RESULT: ${{ needs.ci.result }} + run: | + echo "ci.result=$CI_RESULT docs_only=$DOCS_ONLY is_master_sync=$IS_MASTER_SYNC" + # next/hotfixes was force-synced to master β€” head SHA matches the + # default branch, nothing new to test, green-light without running CI. + if [ "$IS_MASTER_SYNC" = "true" ]; then + echo "Branch tip matches master β€” Required PR Check passes without running CI." + exit 0 + fi + # Docs-only PR β€” the reusable skipped the heavy chain and exported + # docs_only=true. Green-light Required PR Check so the ruleset + # doesn't block a docs change. + if [ "$DOCS_ONLY" = "true" ]; then + echo "Docs-only change β€” Required PR Check passes without running CI." + exit 0 + fi + if [ "$CI_RESULT" = "success" ]; then + echo "Reusable CI passed." + exit 0 + elif [ "$CI_RESULT" = "failure" ] || [ "$CI_RESULT" = "cancelled" ]; then + echo "Reusable CI did not pass." + exit 1 + else + # covers 'skipped' or undefined; force red to avoid silent green + echo "Reusable CI produced no pass/fail; treating as failure." + exit 1 + fi diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..faed0f4 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,70 @@ +# +# @Project: @cldmv/.github +# @Filename: /examples/individual-repo-workflows/security/codeql.yml +# @Date: 2026-05-20 00:00:00 -07:00 (1779606000) +# @Author: Nate Corcoran +# @Email: +# @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. +# + +# Individual repo: .github/workflows/codeql.yml +# +# REQUIRED REPO SETTING β€” CodeQL must be in "Advanced" mode for this workflow +# to upload SARIF. If the repo has CodeQL "Default setup" enabled (the +# GitHub-managed alternative), upload runs fail with: +# +# "Code Scanning could not process the submitted SARIF file: CodeQL +# analyses from advanced configurations cannot be processed when the +# default setup is enabled" +# +# The org-bootstrap-repo action automatically disables default setup +# (overwrite-with-warn) on every fanout run, so a freshly-bootstrapped +# repo lands in the right state by default. If you want to KEEP default +# setup (the GitHub-managed config) instead of this workflow, DELETE +# this codeql.yml file β€” with the conflict gone, the bootstrap leaves +# default setup alone on subsequent runs. +# +# Manual fix when running outside the bootstrap: +# Settings β†’ Code security and analysis β†’ Code scanning β†’ CodeQL +# analysis β†’ βš™οΈ β†’ Switch to advanced. +name: πŸ” CodeQL + +on: + push: + branches: [master, main] + # Same fork-PR consideration as ci.yml: pull_request fires for forks; SARIF + # upload to base-repo Security tab fails with read-only token. Acceptable β€” + # push-to-master analysis after merge catches anything missed. DO NOT use + # pull_request_target (runs base-repo workflow with secrets against fork + # code; dangerous). + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + # Include the v4 integration branches (`next`, `hotfixes`) so feature + # and hotfix PRs trigger CodeQL. Without these, branch protection + # rulesets that require the CodeQL check on `next`/`hotfixes` will + # sit on "waiting for results" indefinitely. Branches that don't + # exist in a given repo simply never trigger the workflow β€” harmless + # for repos that haven't adopted the v4 staging-branch flow. + branches: [master, main, next, hotfixes] + schedule: + - cron: "37 14 * * 1" # weekly Monday 14:37 UTC; GitHub updates queries over time + +permissions: + security-events: write + contents: read + actions: read + +concurrency: + group: codeql-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/master' && github.ref != 'refs/heads/main' }} + +jobs: + analyze: + uses: CLDMV/.github/.github/workflows/reusable-codeql.yml@v4 + with: + languages: "javascript-typescript" + # Override defaults if needed: + # queries: "security-extended,security-and-quality" + # paths_ignore: "node_modules/,dist/,coverage/,**/test/**" + # config_file: ".github/codeql-config.yml" + # build_mode: "autobuild" diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml new file mode 100644 index 0000000..654e8a3 --- /dev/null +++ b/.github/workflows/dependabot-auto-merge.yml @@ -0,0 +1,54 @@ +# +# @Project: @cldmv/.github +# @Filename: /examples/individual-repo-workflows/automation/dependabot-auto-merge.yml +# @Date: 2026-05-20 00:00:00 -07:00 (1779606000) +# @Author: Nate Corcoran +# @Email: +# @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. +# + +# Individual repo: .github/workflows/dependabot-auto-merge.yml +# +# Auto-approves + queues auto-merge for Dependabot patch/minor bumps after +# CI passes. Major bumps are left for a human. +# +# Default in v4: ON. To opt out, delete this file β€” Dependabot PRs still +# flow into `next` (via dependabot.yml) but require a manual merge click. +# +# How v4 routing works: +# - dependabot.yml sets `target-branch: next`, so Dependabot opens PRs +# against `next`. This workflow auto-merges those PRs into `next` after +# CI; they batch into the next release like every other change. +# - For security advisories, hotfix-redirector.yml (release-flow-v4/) +# detects GHSA references in the PR body and retargets the PR from +# `next` β†’ `hotfixes` *before* this workflow runs, so security updates +# auto-merge into the hotfix lane instead of waiting for the next batch. +# +# Required setup (one-time per repo): +# 1. Settings β†’ Pull Requests β†’ "Allow auto-merge" β†’ ON +# (enabled automatically by `release-flow-v4/v4-bootstrap.yml`) +# 2. Branch protection on `next` and `hotfixes` with required CI status +# checks β€” the action refuses to merge into an unprotected branch. +# Both are validated by the action; the workflow fails loudly if missing. +name: πŸ€– Dependabot Auto-Merge + +on: + pull_request: + types: [opened, reopened, synchronize, ready_for_review] + +permissions: + contents: write + pull-requests: write + +jobs: + automerge: + # Pre-filter at workflow level so this doesn't spin up for every PR. + if: github.event.pull_request.user.login == 'dependabot[bot]' + uses: CLDMV/.github/.github/workflows/reusable-dependabot-auto-merge.yml@v4 + with: + bump_types: "patch,minor" + merge_method: "squash" + # also_for_actors: "renovate[bot]" # extend if you adopt Renovate + secrets: + BOT_APP_CLIENT_ID: ${{ secrets.CLDMV_BOT_APP_CLIENT_ID }} + BOT_APP_PRIVATE_KEY: ${{ secrets.CLDMV_BOT_APP_PRIVATE_KEY }} diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml new file mode 100644 index 0000000..82a55c9 --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,34 @@ +# +# @Project: @cldmv/.github +# @Filename: /examples/individual-repo-workflows/security/dependency-review.yml +# @Date: 2026-05-20 00:00:00 -07:00 (1779606000) +# @Author: Nate Corcoran +# @Email: +# @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. +# + +# Individual repo: .github/workflows/dependency-review.yml +name: πŸ”’ Dependency Review + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + branches: [master, main] + +permissions: + contents: read + pull-requests: write + +jobs: + review: + uses: CLDMV/.github/.github/workflows/reusable-dependency-review.yml@v4 + with: + fail_on_severity: "moderate" + # Per-repo license policy override: + # deny_licenses: "AGPL-3.0,LGPL-3.0" # block copyleft for an Apache-2.0 repo + # Bot App credentials. When set, the dependency-review PR comment is + # posted by the consumer's bot App instead of github-actions[bot]. + # Both lines are optional; remove them to fall back to GITHUB_TOKEN. + secrets: + BOT_APP_CLIENT_ID: ${{ secrets.CLDMV_BOT_APP_CLIENT_ID }} + BOT_APP_PRIVATE_KEY: ${{ secrets.CLDMV_BOT_APP_PRIVATE_KEY }} diff --git a/.github/workflows/feature-pr.yml b/.github/workflows/feature-pr.yml new file mode 100644 index 0000000..ffbb123 --- /dev/null +++ b/.github/workflows/feature-pr.yml @@ -0,0 +1,264 @@ +# +# @Project: @cldmv/git-embedded +# @Filename: /.github/workflows/feature-pr.yml +# @Date: 2026-07-18 15:49:12 -07:00 (1784414952) +# @Author: Nate Corcoran +# @Email: +# @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. +# + +# Individual repo: .github/workflows/feature-pr.yml +# +# v4 ergonomics: auto-opens (and refreshes) a PR from a code-side branch to +# the right integration branch on every push. +# +# Mapping (matches CLDMV/.github docs/conventions/branch-naming.md): +# feat/*, feature/*, fix/*, release/*, chore/*, refactor/*, +# docs/*, ci/*, perf/*, test/*, style/* β†’ next +# hotfix/* β†’ hotfixes +# +# Reserved branches NOT auto-PR'd: dependabot/* and copilot/* (they manage +# their own PRs); badges, gh-pages (bot-only); master/main (the target). +# +# On first push: creates the PR with a categorized changelog body (same +# format the v4 release-PR machinery generates). On subsequent pushes: +# refreshes the existing PR's body with the latest categorized commits. +# Uses the shared get-commit-range + generate-comprehensive-changelog +# actions for the format, so consumer PRs look identical to release PRs +# in structure (Breaking Changes / Features / Bug Fixes / Other Changes / +# Contributors). +# +# Skipped automatically: bot pushes (your bot App's login / github-actions[bot]) +# and any push whose head commit starts with 'chore: bump version'. +name: πŸ”€ Feature PR (v4) + +on: + push: + branches: + # CUSTOMIZE: prune this list to whichever branch prefixes your + # repo uses. Must align with the `case` statement below. + - 'feat/**' + - 'feature/**' + - 'fix/**' + - 'release/**' + - 'chore/**' + - 'refactor/**' + - 'docs/**' + - 'ci/**' + - 'perf/**' + - 'test/**' + - 'style/**' + - 'hotfix/**' + +permissions: + contents: read + pull-requests: write + +# Serialize per-branch so a flurry of pushes doesn't race the +# "does a PR already exist?" check. +concurrency: + group: feature-pr-${{ github.repository }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + open-pr: + # Loop guard: replace 'cldmv-bot[bot]' with your bot App's login. + if: | + github.actor != 'cldmv-bot[bot]' && + github.actor != 'github-actions[bot]' && + !startsWith(github.event.head_commit.message, 'chore: bump version') + runs-on: ubuntu-latest + steps: + - name: Determine target branch + id: target + shell: bash + run: | + branch="${GITHUB_REF#refs/heads/}" + echo "branch=$branch" >> "$GITHUB_OUTPUT" + # CUSTOMIZE: adjust the case arms to match your branch + # conventions. Anything not matched is silently skipped + # (so master/main, badges, gh-pages, dependabot/*, etc. + # are safe regardless of what fires the workflow). + # The flow_label sorts first in the PR's label list + # (the leading `!` precedes every letter alphabetically) + # so a glance at any PR's badges reveals which lane it's in. + # Lane (target) AND declared type both come from the branch + # prefix β€” the v4 convention requires a typed prefix, so a + # `docs/*` branch is a docs change, `fix/*` a fix, etc. The flow + # label is `! β†’ ` so it reflects what the PR actually + # is, not a blanket "feature". (Previously every next-lane branch + # got `! feature β†’ next`, mislabelling docs/fix/chore PRs.) + case "$branch" in + hotfix/*) target="hotfixes"; type="hotfix" ;; + feat/*|feature/*) target="next"; type="feature" ;; + fix/*) target="next"; type="fix" ;; + docs/*) target="next"; type="docs" ;; + chore/*) target="next"; type="chore" ;; + refactor/*) target="next"; type="refactor" ;; + ci/*) target="next"; type="ci" ;; + perf/*) target="next"; type="perf" ;; + test/*) target="next"; type="test" ;; + style/*) target="next"; type="style" ;; + release/*) target="next"; type="release" ;; + *) + target=""; type=""; flow_label="" + echo "::notice::Branch '$branch' does not match any auto-PR pattern; skipping." + ;; + esac + # Leading `!` sorts the flow label first in the PR's badge list, + # so a glance reveals both the change type and its lane. + if [ -n "$target" ]; then + flow_label="! ${type} β†’ ${target}" + fi + echo "target=$target" >> "$GITHUB_OUTPUT" + echo "flow_label=$flow_label" >> "$GITHUB_OUTPUT" + + - name: Create App token + id: app-token + if: steps.target.outputs.target != '' + uses: CLDMV/.github/.github/actions/github/steps/create-app-token@v4 + with: + client_id: ${{ secrets.CLDMV_BOT_APP_CLIENT_ID }} + private_key: ${{ secrets.CLDMV_BOT_APP_PRIVATE_KEY }} + env: + BOT_APP_CLIENT_ID: ${{ secrets.CLDMV_BOT_APP_CLIENT_ID }} + BOT_APP_PRIVATE_KEY: ${{ secrets.CLDMV_BOT_APP_PRIVATE_KEY }} + + - name: Check for existing PR + id: existing + if: steps.target.outputs.target != '' + shell: bash + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + pr=$(gh pr list --repo "$GITHUB_REPOSITORY" \ + --head "${{ steps.target.outputs.branch }}" \ + --base "${{ steps.target.outputs.target }}" \ + --state open \ + --json number --jq '.[0].number // ""') + echo "number=$pr" >> "$GITHUB_OUTPUT" + if [ -n "$pr" ]; then + echo "::notice::Existing PR #$pr will be refreshed." + fi + + - name: Checkout (full history for git log) + if: steps.target.outputs.target != '' + uses: CLDMV/.github/.github/actions/common/steps/checkout-code@v4 + with: + fetch-depth: 0 + + - name: Fetch target branch ref + if: steps.target.outputs.target != '' + shell: bash + run: | + git fetch --quiet origin "${{ steps.target.outputs.target }}" + + - name: Get categorized commits (base..head) + id: commits + if: steps.target.outputs.target != '' + uses: CLDMV/.github/.github/actions/git/steps/get-commit-range@v4 + with: + base-ref: origin/${{ steps.target.outputs.target }} + head-ref: HEAD + + - name: Detect feature commits in range + id: feat + if: steps.target.outputs.target != '' + shell: bash + env: + COMMITS: ${{ steps.commits.outputs.commits }} + run: | + # `type: feature` is applied when the range contains a feature, + # mirroring the changelog's own "Features" section: get-commit- + # range tags `feat:` (and content-categorized add/new) commits + # as category "feature". Reuses the already-computed commits. + has_feature=false + if printf '%s' "$COMMITS" | jq -e 'any(.[]; .category == "feature")' >/dev/null 2>&1; then + has_feature=true + fi + echo "has_feature=$has_feature" >> "$GITHUB_OUTPUT" + echo "πŸ“Š feature detected in range: $has_feature" + + - name: Generate categorized changelog body + id: changelog + if: steps.target.outputs.target != '' + uses: CLDMV/.github/.github/actions/git/steps/generate-comprehensive-changelog@v4 + with: + commits: ${{ steps.commits.outputs.commits }} + commit-range: ${{ steps.commits.outputs.commit-range }} + env: + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} + + - name: Save body to file + if: steps.target.outputs.target != '' + shell: bash + env: + BODY: ${{ steps.changelog.outputs.changelog-content }} + run: | + printf '%s' "$BODY" > /tmp/pr-body.md + + - name: Create PR (first push) + if: steps.target.outputs.target != '' && steps.existing.outputs.number == '' + shell: bash + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + HEAD_COMMIT_MSG: ${{ github.event.head_commit.message }} + HEAD_BRANCH: ${{ steps.target.outputs.branch }} + BASE_BRANCH: ${{ steps.target.outputs.target }} + run: | + # Title = head commit's first line β€” preserves the + # conventional-commit prefix the release-PR title-normalizer + # and commit-type aggregator expect. + title=$(printf '%s\n' "$HEAD_COMMIT_MSG" | head -1) + pr_url=$(gh pr create \ + --repo "$GITHUB_REPOSITORY" \ + --base "$BASE_BRANCH" \ + --head "$HEAD_BRANCH" \ + --title "$title" \ + --body-file /tmp/pr-body.md) + echo "::notice::Opened $pr_url" + # Apply the flow label (sorts first in the PR's badge list). + # `|| true` so a missing label in the repo (catalog not yet + # synced) doesn't fail the workflow. + if [ -n "${{ steps.target.outputs.flow_label }}" ]; then + gh pr edit "$pr_url" --add-label "${{ steps.target.outputs.flow_label }}" || true + fi + # Apply `type: feature` when the range implements a feature. + if [ "${{ steps.feat.outputs.has_feature }}" = "true" ]; then + gh pr edit "$pr_url" --add-label "type: feature" || true + fi + { + echo "### πŸ”€ Auto-opened PR" + echo "" + echo "$pr_url" + } >> "$GITHUB_STEP_SUMMARY" + + - name: Refresh existing PR body + if: steps.target.outputs.target != '' && steps.existing.outputs.number != '' + shell: bash + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + PR_NUMBER: ${{ steps.existing.outputs.number }} + run: | + gh pr edit "$PR_NUMBER" \ + --repo "$GITHUB_REPOSITORY" \ + --body-file /tmp/pr-body.md + # Re-apply the flow label so a manual removal doesn't + # strand the PR without its lane indicator. + if [ -n "${{ steps.target.outputs.flow_label }}" ]; then + gh pr edit "$PR_NUMBER" \ + --repo "$GITHUB_REPOSITORY" \ + --add-label "${{ steps.target.outputs.flow_label }}" || true + fi + # Apply `type: feature` when the range implements a feature. + if [ "${{ steps.feat.outputs.has_feature }}" = "true" ]; then + gh pr edit "$PR_NUMBER" \ + --repo "$GITHUB_REPOSITORY" \ + --add-label "type: feature" || true + fi + echo "::notice::Refreshed PR #${PR_NUMBER} body" + { + echo "### πŸ”€ Refreshed PR body" + echo "" + echo "PR #${PR_NUMBER}" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/hotfix-redirector.yml b/.github/workflows/hotfix-redirector.yml new file mode 100644 index 0000000..c45bdbd --- /dev/null +++ b/.github/workflows/hotfix-redirector.yml @@ -0,0 +1,67 @@ +# +# @Project: @cldmv/.github +# @Filename: /examples/individual-repo-workflows/release-flow-v4/hotfix-redirector.yml +# @Date: 2026-05-22 00:00:00 -07:00 (1779778800) +# @Author: Nate Corcoran +# @Email: +# @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. +# + +# Individual repo: .github/workflows/hotfix-redirector.yml +# +# v4 hotfix lane: retarget hotfix/security PRs to the `hotfixes` branch. +# +# Two paths trigger a redirect (CLDMV/.github docs/conventions/release-flow-v4.md Β§5.2, Β§6.5): +# 1. Head branch matches `hotfix/*` or `security/*` (human-driven hotfix flow). +# 2. Author is `dependabot[bot]` AND the PR body references a GHSA security +# advisory (Dependabot's security-update PRs flow into the hotfix lane; +# routine version bumps stay on `next`). +# +# The redirect-hotfix-pr action owns all detection logic β€” it skips non-matching +# bot PRs, non-matching heads, and PRs already on `hotfixes`, and posts a +# one-time explanatory comment with the appropriate reason. +name: πŸ”€ Hotfix PR Redirector (v4) + +# SECURITY NOTE: pull_request_target runs in the BASE repo's context with +# WRITE permissions + secrets. SAFE here because it is API-only β€” the +# redirect-hotfix-pr action never checks out or executes PR content. +# DO NOT add a checkout step. +# +# `opened` only (NOT `edited`): if a maintainer manually re-targets the PR, +# we must not fight them by redirecting again. +on: + pull_request_target: + types: [opened] + +permissions: + contents: read + pull-requests: write + +concurrency: + group: hotfix-redirector-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + redirect: + name: "πŸ”€ Redirect to hotfixes" + runs-on: ubuntu-latest + steps: + - name: Create App token + id: app-token + uses: CLDMV/.github/.github/actions/github/steps/create-app-token@v4 + with: + client_id: ${{ secrets.CLDMV_BOT_APP_CLIENT_ID }} + private_key: ${{ secrets.CLDMV_BOT_APP_PRIVATE_KEY }} + env: + BOT_APP_CLIENT_ID: ${{ secrets.CLDMV_BOT_APP_CLIENT_ID }} + BOT_APP_PRIVATE_KEY: ${{ secrets.CLDMV_BOT_APP_PRIVATE_KEY }} + + - name: Redirect hotfix/security PR to hotfixes + uses: CLDMV/.github/.github/actions/github/steps/redirect-hotfix-pr@v4 + with: + pr-number: ${{ github.event.pull_request.number }} + github-token: ${{ steps.app-token.outputs.token }} + head-ref: ${{ github.event.pull_request.head.ref }} + base-ref: ${{ github.event.pull_request.base.ref }} + user-type: ${{ github.event.pull_request.user.type }} + target-base: hotfixes diff --git a/.github/workflows/hotfixes-release.yml b/.github/workflows/hotfixes-release.yml new file mode 100644 index 0000000..eee6f79 --- /dev/null +++ b/.github/workflows/hotfixes-release.yml @@ -0,0 +1,164 @@ +# +# @Project: @cldmv/.github +# @Filename: /examples/individual-repo-workflows/release-flow-v4/hotfixes-release.yml +# @Date: 2026-05-22 00:00:00 -07:00 (1779778800) +# @Author: Nate Corcoran +# @Email: +# @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. +# + +# Individual repo: .github/workflows/hotfixes-release.yml +# +# v4 hotfix lane: maintain the ONE persistent `hotfixes β†’ master` release PR. +# +# Mirror of next-release.yml but for the `hotfixes` integration branch +# (CLDMV/.github docs/conventions/release-flow-v4.md Β§5.4, Β§6.2). Fires on +# every push to `hotfixes` (hotfix/security PR squash-merges land here), and +# resolves-or-creates the persistent `hotfixes β†’ master` release PR. Patches +# the current release independently of whatever is pending on `next`. +# +# Same model as the next lane: the version bump rides on `hotfixes` as a +# `chore: bump version` commit, carried to master through the squash (Β§8.1). +name: πŸš‘ Hotfixes Release (v4) + +on: + push: + branches: [hotfixes] + +permissions: + contents: write + pull-requests: write + +concurrency: + group: hotfixes-release-${{ github.repository }} + cancel-in-progress: false + +jobs: + plan: + # Loop guard: skip the bot's own chore-bump pushes and reset pushes. + # Replace `cldmv-bot[bot]` with your bot App's login if different. + if: | + github.actor != 'cldmv-bot[bot]' && + github.actor != 'github-actions[bot]' && + !startsWith(github.event.head_commit.message, 'chore: bump version') + name: "πŸ” Plan (detect changes + resolve PR)" + runs-on: ubuntu-latest + outputs: + has-changes: ${{ steps.detect.outputs.has-changes }} + pr-number: ${{ steps.resolve.outputs.pr-number }} + steps: + - name: Create App token + id: app-token + uses: CLDMV/.github/.github/actions/github/steps/create-app-token@v4 + with: + client_id: ${{ secrets.CLDMV_BOT_APP_CLIENT_ID }} + private_key: ${{ secrets.CLDMV_BOT_APP_PRIVATE_KEY }} + env: + BOT_APP_CLIENT_ID: ${{ secrets.CLDMV_BOT_APP_CLIENT_ID }} + BOT_APP_PRIVATE_KEY: ${{ secrets.CLDMV_BOT_APP_PRIVATE_KEY }} + + - name: Checkout hotfixes + uses: CLDMV/.github/.github/actions/common/steps/checkout-code@v4 + with: + ref: hotfixes + fetch-depth: 0 + + - name: Detect master..hotfixes changes + id: detect + shell: bash + run: | + git fetch origin master --quiet + count=$(git rev-list --count origin/master..HEAD) + echo "πŸ“Š commits on hotfixes not yet on master: $count" + if [ "$count" -gt 0 ]; then + echo "has-changes=true" >> "$GITHUB_OUTPUT" + else + echo "has-changes=false" >> "$GITHUB_OUTPUT" + echo "ℹ️ hotfixes is in sync with master β€” nothing to release." + fi + + - name: Resolve persistent hotfixesβ†’master PR + id: resolve + if: steps.detect.outputs.has-changes == 'true' + shell: bash + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + pr=$(gh pr list --repo "$GITHUB_REPOSITORY" --head hotfixes --base master \ + --state open --json number --jq '.[0].number // ""') + echo "pr-number=$pr" >> "$GITHUB_OUTPUT" + if [ -n "$pr" ]; then + echo "πŸ” existing hotfix release PR: #$pr β€” will refresh" + else + echo "πŸ†• no hotfix release PR yet β€” will create" + fi + + create: + name: "πŸ†• Create hotfix release PR" + needs: plan + if: needs.plan.outputs.has-changes == 'true' && needs.plan.outputs.pr-number == '' + runs-on: ubuntu-latest + steps: + - name: Create App token + id: app-token + uses: CLDMV/.github/.github/actions/github/steps/create-app-token@v4 + with: + client_id: ${{ secrets.CLDMV_BOT_APP_CLIENT_ID }} + private_key: ${{ secrets.CLDMV_BOT_APP_PRIVATE_KEY }} + env: + BOT_APP_CLIENT_ID: ${{ secrets.CLDMV_BOT_APP_CLIENT_ID }} + BOT_APP_PRIVATE_KEY: ${{ secrets.CLDMV_BOT_APP_PRIVATE_KEY }} + + # Customize `package-name` + `build-command` to match this repo β€” + # see notes in next-release.yml. + - name: Create release PR + uses: CLDMV/.github/.github/actions/github/jobs/create-release-pr@v4 + with: + package-name: "@cldmv/git-embedded" + build-command: "echo 'βœ“ no build step'" + github-token: ${{ steps.app-token.outputs.token }} + + refresh: + name: "πŸ” Refresh hotfix release PR #${{ needs.plan.outputs.pr-number }}" + needs: plan + if: needs.plan.outputs.has-changes == 'true' && needs.plan.outputs.pr-number != '' + runs-on: ubuntu-latest + steps: + - name: Create App token + id: app-token + uses: CLDMV/.github/.github/actions/github/steps/create-app-token@v4 + with: + client_id: ${{ secrets.CLDMV_BOT_APP_CLIENT_ID }} + private_key: ${{ secrets.CLDMV_BOT_APP_PRIVATE_KEY }} + env: + BOT_APP_CLIENT_ID: ${{ secrets.CLDMV_BOT_APP_CLIENT_ID }} + BOT_APP_PRIVATE_KEY: ${{ secrets.CLDMV_BOT_APP_PRIVATE_KEY }} + + - name: Refresh release PR + id: refresh + uses: CLDMV/.github/.github/actions/github/jobs/update-release-pr@v4 + with: + head-ref: hotfixes + pr-number: ${{ needs.plan.outputs.pr-number }} + package-name: "@cldmv/git-embedded" + build-command: "echo 'βœ“ no build step'" + github-token: ${{ steps.app-token.outputs.token }} + + # Optional: release-PR notifier. See next-release.yml for the + # rationale. Delete the step to opt out entirely; leave a webhook + # secret unset to opt out of that one channel. + - name: Notify on release-PR version bump + if: steps.refresh.outputs.version-changed == 'true' + uses: CLDMV/.github/.github/actions/community/jobs/release-notifier@v4 + with: + event_kind: release_pr + pr_number: ${{ needs.plan.outputs.pr-number }} + version: ${{ steps.refresh.outputs.new-version }} + github_token: ${{ steps.app-token.outputs.token }} + env: + DISCORD_RELEASE_PR_PUBLIC_WEBHOOK: ${{ secrets.DISCORD_RELEASE_PR_PUBLIC_WEBHOOK }} + DISCORD_RELEASE_PR_PRIVATE_WEBHOOK: ${{ secrets.DISCORD_RELEASE_PR_PRIVATE_WEBHOOK }} + SLACK_RELEASE_PR_PUBLIC_WEBHOOK: ${{ secrets.SLACK_RELEASE_PR_PUBLIC_WEBHOOK }} + SLACK_RELEASE_PR_PRIVATE_WEBHOOK: ${{ secrets.SLACK_RELEASE_PR_PRIVATE_WEBHOOK }} + GENERIC_RELEASE_PR_PUBLIC_WEBHOOK: ${{ secrets.GENERIC_RELEASE_PR_PUBLIC_WEBHOOK }} + GENERIC_RELEASE_PR_PRIVATE_WEBHOOK: ${{ secrets.GENERIC_RELEASE_PR_PRIVATE_WEBHOOK }} diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml new file mode 100644 index 0000000..3e8b8f0 --- /dev/null +++ b/.github/workflows/labeler.yml @@ -0,0 +1,44 @@ +# +# @Project: @cldmv/.github +# @Filename: /examples/individual-repo-workflows/automation/labeler.yml +# @Date: 2026-05-20 00:00:00 -07:00 (1779606000) +# @Author: Nate Corcoran +# @Email: +# @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. +# + +# Individual repo: .github/workflows/labeler.yml +# +# Path-based PR auto-labeler. Uses CLDMV's org-default labeler.default.yml; +# override per-repo by adding .github/labeler.yml in this repo (same shape). +# +# Labels applied additively β€” never removes labels added by humans or other +# automation. +# +# Batch 5.2 from tmp/plan-future-workflows.md. +name: 🏷️ PR Labeler + +# SECURITY NOTE: This workflow uses pull_request_target so it can apply labels +# to fork PRs. pull_request_target runs in the BASE repo's context with WRITE +# permissions and access to secrets. This is SAFE for THIS workflow because: +# - We never checkout the PR head ref +# - We never run code from the PR (no `run:` step uses PR data) +# - We only call REST APIs to read the file list and post labels +# DO NOT add a checkout step or any step that executes PR-supplied content +# (build commands, scripts, test runs, etc.) to this workflow. +on: + pull_request_target: + types: [opened, synchronize, reopened, ready_for_review] + +permissions: + contents: read + pull-requests: write + +jobs: + label: + uses: CLDMV/.github/.github/workflows/reusable-pr-labeler.yml@v4 + # Optional. Without these, labels are attributed to github-actions[bot]. + # With these, they're attributed to your CLDMV bot App. + secrets: + BOT_APP_CLIENT_ID: ${{ secrets.CLDMV_BOT_APP_CLIENT_ID }} + BOT_APP_PRIVATE_KEY: ${{ secrets.CLDMV_BOT_APP_PRIVATE_KEY }} diff --git a/.github/workflows/master-commit-audit.yml b/.github/workflows/master-commit-audit.yml new file mode 100644 index 0000000..4be375f --- /dev/null +++ b/.github/workflows/master-commit-audit.yml @@ -0,0 +1,63 @@ +# +# @Project: @cldmv/.github +# @Filename: /examples/individual-repo-workflows/release-companions/master-commit-audit.yml +# @Date: 2026-05-20 00:00:00 -07:00 (1779606000) +# @Author: Nate Corcoran +# @Email: +# @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. +# + +# Individual repo: .github/workflows/master-commit-audit.yml +# +# Post-merge safety net: when any commit lands on the default branch, verify +# its subject matches the expected release-flow patterns. On miss, auto-file +# a GitHub Issue (deduped by SHA) so the alert is persistent and assignable +# β€” not just a red ❌ that dies in inbox. +# +# Catches: release-workflow title-generation regressions, branch-protection +# bypasses, unexpected bot commits, direct emergency pushes. +# +# Batch 5.1 from tmp/plan-future-workflows.md. +name: 🧾 Master Commit Audit + +on: + push: + branches: [master, main] + +permissions: + contents: read + issues: write + +jobs: + audit: + runs-on: ubuntu-latest + steps: + # Optional. Without these, the audit issue is filed by + # github-actions[bot]. With them, the issue is filed by your bot App. + - name: Create App token (falls back to GITHUB_TOKEN) + id: app-token + uses: CLDMV/.github/.github/actions/github/steps/create-app-token@v4 + with: + client_id: ${{ secrets.CLDMV_BOT_APP_CLIENT_ID }} + private_key: ${{ secrets.CLDMV_BOT_APP_PRIVATE_KEY }} + env: + BOT_APP_CLIENT_ID: ${{ secrets.CLDMV_BOT_APP_CLIENT_ID }} + BOT_APP_PRIVATE_KEY: ${{ secrets.CLDMV_BOT_APP_PRIVATE_KEY }} + + - name: Audit commit subject + uses: CLDMV/.github/.github/actions/git/jobs/audit-commit-subject@v4 + with: + commit_sha: ${{ github.sha }} + # Allow release commits (from the release workflow), maintenance + # commits, and standard merge commits. Customize per repo if + # your conventions differ. + allowed_patterns: | + ^release: v\d+\.\d+\.\d+( \(#\d+\))?$ + ^chore(\([^)]+\))?: .+ + ^Merge pull request #\d+ from .+ + # Canonical label names from CLDMV/.github's data/github-labels.json + # (note the space after each colon). Replace with names that exist + # in your repo's label catalog. + issue_labels: "type: ci,priority: high" + # issue_assignee: "shinrai" # uncomment to auto-assign + github_token: ${{ steps.app-token.outputs.token }} diff --git a/.github/workflows/next-release.yml b/.github/workflows/next-release.yml new file mode 100644 index 0000000..915b5d7 --- /dev/null +++ b/.github/workflows/next-release.yml @@ -0,0 +1,178 @@ +# +# @Project: @cldmv/.github +# @Filename: /examples/individual-repo-workflows/release-flow-v4/next-release.yml +# @Date: 2026-05-22 00:00:00 -07:00 (1779778800) +# @Author: Nate Corcoran +# @Email: +# @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. +# + +# Individual repo: .github/workflows/next-release.yml +# +# v4 core: maintain the ONE persistent `next β†’ master` release PR for this repo. +# +# Fires on every push to `next` (contributor PR squash-merges land here). +# Resolves the existing release PR and refreshes it, or creates it the first +# time `next` diverges from master. The release PR batches all accumulated +# feature commits into a single release β€” that batching is v4's whole point +# (see CLDMV/.github docs/conventions/release-flow-v4.md Β§5.3, Β§6.1). +# +# The version bump rides on `next` as a `chore: bump version` commit pushed +# by the release-PR machinery; it's carried to master through the squash +# (Β§8.1 β€” master accepts changes only via PR squash, and the publish flow +# reads package.json as-is). +name: πŸš€ Next Release (v4) + +on: + push: + branches: [next] + +permissions: + contents: write + pull-requests: write + +# Serialize: each run re-resolves the current PR state, so queueing (not +# cancelling) avoids a create/refresh race when pushes land back-to-back. +concurrency: + group: next-release-${{ github.repository }} + cancel-in-progress: false + +jobs: + plan: + # Loop guard: the refresh/create steps push a `chore: bump version` + # commit to `next` (as the bot), and next-reset.yml force-pushes + # `next` (as the bot). Neither should re-trigger a release-PR refresh. + # Replace `cldmv-bot[bot]` with your bot App's login if different. + if: | + github.actor != 'cldmv-bot[bot]' && + github.actor != 'github-actions[bot]' && + !startsWith(github.event.head_commit.message, 'chore: bump version') + name: "πŸ” Plan (detect changes + resolve PR)" + runs-on: ubuntu-latest + outputs: + has-changes: ${{ steps.detect.outputs.has-changes }} + pr-number: ${{ steps.resolve.outputs.pr-number }} + steps: + - name: Create App token + id: app-token + uses: CLDMV/.github/.github/actions/github/steps/create-app-token@v4 + with: + client_id: ${{ secrets.CLDMV_BOT_APP_CLIENT_ID }} + private_key: ${{ secrets.CLDMV_BOT_APP_PRIVATE_KEY }} + env: + BOT_APP_CLIENT_ID: ${{ secrets.CLDMV_BOT_APP_CLIENT_ID }} + BOT_APP_PRIVATE_KEY: ${{ secrets.CLDMV_BOT_APP_PRIVATE_KEY }} + + - name: Checkout next + uses: CLDMV/.github/.github/actions/common/steps/checkout-code@v4 + with: + ref: next + fetch-depth: 0 + + - name: Detect master..next changes + id: detect + shell: bash + run: | + git fetch origin master --quiet + count=$(git rev-list --count origin/master..HEAD) + echo "πŸ“Š commits on next not yet on master: $count" + if [ "$count" -gt 0 ]; then + echo "has-changes=true" >> "$GITHUB_OUTPUT" + else + echo "has-changes=false" >> "$GITHUB_OUTPUT" + echo "ℹ️ next is in sync with master β€” nothing to release." + fi + + - name: Resolve persistent nextβ†’master PR + id: resolve + if: steps.detect.outputs.has-changes == 'true' + shell: bash + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + # The persistent release PR is the open PR with head=next, + # base=master. There is at most one (concurrency-serialized). + pr=$(gh pr list --repo "$GITHUB_REPOSITORY" --head next --base master \ + --state open --json number --jq '.[0].number // ""') + echo "pr-number=$pr" >> "$GITHUB_OUTPUT" + if [ -n "$pr" ]; then + echo "πŸ” existing release PR: #$pr β€” will refresh" + else + echo "πŸ†• no release PR yet β€” will create" + fi + + create: + name: "πŸ†• Create release PR" + needs: plan + if: needs.plan.outputs.has-changes == 'true' && needs.plan.outputs.pr-number == '' + runs-on: ubuntu-latest + steps: + - name: Create App token + id: app-token + uses: CLDMV/.github/.github/actions/github/steps/create-app-token@v4 + with: + client_id: ${{ secrets.CLDMV_BOT_APP_CLIENT_ID }} + private_key: ${{ secrets.CLDMV_BOT_APP_PRIVATE_KEY }} + env: + BOT_APP_CLIENT_ID: ${{ secrets.CLDMV_BOT_APP_CLIENT_ID }} + BOT_APP_PRIVATE_KEY: ${{ secrets.CLDMV_BOT_APP_PRIVATE_KEY }} + + # Runs on the `next` ref β†’ create-release-pr opens next β†’ master and + # pushes the chore-bump commit to next. Customize: + # - `package-name` β†’ your npm package (or any unique identifier) + # - `build-command` β†’ your build script, or a stub like + # `echo 'βœ“ no build step'` for a meta package + - name: Create release PR + uses: CLDMV/.github/.github/actions/github/jobs/create-release-pr@v4 + with: + package-name: "@cldmv/git-embedded" + build-command: "echo 'βœ“ no build step'" + github-token: ${{ steps.app-token.outputs.token }} + + refresh: + name: "πŸ” Refresh release PR #${{ needs.plan.outputs.pr-number }}" + needs: plan + if: needs.plan.outputs.has-changes == 'true' && needs.plan.outputs.pr-number != '' + runs-on: ubuntu-latest + steps: + - name: Create App token + id: app-token + uses: CLDMV/.github/.github/actions/github/steps/create-app-token@v4 + with: + client_id: ${{ secrets.CLDMV_BOT_APP_CLIENT_ID }} + private_key: ${{ secrets.CLDMV_BOT_APP_PRIVATE_KEY }} + env: + BOT_APP_CLIENT_ID: ${{ secrets.CLDMV_BOT_APP_CLIENT_ID }} + BOT_APP_PRIVATE_KEY: ${{ secrets.CLDMV_BOT_APP_PRIVATE_KEY }} + + - name: Refresh release PR + id: refresh + uses: CLDMV/.github/.github/actions/github/jobs/update-release-pr@v4 + with: + head-ref: next + pr-number: ${{ needs.plan.outputs.pr-number }} + package-name: "@cldmv/git-embedded" + build-command: "echo 'βœ“ no build step'" + github-token: ${{ steps.app-token.outputs.token }} + + # Optional: release-PR notifier. Fires only when the target + # version actually changes (PR open or version-bump shift), not + # on the changelog-only refreshes that run on every push. Each + # secret is independently opt-in: leave a webhook unset and that + # channel is silently skipped. Delete this step to opt out + # entirely. + - name: Notify on release-PR version bump + if: steps.refresh.outputs.version-changed == 'true' + uses: CLDMV/.github/.github/actions/community/jobs/release-notifier@v4 + with: + event_kind: release_pr + pr_number: ${{ needs.plan.outputs.pr-number }} + version: ${{ steps.refresh.outputs.new-version }} + github_token: ${{ steps.app-token.outputs.token }} + env: + DISCORD_RELEASE_PR_PUBLIC_WEBHOOK: ${{ secrets.DISCORD_RELEASE_PR_PUBLIC_WEBHOOK }} + DISCORD_RELEASE_PR_PRIVATE_WEBHOOK: ${{ secrets.DISCORD_RELEASE_PR_PRIVATE_WEBHOOK }} + SLACK_RELEASE_PR_PUBLIC_WEBHOOK: ${{ secrets.SLACK_RELEASE_PR_PUBLIC_WEBHOOK }} + SLACK_RELEASE_PR_PRIVATE_WEBHOOK: ${{ secrets.SLACK_RELEASE_PR_PRIVATE_WEBHOOK }} + GENERIC_RELEASE_PR_PUBLIC_WEBHOOK: ${{ secrets.GENERIC_RELEASE_PR_PUBLIC_WEBHOOK }} + GENERIC_RELEASE_PR_PRIVATE_WEBHOOK: ${{ secrets.GENERIC_RELEASE_PR_PRIVATE_WEBHOOK }} diff --git a/.github/workflows/next-reset.yml b/.github/workflows/next-reset.yml new file mode 100644 index 0000000..d47f046 --- /dev/null +++ b/.github/workflows/next-reset.yml @@ -0,0 +1,199 @@ +# +# @Project: @cldmv/.github +# @Filename: /examples/individual-repo-workflows/release-flow-v4/next-reset.yml +# @Date: 2026-05-22 00:00:00 -07:00 (1779778800) +# @Author: Nate Corcoran +# @Email: +# @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. +# + +# Individual repo: .github/workflows/next-reset.yml +# +# v4 core: after a release lands on master, re-sync the integration branches +# (CLDMV/.github docs/conventions/release-flow-v4.md Β§6.3, Β§7). +# +# - `hotfixes` is ALWAYS force-reset to master HEAD after any release. +# - `next` depends on which lane released: +# * normal release (next β†’ master, or a v3-style feat β†’ master): +# force-reset `next` to master HEAD (Β§7.1). +# * hotfix release (hotfixes β†’ master): MERGE master into `next` +# instead, so next's accumulated feature work is preserved (Β§7.2, +# option B). The merge is a no-op (204) when next has no extra work. +# +# The released lane is detected from the PR head ref behind the squash +# commit's trailing "(#N)". +# +# wait-for-tags gate: a release also fires update-major-version-tags, which +# rolls the major tags. Jobs resolve `uses: ...@vN` at job start, so without +# this gate the sync job can run the PREVIOUS release's action code. The gate +# polls the RELEASED major's tag β€” parsed from the `release: vX.Y.Z` commit +# β€” until it matches the release commit. +# +# Self-healing: no-ops pre-cutover (neither integration branch exists), but +# post-cutover it RECREATES a branch that went missing β€” e.g. branch-retention +# deleting `next` as a merged PR head. force-reset-branch creates the ref +# when it's absent. +name: ♻️ Next/Hotfixes Reset (v4) + +on: + push: + branches: [master, main] + +permissions: + contents: write + +concurrency: + group: next-reset-${{ github.repository }} + cancel-in-progress: false + +jobs: + wait-for-tags: + # Only fire on a release commit (the squash-merge of a release PR). + if: startsWith(github.event.head_commit.message, 'release:') + name: "⏳ Wait for the released major tag to roll forward" + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Poll the released major tag until it matches the release commit + shell: bash + env: + TARGET_SHA: ${{ github.sha }} + REPO: ${{ github.repository }} + COMMIT_MSG: ${{ github.event.head_commit.message }} + run: | + echo "πŸ” Release commit: $TARGET_SHA" + # Parse the released MAJOR from the `release: vX.Y.Z` subject + # and poll THAT tag (e.g. @v4 for v4.x). update-major-version- + # tags rolls @v to the release commit; a hardcoded @v3 + # would never match on a major bump (which creates @v4). + major=$(printf '%s' "$COMMIT_MSG" | grep -oiE 'release:[^0-9]*v?[0-9]+' | grep -oE '[0-9]+$' | head -1) + if [ -z "$major" ]; then + echo "⚠️ Could not parse a major version from the commit subject β€” skipping the gate." + exit 0 + fi + tag="v${major}" + echo "⏳ Gating on @${tag}…" + max_attempts=24 # 24 * 5s = 120s + for attempt in $(seq 1 $max_attempts); do + sha=$(git ls-remote "https://github.com/${REPO}.git" "refs/tags/${tag}^{}" 2>/dev/null | awk '{print $1}') + [ -z "$sha" ] && sha=$(git ls-remote "https://github.com/${REPO}.git" "refs/tags/${tag}" 2>/dev/null | awk '{print $1}') + echo "Attempt $attempt/$max_attempts: @${tag} β†’ ${sha:-}" + if [ "$sha" = "$TARGET_SHA" ]; then + echo "βœ… @${tag} matches the release commit β€” safe to proceed" + exit 0 + fi + [ "$attempt" -lt "$max_attempts" ] && sleep 5 + done + echo "⚠️ Timed out waiting for @${tag} β€” proceeding anyway." + + sync-branches: + name: "♻️ Sync next + hotfixes to master" + needs: wait-for-tags + if: startsWith(github.event.head_commit.message, 'release:') + runs-on: ubuntu-latest + steps: + - name: Create App token + id: app-token + uses: CLDMV/.github/.github/actions/github/steps/create-app-token@v4 + with: + client_id: ${{ secrets.CLDMV_BOT_APP_CLIENT_ID }} + private_key: ${{ secrets.CLDMV_BOT_APP_PRIVATE_KEY }} + # Pushing/merging master's tree (which includes + # .github/workflows/**) requires contents + workflows write. + permission_contents: "true" + permission_workflows: "true" + env: + BOT_APP_CLIENT_ID: ${{ secrets.CLDMV_BOT_APP_CLIENT_ID }} + BOT_APP_PRIVATE_KEY: ${{ secrets.CLDMV_BOT_APP_PRIVATE_KEY }} + + - name: Checkout master + uses: CLDMV/.github/.github/actions/common/steps/checkout-code@v4 + with: + fetch-depth: 0 + + - name: Determine released lane + id: lane + shell: bash + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + COMMIT_MSG: ${{ github.event.head_commit.message }} + run: | + # The squash commit ends with "(#N)" β€” the merged PR number. + prnum=$(printf '%s' "$COMMIT_MSG" | grep -oE '#[0-9]+' | tail -1 | tr -d '#') + head="" + if [ -n "$prnum" ]; then + head=$(gh pr view "$prnum" --repo "$GITHUB_REPOSITORY" \ + --json headRefName --jq '.headRefName' 2>/dev/null || echo "") + fi + echo "released PR #${prnum:-?} head ref: ${head:-}" + if [ "$head" = "hotfixes" ]; then + echo "lane=hotfix" >> "$GITHUB_OUTPUT" + else + echo "lane=other" >> "$GITHUB_OUTPUT" + fi + + - name: Guard β€” is this repo on v4? (do next/hotfixes exist?) + id: guard + shell: bash + run: | + next_exists=false; hotfixes_exists=false + if git ls-remote --exit-code --heads origin next >/dev/null 2>&1; then + next_exists=true + git fetch origin next:refs/remotes/origin/next --quiet || true + fi + if git ls-remote --exit-code --heads origin hotfixes >/dev/null 2>&1; then + hotfixes_exists=true + git fetch origin hotfixes:refs/remotes/origin/hotfixes --quiet || true + fi + # "v4 adopted" = at least one integration branch exists. Keeps + # the reset a no-op on pre-cutover repos (neither exists) while + # letting it RECREATE a branch that went missing post-cutover + # (e.g. one was deleted as a merged PR head). force-reset-branch + # creates if absent. + v4_adopted=false + { [ "$next_exists" = true ] || [ "$hotfixes_exists" = true ]; } && v4_adopted=true + { + echo "next-exists=$next_exists" + echo "hotfixes-exists=$hotfixes_exists" + echo "v4-adopted=$v4_adopted" + } >> "$GITHUB_OUTPUT" + echo "ℹ️ next=$next_exists hotfixes=$hotfixes_exists v4-adopted=$v4_adopted lane=${{ steps.lane.outputs.lane }}" + + # hotfixes always tracks master after a release β€” created if missing. + - name: Ensure hotfixes = master HEAD (reset; create if missing) + if: steps.guard.outputs.v4-adopted == 'true' + uses: CLDMV/.github/.github/actions/git/steps/force-reset-branch@v4 + with: + target-branch: hotfixes + source-ref: master + github-token: ${{ steps.app-token.outputs.token }} + + # Normal release β†’ next is force-reset (its work just shipped), and + # recreated if it was deleted on merge. + - name: Ensure next = master HEAD (normal release; create if missing) + if: steps.guard.outputs.v4-adopted == 'true' && steps.lane.outputs.lane != 'hotfix' + uses: CLDMV/.github/.github/actions/git/steps/force-reset-branch@v4 + with: + target-branch: next + source-ref: master + github-token: ${{ steps.app-token.outputs.token }} + + # Hotfix release + next still exists β†’ merge master into next to + # preserve its accumulated feature work (Β§7.2 option B). + - name: Merge master into next (hotfix release; next exists) + if: steps.guard.outputs.v4-adopted == 'true' && steps.lane.outputs.lane == 'hotfix' && steps.guard.outputs.next-exists == 'true' + uses: CLDMV/.github/.github/actions/github/steps/merge-master-into-branch@v4 + with: + target-branch: next + source-ref: master + github-token: ${{ steps.app-token.outputs.token }} + + # Hotfix release but next is MISSING (deleted) β†’ recreate it at master + # HEAD; there's no accumulated work to preserve. + - name: Recreate next at master HEAD (hotfix release; next missing) + if: steps.guard.outputs.v4-adopted == 'true' && steps.lane.outputs.lane == 'hotfix' && steps.guard.outputs.next-exists == 'false' + uses: CLDMV/.github/.github/actions/git/steps/force-reset-branch@v4 + with: + target-branch: next + source-ref: master + github-token: ${{ steps.app-token.outputs.token }} diff --git a/.github/workflows/pr-title-normalizer.yml b/.github/workflows/pr-title-normalizer.yml new file mode 100644 index 0000000..25c8d85 --- /dev/null +++ b/.github/workflows/pr-title-normalizer.yml @@ -0,0 +1,64 @@ +# +# @Project: @cldmv/.github +# @Filename: /examples/individual-repo-workflows/release-flow-v4/pr-title-normalizer.yml +# @Date: 2026-05-22 00:00:00 -07:00 (1779778800) +# @Author: Nate Corcoran +# @Email: +# @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. +# + +# Individual repo: .github/workflows/pr-title-normalizer.yml +# +# Normalize contributor PR titles to Conventional Commits format, derived +# from the highest-priority commit in the PR. The release flow expects this +# shape, so a v4 repo wants this enabled. (Also backportable to v3 repos β€” +# it wires the normalize-pr-title action, shipped in v3.3.0; the action owns +# all skip logic: bot authors, the long-running release PRs, titles already +# starting with `release:`, and titles that already conform.) +name: 🏷️ PR Title Normalizer + +# SECURITY NOTE: pull_request_target runs in the BASE repo's context with +# WRITE permissions and access to secrets. SAFE for THIS workflow because it +# is API-only β€” the normalize-pr-title action never checks out the PR head +# and never executes PR content. DO NOT add a checkout step. +# +# Triggers on opened + synchronize only (NOT edited): a maintainer hand- +# editing the title must not kick off a re-normalize loop. +on: + pull_request_target: + types: [opened, synchronize] + +permissions: + contents: read + pull-requests: write + +# Collapse a burst of pushes to one normalize run per PR; the newest push +# carries the authoritative commit set, so cancelling an in-flight run is fine. +concurrency: + group: pr-title-normalizer-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + normalize: + name: "✏️ Normalize title" + runs-on: ubuntu-latest + steps: + - name: Create App token (falls back to GITHUB_TOKEN) + id: app-token + uses: CLDMV/.github/.github/actions/github/steps/create-app-token@v4 + with: + client_id: ${{ secrets.CLDMV_BOT_APP_CLIENT_ID }} + private_key: ${{ secrets.CLDMV_BOT_APP_PRIVATE_KEY }} + env: + BOT_APP_CLIENT_ID: ${{ secrets.CLDMV_BOT_APP_CLIENT_ID }} + BOT_APP_PRIVATE_KEY: ${{ secrets.CLDMV_BOT_APP_PRIVATE_KEY }} + + - name: Normalize PR title + uses: CLDMV/.github/.github/actions/github/steps/normalize-pr-title@v4 + with: + pr-number: ${{ github.event.pull_request.number }} + github-token: ${{ steps.app-token.outputs.token }} + base-ref: ${{ github.event.pull_request.base.ref }} + head-ref: ${{ github.event.pull_request.head.ref }} + user-type: ${{ github.event.pull_request.user.type }} + user-login: ${{ github.event.pull_request.user.login }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..a214219 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,125 @@ +# +# @Project: @cldmv/.github +# @Filename: /examples/individual-repo-workflows/core-cicd/publish.yml +# @Date: 2026-05-20 00:00:00 -07:00 (1779606000) +# @Author: Nate Corcoran +# @Email: +# @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. +# + +# Individual repo: .github/workflows/publish.yml +name: πŸ“¦ Release and Publish + +on: + push: + branches: [master, main] + paths-ignore: + - "**.md" + - ".github/ISSUE_TEMPLATE/**" + - ".github/PULL_REQUEST_TEMPLATE/**" + workflow_dispatch: + inputs: + debug: + description: "Enable debug logging for troubleshooting" + type: boolean + required: false + default: false + dry_run: + description: "Dry run mode - validate everything but don't publish or create releases" + type: boolean + required: false + default: false + node_version: + description: "Node.js version to use (default: lts/*)" + type: string + required: false + default: "lts/*" + package_manager: + description: "Package manager (npm or yarn)" + type: string + required: false + default: "npm" + test_environment: + description: "Environment for tests (affects NODE_ENV and NODE_OPTIONS --conditions flag)" + type: string + required: false + default: "development" + version: + description: "Version to publish (auto-detected from package.json if not provided)" + type: string + required: false + default: "" + publish_to_npm: + description: "Publish to NPM registry" + type: boolean + required: false + default: true + publish_to_github_packages: + description: "Publish to GitHub Packages registry" + type: boolean + required: false + default: true + min_node_version: + description: "Minimum Node.js version for matrix testing (enables matrix when set)" + type: string + required: false + default: "20" + max_node_major: + description: "Override max Node.js major version (default: 22)" + type: string + required: false + default: "22" + use_gpg: + description: "Enable GPG signing (if GPG secrets provided)" + type: boolean + required: false + default: false + +# NEVER cancel an in-flight publish β€” half-published versions are nasty to +# clean up. Concurrent publishes for the same ref queue instead so they +# serialize naturally. +concurrency: + group: publish-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + publish-package: + if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' + permissions: + contents: write + packages: write + id-token: write + uses: CLDMV/.github/.github/workflows/workflow-publish.yml@v4 + with: + package_name: "@cldmv/git-embedded" # Required: replace with your NPM package name + debug: ${{ github.event.inputs.debug == 'true' }} + dry_run: ${{ github.event.inputs.dry_run == 'true' }} + node_version: ${{ github.event.inputs.node_version || 'lts/*' }} + package_manager: ${{ github.event.inputs.package_manager || 'npm' }} + version: ${{ github.event.inputs.version || '' }} + publish_to_npm: ${{ github.event.inputs.publish_to_npm != 'false' }} + publish_to_github_packages: ${{ github.event.inputs.publish_to_github_packages != 'false' }} + publish_command: "" + github_packages_publish_command: "" + min_node_version: ${{ github.event.inputs.min_node_version || '20' }} + max_node_major: ${{ github.event.inputs.max_node_major || '22' }} + test_command: "npm test" # Use defaults: NODE_ENV=development, NODE_OPTIONS=--conditions=development + # test_command: "NODE_OPTIONS='--conditions=slothlet-dev' npm test" # Override NODE_OPTIONS only + # test_command: "NODE_ENV=test npm test" # Override NODE_ENV only + # test_command: "NODE_ENV=test NODE_OPTIONS='--conditions=slothlet-dev' npm test" # Override both + test_environment: ${{ github.event.inputs.test_environment || 'development' }} # Alternative to setting in test_command + build_command: "npm run build:ci" + is_prerelease: false + release_source_only: false + create_documentation: true + skip_performance_tests: false + skip_matrix_tests: false + use_gpg: ${{ github.event.inputs.use_gpg == 'true' }} + secrets: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + BOT_APP_CLIENT_ID: ${{ secrets.CLDMV_BOT_APP_CLIENT_ID }} + BOT_APP_PRIVATE_KEY: ${{ secrets.CLDMV_BOT_APP_PRIVATE_KEY }} + TAGGER_NAME: ${{ secrets.CLDMV_BOT_NAME }} + TAGGER_EMAIL: ${{ secrets.CLDMV_BOT_EMAIL }} + GPG_PRIVATE_KEY: ${{ secrets.CLDMV_BOT_GPG_PRIVATE_KEY }} + GPG_PASSPHRASE: ${{ secrets.CLDMV_BOT_GPG_PASSPHRASE }} diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 0000000..bdcabbd --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,46 @@ +# +# @Project: @cldmv/.github +# @Filename: /examples/individual-repo-workflows/automation/stale.yml +# @Date: 2026-05-20 00:00:00 -07:00 (1779606000) +# @Author: Nate Corcoran +# @Email: +# @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. +# + +# Individual repo: .github/workflows/stale.yml +# +# First-run guidance: on a repo with an existing backlog, the first +# scheduled run can mark a LOT of issues stale at once (notification storm). +# Recommended: enable with `dry_run: true` first, dispatch manually to +# preview, then flip to live. The dispatch input below makes this easy. +# +# Batch 2.3 from tmp/plan-future-workflows.md. +name: πŸ‚ Stale Issues & PRs + +on: + schedule: + - cron: "13 5 * * *" # daily 05:13 UTC (off-the-hour to avoid GH cron stampede) + workflow_dispatch: + inputs: + dry_run: + description: "Preview only β€” no changes will be made" + type: boolean + default: false + +permissions: + issues: write + pull-requests: write + +jobs: + sweep: + uses: CLDMV/.github/.github/workflows/reusable-stale.yml@v4 + with: + dry_run: ${{ github.event.inputs.dry_run == 'true' }} + # Override timers if needed: + # days_before_issue_stale: 60 + # days_before_issue_close: 14 + # days_before_pr_stale: 30 + # days_before_pr_close: 7 + secrets: + BOT_APP_CLIENT_ID: ${{ secrets.CLDMV_BOT_APP_CLIENT_ID }} + BOT_APP_PRIVATE_KEY: ${{ secrets.CLDMV_BOT_APP_PRIVATE_KEY }} diff --git a/.github/workflows/tag-health.yml b/.github/workflows/tag-health.yml new file mode 100644 index 0000000..80701a5 --- /dev/null +++ b/.github/workflows/tag-health.yml @@ -0,0 +1,64 @@ +# +# @Project: @cldmv/.github +# @Filename: /examples/individual-repo-workflows/release-companions/tag-health.yml +# @Date: 2026-05-20 00:00:00 -07:00 (1779606000) +# @Author: Nate Corcoran +# @Email: +# @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. +# + +# Individual repo: .github/workflows/tag-health.yml +# +# Wakes the reusable-tag-health.yml workflow on a weekly schedule. The +# reusable already implements validation, bot-signature fixes, unsigned-tag +# fixes, orphaned-release recovery, orphaned-tag relocation, and rolling +# major/minor tag maintenance β€” but it's dormant by default. This template +# is what triggers it. +# +# Batch 3.1 from tmp/plan-future-workflows.md. +name: πŸ₯ Tag Health + +on: + schedule: + # Weekly Sunday 04:04 UTC. Off-the-hour to dodge the GitHub :00-cron + # stampede; weekly cadence because tag drift accumulates slowly. + - cron: "4 4 * * 0" + workflow_dispatch: + inputs: + debug: + description: "Enable debug logging for troubleshooting" + type: boolean + required: false + default: false + create_documentation: + description: "Update VERSION_TAGS.md if rolling tags moved" + type: boolean + required: false + default: false + use_gpg: + description: "Enable GPG signing for any tags the sweep creates/recreates" + type: boolean + required: false + default: true + +permissions: + contents: write + +jobs: + health: + uses: CLDMV/.github/.github/workflows/reusable-tag-health.yml@v4 + with: + debug: ${{ github.event.inputs.debug == 'true' }} + # Full unified sweep: validates, fixes bot signatures, fixes + # unsigned tags, recovers orphaned releases, relocates orphaned + # tags, and updates rolling major/minor refs. + run_unified_tag_health: true + create_documentation: ${{ github.event.inputs.create_documentation == 'true' }} + use_gpg: ${{ github.event.inputs.use_gpg != 'false' }} + secrets: + BOT_APP_CLIENT_ID: ${{ secrets.CLDMV_BOT_APP_CLIENT_ID }} + BOT_APP_PRIVATE_KEY: ${{ secrets.CLDMV_BOT_APP_PRIVATE_KEY }} + TAGGER_NAME: ${{ secrets.CLDMV_BOT_NAME }} + TAGGER_EMAIL: ${{ secrets.CLDMV_BOT_EMAIL }} + GPG_PRIVATE_KEY: ${{ secrets.CLDMV_BOT_GPG_PRIVATE_KEY }} + GPG_PASSPHRASE: ${{ secrets.CLDMV_BOT_GPG_PASSPHRASE }} diff --git a/.github/workflows/update-major-version-tags.yml b/.github/workflows/update-major-version-tags.yml new file mode 100644 index 0000000..cabcb26 --- /dev/null +++ b/.github/workflows/update-major-version-tags.yml @@ -0,0 +1,87 @@ +# +# @Project: @cldmv/.github +# @Filename: /examples/individual-repo-workflows/core-cicd/update-major-version-tags.yml +# @Date: 2026-05-20 00:00:00 -07:00 (1779606000) +# @Author: Nate Corcoran +# @Email: +# @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. +# + +# Individual repo: .github/workflows/update-major-version-tags.yml +name: 🏷️ Update Major Version Tags + +on: + release: + types: [published] + workflow_dispatch: + inputs: + debug: + description: "Enable debug logging for troubleshooting" + type: boolean + required: false + default: false + create_documentation: + description: "Whether to create/update VERSION_TAGS.md documentation" + type: boolean + required: false + default: false + use_gpg: + description: "Enable GPG signing (if GPG secrets provided)" + type: boolean + required: false + default: true + # Tag health configuration + max_tags: + description: "Maximum number of tags to process (safety limit)" + required: false + default: "100" + max_major_versions: + description: "Maximum number of major versions to process" + required: false + default: "10" + max_minor_versions: + description: "Maximum number of minor versions per major to process" + required: false + default: "10" + bot_patterns: + description: "JSON array of bot name patterns to identify bot signatures" + required: false + default: '["CLDMV Bot", "cldmv-bot", "github-actions[bot]"]' + include_patterns: + description: "JSON array of tag patterns to include (e.g. ['v*', 'release-*'])" + required: false + default: '["v*"]' + exclude_patterns: + description: "JSON array of tag patterns to exclude" + required: false + default: "[]" + +jobs: + update-tags: + # Skip release events fired without a tag_name (e.g. "untagged-" runs + # the bot or a prior code path can produce). The reusable workflow has its + # own tag-readiness polling for forward-facing prevention; this guard + # protects against legacy / external sources of untagged release events. + # Batch 1.2 from tmp/plan-future-workflows.md. + if: github.event_name != 'release' || github.event.release.tag_name != '' + uses: CLDMV/.github/.github/workflows/workflow-update-major-version-tags.yml@v4 + permissions: + contents: write + with: + debug: ${{ github.event.inputs.debug == 'true' }} + create_documentation: ${{ github.event.inputs.create_documentation == 'true' }} + use_gpg: ${{ github.event.inputs.use_gpg != 'false' }} + max_tags: ${{ github.event.inputs.max_tags || '100' }} + max_major_versions: ${{ github.event.inputs.max_major_versions || '10' }} + max_minor_versions: ${{ github.event.inputs.max_minor_versions || '10' }} + bot_patterns: ${{ github.event.inputs.bot_patterns || '["CLDMV Bot", "cldmv-bot", "github-actions[bot]"]' }} + include_patterns: ${{ github.event.inputs.include_patterns || '["v*"]' }} + exclude_patterns: ${{ github.event.inputs.exclude_patterns || '[]' }} + secrets: + # Map your repo/org secrets to the expected names + TAGGER_NAME: ${{ secrets.CLDMV_BOT_NAME }} + TAGGER_EMAIL: ${{ secrets.CLDMV_BOT_EMAIL }} + GPG_PRIVATE_KEY: ${{ secrets.CLDMV_BOT_GPG_PRIVATE_KEY }} + GPG_PASSPHRASE: ${{ secrets.CLDMV_BOT_GPG_PASSPHRASE }} + BOT_APP_CLIENT_ID: ${{ secrets.CLDMV_BOT_APP_CLIENT_ID }} + BOT_APP_PRIVATE_KEY: ${{ secrets.CLDMV_BOT_APP_PRIVATE_KEY }} diff --git a/.github/workflows/v4-bootstrap.yml b/.github/workflows/v4-bootstrap.yml new file mode 100644 index 0000000..d8c86c2 --- /dev/null +++ b/.github/workflows/v4-bootstrap.yml @@ -0,0 +1,106 @@ +# +# @Project: @cldmv/.github +# @Filename: /examples/individual-repo-workflows/release-flow-v4/v4-bootstrap.yml +# @Date: 2026-05-26 00:00:00 -07:00 (1780124400) +# @Author: Nate Corcoran +# @Email: +# @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. +# + +# Individual repo: .github/workflows/v4-bootstrap.yml +# +# Per-repo v4 bootstrap β€” thin wrapper around the shared +# `org-bootstrap-repo@v4` action. Run once per repo from the Actions tab +# (or, for org-wide rollout, prefer `local-org-onboarding.yml` in +# CLDMV/.github which fans out across many repos in parallel). +# +# What gets applied (overwrite-with-warn β€” divergences are surfaced in the +# run summary): +# - `next` + `hotfixes` branches created from master HEAD if missing +# - repo settings: allow_auto_merge=true, delete_branch_on_merge=false, +# allow_squash_merge=true, allow_merge_commit=true, +# allow_rebase_merge=false, allow_update_branch=true; plus PR-merge +# dialog defaults (merge_commit_title / squash_merge_commit_title = +# PR_TITLE, merge_commit_message / squash_merge_commit_message = +# PR_BODY) so the resulting commit captures the PR title + body +# verbatim (release-PR body = the categorized changelog β†’ lands on +# master). Per-branch ruleset allowed_merge_methods picks the method. +# - security toggles: dependabot alerts + security updates, secret +# scanning + push protection, private vulnerability reporting +# - rulesets: replaces the three rulesets (Protect Master/Next/Hotfixes) +# with the org canonical defaults +# +# What is NOT applied (GitHub doesn't expose it via REST / GraphQL / gh CLI +# β€” confirmed against community/community#188598; the bootstrap surfaces +# this as a 'Manual one-time toggles' line in the run summary): +# - Settings β†’ General β†’ Pull Requests β†’ "Auto-close issues with merged +# linked pull requests" (recommended ON). Toggle in the repo UI once. +# +# Idempotent β€” re-running is safe. Default `dry_run: true` previews +# everything before applying. +# +# Full design: CLDMV/.github docs/conventions/release-flow-v4.md +# Migration checklist: CLDMV/.github docs/migration/v3-to-v4.md +name: πŸš€ v4 Bootstrap + +on: + workflow_dispatch: + inputs: + dry_run: + description: "Dry-run: preview every mutation without firing it. Default `true` β€” set to `false` to actually apply changes." + type: boolean + required: false + default: true + code_security: + description: "Code Security policy. off = disable. public-only = enable only if this repo is public (free). all = enable (paid on private)." + type: choice + required: false + default: "off" + options: + - "off" + - "public-only" + - "all" + secret_protection: + description: "Secret Protection (scanning + push protection) policy. Same shape as code_security." + type: choice + required: false + default: "off" + options: + - "off" + - "public-only" + - "all" + steps: + description: "Subset of phases to run, comma-separated." + required: false + default: "branches,settings,security,rulesets" + +permissions: + contents: read + +jobs: + bootstrap: + name: "πŸš€ Bootstrap v4 (this repo)" + runs-on: ubuntu-latest + steps: + - name: Create App token + id: app-token + # Full-permission App token β€” bootstrap needs administration:write + # for security toggles + ruleset import, plus contents:write for + # branch creation. + uses: CLDMV/.github/.github/actions/github/steps/create-app-token@v4 + with: + client_id: ${{ secrets.CLDMV_BOT_APP_CLIENT_ID }} + private_key: ${{ secrets.CLDMV_BOT_APP_PRIVATE_KEY }} + env: + BOT_APP_CLIENT_ID: ${{ secrets.CLDMV_BOT_APP_CLIENT_ID }} + BOT_APP_PRIVATE_KEY: ${{ secrets.CLDMV_BOT_APP_PRIVATE_KEY }} + + - name: Bootstrap + uses: CLDMV/.github/.github/actions/github/jobs/org-bootstrap-repo@v4 + with: + # target_repo defaults to GITHUB_REPOSITORY (this repo). + github_token: ${{ steps.app-token.outputs.token }} + dry_run: ${{ github.event.inputs.dry_run }} + steps: ${{ github.event.inputs.steps }} + code_security: ${{ github.event.inputs.code_security }} + secret_protection: ${{ github.event.inputs.secret_protection }} diff --git a/.github/workflows/welcome.yml b/.github/workflows/welcome.yml new file mode 100644 index 0000000..b414467 --- /dev/null +++ b/.github/workflows/welcome.yml @@ -0,0 +1,38 @@ +# +# @Project: @cldmv/.github +# @Filename: /examples/individual-repo-workflows/automation/welcome.yml +# @Date: 2026-05-20 00:00:00 -07:00 (1779606000) +# @Author: Nate Corcoran +# @Email: +# @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. +# + +# Individual repo: .github/workflows/welcome.yml +# +# Batch 5.3 from tmp/plan-future-workflows.md. +name: πŸ‘‹ Welcome Contributor + +# SECURITY NOTE: pull_request_target runs in the BASE repo's context with +# WRITE permissions and access to secrets. SAFE for THIS workflow because: +# - We never checkout the PR head ref +# - We never run code from the PR (no `run:` step uses PR data) +# - We only call REST APIs to read prior interactions and post a comment +# DO NOT add a checkout step or any step that executes PR-supplied content. +on: + issues: + types: [opened] + pull_request_target: + types: [opened] + +permissions: + issues: write + pull-requests: write + +jobs: + welcome: + uses: CLDMV/.github/.github/workflows/reusable-welcome.yml@v4 + # Optional. Without these, the welcome comment is posted by + # github-actions[bot]. With these, it's posted by your CLDMV bot App. + secrets: + BOT_APP_CLIENT_ID: ${{ secrets.CLDMV_BOT_APP_CLIENT_ID }} + BOT_APP_PRIVATE_KEY: ${{ secrets.CLDMV_BOT_APP_PRIVATE_KEY }} diff --git a/.gitignore b/.gitignore index d14329c..a79a04f 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ node_modules/ dist/ coverage/ reference/ +tmp/ .idea/ .vscode/ *.tsbuildinfo diff --git a/README.md b/README.md index 0b5d991..3cc7cdf 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,17 @@ git embedded install-hooks # install hooks into this repo's .git/hooks git embedded uninstall-hooks # remove hooks installed by this CLI ``` +### Guard behavior (config knobs) + +The installed hooks read two settings (`git config`, local overrides global; one-shot override with `git -c = `): + +| Key | Values | Default | What it controls | +| ---------------------- | ----------------------------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `embedded.guard` | `precise` Β· `strict` Β· `off` | `precise` | When HEAD moves are blocked. `precise` blocks only a move that would re-pin a child with uncommitted changes. `strict` is the everything-synced policy: any dirty child blocks any move, and a parent commit is refused while any child's pin is stale (child HEAD not recorded) β€” for workspaces where the parent must always snapshot a fully-committed, fully-recorded state. | +| `embedded.pushRecurse` | `check` Β· `on-demand` Β· `off` | `check` | Whether a parent push verifies that newly-pinned child commits are reachable from each child's origin. `check` rejects with a "push the child first" message; `on-demand` tries pushing the child's current branch first. Prevents publishing a parent whose pins dangle for every other machine. | + +Two-part keys like these can never collide with the per-child registry entries (`embedded..url` / `.branch`), which are always three-part. + `install-hooks` adapts to whatever's already in place: - **Nothing configured** β€” offers to install a small dispatcher script at `~/.config/git/hooks/_dispatch`, link every standard hook name to it, and set `git config --global core.hooksPath` to that directory. Then drops this package's hook scripts into the repo's `.git/hooks/`. The dispatcher chains to per-repo hooks, so every other repo on the machine keeps working as before. @@ -86,6 +97,88 @@ git config advice.addEmbeddedRepo false The committed parent tree now contains a gitlink at `embedded-child` pinning the child's current HEAD. No `.gitmodules` is created; the child's URL never lands in the public repo. +`link` clones into a missing **or empty** target directory (a fresh clone of a parent materializes each gitlink as an empty dir, so `link` works to fill one in); it refuses anything else β€” a non-empty directory, a file, a symlink (even to an empty dir), or an unreadable path. After staging, it also records the child's URL and branch into this clone's local registry (see below). + +## Restoring embedded children (machine-B bootstrap) + +The parent commits only anonymous gitlinks β€” a path and a pinned SHA, never a URL. So a fresh clone of the parent materializes each embedded child as an _empty directory_: git knows the pin but has nowhere to fetch it from. `git embedded restore` fills those directories in. + +```bash +git clone myproject +cd myproject +git embedded restore # clone every embedded child and check out its pinned SHA +``` + +`restore` resolves each child's clone URL from up to four **optional** sources, strictest first, stopping at the first that yields a URL: + +1. **Local config registry** β€” `embedded..url` (and `embedded..branch`, see below) in _this clone's_ `.git/config`. Per-clone, never committed. Written automatically after a successful restore, and by `record` / `link`. +2. **Manifest file** (`--from `) β€” a JSON transfer file carried out-of-band (never committed). See `export` below. +3. **`--base `** β€” derives `/.git` for each child. +4. **Convention** (zero state) β€” the child is a sibling of wherever the parent was cloned from: the parent's origin with its last path segment replaced by `.git`. A URL- or path-style origin splits on the final `/` (`https://host/org/parent.git` β†’ `https://host/org/tests.git`); a scp-style origin whose repo sits at the path root has no `/`, so the sibling is taken after the last `:` instead (`git@host:parent.git` β†’ `git@host:tests.git`). No configuration, but it only resolves when the child's repository is actually named after the gitlink path and sits beside the parent. A convention guess can only ever name strings already derivable from the committed tree, so it discloses nothing new. + +Every clone is **SHA-verified**: the parent's pinned commit must exist in the freshly cloned child (a `git fetch` is attempted first). If it doesn't β€” e.g. a convention guess resolved to the wrong repository β€” the clone `restore` created is removed and the child is reported `pinned-mismatch`. A wrong guess fails closed; it never plants the wrong code. + +Per-child outcomes are `restored`, `already-present`, `unresolved`, `pinned-mismatch`, or `skipped`, and `restore` exits non-zero if any child ends `unresolved` or `pinned-mismatch`. Use `--dry-run` to report resolution without cloning. + +### Branch-aware checkout + +A restored child does not have to end up detached. `restore` resolves a **branch** for each child with the same layering as the URL β€” `embedded..branch` in the local registry, then the manifest β€” and when neither supplies one, it infers the branch from the pin: if exactly **one** `origin` branch contains the pinned commit, that branch is used. With a branch, the child ends ON it at the pin (`checkout -B`), with upstream tracking set to `origin/` when it exists, and the branch is auto-registered like the URL. An ambiguous pin (on several branches) or an unmatchable one keeps today's detached checkout β€” inference never guesses. + +**Partial restore is the normal case.** A public contributor without access to a private child simply skips it: + +```bash +git embedded restore --skip tests # comma-separate several: --skip tests,vendor/foo +``` + +### Obscured children + +A child whose repository name does not match its gitlink path β€” the intended state for a hidden private child β€” is deliberately _not_ convention-resolvable. Provide its URL once (via `link` into the empty gitlink dir, or `record` if it is already cloned) and this clone's registry remembers it for every later restore: + +```bash +git embedded link tests git@example.com:org/private-tests.git +# ...or, if the child is already present on disk: +git embedded record +``` + +### Sharing URLs between machines: `export` / `record` + +`record` writes the origin URL (and current branch) of every present child into the local registry. `export` serializes that registry to a manifest another machine can consume: + +```bash +git embedded export --scan -o children.json # record present children, then write the manifest +``` + +On the other machine: + +```bash +git clone myproject && cd myproject +git embedded restore --from children.json +``` + +> **Never commit the manifest.** It contains the very URLs the anonymous-gitlink design keeps out of the tree. When `export -o` writes inside the worktree it appends the filename to `.git/info/exclude` as a courtesy, but keeping the manifest out-of-band is your responsibility. + +## Day-2: syncing pins + +When the parent pulls commits that move gitlink pins, the children on disk are still at the old SHAs. `git embedded sync` moves them β€” and only them; sync never touches the parent, so pulling the parent first is your step: + +```bash +git pull +git embedded sync +``` + +Per child, sync is deliberately conservative β€” a clean child follows the pin, anything that looks like your work is reported and left alone: + +- **already at the pin** β€” nothing to do (`in-sync`). +- **uncommitted changes** β€” left alone (`dirty`). +- **on the registered branch** (`embedded..branch`), clean β€” the branch is moved to the pin **fast-forward only**: the child's HEAD must be an ancestor of the pin. Commits beyond the pin are your work (`ahead`, left alone). +- **on any other branch** β€” left alone (`unregistered-branch`). +- **detached**, clean β€” snapped to the pin, staying detached (`synced`). +- **pin not present locally** β€” one `git fetch origin` inside the child; if the pin still cannot be found the child is reported `pin-unavailable` and sync exits non-zero. + +Only `pin-unavailable` (and an unexpected checkout failure) fail the run β€” the left-alone outcomes protect in-progress work and exit zero. `sync` takes the same `[paths…]`, `--skip`, and `--dry-run` surface as `restore`. + +If the hooks from this package are installed, most parent operations already update the children automatically (detached, like standard submodules). `sync` covers the rest: hook-less clones, the `git reset --hard` gap, and keeping a child _on its branch_ as pins advance. + ## Manual install (no CLI) If you'd rather wire things up by hand: diff --git a/docs/design.md b/docs/design.md index 4d97759..42739be 100644 --- a/docs/design.md +++ b/docs/design.md @@ -1,6 +1,6 @@ # Design: hooks for embedded git repositories -This document describes the hook system that `@cldmv/git-embedded` installs and the design choices behind it. It is intended for anyone evaluating the approach, debugging an installed hook, or working on the planned CLI. +This document describes the hook system that `@cldmv/git-embedded` installs and the design choices behind it. It is intended for anyone evaluating the approach, debugging an installed hook, or working on the CLI. ## Background: gitlinks, submodules, and the registration gap @@ -19,7 +19,7 @@ The hooks in this package close the registration gap without requiring a registr ## Why this matters: the URL is the leak -For most submodule use cases, the URL in `.gitmodules` is uncontroversial β€” the parent is open and the child is open, the URL is just a convenience for `clone --recurse-submodules`. For a parent that wants to hide the *existence* of a private child repo, the `.gitmodules` URL is the leak. Anyone who can read the public parent can read `.gitmodules`, see the URL of the private child, and at minimum learn that a private resource exists at that location. +For most submodule use cases, the URL in `.gitmodules` is uncontroversial β€” the parent is open and the child is open, the URL is just a convenience for `clone --recurse-submodules`. For a parent that wants to hide the _existence_ of a private child repo, the `.gitmodules` URL is the leak. Anyone who can read the public parent can read `.gitmodules`, see the URL of the private child, and at minimum learn that a private resource exists at that location. Avoiding `.gitmodules` is the obvious fix, but doing so loses the working-tree automation. This package restores the automation while keeping the parent free of URL data. @@ -69,50 +69,121 @@ For each gitlink path, the script: 4. If the pinned SHA is not in the child's local object store, runs `git fetch` inside the child (using the child's own remote config β€” `.gitmodules` is not consulted). 5. Runs `git checkout --detach ` inside the child. -The detached-HEAD checkout matches standard submodule behavior: parents pin specific commits, not branches, so the child ends up in detached-HEAD state after each parent operation. If the child needs to be on a branch for editing, the developer attaches to one (`git -C embedded-child switch -c work` or `git -C embedded-child checkout main`) after the operation completes. +The detached-HEAD checkout matches standard submodule behavior: parents pin specific commits, not branches, so the child ends up in detached-HEAD state after each parent operation. If the child needs to be on a branch for editing, the developer attaches to one (`git -C embedded-child switch -c work` or `git -C embedded-child checkout main`) after the operation completes. The provisioning CLI is branch-aware where the hooks are not: `restore` can put a child ON a branch at the pin and `sync` fast-forwards a registered branch (see [Branch-aware checkout](#branch-aware-checkout) and [Day-2 pin sync](#day-2-pin-sync)). **What it catches.** Together, the three hook names cover essentially every checkout-flavored parent operation. See the coverage matrix below. **What it does not catch.** Two notable gaps: -- `git reset --hard ` updates the index and working tree but does **not** fire `post-checkout`, `post-merge`, or `post-rewrite`. The `reference-transaction` guard catches this case at the prepared phase (because `reset` does move HEAD via a ref transaction), so a `--hard` reset with a dirty child is refused β€” but a `--hard` reset with a clean child completes without the children being auto-updated. The mitigation is to either accept the gap, manually re-run the script, or use a `git-foo` wrapper command. +- `git reset --hard ` updates the index and working tree but does **not** fire `post-checkout`, `post-merge`, or `post-rewrite`. The `reference-transaction` guard catches this case at the prepared phase (because `reset` does move HEAD via a ref transaction), so a `--hard` reset with a dirty child is refused β€” but a `--hard` reset with a clean child completes without the children being auto-updated. The mitigation is `git embedded sync` (see [Day-2 pin sync](#day-2-pin-sync)), which snaps clean children to the pins on demand. - `git stash pop` modifies the working tree without moving HEAD. It does not affect embedded children (stash entries are recorded in the parent's stash ref, not in the children), but anyone expecting "all working-tree-modifying commands are guarded" will not see consistency here. +### `pre-push` (pin publication check) + +**Purpose.** Refuse to push parent commits whose gitlink pins reference child commits that are not reachable from the child's own origin. Without this, a parent that pins a committed-but-unpushed child publishes a dangling pointer: every other machine's `git embedded restore` fails on that child with `pinned-mismatch`, because the child's origin has never seen the commit. The dirty-state guard cannot catch this β€” a committed-but-unpushed child is clean. + +This is git-embedded's analog of `git push --recurse-submodules=check`; stock git cannot provide it here because that machinery locates children via `.gitmodules` registration, which anonymous gitlinks deliberately omit β€” the same registration gap the other hooks close for checkout. + +**Mechanism.** For each pushed ref, the hook collects the gitlink pins the remote is about to learn: the pins _changed_ by each commit new to the remote (`git diff-tree`, cheap), plus β€” only when the remote ref is being _created_ β€” every gitlink in the tip's tree. Each unique `(path, pin)` is verified inside the child working copy: reachable from some `refs/remotes/origin/*` tip, with one `git fetch origin` refresh on a miss so stale tracking refs don't produce false rejections. A pin change for a child that is not present in the working tree is rejected (it cannot be verified). Because only _newly-introduced_ pins are checked on existing-ref updates, a clone that never restored its children can still push commits that touch no pin. + +**Modes** (`git config embedded.pushRecurse`, local over global): + +- `check` _(default)_ β€” reject the push with a "push the child first" message. +- `on-demand` β€” first try to publish the pin by pushing the child's CURRENT branch (only when that branch contains the pin and the child is not detached), then fall back to `check`'s rejection. Opt-in because implicitly pushing a child branch as a side effect of a parent push is surprising. +- `off` β€” no verification. + +One-shot override: `git -c embedded.pushRecurse= push …`. + ## Coverage matrix -| Operation | `reference-transaction` (guard) | `update-embedded-repos` (update) | -|---|---|---| -| `git checkout ` | Refuses if any child is dirty | Updates children to new pins | -| `git switch ` | Refuses if any child is dirty | Updates children to new pins | -| `git reset --hard ` | Refuses if any child is dirty | **Gap** β€” does not fire `post-*` hooks | -| `git reset --soft/--mixed ` | Refuses if any child is dirty (HEAD moves) | Does not fire `post-*` hooks (HEAD-only change) | -| `git pull` (fast-forward) | Refuses if any child is dirty | Updates children | -| `git pull --rebase` | Refuses at each rebase step | Updates children after rebase completes | -| `git merge ` | Refuses if any child is dirty | Updates children via `post-merge` | -| `git rebase` | Refuses at each step | Updates children via `post-rewrite` | -| `git bisect ` | Refuses if any child is dirty | Updates children at each bisect step | -| `git cherry-pick` | Refuses if any child is dirty | Updates children via `post-checkout` | -| `git stash pop` | Not guarded (no HEAD move) | Not updated (no HEAD move; not needed) | -| `git commit` | Not guarded (no HEAD move) | Not updated (no HEAD move; not needed) | -| `git checkout -- file` | Not guarded (no HEAD move) | Not updated (no HEAD move; not needed) | +| Operation | `reference-transaction` (guard) | `update-embedded-repos` (update) | +| ----------------------------------- | -------------------------------------------------------------------------------------- | ---------------------------------------------------- | +| `git checkout ` | Refuses if any child is dirty | Updates children to new pins | +| `git switch ` | Refuses if any child is dirty | Updates children to new pins | +| `git reset --hard ` | Refuses if any child is dirty | **Gap** β€” run `git embedded sync` after | +| `git reset --soft/--mixed ` | Refuses if any child is dirty (HEAD moves) | Does not fire `post-*` hooks (HEAD-only change) | +| `git pull` (fast-forward) | Refuses if any child is dirty | Updates children | +| `git pull --rebase` | Refuses at each rebase step | Updates children after rebase completes | +| `git merge ` | Refuses if any child is dirty | Updates children via `post-merge` | +| `git rebase` | Refuses at each step | Updates children via `post-rewrite` | +| `git bisect ` | Refuses if any child is dirty | Updates children at each bisect step | +| `git cherry-pick` | Refuses if any child is dirty | Updates children via `post-checkout` | +| `git stash pop` | Not guarded (no HEAD move) | Not updated (no HEAD move; not needed) | +| `git commit` | Refuses (precise: a dirty child it would re-pin; strict: any dirty child or stale pin) | Not updated (records current pins; no `post-*` hook) | +| `git checkout -- file` | Not guarded (no HEAD move) | Not updated (no HEAD move; not needed) | ## Comparison to standard submodules -| Property | Standard submodule | Anonymous gitlink + these hooks | -|---|---|---| -| Child URL in parent | Yes, in `.gitmodules` | No | -| Tree-level pin | Gitlink | Gitlink | -| Public viewer sees | URL, path, current SHA | Just the SHA (no link to follow) | -| `git submodule update` | Works | Not used (registry-bound; hooks replace it) | -| `submodule.recurse=true` | Works | Not used (registry-bound; hooks replace it) | -| `git status` divergence | Yes | Yes | -| `git add path` infers SHA | Yes | Yes | -| `--recurse-submodules` clone | Pulls child | No-op (no registry) | -| Initial child clone | Automatic via registry | Manual or via the planned CLI | -| Dirty-child guard | Default refuses on update | Hook refuses on the HEAD move itself | +| Property | Standard submodule | Anonymous gitlink + these hooks | +| ---------------------------- | ------------------------- | ---------------------------------------------------------------------------------------------------- | +| Child URL in parent | Yes, in `.gitmodules` | No | +| Tree-level pin | Gitlink | Gitlink | +| Public viewer sees | URL, path, current SHA | Just the SHA (no link to follow) | +| `git submodule update` | Works | Not used (registry-bound; hooks replace it) | +| `submodule.recurse=true` | Works | Not used (registry-bound; hooks replace it) | +| `git status` divergence | Yes | Yes | +| `git add path` infers SHA | Yes | Yes | +| `--recurse-submodules` clone | Pulls child | No-op (no registry) | +| Initial child clone | Automatic via registry | `git embedded restore` (SHA-verified; see [Provisioning](#provisioning-restoring-embedded-children)) | +| Dirty-child guard | Default refuses on update | Hook refuses on the HEAD move itself | The most useful difference is the **guard timing**. Standard submodules let the parent operation proceed and then refuse the child update, leaving the developer in a parent-moved-child-stale state that has to be backed out. The `reference-transaction` guard refuses the whole transaction at the parent level, so the working tree never reaches the inconsistent state. +## Provisioning: restoring embedded children + +The hooks above keep an _already-cloned_ child in sync with the parent's pin. They do not perform the _initial_ clone, because the parent tree deliberately records no URL to clone from. Standard submodules get the initial clone from the `.gitmodules` registry; anonymous gitlinks need another way to answer "where does this child come from?" without committing the answer. + +`git embedded restore` is that mechanism. It enumerates the gitlinks in HEAD (the same `git ls-tree -r HEAD`, mode-`160000` walk the hooks use) and, for every child that is missing, empty, or lacks a `.git`, resolves a clone URL, clones, verifies, and checks out the pin. The design's core property holds throughout: child URLs are never committed. + +### URL knowledge lives in four optional sources + +URL knowledge is never in the committed tree. It can only come from one of four optional sources, tried strictest-first at resolve time: + +1. **Local config registry** β€” `embedded..url` / `embedded..branch` in the parent clone's `.git/config`. Per-clone, never committed, never leaves the machine that wrote it. This is the durable record: a successful restore writes it, as do `record` and `link`. The `.branch` key records the branch this clone keeps the child on β€” `restore` attaches the child to it and `sync` fast-forwards it; unset means the child lives detached. +2. **Manifest** β€” a JSON transfer file (`{ "version": 1, "children": { "": { "url": …, "branch": … } } }`) passed via `--from`. It is a transfer format only: it lives outside any repo, in the operator's hands, and is never committed. `export` produces it from the registry; `restore --from` consumes it. +3. **Explicit base** β€” `--base ` derives `/.git`; a per-invocation override for children living under a known base that differs from the parent's origin. Supplied on the command line, recorded nowhere. +4. **Convention** β€” with zero supplied state, the child is assumed to be a sibling of wherever the parent was cloned from: `dirname(parent remote.origin.url) + "/" + basename() + ".git"`. + +### Why convention discloses nothing + +The convention layer looks like it might leak, but it cannot reveal anything not already implied by the committed tree. The gitlink path (e.g. `tests`) and the parent's own origin are both already visible to anyone who has the parent. Convention only _combines_ them into a guess β€” it invents no new information β€” and because the guess is a guess, it is not trusted. It is SHA-verified. + +### SHA verification makes wrong guesses fail closed + +After every clone, the parent's pinned SHA must exist in the cloned child (`git cat-file -e ^{commit}`, retried once after a `git fetch origin`). If it is absent, the clone `restore` created is removed β€” never a pre-existing directory β€” and the child is reported `pinned-mismatch` with a non-zero exit. A convention guess that resolves to the wrong repository (or an out-of-date one) therefore fails closed rather than silently planting unrelated code at the pinned path. Only a repository that actually contains the pinned commit is accepted. + +An _obscured_ child β€” one whose repository name does not match its gitlink path β€” is by construction not convention-resolvable, which is exactly the property that keeps a private child hidden. Such a child is reachable only through layer 1 or layer 2: someone with access records its URL (via `link` or `record`) or is handed a manifest. A public cloner without either simply `--skip`s it; partial restore is the expected outcome, not an error. + +### Branch-aware checkout + +Gitlinks pin commits, not branches, so the baseline checkout is detached β€” but a child a developer works in usually _lives_ on a branch, and re-attaching by hand after every restore is friction. `restore` therefore resolves a branch per child with the same layering as the URL: the registry (`embedded..branch`), then the manifest, and β€” when neither supplies one β€” **inference from the pin**: if exactly one `origin` branch contains the pinned commit, that branch is taken. With a branch, the child ends ON it at the pin (`checkout -B `), upstream tracking is set to `origin/` when that ref exists (best-effort β€” a registered local-only branch is legitimate), and the branch is auto-registered exactly like the URL. Ambiguity β€” the pin reachable from several branches β€” declines to detached; inference never guesses. + +One implementation detail is load-bearing: containing branches are listed with **full refnames** (`refs/remotes/origin/`). The short form renders `origin/HEAD` as bare `origin`, which enters the candidate set as a phantom branch and poisons the exactly-one uniqueness check whenever the remote HEAD symref is set (i.e. after every normal clone). + +### Day-2 pin sync + +The hooks update children when a parent operation moves HEAD, but they detach (standard submodule semantics), require installation, and have the `git reset --hard` gap. `git embedded sync` is the explicit, branch-preserving alternative: after the parent has pulled new pins (pulling the parent is the caller's step β€” sync, like restore, never touches the parent), it walks the present children and moves each clean one to its pin. The dispositions, in evaluation order: + +| Child state | Disposition | +| -------------------------------------------------- | ----------------------------------------------------------------------------------------- | +| HEAD already at the pin | `in-sync` β€” nothing to do | +| Uncommitted changes | `dirty` β€” left alone (your work) | +| Pin absent after one `git fetch origin` | `pin-unavailable` β€” reported, non-zero exit | +| On the **registered** branch, HEAD ancestor of pin | `synced` β€” branch moved to the pin (`checkout -B`, fast-forward only), upstream refreshed | +| On the registered branch, commits beyond the pin | `ahead` β€” left alone (your work) | +| On any **unregistered** branch | `unregistered-branch` β€” left alone (reported) | +| Detached, clean | `synced` β€” detached to the pin | + +Only `pin-unavailable` (and an unexpected checkout failure, `sync-failed`) make the exit code non-zero: the left-alone outcomes are deliberate protection of in-progress work, not errors. A dry run classifies without fetching or moving anything β€” with the pin not yet in the local object store it reports optimistically (like restore's dry run) and says a real run would fetch. + +### The commands + +- `restore [paths…] [--from ] [--base ] [--skip ] [--dry-run]` β€” resolve, clone, SHA-verify, check out the pin (on the resolved branch, else detached), and record the resolved URL and branch. Per-child outcome is one of `restored`, `already-present`, `unresolved`, `pinned-mismatch`, `skipped`; the command exits non-zero when any non-skipped child ends `unresolved` or `pinned-mismatch`. +- `sync [paths…] [--skip ] [--dry-run]` β€” move present children to the pins in the parent's HEAD, per the disposition table above. Exits non-zero only on `pin-unavailable` / `sync-failed`. +- `record [paths…]` β€” write each present child's `remote.origin.url` and current branch into the registry. +- `export [-o ] [--scan]` β€” serialize the registry (URLs and branches) to a manifest (stdout by default; `--scan` records first). The manifest must never be committed; when `-o` writes inside the worktree the filename is appended to `.git/info/exclude` as a courtesy. `restore --from` consumes both the URL and the branch, so the record β†’ export β†’ restore loop round-trips the branch. +- `link ` β€” clone a child into a missing or empty gitlink directory, stage the gitlink, and record its URL and branch. + ## Implementation notes - The hooks are POSIX-shell scripts to avoid Node or other runtime dependencies at hook execution time. They use `git ls-tree`, `git diff-index`, `git rev-parse`, `git cat-file`, `git fetch`, and `git checkout` β€” all standard plumbing. diff --git a/hooks/pre-push b/hooks/pre-push new file mode 100755 index 0000000..6b6da0c --- /dev/null +++ b/hooks/pre-push @@ -0,0 +1,162 @@ +#!/usr/bin/env bash +# pre-push +# +# Refuses to push parent commits whose gitlink pins reference child commits +# that are NOT reachable from the child's own origin. Without this, a parent +# that pins a committed-but-unpushed child publishes a dangling pointer: +# every other machine's `git embedded restore` fails on that child with +# pinned-mismatch, because the child's origin has never seen the commit. +# +# This is git-embedded's analog of `git push --recurse-submodules=check` β€” +# stock git can't provide it here because that machinery locates children via +# .gitmodules registration, which anonymous gitlinks deliberately omit. +# +# Mode (git config embedded.pushRecurse β€” local overrides global; default check): +# check Verify each pin new to the remote is reachable from some +# refs/remotes/origin/* tip in the child (refreshing with one +# `git fetch origin` on a miss). Unreachable β†’ reject the push +# with a "push the child first" message. +# on-demand Like check, but first try to publish the pin by pushing the +# child's CURRENT branch (only when that branch contains the pin +# and the child is not detached). Falls back to check's rejection +# when it can't. +# off No verification. +# +# One-shot override: git -c embedded.pushRecurse= push … +# +# Args: $1 = remote name, $2 = remote URL. +# Stdin: SP SP SP per ref. + +push_mode=$(git config --get embedded.pushRecurse 2>/dev/null) +case "$push_mode" in +off) exit 0 ;; +check | on-demand) ;; +*) push_mode="check" ;; +esac + +zeros="0000000000000000000000000000000000000000" + +# Is $2 (a commit SHA) reachable from any origin remote-tracking tip inside +# the child repo at $1? +reachable_from_origin() ( + cd "$1" || return 1 + for tip in $(git for-each-ref --format='%(objectname)' refs/remotes/origin 2>/dev/null); do + if git merge-base --is-ancestor "$2" "$tip" 2>/dev/null; then + return 0 + fi + done + return 1 +) + +# Verify one (path, pin). Prints the rejection message and returns 1 when the +# pin cannot be confirmed published. +verify_pin() { + local path="$1" pin="$2" + + if ! [ -d "$path/.git" ] && ! [ -f "$path/.git" ]; then + echo "git-embedded: βœ— cannot verify $path pin ${pin:0:12} β€” the child repo is not present here" >&2 + echo " restore it first (git embedded restore '$path'), or bypass once with -c embedded.pushRecurse=off" >&2 + return 1 + fi + + # Fast path: current remote-tracking knowledge. + reachable_from_origin "$path" "$pin" && return 0 + + # Refresh once β€” local refs/remotes may simply be stale. + (cd "$path" && git fetch --quiet origin 2>/dev/null) + reachable_from_origin "$path" "$pin" && return 0 + + if [ "$push_mode" = "on-demand" ]; then + # Publish the child's current branch iff it is a real branch that + # contains the pin. Never invent a ref for a detached child. + local branch + branch=$(cd "$path" && git symbolic-ref --quiet --short HEAD 2>/dev/null) + if [ -n "$branch" ] && (cd "$path" && git merge-base --is-ancestor "$pin" "$branch" 2>/dev/null); then + echo "git-embedded: pushing $path ($branch) to publish pin ${pin:0:12}…" >&2 + # `>&2 2>&1` (not `2>&1 >&2`) sends BOTH streams to stderr β€” order + # matters. A successful push means the pin (an ancestor of $branch, + # verified above) is now on origin, so succeed directly rather than + # re-checking refs/remotes/origin, which a plain push may not refresh. + if (cd "$path" && git push --quiet origin "$branch" >&2 2>&1); then + return 0 + fi + fi + fi + + echo "git-embedded: βœ— $path pin ${pin:0:12} is not on that child's origin" >&2 + echo " push the child first (git -C '$path' push), then retry this push" >&2 + return 1 +} + +block=0 +checked="" + +# For each pushed ref, examine every commit that is new to the remote and +# collect its gitlink pins: the pins CHANGED by each new commit (diff-tree, +# cheap) plus the full gitlink set of the tip (ls-tree) β€” a pin unchanged +# throughout the range is by definition still in the tip's tree, so the union +# covers every pin the remote is about to learn. +while read local_ref local_sha remote_ref remote_sha; do + [ "$local_sha" = "$zeros" ] && continue # deletion β€” nothing to verify + + if [ "$remote_sha" = "$zeros" ]; then + # New remote ref: bound the walk by everything already on any remote. + range_args=("$local_sha" --not --remotes) + else + range_args=("$remote_sha..$local_sha") + fi + + pins_to_check="" + + # Changed gitlink pins across the new commits. + for c in $(git rev-list "${range_args[@]}" 2>/dev/null); do + while IFS=$'\t' read -r meta path; do + set -- $meta + # diff-tree --raw: : + [ "$2" = "160000" ] || continue + new_pin="$4" + [ "$new_pin" = "$zeros" ] && continue # gitlink removed + # pin-FIRST so a $path with spaces survives the read-back below + # (pin is a fixed-width sha; path is the remainder). + pins_to_check="$pins_to_check$new_pin $path"$'\n' + # --no-renames: without it, a rename/copy raw line carries TWO tab- + # separated paths (oldnew), which would land a tabbed value in $path. + done < <(git diff-tree -r --no-commit-id --no-renames --raw "$c" 2>/dev/null) + done + + # Verify every gitlink in the tip when the remote can't derive the tree from + # the delta: a NEW ref (learning the whole tree), OR a NON-fast-forward update + # (force-push / rewritten history) β€” there a pin can be new to the remote yet + # unchanged within remote_sha..local_sha, so the diff pass alone would miss it. + # For an ordinary fast-forward the diff pass suffices (an unchanged pin was + # already in the remote's tree and verified when first pushed), so a clone that + # never restored its children can still push commits that touch no pin. + if [ "$remote_sha" = "$zeros" ] || ! git merge-base --is-ancestor "$remote_sha" "$local_sha" 2>/dev/null; then + while IFS=$'\t' read -r meta path; do + set -- $meta + [ "$2" = "commit" ] || continue + pins_to_check="$pins_to_check$3 $path"$'\n' + done < <(git ls-tree -r "$local_sha" 2>/dev/null) + fi + + # Verify each unique (path, pin) once. Read each line WHOLE (IFS= disables + # field-splitting), then split the pin off at the FIRST space: a plain + # `read -r pin path` strips a LEADING space from $path (default-IFS trims the + # last field's leading whitespace), so a gitlink dir whose name begins with a + # space would be looked up at the wrong location β€” and a trailing space would + # be dropped too. The pin is a fixed-width sha with no spaces, so the first + # space is always the pin/path boundary; everything after it (leading or + # trailing space included) is the path verbatim. + while IFS= read -r line; do + case "$line" in *" "*) ;; *) continue ;; esac # skip blank/malformed + pin=${line%% *} + path=${line#* } + [ -n "$pin" ] && [ -n "$path" ] || continue + case "$checked" in *"|$path=$pin|"*) continue ;; esac + checked="$checked|$path=$pin|" + verify_pin "$path" "$pin" || block=1 + done <<<"$pins_to_check" +done + +[ "$block" = "1" ] && exit 1 +exit 0 diff --git a/hooks/reference-transaction b/hooks/reference-transaction index 9db1c19..b90a7a1 100755 --- a/hooks/reference-transaction +++ b/hooks/reference-transaction @@ -1,54 +1,155 @@ #!/usr/bin/env bash # reference-transaction # -# Refuses HEAD-moving git operations if any embedded git repo in the parent's -# tree has uncommitted changes. Without this, a `git checkout B` in the parent -# would silently leave the child stale (with the post-checkout updater unable -# to update over dirty state), producing a confusing inconsistent state. +# Guards HEAD-moving git operations against harming embedded child repos. +# The companion update-embedded-repos hook (post-checkout/-merge/-rewrite) +# force-syncs every child to the pin recorded in the parent's new HEAD; this +# hook refuses, up-front, the moves where that sync (or the move itself) +# would confuse or destroy in-flight child work. # -# Requires git 2.28+ (released July 2020, when reference-transaction was -# introduced). +# IMPORTANT PLUMBING FACT: a plain `git commit` moves the ref HEAD points to, +# reported in the reference transaction as HEAD (git <=2.43) or as the branch +# ref refs/heads/ (git 2.54+) β€” the guard watches BOTH. Either way, +# "HEAD moved" alone cannot distinguish a commit from a checkout, so the mode +# logic below reasons about what the move would actually DO to each child, and +# classifies append-vs-jump by commit parentage where it matters. # -# Phases (passed as $1 by git): -# - prepared: updates queued, not yet applied. Exiting non-zero ABORTS the -# transaction. This is the only phase we act on. -# - committed: updates already applied (informational; we ignore). -# - aborted: updates rejected by some other handler (informational). +# Mode (git config embedded.guard β€” local overrides global; default precise): +# precise Block only when a DIRTY child's HEAD differs from the pin in the +# NEW commit β€” i.e. exactly when update-embedded-repos would try to +# move a child that has uncommitted changes. Clean children, and +# dirty children whose pin already equals their HEAD (the sync +# no-ops), never block. +# strict The everything-synced policy. Any dirty child blocks any HEAD +# move. Additionally, on APPENDS (commit/merge/cherry-pick β€” the +# new commit lists the old HEAD among its parents) every child's +# pin in the new commit must equal that child's current HEAD, so a +# parent commit can never ship stale pins. Jumps (checkout/reset) +# only require all-clean: their pins are EXPECTED to differ, and +# the post-hook sync moves the (clean) children afterwards. +# off No guarding. # -# The hook reads proposed ref updates from stdin, one per line: -# +# One-shot override: git -c embedded.guard= # -# We filter to HEAD updates with old != new (an actual HEAD move). Other ref -# updates (branch fast-forwards from fetch, stash refs, tag creation, etc.) -# don't touch the working tree and shouldn't be guarded. - -# Only the 'prepared' phase can reject the transaction. +# Requires git 2.28+ (reference-transaction hook). +# +# Phases (passed as $1 by git): only 'prepared' can reject the transaction. [ "$1" = "prepared" ] || exit 0 -# Detect whether any update in this transaction moves HEAD. -moving_head=0 +guard_mode=$(git config --get embedded.guard 2>/dev/null) +case "$guard_mode" in +off) exit 0 ;; +strict | precise) ;; +*) guard_mode="precise" ;; +esac + +# Anchor every git command we run INSIDE a child to the child's own repo: +# GIT_CEILING_DIRECTORIES stops repo discovery from walking UP into this parent, +# so a child with a broken/corrupt .git fails cleanly here (empty HEAD, no +# symref) instead of silently resolving the PARENT repo's HEAD. +parent_root=$(pwd) +child_git() ( + cd "$1" 2>/dev/null || return 1 + shift + GIT_CEILING_DIRECTORIES="$parent_root" git "$@" +) + +# Detect a HEAD move and capture its endpoints. Stdin lines: +# +# Other ref updates (fetch fast-forwards, stash refs, tags) don't touch the +# working tree and aren't guarded. +zeros="0000000000000000000000000000000000000000" +# The ref a working-tree HEAD move touches is either literal HEAD (a detached +# checkout, or a commit on a detached HEAD) or β€” when HEAD is on a branch β€” the +# BRANCH ref HEAD points to. git <=2.43 emitted a redundant HEAD line for a +# commit, so matching "HEAD" alone sufficed; git 2.54 emits ONLY the branch ref +# (refs/heads/) for a commit, so without also matching the current +# branch every commit slips past the guard. Resolve HEAD's branch and watch both. +head_ref=$(git symbolic-ref --quiet HEAD 2>/dev/null) +moving=0 +old_head="" +new_head="" while read old_sha new_sha ref; do - [ "$ref" = "HEAD" ] || continue + [ "$ref" = "HEAD" ] || { [ -n "$head_ref" ] && [ "$ref" = "$head_ref" ]; } || continue [ "$old_sha" = "$new_sha" ] && continue - moving_head=1 + moving=1 + old_head=$old_sha + new_head=$new_sha done +[ "$moving" = "1" ] || exit 0 +[ -n "$new_head" ] && [ "$new_head" != "$zeros" ] || exit 0 -# Nothing to check if HEAD is not moving. -[ "$moving_head" = "1" ] || exit 0 +# Append vs jump (strict mode only cares): an append's new commit lists the +# CURRENT (pre-move) HEAD among its parents β€” commit, merge, cherry-pick step. +# The pre-move HEAD is resolved directly rather than trusting the transaction +# line's old value: a checkout-to-SHA (detach) reports its HEAD line with the +# null SHA on the old side, which must NOT read as "initial commit". An unborn +# HEAD (the real initial commit) counts as an append. Known edge: switching to +# a branch whose tip is a direct child of the current HEAD is indistinguishable +# from a commit by parentage and is treated as an append; in strict mode use +# `git -c embedded.guard=precise checkout …` if that blocks a legitimate move. +is_append=0 +current_head=$(git rev-parse -q --verify "HEAD^{commit}" 2>/dev/null) +if [ -z "$current_head" ]; then + is_append=1 +else + for parent in $(git rev-list --parents -n 1 "$new_head" 2>/dev/null | cut -d' ' -f2-); do + [ "$parent" = "$current_head" ] && is_append=1 + done +fi -# Walk every gitlink in the current HEAD and check for dirty state. -# A gitlink is a tree entry with type 'commit' (mode 160000). The path is -# the directory in the parent's working tree that holds the embedded repo. -while read mode type sha path; do - [ "$type" = "commit" ] || continue +# Walk every gitlink in the NEW commit β€” those pins are what the post-hook +# sync will enforce after the move. Tree entries from `git ls-tree -r`: +# SP SP TAB +block=0 +# Split ls-tree output on the TAB so a child path containing spaces stays intact +# (the meta side, ` `, is space-separated and re-split below). +while IFS=$'\t' read -r meta path; do + set -- $meta + entry_type=$2 + pin=$3 + [ "$entry_type" = "commit" ] || continue [ -d "$path/.git" ] || [ -f "$path/.git" ] || continue - # Refuse if there are uncommitted changes inside the embedded repo. - if ! (cd "$path" && git diff-index --quiet HEAD --) 2>/dev/null; then - echo "git-embedded: βœ— $path has uncommitted changes" >&2 - echo " commit or stash inside $path/ before moving HEAD here" >&2 - exit 1 + # --verify: fail cleanly with EMPTY stdout when HEAD can't resolve (plain + # `rev-parse HEAD` echoes the literal "HEAD" on an unborn branch, which would + # slip past the emptiness check below and be misread as a dirty child). + child_head=$(child_git "$path" rev-parse --verify HEAD 2>/dev/null) + if [ -z "$child_head" ]; then + # HEAD is unreadable. Distinguish a legitimately UNBORN child (fresh + # `git init`, no commits yet β€” HEAD is still a valid symref to a branch) + # from a genuinely broken/corrupt repo (neither a commit nor a symref + # resolves). An unborn child has nothing to guard, so skip it. In strict + # mode, fail closed on a broken one rather than silently waving it through; + # precise stays lenient and skips either way. + if [ "$guard_mode" = "strict" ] && ! child_git "$path" symbolic-ref --quiet HEAD >/dev/null 2>&1; then + echo "git-embedded: βœ— $path β€” cannot read HEAD (missing or corrupt repo?) (embedded.guard=strict)" >&2 + echo " fix or re-restore the child at '$path'/, or bypass once with -c embedded.guard=off" >&2 + block=1 + fi + continue + fi + dirty=0 + child_git "$path" diff-index --quiet HEAD -- 2>/dev/null || dirty=1 + + if [ "$guard_mode" = "strict" ]; then + if [ "$dirty" = "1" ]; then + echo "git-embedded: βœ— $path has uncommitted changes (embedded.guard=strict)" >&2 + echo " commit or stash inside '$path'/ before moving HEAD here" >&2 + block=1 + elif [ "$is_append" = "1" ] && [ "$child_head" != "$pin" ]; then + echo "git-embedded: βœ— $path is not synced: pin ${pin:0:12} != child HEAD ${child_head:0:12} (embedded.guard=strict)" >&2 + echo " record the child's current commit (git add -- '$path') or move the child to the pin before committing the parent" >&2 + block=1 + fi + else # precise + if [ "$dirty" = "1" ] && [ "$child_head" != "$pin" ]; then + echo "git-embedded: βœ— $path has uncommitted changes and this move would re-pin it (${child_head:0:12} β†’ ${pin:0:12})" >&2 + echo " commit or stash inside '$path'/ before moving HEAD here" >&2 + block=1 + fi fi -done < <(git ls-tree -r HEAD 2>/dev/null) +done < <(git ls-tree -r "$new_head" 2>/dev/null) +[ "$block" = "1" ] && exit 1 exit 0 diff --git a/package-lock.json b/package-lock.json index 3cc1dd0..2fa3df0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@cldmv/git-embedded", - "version": "1.0.0", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@cldmv/git-embedded", - "version": "1.0.0", + "version": "1.1.0", "license": "Apache-2.0", "dependencies": { "@cldmv/slothlet": "^3.7.0", @@ -23,11 +23,11 @@ "@eslint/js": "^9.18.0", "@eslint/json": "^0.10.0", "@eslint/markdown": "^6.2.2", - "@vitest/coverage-v8": "^2.1.9", + "@vitest/coverage-v8": "^4.1.10", "eslint": "^9.18.0", "globals": "^15.14.0", "prettier": "^3.4.2", - "vitest": "^2.1.9" + "vitest": "^4.1.10" }, "engines": { "node": ">=20.19.0" @@ -37,24 +37,10 @@ "url": "https://github.com/sponsors/shinrai" } }, - "node_modules/@ampproject/remapping": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", - "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -62,9 +48,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -72,13 +58,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.3", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", - "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -88,25 +74,28 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@bcoe/v8-coverage": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", - "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/@cldmv/slothlet": { "version": "3.7.0", @@ -162,6 +151,40 @@ "node": ">=0.1.90" } }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -500,117 +523,6 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@isaacs/cliui/node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@isaacs/cliui/node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/@istanbuljs/schema": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", - "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -639,54 +551,39 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", "dev": true, "license": "MIT", "optional": true, - "engines": { - "node": ">=14" - } - }, - "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==", - "cpu": [ - "x64" - ], - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" } }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", - "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", - "cpu": [ - "arm" - ], + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ] + "funding": { + "url": "https://github.com/sponsors/Boshen" + } }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", - "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", "cpu": [ "arm64" ], @@ -695,12 +592,15 @@ "optional": true, "os": [ "android" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", - "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", "cpu": [ "arm64" ], @@ -709,12 +609,15 @@ "optional": true, "os": [ "darwin" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", - "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", "cpu": [ "x64" ], @@ -723,26 +626,15 @@ "optional": true, "os": [ "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", - "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", - "cpu": [ - "arm64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", - "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", "cpu": [ "x64" ], @@ -751,46 +643,32 @@ "optional": true, "os": [ "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", - "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", - "cpu": [ - "arm" - ], - "dev": true, - "libc": [ - "glibc" ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", - "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", "cpu": [ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", - "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", "cpu": [ "arm64" ], @@ -802,12 +680,15 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", - "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", "cpu": [ "arm64" ], @@ -819,14 +700,17 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", - "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", "cpu": [ - "loong64" + "ppc64" ], "dev": true, "libc": [ @@ -836,33 +720,38 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", - "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", "cpu": [ - "loong64" + "s390x" ], "dev": true, "libc": [ - "musl" + "glibc" ], "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", - "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==", "cpu": [ - "ppc64" + "x64" ], - "dev": true, "libc": [ "glibc" ], @@ -870,14 +759,17 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", - "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", "cpu": [ - "ppc64" + "x64" ], "dev": true, "libc": [ @@ -887,176 +779,87 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", - "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", "cpu": [ - "riscv64" + "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ - "linux" - ] + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", - "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", "cpu": [ - "riscv64" + "wasm32" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, - "os": [ - "linux" - ] + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", - "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", "cpu": [ - "s390x" + "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ - "linux" - ] + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", - "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", "cpu": [ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", - "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" + "win32" ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", - "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", - "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", - "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", - "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", - "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", - "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", - "cpu": [ - "x64" - ], + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "license": "MIT" }, "node_modules/@sindresorhus/is": { "version": "4.6.0", @@ -1070,6 +873,35 @@ "url": "https://github.com/sindresorhus/is?sponsor=1" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/debug": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", @@ -1080,6 +912,13 @@ "@types/ms": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -1119,31 +958,29 @@ "license": "MIT" }, "node_modules/@vitest/coverage-v8": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.9.tgz", - "integrity": "sha512-Z2cOr0ksM00MpEfyVE8KXIYPEcBFxdbLSs56L8PO0QQMxt/6bDj45uQfxoc96v05KW3clk7vvgP0qfDit9DmfQ==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz", + "integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==", "dev": true, "license": "MIT", "dependencies": { - "@ampproject/remapping": "^2.3.0", - "@bcoe/v8-coverage": "^0.2.3", - "debug": "^4.3.7", + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.10", + "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", - "istanbul-lib-source-maps": "^5.0.6", - "istanbul-reports": "^3.1.7", - "magic-string": "^0.30.12", - "magicast": "^0.3.5", - "std-env": "^3.8.0", - "test-exclude": "^7.0.1", - "tinyrainbow": "^1.2.0" + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "2.1.9", - "vitest": "2.1.9" + "@vitest/browser": "4.1.10", + "vitest": "4.1.10" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -1152,38 +989,40 @@ } }, "node_modules/@vitest/expect": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", - "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "2.1.9", - "@vitest/utils": "2.1.9", - "chai": "^5.1.2", - "tinyrainbow": "^1.2.0" + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/mocker": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", - "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "2.1.9", + "@vitest/spy": "4.1.10", "estree-walker": "^3.0.3", - "magic-string": "^0.30.12" + "magic-string": "^0.30.21" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "msw": "^2.4.9", - "vite": "^5.0.0" + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "msw": { @@ -1195,70 +1034,68 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", - "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^1.2.0" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/runner": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", - "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "2.1.9", - "pathe": "^1.1.2" + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/snapshot": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", - "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "2.1.9", - "magic-string": "^0.30.12", - "pathe": "^1.1.2" + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/spy": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", - "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", "dev": true, "license": "MIT", - "dependencies": { - "tinyspy": "^3.0.2" - }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/utils": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", - "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "2.1.9", - "loupe": "^3.1.2", - "tinyrainbow": "^1.2.0" + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -1369,6 +1206,18 @@ "node": ">=12" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.5.tgz", + "integrity": "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1387,16 +1236,6 @@ "concat-map": "0.0.1" } }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -1419,18 +1258,11 @@ } }, "node_modules/chai": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", - "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", "dev": true, "license": "MIT", - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, "engines": { "node": ">=18" } @@ -1467,16 +1299,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/check-error": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", - "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - } - }, "node_modules/cli-highlight": { "version": "2.1.11", "resolved": "https://registry.npmjs.org/cli-highlight/-/cli-highlight-2.1.11.tgz", @@ -1574,6 +1396,13 @@ "dev": true, "license": "MIT" }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -1621,16 +1450,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -1648,6 +1467,16 @@ "node": ">=6" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/devlop": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", @@ -1662,13 +1491,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true, - "license": "MIT" - }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -1694,9 +1516,9 @@ } }, "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", "dev": true, "license": "MIT" }, @@ -1931,9 +1753,9 @@ } }, "node_modules/expect-type": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -1975,6 +1797,24 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -2026,23 +1866,6 @@ "dev": true, "license": "ISC" }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dev": true, - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/format": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz", @@ -2083,28 +1906,6 @@ "dev": true, "license": "ISC" }, - "node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -2118,32 +1919,6 @@ "node": ">=10.13.0" } }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", - "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/glob/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.2" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/globals": { "version": "15.15.0", "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", @@ -2283,21 +2058,6 @@ "node": ">=10" } }, - "node_modules/istanbul-lib-source-maps": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", - "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.23", - "debug": "^4.1.1", - "istanbul-lib-coverage": "^3.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/istanbul-reports": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", @@ -2312,27 +2072,28 @@ "node": ">=8" } }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } + "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -2386,53 +2147,312 @@ "node": ">= 0.8.0" } }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", "dev": true, - "license": "MIT", + "license": "MPL-2.0", "dependencies": { - "p-locate": "^5.0.0" + "detect-libc": "^2.0.3" }, "engines": { - "node": ">=10" + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT" + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } }, - "node_modules/longest-streak": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", - "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/loupe": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", - "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true, "license": "MIT" }, - "node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", "dev": true, - "license": "ISC" + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } }, "node_modules/magic-string": { "version": "0.30.21", @@ -2445,15 +2465,15 @@ } }, "node_modules/magicast": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", - "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.25.4", - "@babel/types": "^7.25.4", - "source-map-js": "^1.2.0" + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" } }, "node_modules/make-dir": { @@ -3382,16 +3402,6 @@ "node": "*" } }, - "node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -3411,9 +3421,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "dev": true, "funding": [ { @@ -3460,6 +3470,20 @@ "node": ">=0.10.0" } }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -3510,13 +3534,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true, - "license": "BlueOak-1.0.0" - }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -3571,40 +3588,13 @@ "node": ">=8" } }, - "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/pathe": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "dev": true, "license": "MIT" }, - "node_modules/pathval": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", - "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.16" - } - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3612,10 +3602,23 @@ "dev": true, "license": "ISC" }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.19", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", + "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", "dev": true, "funding": [ { @@ -3696,57 +3699,59 @@ "node": ">=4" } }, - "node_modules/rollup": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", - "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "1.0.8" + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" }, "bin": { - "rollup": "dist/bin/rollup" + "rolldown": "bin/cli.mjs" }, "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" + "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.60.4", - "@rollup/rollup-android-arm64": "4.60.4", - "@rollup/rollup-darwin-arm64": "4.60.4", - "@rollup/rollup-darwin-x64": "4.60.4", - "@rollup/rollup-freebsd-arm64": "4.60.4", - "@rollup/rollup-freebsd-x64": "4.60.4", - "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", - "@rollup/rollup-linux-arm-musleabihf": "4.60.4", - "@rollup/rollup-linux-arm64-gnu": "4.60.4", - "@rollup/rollup-linux-arm64-musl": "4.60.4", - "@rollup/rollup-linux-loong64-gnu": "4.60.4", - "@rollup/rollup-linux-loong64-musl": "4.60.4", - "@rollup/rollup-linux-ppc64-gnu": "4.60.4", - "@rollup/rollup-linux-ppc64-musl": "4.60.4", - "@rollup/rollup-linux-riscv64-gnu": "4.60.4", - "@rollup/rollup-linux-riscv64-musl": "4.60.4", - "@rollup/rollup-linux-s390x-gnu": "4.60.4", - "@rollup/rollup-linux-x64-gnu": "4.60.4", - "@rollup/rollup-linux-x64-musl": "4.60.4", - "@rollup/rollup-openbsd-x64": "4.60.4", - "@rollup/rollup-openharmony-arm64": "4.60.4", - "@rollup/rollup-win32-arm64-msvc": "4.60.4", - "@rollup/rollup-win32-ia32-msvc": "4.60.4", - "@rollup/rollup-win32-x64-gnu": "4.60.4", - "@rollup/rollup-win32-x64-msvc": "4.60.4", - "fsevents": "~2.3.2" - } - }, - "node_modules/rollup/node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, + "node_modules/rolldown/node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT" + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, "node_modules/semver": { "version": "7.8.1", @@ -3791,19 +3796,6 @@ "dev": true, "license": "ISC" }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/skin-tone": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/skin-tone/-/skin-tone-2.0.0.tgz", @@ -3834,9 +3826,9 @@ "license": "MIT" }, "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", "dev": true, "license": "MIT" }, @@ -3854,22 +3846,6 @@ "node": ">=8" } }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -3882,30 +3858,6 @@ "node": ">=8" } }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/strip-ansi/node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -3956,60 +3908,6 @@ "url": "https://github.com/chalk/supports-hyperlinks?sponsor=1" } }, - "node_modules/test-exclude": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", - "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==", - "dev": true, - "license": "ISC", - "dependencies": { - "@istanbuljs/schema": "^0.1.2", - "glob": "^10.4.1", - "minimatch": "^10.2.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/test-exclude/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/test-exclude/node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", @@ -4039,42 +3937,50 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinypool": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", - "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", "dev": true, "license": "MIT", "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": ">=18" } }, - "node_modules/tinyrainbow": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", - "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, "engines": { - "node": ">=14.0.0" + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tinyspy": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", - "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", "dev": true, "license": "MIT", "engines": { "node": ">=14.0.0" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -4167,21 +4073,23 @@ } }, "node_modules/vite": { - "version": "5.4.21", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", - "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", + "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.17", + "rolldown": "~1.1.5", + "tinyglobby": "^0.2.17" }, "bin": { "vite": "bin/vite.js" }, "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": "^20.19.0 || >=22.12.0" }, "funding": { "url": "https://github.com/vitejs/vite?sponsor=1" @@ -4190,23 +4098,33 @@ "fsevents": "~2.3.3" }, "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" }, "peerDependenciesMeta": { "@types/node": { "optional": true }, - "less": { + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { "optional": true }, - "lightningcss": { + "less": { "optional": true }, "sass": { @@ -4223,515 +4141,89 @@ }, "terser": { "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true } } }, - "node_modules/vite-node": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", - "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", - "dev": true, - "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.3.7", - "es-module-lexer": "^1.5.4", - "pathe": "^1.1.2", - "vite": "^5.0.0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/vite/node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" - } - }, "node_modules/vitest": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", - "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/expect": "2.1.9", - "@vitest/mocker": "2.1.9", - "@vitest/pretty-format": "^2.1.9", - "@vitest/runner": "2.1.9", - "@vitest/snapshot": "2.1.9", - "@vitest/spy": "2.1.9", - "@vitest/utils": "2.1.9", - "chai": "^5.1.2", - "debug": "^4.3.7", - "expect-type": "^1.1.0", - "magic-string": "^0.30.12", - "pathe": "^1.1.2", - "std-env": "^3.8.0", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", - "tinyexec": "^0.3.1", - "tinypool": "^1.0.1", - "tinyrainbow": "^1.2.0", - "vite": "^5.0.0", - "vite-node": "2.1.9", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "bin": { "vitest": "vitest.mjs" }, "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "@edge-runtime/vm": "*", - "@types/node": "^18.0.0 || >=20.0.0", - "@vitest/browser": "2.1.9", - "@vitest/ui": "2.1.9", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", "happy-dom": "*", - "jsdom": "*" + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { "optional": true }, + "@opentelemetry/api": { + "optional": true + }, "@types/node": { "optional": true }, - "@vitest/browser": { + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { "optional": true }, "@vitest/ui": { @@ -4742,6 +4234,9 @@ }, "jsdom": { "optional": true + }, + "vite": { + "optional": false } } }, @@ -4805,25 +4300,6 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 0ffe40c..bdfb869 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@cldmv/git-embedded", - "version": "1.0.0", + "version": "1.1.0", "description": "Manage embedded git repositories (anonymous gitlinks) without .gitmodules. Provides hooks that restore standard git-command ergonomics for embedded children while keeping the child's origin URL out of the public parent repo.", "type": "module", "license": "Apache-2.0", @@ -87,10 +87,10 @@ "@eslint/js": "^9.18.0", "@eslint/json": "^0.10.0", "@eslint/markdown": "^6.2.2", - "@vitest/coverage-v8": "^2.1.9", + "@vitest/coverage-v8": "^4.1.10", "eslint": "^9.18.0", "globals": "^15.14.0", "prettier": "^3.4.2", - "vitest": "^2.1.9" + "vitest": "^4.1.10" } } diff --git a/src/api/cli/export.mjs b/src/api/cli/export.mjs new file mode 100644 index 0000000..ad29f4e --- /dev/null +++ b/src/api/cli/export.mjs @@ -0,0 +1,71 @@ +import { self, context } from "@cldmv/slothlet/runtime"; + +export const spec = { + command: "export", + description: + "Serialize the local-config registry to a manifest JSON (stdout by default). The manifest is a TRANSFER FILE β€” carry it out-of-band and NEVER commit it; committing child URLs defeats anonymous gitlinks.", + options: [ + ["-o ", "Write the manifest to instead of stdout"], + ["--scan", "Record every present child (like 'record') before exporting"] + ], + examples: ["$ git-embedded export", "$ git-embedded export -o children.json", "$ git-embedded export --scan -o children.json"] +}; + +/** + * Append `relPath` to the repo's `.git/info/exclude` if not already listed, so a + * manifest written inside the worktree is not accidentally staged. + * @param {string} gitDir absolute git dir + * @param {string} relPath worktree-relative path to exclude + * @returns {boolean} true when a new line was added + */ +function addToExclude(gitDir, relPath) { + const { fs, path } = context; + const exclude = path.join(gitDir, "info", "exclude"); + let body = ""; + try { + body = fs.readFileSync(exclude, "utf8"); + } catch { + body = ""; + } + const lines = body.split(/\r?\n/).map((l) => l.trim()); + if (lines.includes(relPath) || lines.includes(`/${relPath}`)) return false; + fs.mkdirSync(path.dirname(exclude), { recursive: true }); + const prefix = body.length === 0 || body.endsWith("\n") ? "" : "\n"; + fs.appendFileSync(exclude, `${prefix}${relPath}\n`); + return true; +} + +export function run(opts = {}) { + const { fs, path } = context; + const cwd = process.cwd(); + const root = self.git.getRepoRoot(cwd) || cwd; + + if (opts.scan) self.embedded.record({ cwd }); + + const entries = self.embedded.registry.entries(root); + const manifest = self.embedded.manifest.build(entries); + const text = self.embedded.manifest.serialize(manifest); + + const outFile = opts.o; + if (!outFile) { + process.stdout.write(text); + return; + } + + const abs = path.isAbsolute(outFile) ? outFile : path.resolve(cwd, outFile); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, text); + self.report.success(`Wrote manifest to ${abs} (${Object.keys(manifest.children).length} children).`); + self.report.warn("This manifest contains child URLs β€” do NOT commit it. Carry it out-of-band."); + + const rel = path.relative(root, abs); + const insideWorktree = rel && !rel.startsWith("..") && !path.isAbsolute(rel); + if (insideWorktree) { + const gitDir = self.git.getGitDir(cwd); + if (gitDir && addToExclude(gitDir, rel.split(path.sep).join("/"))) { + self.report.plain(` (added ${rel} to .git/info/exclude as a courtesy)`); + } + } +} + +export default { spec, run }; diff --git a/src/api/cli/link.mjs b/src/api/cli/link.mjs index 3e4c9db..d535084 100644 --- a/src/api/cli/link.mjs +++ b/src/api/cli/link.mjs @@ -5,7 +5,7 @@ export const spec = { description: "Clone a remote repo into and stage it as an anonymous gitlink. Does NOT commit (you may want to stage other things in the same commit).", args: [ - ["", "Where to clone the child repo (created if missing)"], + ["", "Where to clone the child repo (created if missing; an empty gitlink dir is accepted)"], ["", "The child repo's clone URL (will NOT be recorded in .gitmodules)"] ], examples: [ @@ -14,28 +14,73 @@ export const spec = { ] }; +/** + * Whether the target path blocks a fresh clone. A missing path is fine, and an + * empty REAL directory is fine (a fresh clone of the parent materializes each + * gitlink as an empty dir). Everything else is refused: a directory with + * contents, an existing repo, a file, an unreadable directory, or a SYMLINK β€” + * even one pointing at an empty dir, since cloning through it would write + * outside the repo. lstat so links are seen (and broken links caught), never + * followed. + * @param {string} target + * @returns {boolean} + */ +function blocksClone(target) { + const { fs } = context; + let st; + try { + st = fs.lstatSync(target); + } catch (err) { + // Only a missing path (ENOENT) is safe β€” git clone creates it. + return err.code !== "ENOENT"; + } + if (st.isSymbolicLink() || !st.isDirectory()) return true; + try { + return fs.readdirSync(target).length > 0; + } catch { + return true; // unreadable directory + } +} + export function run(localPath, remoteUrl) { - const { fs, spawnSync } = context; + const { spawnSync, path } = context; - if (fs.existsSync(localPath)) { - self.report.error(`${localPath} already exists. Remove it or pick a different path before linking.`); + // Normalize to the repo-root-relative, slash-normalized gitlink path β€” the + // key restore/gitlinks/export all use. "./tests" or "tests/" must record as + // "tests", and a target outside the worktree is refused outright. + const root = self.git.getRepoRoot() || process.cwd(); + const rel = path.relative(root, path.resolve(process.cwd(), localPath)).split(path.sep).join("/"); + if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) { + self.report.error(`${localPath} is outside the repository worktree β€” link inside the parent repo.`); process.exit(2); } - self.report.plain(`Cloning ${remoteUrl} into ${localPath}…`); - const clone = spawnSync("git", ["clone", remoteUrl, localPath], { stdio: "inherit" }); + if (blocksClone(localPath)) { + self.report.error(`${localPath} exists and is not an empty directory. Remove it or pick a different path before linking.`); + process.exit(2); + } + + self.report.plain(`Cloning ${remoteUrl} into ${rel}…`); + // `--` ends option parsing: a URL or path starting with "-" must never be + // interpreted as a git option (e.g. --upload-pack). + const clone = spawnSync("git", ["clone", "--", remoteUrl, localPath], { stdio: "inherit" }); if (clone.status !== 0) { self.report.error(`git clone exited with status ${clone.status}`); process.exit(clone.status || 1); } - const add = spawnSync("git", ["add", localPath], { stdio: "inherit" }); + const add = spawnSync("git", ["add", "--", localPath], { stdio: "inherit" }); if (add.status !== 0) { self.report.error(`git add ${localPath} exited with status ${add.status}`); process.exit(add.status || 1); } - self.report.success(`Staged gitlink at ${localPath} (no .gitmodules entry written).`); + // Record the URL + branch into the parent's LOCAL config registry (never + // committed) so a later restore/export already knows this child β€” keyed by + // the NORMALIZED gitlink path so day-2 restore/export find it. + self.embedded.registry.recordOne(rel, root); + + self.report.success(`Staged gitlink at ${rel} (no .gitmodules entry written).`); self.report.plain("Commit when ready: git commit -m 'embed '"); } diff --git a/src/api/cli/print-hook-script.mjs b/src/api/cli/print-hook-script.mjs index 508463a..45d95fa 100644 --- a/src/api/cli/print-hook-script.mjs +++ b/src/api/cli/print-hook-script.mjs @@ -6,6 +6,7 @@ const NAME_TO_SOURCE = { "post-rewrite": "update-embedded-repos", "reference-transaction": "reference-transaction", "update-embedded-repos": "update-embedded-repos", + "pre-push": "pre-push", _dispatch: "_dispatch.template", dispatcher: "_dispatch.template" }; diff --git a/src/api/cli/record.mjs b/src/api/cli/record.mjs new file mode 100644 index 0000000..d1eab92 --- /dev/null +++ b/src/api/cli/record.mjs @@ -0,0 +1,36 @@ +import { self } from "@cldmv/slothlet/runtime"; + +export const spec = { + command: "record", + description: + "Record the origin URL (and current branch) of each embedded child present on disk into the parent's LOCAL config registry, so a later export or re-restore does not have to re-derive it. The registry is never committed.", + args: [["[paths...]", "Restrict to these gitlink paths (default: every child present on disk)"]], + examples: ["$ git-embedded record", "$ git-embedded record tests vendor/foo"] +}; + +const LABEL = { + recorded: (r) => `${r.path} β†’ ${r.url}${r.branch ? ` (${r.branch})` : ""}`, + "no-repo": (r) => `${r.path} not present on disk`, + "no-origin": (r) => `${r.path} has no remote.origin.url` +}; + +export function run(paths = []) { + const { results } = self.embedded.record({ cwd: process.cwd(), paths }); + + if (!results.length) { + self.report.plain("No embedded children present on disk to record."); + return; + } + + for (const r of results) { + const line = LABEL[r.outcome] ? LABEL[r.outcome](r) : `${r.path}: ${r.outcome}`; + if (r.outcome === "recorded") self.report.success(line); + else self.report.warn(line); + } + + const recorded = results.filter((r) => r.outcome === "recorded").length; + self.report.plain(""); + self.report.success(`Recorded ${recorded} of ${results.length} into the local registry (not committed).`); +} + +export default { spec, run }; diff --git a/src/api/cli/restore.mjs b/src/api/cli/restore.mjs new file mode 100644 index 0000000..a0714c9 --- /dev/null +++ b/src/api/cli/restore.mjs @@ -0,0 +1,74 @@ +import { self } from "@cldmv/slothlet/runtime"; + +export const spec = { + command: "restore", + description: + "Clone missing embedded child repos and check out their pinned SHAs. Each child's URL is resolved strictest-first β€” local config, a manifest (--from), --base, then the parent's origin convention β€” and every clone is SHA-verified so a wrong guess fails closed. A branch from the registry/manifest (or inferred when exactly one origin branch contains the pin) puts the child ON that branch at the pin; otherwise the checkout is detached.", + args: [["[paths...]", "Restrict to these gitlink paths (default: every embedded gitlink)"]], + options: [ + ["--from ", "Read child URLs from a manifest JSON file (a transfer file; never committed)"], + ["--base ", "Derive each child URL as /.git"], + ["--skip ", "Comma-separated gitlink paths to skip (for a partial restore without access to a private child)"], + ["--dry-run", "Report what would happen without cloning or writing config"] + ], + examples: [ + "$ git-embedded restore", + "$ git-embedded restore tests", + "$ git-embedded restore --from children.json", + "$ git-embedded restore --base git@example.com:org", + "$ git-embedded restore --skip tests --dry-run" + ] +}; + +const LABEL = { + restored: (r) => `${r.dryRun ? "would restore" : "restored"} ${r.path} from ${r.source} (${r.url})${r.branch ? ` on branch ${r.branch}` : ""}`, + "already-present": (r) => `${r.path} already present`, + skipped: (r) => `${r.path} skipped`, + unresolved: (r) => `${r.path} unresolved${r.note ? ` β€” ${r.note}` : ""}`, + "pinned-mismatch": (r) => `${r.path} pinned-mismatch${r.note ? ` β€” ${r.note}` : ""}` +}; + +export function run(paths = [], opts = {}) { + const skip = + typeof opts.skip === "string" + ? opts.skip + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + : []; + + const { results, exitCode } = self.embedded.restore({ + cwd: process.cwd(), + paths, + from: opts.from || null, + base: opts.base || null, + skip, + dryRun: Boolean(opts.dryRun) + }); + + if (!results.length) { + self.report.plain("No embedded gitlinks in HEAD."); + process.exit(0); + } + + for (const r of results) { + const line = LABEL[r.outcome] ? LABEL[r.outcome](r) : `${r.path}: ${r.outcome}`; + if (r.outcome === "restored") self.report.success(line); + else if (r.outcome === "unresolved" || r.outcome === "pinned-mismatch") self.report.error(line); + else self.report.warn(line); + } + + // Count each outcome into exactly one bucket β€” "unchanged" is only + // already-present, never a failure or a skip counted twice. + const restored = results.filter((r) => r.outcome === "restored").length; + const unchanged = results.filter((r) => r.outcome === "already-present").length; + const skipped = results.filter((r) => r.outcome === "skipped").length; + const failed = results.filter((r) => r.outcome === "unresolved" || r.outcome === "pinned-mismatch").length; + self.report.plain(""); + self.report.plain( + `${restored} ${opts.dryRun ? "resolvable" : "restored"}, ${unchanged} unchanged${skipped ? `, ${skipped} skipped` : ""}, ${failed} failed.` + ); + process.exit(exitCode); +} + +export default { spec, run }; diff --git a/src/api/cli/sync.mjs b/src/api/cli/sync.mjs new file mode 100644 index 0000000..7de2a29 --- /dev/null +++ b/src/api/cli/sync.mjs @@ -0,0 +1,70 @@ +import { self } from "@cldmv/slothlet/runtime"; + +export const spec = { + command: "sync", + description: + "Move already-present embedded children to the pins in the parent's HEAD (day-2, after pulling the parent β€” sync never touches the parent itself). Clean children follow the pin: the registered branch fast-forwards, a detached child snaps. Dirty children, commits beyond the pin, and unregistered branches are your work β€” reported and left alone.", + args: [["[paths...]", "Restrict to these gitlink paths (default: every embedded gitlink)"]], + options: [ + ["--skip ", "Comma-separated gitlink paths to skip"], + ["--dry-run", "Report what would happen without fetching or moving anything"] + ], + examples: ["$ git pull && git-embedded sync", "$ git-embedded sync tests", "$ git-embedded sync --dry-run"] +}; + +const LABEL = { + synced: (r) => + `${r.dryRun ? "would sync" : "synced"} ${r.path} β†’ ${r.sha.slice(0, 12)}${r.branch ? ` (branch ${r.branch})` : " (detached)"}${r.note ? ` β€” ${r.note}` : ""}`, + "in-sync": (r) => `${r.path} already at pin`, + dirty: (r) => `${r.path} ${r.note}`, + ahead: (r) => `${r.path} ${r.note}`, + "unregistered-branch": (r) => `${r.path} ${r.note}`, + "pin-unavailable": (r) => `${r.path} pin-unavailable${r.note ? ` β€” ${r.note}` : ""}`, + "sync-failed": (r) => `${r.path} sync-failed${r.note ? ` β€” ${r.note}` : ""}`, + skipped: (r) => `${r.path} skipped`, + "no-repo": (r) => `${r.path} ${r.note}` +}; + +export function run(paths = [], opts = {}) { + const skip = + typeof opts.skip === "string" + ? opts.skip + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + : []; + + const { results, exitCode } = self.embedded.sync({ + cwd: process.cwd(), + paths, + skip, + dryRun: Boolean(opts.dryRun) + }); + + if (!results.length) { + self.report.plain("No embedded children present to sync."); + process.exit(0); + } + + for (const r of results) { + const line = LABEL[r.outcome] ? LABEL[r.outcome](r) : `${r.path}: ${r.outcome}`; + if (r.outcome === "synced") self.report.success(line); + else if (r.outcome === "pin-unavailable" || r.outcome === "sync-failed") self.report.error(line); + else self.report.warn(line); + } + + // Each outcome lands in exactly one bucket; "left alone" collects the + // deliberate your-work outcomes, which are not failures. + const synced = results.filter((r) => r.outcome === "synced").length; + const unchanged = results.filter((r) => r.outcome === "in-sync").length; + const leftAlone = results.filter((r) => ["dirty", "ahead", "unregistered-branch"].includes(r.outcome)).length; + const skipped = results.filter((r) => ["skipped", "no-repo"].includes(r.outcome)).length; + const failed = results.filter((r) => r.outcome === "pin-unavailable" || r.outcome === "sync-failed").length; + self.report.plain(""); + self.report.plain( + `${synced} ${opts.dryRun ? "syncable" : "synced"}, ${unchanged} unchanged, ${leftAlone} left alone${skipped ? `, ${skipped} skipped` : ""}, ${failed} failed.` + ); + process.exit(exitCode); +} + +export default { spec, run }; diff --git a/src/api/embedded/branch.mjs b/src/api/embedded/branch.mjs new file mode 100644 index 0000000..8e829c9 --- /dev/null +++ b/src/api/embedded/branch.mjs @@ -0,0 +1,62 @@ +import { context } from "@cldmv/slothlet/runtime"; + +function git(args, opts = {}) { + const res = context.spawnSync("git", args, { encoding: "utf8", ...opts }); + return { code: res.status ?? 1, stdout: (res.stdout || "").trim(), stderr: (res.stderr || "").trim() }; +} + +/** + * Branch helpers for embedded children β€” inference of the branch a pin lives + * on, and attaching a child to a branch at a pin. Used by `restore` (initial + * branch-aware checkout) and `sync` (day-2 fast-forward of the registered + * branch). + * + * @namespace api.embedded.branch + */ + +/** + * Infer the branch a pinned commit lives on: exactly ONE `origin` remote + * branch must contain the pin, otherwise inference declines (returns null) and + * the caller keeps detached-HEAD behavior. + * + * Full refnames (`%(refname)`) are load-bearing: `origin/HEAD` short-forms to + * bare `origin`, which would enter the candidate set as a phantom "branch" and + * poison the uniqueness check. Matching `refs/remotes/origin/` and + * excluding `HEAD` explicitly keeps the symref out. + * + * @param {string} childDir absolute path of the child working tree + * @param {string} sha the pinned commit + * @returns {string|null} the single containing branch name, or null when the + * pin is on no remote branch or more than one (ambiguous) + */ +export function infer(childDir, sha) { + const res = git(["-C", childDir, "branch", "-r", "--contains", sha, "--format=%(refname)"]); + if (res.code !== 0) return null; + const names = res.stdout + .split(/\r?\n/) + .map((line) => (line.match(/^refs\/remotes\/origin\/(?!HEAD$)(.+)$/) || [])[1]) + .filter(Boolean); + const unique = new Set(names); + return unique.size === 1 ? names[0] : null; +} + +/** + * Put a child ON `branch` at `sha`: `checkout -B` (create or reset the local + * branch at the pin) plus a soft `--set-upstream-to=origin/` β€” soft + * because a registered branch need not exist on the remote (a local working + * branch is legitimate), and tracking is a convenience, not a correctness + * requirement. + * + * @param {string} childDir absolute path of the child working tree + * @param {string} branch branch name to attach + * @param {string} sha the pinned commit the branch should point at + * @returns {boolean} true when the checkout succeeded (upstream is best-effort) + */ +export function attach(childDir, branch, sha) { + const checkout = git(["-C", childDir, "checkout", "--quiet", "-B", branch, sha]); + if (checkout.code !== 0) return false; + git(["-C", childDir, "branch", `--set-upstream-to=origin/${branch}`, "--", branch]); + return true; +} + +export default { infer, attach }; diff --git a/src/api/embedded/gitlinks.mjs b/src/api/embedded/gitlinks.mjs new file mode 100644 index 0000000..0e7264c --- /dev/null +++ b/src/api/embedded/gitlinks.mjs @@ -0,0 +1,40 @@ +import { context } from "@cldmv/slothlet/runtime"; + +function git(args, opts = {}) { + const res = context.spawnSync("git", args, { encoding: "utf8", ...opts }); + return { code: res.status ?? 1, stdout: (res.stdout || "").trim(), stderr: (res.stderr || "").trim() }; +} + +/** + * Enumerate the anonymous gitlinks recorded in the parent's HEAD tree. + * + * Reads `git ls-tree -r HEAD` and keeps only mode-`160000` / type-`commit` + * entries β€” the same detection the `update-embedded-repos` and + * `reference-transaction` hooks use. No `.gitmodules` is consulted; the pinned + * SHA in the parent tree is the only committed information about a child. + * + * @param {string} [cwd] working directory inside the parent repo (default: cwd) + * @returns {Array<{ path: string, sha: string }>} gitlink path + pinned SHA, + * in tree order. Empty when HEAD has no gitlinks or `cwd` is not a repo. + * + * @example + * const links = self.embedded.gitlinks(); + * // β†’ [{ path: "tests", sha: "a1b2c3…" }, { path: "vendor/foo", sha: "d4e5…" }] + */ +export default function gitlinks(cwd = process.cwd()) { + const res = git(["ls-tree", "-r", "HEAD"], { cwd }); + if (res.code !== 0) return []; + const out = []; + for (const line of res.stdout.split(/\r?\n/)) { + if (!line) continue; + // SP SP TAB + const tab = line.indexOf("\t"); + if (tab < 0) continue; + const meta = line.slice(0, tab).split(/\s+/); + if (meta.length < 3) continue; + const [mode, type, sha] = meta; + if (mode !== "160000" || type !== "commit") continue; + out.push({ path: line.slice(tab + 1), sha }); + } + return out; +} diff --git a/src/api/embedded/manifest.mjs b/src/api/embedded/manifest.mjs new file mode 100644 index 0000000..711f567 --- /dev/null +++ b/src/api/embedded/manifest.mjs @@ -0,0 +1,71 @@ +import { context } from "@cldmv/slothlet/runtime"; + +/** + * The manifest is a TRANSFER FORMAT only β€” a JSON document that carries child + * URLs between machines by hand. It is never committed to any repo (that would + * defeat the whole point of anonymous gitlinks); it lives outside the tree, in + * the user's own hands. Shape: + * + * { "version": 1, "children": { "": { "url": "…", "branch": "…" } } } + * + * @namespace api.embedded.manifest + */ + +/** + * Read and parse a manifest file. + * @param {string} file manifest path (absolute, or relative to `cwd`) + * @param {string} [cwd] base directory for a relative `file` + * @returns {{ version: number, children: object }|null} parsed manifest, or + * null when the file does not exist + * @throws {Error} when the file exists but is not valid manifest JSON + */ +export function read(file, cwd = process.cwd()) { + const { fs, path } = context; + const abs = path.isAbsolute(file) ? file : path.resolve(cwd, file); + if (!fs.existsSync(abs)) return null; + let obj; + try { + obj = JSON.parse(fs.readFileSync(abs, "utf8")); + } catch (err) { + throw new Error(`manifest ${abs} is not valid JSON: ${err.message}`); + } + if (!obj || typeof obj !== "object" || typeof obj.children !== "object" || obj.children === null || Array.isArray(obj.children)) { + throw new Error(`manifest ${abs} is missing a "children" object (a path β†’ { url, branch } map, not an array)`); + } + // Gate the format version so an incompatible manifest fails loudly at read + // time instead of producing hard-to-diagnose behavior downstream. + if (obj.version !== 1) { + throw new Error(`manifest ${abs} has unsupported version ${JSON.stringify(obj.version)} (expected 1)`); + } + return obj; +} + +/** + * Build a manifest object from registry entries. + * @param {Array<{ path: string, url?: string, branch?: string }>} entries + * @returns {{ version: number, children: object }} manifest object; entries + * without a URL are dropped (a manifest without a URL is useless) + */ +export function build(entries) { + // Null-prototype map: a child path named __proto__ must become a plain own + // key, never a prototype mutation. + const children = Object.create(null); + for (const e of entries || []) { + if (!e || !e.url) continue; + children[e.path] = { url: e.url }; + if (e.branch) children[e.path].branch = e.branch; + } + return { version: 1, children }; +} + +/** + * Serialize a manifest object to its on-disk JSON text (tab-indented, trailing + * newline). + * @param {object} manifestObj manifest object from {@link build} + * @returns {string} + */ +export function serialize(manifestObj) { + return JSON.stringify(manifestObj, null, "\t") + "\n"; +} + +export default { read, build, serialize }; diff --git a/src/api/embedded/record.mjs b/src/api/embedded/record.mjs new file mode 100644 index 0000000..3e57727 --- /dev/null +++ b/src/api/embedded/record.mjs @@ -0,0 +1,46 @@ +import { self, context } from "@cldmv/slothlet/runtime"; + +/** + * Record engine: for each embedded child present on disk, write its + * `remote.origin.url` and current branch into the parent's LOCAL config + * registry. This is how a machine that already has the children populates the + * registry so it can later `export` a manifest or re-`restore` without + * re-deriving URLs. + * + * @param {object} [opts] + * @param {string} [opts.cwd] working directory inside the parent repo + * @param {string[]} [opts.paths] restrict to these gitlink paths (default: all + * gitlink children present on disk) + * @returns {{ results: Array<{ path: string, url?: string, branch?: string|null, + * outcome: "recorded"|"no-repo"|"no-origin" }> }} + */ +export default function record(opts = {}) { + const { cwd = process.cwd() } = opts; + const { paths = [] } = opts; + + const root = self.git.getRepoRoot(cwd) || cwd; + // Same filter-spelling normalization as restore/sync: gitlink paths from + // gitlinks() are root-relative with forward slashes, so accept "./tests", + // "tests/", and Windows "vendor\\foo" instead of silently not matching. + const normalizePath = (p) => + String(p) + .replace(/\\/g, "/") + .replace(/^\.\/+/, "") + .replace(/\/+$/, ""); + const wantSet = paths.length ? new Set(paths.map(normalizePath)) : null; + + const links = self.embedded.gitlinks(root); + const results = []; + for (const { path: childPath } of links) { + if (wantSet && !wantSet.has(childPath)) continue; + const abs = context.path.resolve(root, childPath); + if (!context.fs.existsSync(context.path.join(abs, ".git"))) { + // Only children present on disk can be recorded; skip the rest silently + // unless explicitly requested. + if (wantSet) results.push({ path: childPath, outcome: "no-repo" }); + continue; + } + results.push(self.embedded.registry.recordOne(childPath, root)); + } + return { results }; +} diff --git a/src/api/embedded/registry.mjs b/src/api/embedded/registry.mjs new file mode 100644 index 0000000..c28cba9 --- /dev/null +++ b/src/api/embedded/registry.mjs @@ -0,0 +1,119 @@ +import { context } from "@cldmv/slothlet/runtime"; + +function git(args, opts = {}) { + const res = context.spawnSync("git", args, { encoding: "utf8", ...opts }); + return { code: res.status ?? 1, stdout: (res.stdout || "").trim(), stderr: (res.stderr || "").trim() }; +} + +/** + * The per-clone URL registry: `embedded..url` / `embedded..branch` + * keys in the PARENT repo's LOCAL `.git/config`. This is registry layer 1 (the + * strictest resolution source) and it is NEVER committed β€” it lives only in the + * clone that wrote it. The gitlink path is stored as the config subsection, so + * paths with slashes (e.g. `vendor/foo`) round-trip correctly. + * + * @namespace api.embedded.registry + */ + +/** + * Read a child's recorded clone URL from the parent's local config. + * @param {string} childPath gitlink path (the config subsection) + * @param {string} [cwd] working directory inside the parent repo + * @returns {string|null} the URL, or null when unset + */ +export function getUrl(childPath, cwd = process.cwd()) { + const res = git(["config", "--local", "--get", `embedded.${childPath}.url`], { cwd }); + return res.code === 0 && res.stdout ? res.stdout : null; +} + +/** + * Read a child's recorded branch from the parent's local config. + * @param {string} childPath gitlink path + * @param {string} [cwd] working directory inside the parent repo + * @returns {string|null} the branch, or null when unset + */ +export function getBranch(childPath, cwd = process.cwd()) { + const res = git(["config", "--local", "--get", `embedded.${childPath}.branch`], { cwd }); + return res.code === 0 && res.stdout ? res.stdout : null; +} + +/** + * Write a child's clone URL into the parent's local config. + * @param {string} childPath gitlink path + * @param {string} url clone URL to record + * @param {string} [cwd] working directory inside the parent repo + * @returns {boolean} true on success + */ +export function setUrl(childPath, url, cwd = process.cwd()) { + // `--` so a value starting with "-" is never parsed as a git option. + return git(["config", "--local", "--", `embedded.${childPath}.url`, url], { cwd }).code === 0; +} + +/** + * Write a child's branch into the parent's local config. + * @param {string} childPath gitlink path + * @param {string} branch branch name to record + * @param {string} [cwd] working directory inside the parent repo + * @returns {boolean} true on success + */ +export function setBranch(childPath, branch, cwd = process.cwd()) { + return git(["config", "--local", "--", `embedded.${childPath}.branch`, branch], { cwd }).code === 0; +} + +/** + * List every registry entry currently in the parent's local config. + * @param {string} [cwd] working directory inside the parent repo + * @returns {Array<{ path: string, url?: string, branch?: string }>} one entry + * per recorded child path + */ +export function entries(cwd = process.cwd()) { + const res = git(["config", "--local", "--get-regexp", "^embedded\\..*\\.(url|branch)$"], { cwd }); + if (res.code !== 0) return []; + const map = new Map(); + for (const line of res.stdout.split(/\r?\n/)) { + if (!line) continue; + const sp = line.indexOf(" "); + if (sp < 0) continue; + const fullKey = line.slice(0, sp); + const value = line.slice(sp + 1); + // fullKey is `embedded..`; git preserves the subsection + // (the path, possibly containing dots) verbatim, so split off the trailing + // `.url`/`.branch` name and the leading `embedded.` section. + const rest = fullKey.slice("embedded.".length); + const lastDot = rest.lastIndexOf("."); + if (lastDot < 0) continue; + const sub = rest.slice(0, lastDot); + const name = rest.slice(lastDot + 1); + if (!map.has(sub)) map.set(sub, { path: sub }); + map.get(sub)[name] = value; + } + return Array.from(map.values()); +} + +/** + * Record one present child: read its `remote.origin.url` and current branch and + * write them to the parent registry. Used by `record`, `export --scan`, and the + * `link` command after a fresh clone. + * @param {string} childPath gitlink path + * @param {string} root parent repo root (child lives at `/`) + * @returns {{ path: string, url?: string, branch?: string|null, outcome: "recorded"|"no-repo"|"no-origin" }} + */ +export function recordOne(childPath, root) { + const { fs, path } = context; + const abs = path.resolve(root, childPath); + const gitMarker = path.join(abs, ".git"); + if (!fs.existsSync(gitMarker)) return { path: childPath, outcome: "no-repo" }; + + const urlRes = git(["-C", abs, "config", "--get", "remote.origin.url"]); + const url = urlRes.code === 0 && urlRes.stdout ? urlRes.stdout : null; + if (!url) return { path: childPath, outcome: "no-origin" }; + setUrl(childPath, url, root); + + const brRes = git(["-C", abs, "symbolic-ref", "--short", "HEAD"]); + const branch = brRes.code === 0 && brRes.stdout ? brRes.stdout : null; + if (branch) setBranch(childPath, branch, root); + + return { path: childPath, url, branch, outcome: "recorded" }; +} + +export default { getUrl, getBranch, setUrl, setBranch, entries, recordOne }; diff --git a/src/api/embedded/resolve.mjs b/src/api/embedded/resolve.mjs new file mode 100644 index 0000000..3c5e96a --- /dev/null +++ b/src/api/embedded/resolve.mjs @@ -0,0 +1,87 @@ +import { self } from "@cldmv/slothlet/runtime"; + +/** + * Last path segment of a gitlink path (its "basename"), slash-normalized so + * `vendor/foo` β†’ `foo` and a trailing slash is ignored. + * @param {string} childPath + * @returns {string} + */ +function basename(childPath) { + const parts = String(childPath).split("/").filter(Boolean); + return parts.length ? parts[parts.length - 1] : String(childPath); +} + +/** + * Convention URL: the child is a sibling of wherever the parent was cloned + * from. Takes the parent's origin URL, drops its final path segment (the + * parent's own repo name), and appends `.git`. + * + * Handles both scp-style (`git@host:org/parent.git`) and URL-style + * (`https://host/org/parent.git`, `/srv/remotes/parent.git`) origins: the split + * is on the last `/` when one exists; a scp-style origin whose repo sits at the + * path root (`git@host:parent.git`) has no `/`, so the sibling lives after the + * last `:` instead. + * + * @param {string|null} parentOrigin the parent's `remote.origin.url` + * @param {string} childPath gitlink path + * @returns {string|null} the derived URL, or null when no origin is available + */ +export function conventionUrl(parentOrigin, childPath) { + if (!parentOrigin) return null; + const trimmed = parentOrigin.replace(/\/+$/, ""); + const idx = trimmed.lastIndexOf("/"); + if (idx >= 0) return `${trimmed.slice(0, idx)}/${basename(childPath)}.git`; + const colon = trimmed.lastIndexOf(":"); + if (colon < 0) return null; + return `${trimmed.slice(0, colon)}:${basename(childPath)}.git`; +} + +/** + * Resolve a child's clone URL, strictest source first. This is the security + * model's heart: URL knowledge is never committed, so a URL can only come from + * one of three OPTIONAL layers, tried in order β€” + * + * 1. `local-config` β€” the per-clone registry (`embedded..url`). + * 2. `manifest` β€” a hand-carried transfer file passed via `--from`. + * 3. `base` β€” an explicit `--base ` + `.git`. + * 4. `convention` β€” sibling of the parent's origin (zero committed state). + * + * A `base`/`convention` result is only a *guess*; the caller SHA-verifies every + * clone so a wrong guess fails closed rather than planting the wrong repo. + * + * @param {string} childPath gitlink path to resolve + * @param {object} [opts] + * @param {string} [opts.cwd] parent repo working directory (for layer 1) + * @param {object|null} [opts.manifest] parsed manifest `{ children: {…} }` (layer 2) + * @param {string|null} [opts.base] explicit URL base (layer 3) + * @param {string|null} [opts.parentOrigin] parent `remote.origin.url` (layer 4) + * @returns {{ url: string, source: "local-config"|"manifest"|"base"|"convention" } + * | { url: null, source: null }} + */ +export default function resolve(childPath, opts = {}) { + const { cwd = process.cwd(), manifest = null, base = null, parentOrigin = null } = opts; + + // 1. Local-config registry β€” strictest, per-clone, never committed. + const cfgUrl = self.embedded.registry.getUrl(childPath, cwd); + if (cfgUrl) return { url: cfgUrl, source: "local-config" }; + + // 2. Manifest file (transfer format, carried out-of-band via --from). + // Own properties only β€” direct indexing could read inherited keys (e.g. a + // path named "constructor"), and Object.hasOwn also behaves correctly for + // null-prototype children maps. + const child = manifest && manifest.children && Object.hasOwn(manifest.children, childPath) ? manifest.children[childPath] : null; + if (child && child.url) return { url: child.url, source: "manifest" }; + + // 3. Explicit --base + basename. + if (base) { + const dir = String(base).replace(/\/+$/, ""); + return { url: `${dir}/${basename(childPath)}.git`, source: "base" }; + } + + // 4. Convention: sibling of the parent's origin. Zero committed state; a + // wrong guess is caught by SHA verification downstream. + const conv = conventionUrl(parentOrigin, childPath); + if (conv) return { url: conv, source: "convention" }; + + return { url: null, source: null }; +} diff --git a/src/api/embedded/restore.mjs b/src/api/embedded/restore.mjs new file mode 100644 index 0000000..b50d85b --- /dev/null +++ b/src/api/embedded/restore.mjs @@ -0,0 +1,231 @@ +import { self, context } from "@cldmv/slothlet/runtime"; + +function git(args, opts = {}) { + const res = context.spawnSync("git", args, { encoding: "utf8", ...opts }); + return { code: res.status ?? 1, stdout: (res.stdout || "").trim(), stderr: (res.stderr || "").trim() }; +} + +/** + * Remove a clone WE created, without ever touching a pre-existing directory. + * When the target did not exist before we cloned, the whole directory is ours + * to delete. When it pre-existed (git materializes a gitlink as an empty dir), + * only our clone's contents are removed β€” the directory itself is left in place. + * @param {string} absChild absolute child path + * @param {boolean} existedBefore whether the directory existed before the clone + */ +function removeClone(absChild, existedBefore) { + const { fs, path } = context; + if (!existedBefore) { + fs.rmSync(absChild, { recursive: true, force: true }); + return; + } + for (const entry of fs.readdirSync(absChild)) { + fs.rmSync(path.join(absChild, entry), { recursive: true, force: true }); + } +} + +/** + * Restore engine: clone missing embedded children and check out their pinned + * SHAs, resolving each URL strictest-source-first and SHA-verifying every clone + * so a wrong convention guess fails closed. + * + * Branch-aware: a branch for the child is resolved with the same layering as + * the URL β€” the registry (`embedded..branch`), then the manifest β€” and + * when neither supplies one it is inferred from the pin (exactly ONE `origin` + * branch containing it). With a branch the child ends ON that branch at the + * pin (upstream set best-effort, branch auto-registered); without one β€” + * including an ambiguous pin β€” the checkout stays detached. + * + * Partial restore is normal β€” a public cloner without access to a private child + * passes that path in `skip` and the rest still restore. + * + * @param {object} [opts] + * @param {string} [opts.cwd] working directory inside the parent repo + * @param {string[]} [opts.paths] restrict to these gitlink paths (default: all) + * @param {string} [opts.from] manifest file to read child URLs from + * @param {string} [opts.base] explicit URL base (`/.git`) + * @param {string[]} [opts.skip] gitlink paths to skip + * @param {boolean} [opts.dryRun] resolve and report only; clone/write nothing + * @returns {{ results: Array, exitCode: number }} per-child outcomes and + * a process exit code (non-zero when any non-skipped child ends `unresolved` + * or `pinned-mismatch`) + */ +export default function restore(opts = {}) { + const { fs, path } = context; + const { cwd = process.cwd(), paths = [], from = null, base = null, skip = [], dryRun = false } = opts; + + const root = self.git.getRepoRoot(cwd) || cwd; + const parentOrigin = git(["-C", root, "config", "--get", "remote.origin.url"]).stdout || null; + const manifest = from ? self.embedded.manifest.read(from, cwd) : null; + + // Gitlink paths from ls-tree are root-relative with forward slashes; accept + // the common user spellings of the same path ("./tests", "tests/", Windows + // "vendor\\foo") for --skip / path filters instead of silently not matching. + const normalizePath = (p) => + String(p) + .replace(/\\/g, "/") + .replace(/^\.\/+/, "") + .replace(/\/+$/, ""); + const skipSet = new Set(skip.map(normalizePath)); + const wantSet = paths.length ? new Set(paths.map(normalizePath)) : null; + + const links = self.embedded.gitlinks(root); + const results = []; + + for (const { path: childPath, sha } of links) { + if (wantSet && !wantSet.has(childPath)) continue; + + const record = { path: childPath, sha, url: null, source: null, branch: null, note: null }; + + if (skipSet.has(childPath)) { + results.push({ ...record, outcome: "skipped" }); + continue; + } + + const absChild = path.resolve(root, childPath); + + // lstat BEFORE probing for `.git` so a symlinked child is refused before + // anything follows it. A symlink that resolves to a real repo would + // otherwise satisfy the existsSync(.git) check below and be blessed as + // already-present β€” yet the packaged hooks cd into the child and would + // follow that link out of the parent worktree. lstat (not stat) sees the + // link itself, and also catches a broken symlink that existsSync misses. + let targetStat = null; + try { + targetStat = fs.lstatSync(absChild); + } catch (err) { + // Only ENOENT means "missing β€” clone will create it". A non-ENOENT + // lstat error (EACCES/ENOTDIR on an existing path) must be refused, not + // assumed absent β€” otherwise we could clone into, and later removeClone + // against, a pre-existing path we can't even stat. + if (err.code !== "ENOENT") { + results.push({ ...record, outcome: "unresolved", note: `target unreadable (${err.code || err.message}) β€” refusing to touch it` }); + continue; + } + } + if (targetStat && targetStat.isSymbolicLink()) { + results.push({ ...record, outcome: "unresolved", note: "target is a symbolic link β€” refusing to touch it" }); + continue; + } + + const hasGit = fs.existsSync(path.join(absChild, ".git")); + if (hasGit) { + results.push({ ...record, outcome: "already-present" }); + continue; + } + + // The only acceptable pre-existing target is an EMPTY, REAL directory β€” + // what a fresh parent clone materializes for a gitlink. A file or a + // directory with contents is user data: never clone into it, never remove + // it. (A symlink was already refused above.) + if (targetStat) { + let refuse = null; + if (!targetStat.isDirectory()) refuse = "target exists and is not a directory"; + else { + try { + if (fs.readdirSync(absChild).length > 0) refuse = "target directory is not empty"; + } catch (err) { + refuse = `target unreadable (${err.code || err.message})`; + } + } + if (refuse) { + results.push({ ...record, outcome: "unresolved", note: `${refuse} β€” refusing to touch it` }); + continue; + } + } + + const resolved = self.embedded.resolve(childPath, { cwd: root, manifest, base, parentOrigin }); + record.url = resolved.url; + record.source = resolved.source; + if (!resolved.url) { + results.push({ ...record, outcome: "unresolved", note: "no URL from local config, manifest, --base, or convention" }); + continue; + } + + // Branch precedence mirrors URL precedence: the per-clone registry first, + // then the manifest. Inference from the pin needs the clone to exist, so + // it runs after SHA verification below. Own-property manifest access for + // the same reason as resolve (a child path named "constructor"). + const manifestChild = + manifest && manifest.children && Object.hasOwn(manifest.children, childPath) ? manifest.children[childPath] : null; + const wantedBranch = self.embedded.registry.getBranch(childPath, root) || (manifestChild && manifestChild.branch) || null; + record.branch = wantedBranch; + + if (dryRun) { + results.push({ ...record, outcome: "restored", dryRun: true }); + continue; + } + + const existedBefore = fs.existsSync(absChild); + // `--` ends option parsing: a URL from config/manifest/--base that starts + // with "-" must never be interpreted as a git option (e.g. --upload-pack). + // cwd=root anchors a RELATIVE url (e.g. "../sibling.git") to the parent repo + // root, so a restore resolves the same regardless of where the caller ran + // from. Without it git resolves the url against the Node process CWD (the + // destination is absolute, so only the source url is affected). + const clone = git(["clone", "--quiet", "--", resolved.url, absChild], { cwd: root }); + if (clone.code !== 0) { + if (fs.existsSync(absChild)) removeClone(absChild, existedBefore); + results.push({ ...record, outcome: "unresolved", note: `clone failed: ${clone.stderr || `exit ${clone.code}`}` }); + continue; + } + + // SHA verification: the parent's pinned commit MUST exist in the clone. + // One fetch is attempted before giving up, in case origin's default + // refspec did not include the pinned commit. + let present = git(["-C", absChild, "cat-file", "-e", `${sha}^{commit}`]).code === 0; + let fetchErr = null; + if (!present) { + const fetch = git(["-C", absChild, "fetch", "--quiet", "origin"]); + if (fetch.code !== 0) fetchErr = fetch.stderr || `git fetch exited ${fetch.code}`; + present = git(["-C", absChild, "cat-file", "-e", `${sha}^{commit}`]).code === 0; + } + if (!present) { + removeClone(absChild, existedBefore); + // A failed fetch (auth/network) is not the same as "wrong repo" β€” surface + // it so a pinned-mismatch isn't misread as a bad convention guess. + const why = fetchErr + ? `fetch from ${resolved.source} repo failed (${fetchErr})` + : `pinned ${sha.slice(0, 12)} absent in ${resolved.source} repo`; + results.push({ + ...record, + outcome: "pinned-mismatch", + note: `${why}; clone removed` + }); + continue; + } + + // Branch-aware checkout: registry/manifest branch wins; otherwise infer it + // from the pin. Attach failure (e.g. an invalid branch name in the + // registry) falls back to today's detached checkout rather than failing + // the restore β€” the pin is verified present, so detached is always safe. + const branch = wantedBranch || self.embedded.branch.infer(absChild, sha); + let attached = false; + if (branch) { + attached = self.embedded.branch.attach(absChild, branch, sha); + if (!attached) record.note = `could not attach branch ${branch}; checked out detached`; + } + record.branch = attached ? branch : null; + if (!attached) { + const checkout = git(["-C", absChild, "checkout", "--quiet", "--detach", sha]); + if (checkout.code !== 0) { + removeClone(absChild, existedBefore); + results.push({ + ...record, + outcome: "pinned-mismatch", + note: `could not check out ${sha.slice(0, 12)}: ${checkout.stderr || `git checkout exited ${checkout.code}`}; clone removed` + }); + continue; + } + } + + // Persist the resolved URL (and the branch the child ended on) so day-2 + // re-restores and `sync` don't re-derive them. + self.embedded.registry.setUrl(childPath, resolved.url, root); + if (attached) self.embedded.registry.setBranch(childPath, branch, root); + results.push({ ...record, outcome: "restored" }); + } + + const exitCode = results.some((r) => r.outcome === "unresolved" || r.outcome === "pinned-mismatch") ? 1 : 0; + return { results, exitCode }; +} diff --git a/src/api/embedded/sync.mjs b/src/api/embedded/sync.mjs new file mode 100644 index 0000000..41053c2 --- /dev/null +++ b/src/api/embedded/sync.mjs @@ -0,0 +1,241 @@ +import { self, context } from "@cldmv/slothlet/runtime"; + +function git(args, opts = {}) { + const res = context.spawnSync("git", args, { encoding: "utf8", ...opts }); + return { code: res.status ?? 1, stdout: (res.stdout || "").trim(), stderr: (res.stderr || "").trim() }; +} + +/** + * Sync engine: move already-present embedded children to the pins in the + * parent's HEAD (day-2 β€” after the parent pulled new gitlink pins). The parent + * itself is never touched; pulling it first is the caller's step. + * + * Per child, in order: + * - symlinked gitlink path β†’ `sync-failed`, refusing to touch it (never run + * git through a link out of the worktree β€” same guard as restore/link). + * - HEAD already at the pin β†’ `in-sync` (done). + * - uncommitted changes β†’ `dirty`, left alone (that's your work). + * - pin absent locally β†’ one `git fetch origin`; still absent β†’ + * `pin-unavailable` (a real failure β€” non-zero exit). + * - on the REGISTERED branch (`embedded..branch`) and clean β†’ + * fast-forward-only: HEAD must be an ancestor of the pin, then the branch + * is moved to the pin (upstream refreshed best-effort). Ahead/diverged β†’ + * `ahead`, left alone (your work). + * - on any other branch β†’ `unregistered-branch`, left alone (reported). + * - detached and clean β†’ detach to the pin. + * + * Only `pin-unavailable` and `sync-failed` (an unexpected git failure β€” reading + * HEAD or status, the branch move, or the checkout) make the exit code + * non-zero; the left-alone outcomes are deliberate protection of in-progress + * work, not errors. + * + * @param {object} [opts] + * @param {string} [opts.cwd] working directory inside the parent repo + * @param {string[]} [opts.paths] restrict to these gitlink paths (default: all) + * @param {string[]} [opts.skip] gitlink paths to skip + * @param {boolean} [opts.dryRun] classify and report only; fetch/move nothing + * @returns {{ results: Array, exitCode: number }} per-child outcomes + * (`synced`, `in-sync`, `dirty`, `ahead`, `unregistered-branch`, + * `pin-unavailable`, `sync-failed`, `skipped`, `no-repo`) and a process exit + * code (non-zero when any child ends `pin-unavailable` or `sync-failed`) + */ +export default function sync(opts = {}) { + const { fs, path } = context; + const { cwd = process.cwd(), paths = [], skip = [], dryRun = false } = opts; + + const root = self.git.getRepoRoot(cwd) || cwd; + + // Same filter-spelling normalization as restore: gitlink paths are + // root-relative with forward slashes; accept "./tests", "tests/", "vendor\\foo". + const normalizePath = (p) => + String(p) + .replace(/\\/g, "/") + .replace(/^\.\/+/, "") + .replace(/\/+$/, ""); + const skipSet = new Set(skip.map(normalizePath)); + const wantSet = paths.length ? new Set(paths.map(normalizePath)) : null; + + const links = self.embedded.gitlinks(root); + const results = []; + + for (const { path: childPath, sha } of links) { + if (wantSet && !wantSet.has(childPath)) continue; + + const record = { path: childPath, sha, branch: null, note: null }; + + if (skipSet.has(childPath)) { + results.push({ ...record, outcome: "skipped" }); + continue; + } + + const absChild = path.resolve(root, childPath); + + // Refuse a symlinked gitlink path before touching it: every git command + // below runs with `-C absChild`, so a symlink pointing outside the parent + // worktree would have us fetch/checkout out there β€” the same risk restore + // and link already refuse. lstat sees the link itself (existsSync follows + // it); a symlink here is always an anomaly, so surface it (non-zero exit) + // even on an unfiltered run. + let linkStat = null; + try { + linkStat = fs.lstatSync(absChild); + } catch (err) { + // Only ENOENT means "missing". A non-ENOENT lstat error (EACCES/ENOTDIR + // on an existing path) is a real failure, not an absent child β€” surface + // it rather than silently proceeding. + if (err.code !== "ENOENT") { + results.push({ ...record, outcome: "sync-failed", note: `gitlink path unreadable (${err.code || err.message})` }); + continue; + } + /* ENOENT β€” missing; handled as no-repo below */ + } + if (linkStat && linkStat.isSymbolicLink()) { + results.push({ ...record, outcome: "sync-failed", note: "gitlink path is a symbolic link β€” refusing to touch it" }); + continue; + } + + if (!fs.existsSync(path.join(absChild, ".git"))) { + // A missing child is restore's job, not sync's; report it only when the + // caller asked for this path explicitly (mirrors record's idiom). + if (wantSet) results.push({ ...record, outcome: "no-repo", note: "not present on disk β€” run restore" }); + continue; + } + + const headRes = git(["-C", absChild, "rev-parse", "HEAD"]); + if (headRes.code !== 0) { + results.push({ + ...record, + outcome: "sync-failed", + note: `could not read HEAD: ${headRes.stderr || `git rev-parse exited ${headRes.code}`}` + }); + continue; + } + const head = headRes.stdout; + if (head === sha) { + results.push({ ...record, outcome: "in-sync" }); + continue; + } + + // A non-zero `git status` is a command failure (corrupt repo, permissions), + // not "uncommitted changes" β€” report it as sync-failed so the exit code is + // non-zero and stderr surfaces, instead of mislabeling it dirty. + const status = git(["-C", absChild, "status", "--porcelain"]); + if (status.code !== 0) { + results.push({ ...record, outcome: "sync-failed", note: `git status failed: ${status.stderr || `exit ${status.code}`}` }); + continue; + } + if (status.stdout) { + results.push({ ...record, outcome: "dirty", note: "pin moved but child has uncommitted changes β€” left alone" }); + continue; + } + + // Pin availability: one fetch before giving up. A dry run must not write + // even to the object store, so it reports optimistically (like restore's + // dry run) with a note instead of fetching. + let pinPresent = git(["-C", absChild, "cat-file", "-e", `${sha}^{commit}`]).code === 0; + if (!pinPresent && !dryRun) { + const fetch = git(["-C", absChild, "fetch", "--quiet", "origin"]); + if (fetch.code !== 0) { + // A failed fetch (auth/network) is a real error, not "pin genuinely + // absent" β€” report sync-failed with stderr so it's actionable. + results.push({ ...record, outcome: "sync-failed", note: `git fetch origin failed: ${fetch.stderr || `exit ${fetch.code}`}` }); + continue; + } + pinPresent = git(["-C", absChild, "cat-file", "-e", `${sha}^{commit}`]).code === 0; + if (!pinPresent) { + results.push({ ...record, outcome: "pin-unavailable", note: `pinned ${sha.slice(0, 12)} not found at origin after fetch` }); + continue; + } + } + if (!pinPresent && dryRun) record.note = "pin not in the local object store β€” a real run would fetch origin first"; + + const branchRes = git(["-C", absChild, "branch", "--show-current"]); + if (branchRes.code !== 0) { + results.push({ + ...record, + outcome: "sync-failed", + note: `could not read current branch: ${branchRes.stderr || `git branch --show-current exited ${branchRes.code}`}` + }); + continue; + } + const branch = branchRes.stdout || null; + const registered = self.embedded.registry.getBranch(childPath, root); + + if (branch && (!registered || branch !== registered)) { + results.push({ + ...record, + branch, + outcome: "unregistered-branch", + note: `pin moved but child is on unregistered branch '${branch}' β€” left alone` + }); + continue; + } + + if (branch) { + // The child LIVES on this branch (registry says so) β€” move the branch to + // the pin, fast-forward only: HEAD must be an ancestor of the pin. + // Commits beyond the pin are your work and stay untouched. + // merge-base --is-ancestor exit codes: 0 = HEAD IS an ancestor of the pin + // (fast-forward), 1 = NOT an ancestor (real divergence β€” your work), and + // anything else (128) is a genuine git error (corrupt repo, missing + // objects). Only exit 1 means "ahead"; a 128 must surface as sync-failed, + // not be mislabeled as your work and silently left alone. With the pin + // object absent (dry run) ancestry is unknowable β€” stay optimistic like + // the rest of the dry-run path. + let ancestor; + if (pinPresent) { + const anc = git(["-C", absChild, "merge-base", "--is-ancestor", "HEAD", sha]); + if (anc.code !== 0 && anc.code !== 1) { + results.push({ + ...record, + branch, + outcome: "sync-failed", + note: `could not test ancestry: ${anc.stderr || `merge-base --is-ancestor exited ${anc.code}`}` + }); + continue; + } + ancestor = anc.code === 0; + } else { + ancestor = true; + } + if (!ancestor) { + results.push({ + ...record, + branch, + outcome: "ahead", + note: `on '${branch}' with commits beyond the pin β€” left alone (your work)` + }); + continue; + } + if (dryRun) { + results.push({ ...record, branch, outcome: "synced", dryRun: true }); + continue; + } + if (!self.embedded.branch.attach(absChild, branch, sha)) { + results.push({ ...record, branch, outcome: "sync-failed", note: `could not move branch ${branch} to ${sha.slice(0, 12)}` }); + continue; + } + results.push({ ...record, branch, outcome: "synced" }); + continue; + } + + // Detached and clean: snap to the pin, staying detached. + if (dryRun) { + results.push({ ...record, outcome: "synced", dryRun: true }); + continue; + } + const checkout = git(["-C", absChild, "checkout", "--quiet", "--detach", sha]); + if (checkout.code !== 0) { + results.push({ + ...record, + outcome: "sync-failed", + note: `could not check out ${sha.slice(0, 12)}: ${checkout.stderr || `git checkout exited ${checkout.code}`}` + }); + continue; + } + results.push({ ...record, outcome: "synced" }); + } + + const exitCode = results.some((r) => r.outcome === "pin-unavailable" || r.outcome === "sync-failed") ? 1 : 0; + return { results, exitCode }; +} diff --git a/src/api/install/hooks.mjs b/src/api/install/hooks.mjs index 8acf66c..b3c08c8 100644 --- a/src/api/install/hooks.mjs +++ b/src/api/install/hooks.mjs @@ -4,13 +4,14 @@ export const PACKAGE_HOOK_MAP = { "post-checkout": "update-embedded-repos", "post-merge": "update-embedded-repos", "post-rewrite": "update-embedded-repos", - "reference-transaction": "reference-transaction" + "reference-transaction": "reference-transaction", + "pre-push": "pre-push" }; /** * Install or uninstall the package's per-repo hook scripts. * - * `op === "install"` copies the four hooks; `op === "uninstall"` removes only + * `op === "install"` copies the package hooks; `op === "uninstall"` removes only * the ones recognizably owned by git-embedded (any file whose content includes * the string "git-embedded"). * diff --git a/tests/embedded-provisioning.test.mjs b/tests/embedded-provisioning.test.mjs new file mode 100644 index 0000000..8c0f253 --- /dev/null +++ b/tests/embedded-provisioning.test.mjs @@ -0,0 +1,893 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { getApi } from "./_setup.mjs"; + +const tmpRoots = []; + +function mkTmp() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "git-embedded-prov-")); + tmpRoots.push(dir); + return dir; +} + +// Whether this environment can CREATE symlinks β€” Windows requires Developer +// Mode or elevation. The symlink-guard cases skip where creation is denied; +// the guards themselves need no symlink rights and stay exercised on POSIX CI. +const canSymlink = (() => { + let dir = null; + try { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "git-embedded-symlink-probe-")); + fs.symlinkSync(dir, path.join(dir, "probe"), "dir"); + return true; + } catch { + return false; + } finally { + // Clean up on BOTH paths β€” a failed probe (Windows without Developer + // Mode) must not leak the temp dir. + if (dir) fs.rmSync(dir, { recursive: true, force: true }); + } +})(); + +function git(args, cwd) { + const res = spawnSync("git", args, { cwd, encoding: "utf8" }); + if (res.status !== 0) throw new Error(`git ${args.join(" ")} (cwd=${cwd}) failed: ${res.stderr || res.stdout}`); + return (res.stdout || "").trim(); +} + +/** + * Build a bare "child source" repo with one commit and return its bare path + + * pinned SHA. The bare lives under `remotes/.git`. + */ +function makeChildBare(work, remotes, bareName, marker) { + const bare = path.join(remotes, `${bareName}.git`); + git(["init", "--bare", "-b", "main", bare]); + const src = path.join(work, `src-${bareName}`); + git(["init", "-b", "main", src]); + fs.writeFileSync(path.join(src, "spec.txt"), marker); + git(["add", "."], src); + git(["commit", "-m", `${bareName} init`], src); + git(["remote", "add", "origin", bare], src); + git(["push", "origin", "main"], src); + const sha = git(["rev-parse", "HEAD"], src); + return { bare, sha }; +} + +/** + * Assemble a parent repo carrying an anonymous gitlink and push it to a bare. + * The gitlink at `gitlinkPath` is pinned to `pinBare`'s HEAD; the convention + * sibling name is controlled by `childBareName` (defaults to the gitlink + * basename β†’ convention resolves; set it different to obscure the child). + */ +function makeParent({ childBareName = null, gitlinkPath = "tests", pinMarker = "child" } = {}) { + const work = mkTmp(); + const remotes = path.join(work, "remotes"); + fs.mkdirSync(remotes, { recursive: true }); + + const bareName = childBareName || gitlinkPath.split("/").pop(); + const child = makeChildBare(work, remotes, bareName, pinMarker); + + const parentBare = path.join(remotes, "parent.git"); + git(["init", "--bare", "-b", "main", parentBare]); + const parentSrc = path.join(work, "src-parent"); + git(["init", "-b", "main", parentSrc]); + fs.writeFileSync(path.join(parentSrc, "README.md"), "parent"); + git(["add", "."], parentSrc); + git(["commit", "-m", "parent init"], parentSrc); + git(["clone", "--quiet", child.bare, path.join(parentSrc, gitlinkPath)]); + git(["add", gitlinkPath], parentSrc); + git(["commit", "-m", `embed ${gitlinkPath}`], parentSrc); + git(["remote", "add", "origin", parentBare], parentSrc); + git(["push", "origin", "main"], parentSrc); + + return { work, remotes, parentBare, childBare: child.bare, childSha: child.sha, gitlinkPath }; +} + +function freshClone(parentBare) { + const dir = path.join(mkTmp(), "clone"); + git(["clone", "--quiet", parentBare, dir]); + return dir; +} + +/** + * Advance the child source repo by one commit. Pushed to the bare's `main` by + * default; `push: false` creates a commit that exists NOWHERE the child clone + * can fetch from (the missing-pin case). Returns the new SHA. + */ +function advanceChild(work, bareName, marker, { push = true } = {}) { + const src = path.join(work, `src-${bareName}`); + fs.writeFileSync(path.join(src, "next.txt"), marker); + git(["add", "."], src); + git(["commit", "-m", `${bareName} advance`], src); + if (push) git(["push", "origin", "main"], src); + return git(["rev-parse", "HEAD"], src); +} + +/** + * Move the parent's gitlink pin to `sha` without touching the child on disk β€” + * exactly the state a `git pull` of new parent commits leaves behind. + * `--cacheinfo` records the gitlink straight into the index, so the pinned + * commit need not exist locally. + */ +function bumpPin(parentDir, childPath, sha) { + git(["update-index", "--cacheinfo", `160000,${sha},${childPath}`], parentDir); + git(["commit", "-m", `bump ${childPath} pin`], parentDir); +} + +let originalEnv; +let originalCwd; + +beforeEach(() => { + originalEnv = { ...process.env }; + originalCwd = process.cwd(); + // Hermetic git: ignore host/global config, supply a commit identity. + process.env.GIT_CONFIG_GLOBAL = os.platform() === "win32" ? "NUL" : "/dev/null"; + process.env.GIT_CONFIG_SYSTEM = os.platform() === "win32" ? "NUL" : "/dev/null"; + process.env.GIT_AUTHOR_NAME = "test"; + process.env.GIT_AUTHOR_EMAIL = "test@example.com"; + process.env.GIT_COMMITTER_NAME = "test"; + process.env.GIT_COMMITTER_EMAIL = "test@example.com"; +}); + +afterEach(() => { + try { + process.chdir(originalCwd); + } catch { + // ignore + } + process.env = originalEnv; + vi.restoreAllMocks(); + while (tmpRoots.length) { + const d = tmpRoots.pop(); + try { + fs.rmSync(d, { recursive: true, force: true }); + } catch { + // ignore + } + } +}); + +let api; +beforeAll(async () => { + api = await getApi(); +}); + +describe("api.embedded.restore (convention)", () => { + it("restores a convention-resolvable child end-to-end and writes the registry", () => { + const { parentBare, childBare, childSha } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + + // Fresh clone materializes the gitlink as an empty dir with no .git. + expect(fs.existsSync(path.join(fresh, "tests"))).toBe(true); + expect(fs.existsSync(path.join(fresh, "tests", ".git"))).toBe(false); + + const { results, exitCode } = api.embedded.restore({ cwd: fresh }); + expect(exitCode).toBe(0); + expect(results).toHaveLength(1); + expect(results[0].outcome).toBe("restored"); + expect(results[0].source).toBe("convention"); + expect(results[0].url).toBe(childBare); + + // Pinned SHA is checked out (detached) inside the child. + expect(fs.existsSync(path.join(fresh, "tests", ".git"))).toBe(true); + expect(git(["rev-parse", "HEAD"], path.join(fresh, "tests"))).toBe(childSha); + + // Registry recorded so day-2 does not re-derive. + expect(api.embedded.registry.getUrl("tests", fresh)).toBe(childBare); + + // Day-2 re-restore is a no-op. + const again = api.embedded.restore({ cwd: fresh }); + expect(again.results[0].outcome).toBe("already-present"); + expect(again.exitCode).toBe(0); + }); + + it("resolves a RELATIVE registry url against the parent repo root, not the process cwd", () => { + // childBareName differs from the gitlink path so convention CANNOT resolve β€” + // the relative registry url is the only resolver, isolating the clone anchor. + const { parentBare, childBare, childSha } = makeParent({ gitlinkPath: "tests", childBareName: "secret-rel" }); + const fresh = freshClone(parentBare); + // Store the child URL as a path RELATIVE to the parent repo root. Anchoring + // the clone to root makes this resolve deterministically; without the anchor + // git resolves it against the Node process CWD (the test runner) and the + // clone fails β†’ the child would come back unresolved. + const relUrl = path.relative(fresh, childBare); + expect(path.isAbsolute(relUrl)).toBe(false); + api.embedded.registry.setUrl("tests", relUrl, fresh); + + const { results, exitCode } = api.embedded.restore({ cwd: fresh }); + expect(exitCode).toBe(0); + const rec = results.find((r) => r.path === "tests"); + expect(rec.outcome).toBe("restored"); + expect(fs.existsSync(path.join(fresh, "tests", ".git"))).toBe(true); + expect(git(["rev-parse", "HEAD"], path.join(fresh, "tests"))).toBe(childSha); + }); + + it("honors --skip for a partial restore (skipped child does not fail the run)", () => { + const { parentBare } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + const { results, exitCode } = api.embedded.restore({ cwd: fresh, skip: ["tests"] }); + expect(results[0].outcome).toBe("skipped"); + expect(exitCode).toBe(0); + expect(fs.existsSync(path.join(fresh, "tests", ".git"))).toBe(false); + }); +}); + +describe("api.embedded.restore (obscured child)", () => { + it("is unresolved by convention, then link into the empty dir makes a later restore already-present", () => { + // Child bare name differs from the gitlink basename β†’ convention guesses + // a non-existent sibling and fails closed. + const { parentBare, childBare } = makeParent({ gitlinkPath: "tests", childBareName: "secret-xyz" }); + const fresh = freshClone(parentBare); + + const first = api.embedded.restore({ cwd: fresh }); + expect(first.results[0].outcome).toBe("unresolved"); + expect(first.exitCode).toBe(1); + // Nothing planted; the materialized empty dir is left intact. + expect(fs.existsSync(path.join(fresh, "tests", ".git"))).toBe(false); + + // link the real (obscured) URL into the empty gitlink dir. + process.chdir(fresh); + vi.spyOn(process, "exit").mockImplementation((code) => { + throw new Error(`process.exit(${code})`); + }); + api.cli.link.run("tests", childBare); + process.chdir(originalCwd); + + expect(fs.existsSync(path.join(fresh, "tests", ".git"))).toBe(true); + expect(api.embedded.registry.getUrl("tests", fresh)).toBe(childBare); + + const second = api.embedded.restore({ cwd: fresh }); + expect(second.results[0].outcome).toBe("already-present"); + expect(second.exitCode).toBe(0); + }); +}); + +describe("api.embedded.restore (pinned-mismatch)", () => { + it("removes a clone whose repo lacks the pinned SHA and exits non-zero", () => { + const work = mkTmp(); + const remotes = path.join(work, "remotes"); + fs.mkdirSync(remotes, { recursive: true }); + + // Decoy at the convention target (tests.git) with unrelated history. + makeChildBare(work, remotes, "tests", "DECOY"); + // Real pin lives in a differently-named bare that convention never finds. + const real = makeChildBare(work, remotes, "real-child", "REAL"); + + const parentBare = path.join(remotes, "parent.git"); + git(["init", "--bare", "-b", "main", parentBare]); + const parentSrc = path.join(work, "src-parent"); + git(["init", "-b", "main", parentSrc]); + fs.writeFileSync(path.join(parentSrc, "README.md"), "parent"); + git(["add", "."], parentSrc); + git(["commit", "-m", "parent init"], parentSrc); + git(["clone", "--quiet", real.bare, path.join(parentSrc, "tests")]); + git(["add", "tests"], parentSrc); + git(["commit", "-m", "embed tests"], parentSrc); + git(["remote", "add", "origin", parentBare], parentSrc); + git(["push", "origin", "main"], parentSrc); + + const fresh = freshClone(parentBare); + const { results, exitCode } = api.embedded.restore({ cwd: fresh }); + + expect(results[0].outcome).toBe("pinned-mismatch"); + expect(results[0].source).toBe("convention"); + expect(exitCode).toBe(1); + + // The clone we created was removed; the pre-existing empty dir remains empty. + expect(fs.existsSync(path.join(fresh, "tests", ".git"))).toBe(false); + expect(fs.readdirSync(path.join(fresh, "tests"))).toHaveLength(0); + // No registry entry was written on failure. + expect(api.embedded.registry.getUrl("tests", fresh)).toBeNull(); + }); +}); + +describe("record / export round-trip", () => { + it("exports a manifest that resolves an obscured child on a second machine", () => { + const { parentBare, childBare, childSha } = makeParent({ gitlinkPath: "tests", childBareName: "secret-xyz" }); + + // Machine A: link the obscured child, then export a manifest. + const machineA = freshClone(parentBare); + process.chdir(machineA); + vi.spyOn(process, "exit").mockImplementation((code) => { + throw new Error(`process.exit(${code})`); + }); + api.cli.link.run("tests", childBare); + process.chdir(originalCwd); + + const entries = api.embedded.registry.entries(machineA); + expect(entries).toEqual([{ path: "tests", url: childBare, branch: "main" }]); + + const manifest = api.embedded.manifest.build(entries); + const manifestFile = path.join(mkTmp(), "children.json"); + fs.writeFileSync(manifestFile, api.embedded.manifest.serialize(manifest)); + + // Round-trip through disk. + const parsed = api.embedded.manifest.read(manifestFile); + expect(parsed.version).toBe(1); + expect(parsed.children.tests.url).toBe(childBare); + + // Machine B: convention cannot find the child; --from manifest resolves it. + const machineB = freshClone(parentBare); + const conv = api.embedded.restore({ cwd: machineB }); + expect(conv.results[0].outcome).toBe("unresolved"); + + const viaManifest = api.embedded.restore({ cwd: machineB, from: manifestFile }); + expect(viaManifest.results[0].outcome).toBe("restored"); + expect(viaManifest.results[0].source).toBe("manifest"); + expect(viaManifest.exitCode).toBe(0); + expect(git(["rev-parse", "HEAD"], path.join(machineB, "tests"))).toBe(childSha); + }); + + it("record writes the child's origin URL into the local registry", () => { + const { parentBare, childBare } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + api.embedded.restore({ cwd: fresh }); + // Clear the registry entry restore wrote, to prove record repopulates it. + git(["config", "--local", "--unset", "embedded.tests.url"], fresh); + expect(api.embedded.registry.getUrl("tests", fresh)).toBeNull(); + + const { results } = api.embedded.record({ cwd: fresh }); + expect(results).toHaveLength(1); + expect(results[0].outcome).toBe("recorded"); + expect(results[0].url).toBe(childBare); + expect(api.embedded.registry.getUrl("tests", fresh)).toBe(childBare); + }); + + it("record normalizes './tests' and 'tests/' path filters to the gitlink path", () => { + const { parentBare, childBare } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + api.embedded.restore({ cwd: fresh }); // child present with origin wired + git(["config", "--local", "--unset", "embedded.tests.url"], fresh); + + // './tests' previously missed the gitlink path ('tests') and silently + // recorded nothing; it must now match and record. + const dotSlash = api.embedded.record({ cwd: fresh, paths: ["./tests"] }); + expect(dotSlash.results).toHaveLength(1); + expect(dotSlash.results[0].outcome).toBe("recorded"); + expect(api.embedded.registry.getUrl("tests", fresh)).toBe(childBare); + + // Trailing-slash spelling matches too. + git(["config", "--local", "--unset", "embedded.tests.url"], fresh); + const trailing = api.embedded.record({ cwd: fresh, paths: ["tests/"] }); + expect(trailing.results[0].outcome).toBe("recorded"); + expect(api.embedded.registry.getUrl("tests", fresh)).toBe(childBare); + }); +}); + +describe("api.cli.link (empty-dir fix)", () => { + it("clones into an empty gitlink dir and refuses a non-empty one", () => { + const { parentBare, childBare } = makeParent({ gitlinkPath: "tests", childBareName: "secret-xyz" }); + const fresh = freshClone(parentBare); + process.chdir(fresh); + vi.spyOn(process, "exit").mockImplementation((code) => { + throw new Error(`process.exit(${code})`); + }); + + // Empty materialized dir β†’ link succeeds. + expect(fs.readdirSync(path.join(fresh, "tests"))).toHaveLength(0); + expect(() => api.cli.link.run("tests", childBare)).not.toThrow(); + expect(fs.existsSync(path.join(fresh, "tests", ".git"))).toBe(true); + + // Non-empty, non-repo dir β†’ link refuses with exit code 2. + fs.mkdirSync(path.join(fresh, "vendor")); + fs.writeFileSync(path.join(fresh, "vendor", "junk.txt"), "x"); + expect(() => api.cli.link.run("vendor", childBare)).toThrow(/process\.exit\(2\)/); + }); +}); + +describe("target safety guards (review hardening)", () => { + it("restore refuses a non-empty directory at a gitlink path and leaves it untouched", () => { + const { parentBare } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + // User data sitting in the materialized gitlink dir must never be touched. + fs.writeFileSync(path.join(fresh, "tests", "precious.txt"), "user data"); + const { results, exitCode } = api.embedded.restore({ cwd: fresh }); + expect(results[0].outcome).toBe("unresolved"); + expect(results[0].note).toMatch(/not empty.*refusing/); + expect(exitCode).toBe(1); + expect(fs.readFileSync(path.join(fresh, "tests", "precious.txt"), "utf8")).toBe("user data"); + expect(fs.existsSync(path.join(fresh, "tests", ".git"))).toBe(false); + }); + + it("restore refuses a file at a gitlink path and leaves it untouched", () => { + const { parentBare } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + fs.rmdirSync(path.join(fresh, "tests")); + fs.writeFileSync(path.join(fresh, "tests"), "a file, not a dir"); + const { results, exitCode } = api.embedded.restore({ cwd: fresh }); + expect(results[0].outcome).toBe("unresolved"); + expect(results[0].note).toMatch(/not a directory.*refusing/); + expect(exitCode).toBe(1); + expect(fs.readFileSync(path.join(fresh, "tests"), "utf8")).toBe("a file, not a dir"); + }); + + it("link refuses a file target up-front with exit 2", () => { + const { parentBare, childBare } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + process.chdir(fresh); + vi.spyOn(process, "exit").mockImplementation((code) => { + throw new Error(`process.exit(${code})`); + }); + fs.writeFileSync(path.join(fresh, "somefile"), "x"); + expect(() => api.cli.link.run("somefile", childBare)).toThrow(/process\.exit\(2\)/); + expect(fs.readFileSync(path.join(fresh, "somefile"), "utf8")).toBe("x"); + }); + + it("manifest.read rejects a missing or unsupported version", () => { + const dir = mkTmp(); + const noVersion = path.join(dir, "no-version.json"); + fs.writeFileSync(noVersion, JSON.stringify({ children: {} })); + expect(() => api.embedded.manifest.read(noVersion)).toThrow(/version/); + const badVersion = path.join(dir, "bad-version.json"); + fs.writeFileSync(badVersion, JSON.stringify({ version: 2, children: {} })); + expect(() => api.embedded.manifest.read(badVersion)).toThrow(/unsupported version 2/); + }); +}); + +describe("review hardening round 2 (scp-root convention + symlink guards)", () => { + it("derives the convention sibling for a scp-style origin with no path component", () => { + const { parentBare } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + // Repo at the scp path root: no "/" in the origin β€” sibling lives after the last ":". + git(["remote", "set-url", "origin", "git@host.example:parent.git"], fresh); + const { results } = api.embedded.restore({ cwd: fresh, dryRun: true }); + expect(results[0].source).toBe("convention"); + expect(results[0].url).toBe("git@host.example:tests.git"); + }); + + it.skipIf(!canSymlink)("restore refuses a symlink at a gitlink path β€” even one pointing at an empty dir", () => { + const { parentBare } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + const target = path.join(mkTmp(), "elsewhere"); + fs.mkdirSync(target); + fs.rmdirSync(path.join(fresh, "tests")); + fs.symlinkSync(target, path.join(fresh, "tests"), "dir"); + const { results, exitCode } = api.embedded.restore({ cwd: fresh }); + expect(results[0].outcome).toBe("unresolved"); + expect(results[0].note).toMatch(/symbolic link.*refusing/); + expect(exitCode).toBe(1); + // The symlink target stays untouched β€” nothing was cloned through it. + expect(fs.readdirSync(target)).toHaveLength(0); + expect(fs.lstatSync(path.join(fresh, "tests")).isSymbolicLink()).toBe(true); + }); + + it.skipIf(!canSymlink)("restore refuses a BROKEN symlink at a gitlink path", () => { + const { parentBare } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + fs.rmdirSync(path.join(fresh, "tests")); + fs.symlinkSync(path.join(fresh, "does-not-exist"), path.join(fresh, "tests"), "dir"); + const { results, exitCode } = api.embedded.restore({ cwd: fresh }); + expect(results[0].outcome).toBe("unresolved"); + expect(results[0].note).toMatch(/symbolic link.*refusing/); + expect(exitCode).toBe(1); + expect(fs.lstatSync(path.join(fresh, "tests")).isSymbolicLink()).toBe(true); + }); + + it.skipIf(!canSymlink)("restore refuses a symlinked child even when it resolves to a real repo with .git", () => { + const { parentBare, childBare } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + // A real child clone OUTSIDE the parent, symlinked in at the gitlink path: + // the link target has a .git, so the old .git-first check blessed it as + // already-present and skipped the symlink refusal. The packaged hooks + // would then cd through the link out of the parent worktree. + const outside = path.join(mkTmp(), "outside-child"); + git(["clone", "--quiet", childBare, outside]); + expect(fs.existsSync(path.join(outside, ".git"))).toBe(true); + fs.rmdirSync(path.join(fresh, "tests")); + fs.symlinkSync(outside, path.join(fresh, "tests"), "dir"); + + const { results, exitCode } = api.embedded.restore({ cwd: fresh }); + expect(results[0].outcome).toBe("unresolved"); + expect(results[0].note).toMatch(/symbolic link.*refusing/); + expect(exitCode).toBe(1); + // Never adopted as already-present; the link and its target stay intact. + expect(fs.lstatSync(path.join(fresh, "tests")).isSymbolicLink()).toBe(true); + expect(fs.existsSync(path.join(outside, ".git"))).toBe(true); + }); + + it.skipIf(!canSymlink)("link refuses a symlink target up-front with exit 2", () => { + const { parentBare, childBare } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + process.chdir(fresh); + vi.spyOn(process, "exit").mockImplementation((code) => { + throw new Error(`process.exit(${code})`); + }); + const target = path.join(mkTmp(), "elsewhere2"); + fs.mkdirSync(target); + fs.symlinkSync(target, path.join(fresh, "linked"), "dir"); + expect(() => api.cli.link.run("linked", childBare)).toThrow(/process\.exit\(2\)/); + expect(fs.readdirSync(target)).toHaveLength(0); + }); +}); + +describe("manifest shape hardening (review round 4)", () => { + it("read rejects an array children value", () => { + const dir = mkTmp(); + const f = path.join(dir, "array-children.json"); + fs.writeFileSync(f, JSON.stringify({ version: 1, children: [] })); + expect(() => api.embedded.manifest.read(f)).toThrow(/children/); + }); + + it("build treats a __proto__ child path as a plain key without polluting prototypes", () => { + const manifest = api.embedded.manifest.build([{ path: "__proto__", url: "ssh://h/p.git" }]); + expect(Object.hasOwn(manifest.children, "__proto__")).toBe(true); + expect({}.url).toBeUndefined(); // Object.prototype untouched + // Round-trips through JSON as an ordinary key. + expect(JSON.parse(JSON.stringify(manifest)).children["__proto__"].url).toBe("ssh://h/p.git"); + }); +}); + +describe("git argument-injection + registry-key normalization (review round 5)", () => { + it("a registry URL starting with '-' is passed as a repo, never a git option", () => { + const { parentBare } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + const marker = path.join(mkTmp(), "pwned"); + // Classic vector: without `--`, git clone would honor --upload-pack and run it. + api.embedded.registry.setUrl("tests", `--upload-pack=touch ${marker}`, fresh); + const { results, exitCode } = api.embedded.restore({ cwd: fresh }); + expect(results[0].outcome).toBe("unresolved"); // clone failed cleanly + expect(exitCode).toBe(1); + expect(fs.existsSync(marker)).toBe(false); // nothing executed + expect(fs.existsSync(path.join(fresh, "tests", ".git"))).toBe(false); + }); + + it("link normalizes './tests' and 'tests/' to the gitlink path for the registry key", () => { + const a = makeParent({ gitlinkPath: "tests" }); + const freshA = freshClone(a.parentBare); + process.chdir(freshA); + vi.spyOn(process, "exit").mockImplementation((code) => { + throw new Error(`process.exit(${code})`); + }); + api.cli.link.run("./tests", a.childBare); + expect(api.embedded.registry.getUrl("tests", freshA)).toBe(a.childBare); + expect(api.embedded.restore({ cwd: freshA }).results[0].outcome).toBe("already-present"); + process.chdir(originalCwd); + + const b = makeParent({ gitlinkPath: "tests" }); + const freshB = freshClone(b.parentBare); + process.chdir(freshB); + api.cli.link.run("tests/", b.childBare); + expect(api.embedded.registry.getUrl("tests", freshB)).toBe(b.childBare); + }); + + it("link refuses a target outside the repository worktree", () => { + const { parentBare, childBare } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + process.chdir(fresh); + vi.spyOn(process, "exit").mockImplementation((code) => { + throw new Error(`process.exit(${code})`); + }); + expect(() => api.cli.link.run("../escaped", childBare)).toThrow(/process\.exit\(2\)/); + expect(fs.existsSync(path.join(path.dirname(fresh), "escaped"))).toBe(false); + }); +}); + +describe("branch-aware restore", () => { + it("puts the child ON the unique containing branch, sets upstream, and auto-registers it", () => { + const { parentBare, childSha } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + + const { results, exitCode } = api.embedded.restore({ cwd: fresh }); + expect(exitCode).toBe(0); + expect(results[0].outcome).toBe("restored"); + expect(results[0].branch).toBe("main"); + + const child = path.join(fresh, "tests"); + expect(git(["rev-parse", "HEAD"], child)).toBe(childSha); + expect(git(["branch", "--show-current"], child)).toBe("main"); + expect(git(["rev-parse", "--abbrev-ref", "main@{upstream}"], child)).toBe("origin/main"); + // Auto-registered like the URL, so day-2 sync knows the child's branch. + expect(api.embedded.registry.getBranch("tests", fresh)).toBe("main"); + }); + + it("stays detached when the pin is on more than one remote branch (ambiguous)", () => { + const { work, parentBare, childSha } = makeParent({ gitlinkPath: "tests" }); + // A second remote branch containing the same pin β†’ inference must decline. + git(["push", "origin", "main:dev"], path.join(work, "src-tests")); + const fresh = freshClone(parentBare); + + const { results, exitCode } = api.embedded.restore({ cwd: fresh }); + expect(exitCode).toBe(0); + expect(results[0].outcome).toBe("restored"); + expect(results[0].branch).toBeNull(); + + const child = path.join(fresh, "tests"); + expect(git(["rev-parse", "HEAD"], child)).toBe(childSha); + expect(git(["branch", "--show-current"], child)).toBe(""); // detached + expect(api.embedded.registry.getBranch("tests", fresh)).toBeNull(); + }); + + it("infer is not poisoned by origin/HEAD (full-refname regression)", () => { + const { work, childBare, childSha } = makeParent({ gitlinkPath: "tests" }); + const clone = path.join(work, "infer-clone"); + git(["clone", "--quiet", childBare, clone]); + git(["remote", "set-head", "origin", "--auto"], clone); + // Precondition: origin/HEAD is set β€” with short refnames it would list as + // bare "origin" and fake a second candidate, breaking uniqueness. + expect(git(["symbolic-ref", "refs/remotes/origin/HEAD"], clone)).toBe("refs/remotes/origin/main"); + expect(api.embedded.branch.infer(clone, childSha)).toBe("main"); + }); + + it("a registered branch beats inference (and survives a missing remote branch)", () => { + const { parentBare, childSha } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + // Inference alone would pick "main"; the registry says otherwise. + api.embedded.registry.setBranch("tests", "pinned-work", fresh); + + const { results } = api.embedded.restore({ cwd: fresh }); + expect(results[0].outcome).toBe("restored"); + expect(results[0].branch).toBe("pinned-work"); + + const child = path.join(fresh, "tests"); + expect(git(["rev-parse", "HEAD"], child)).toBe(childSha); + expect(git(["branch", "--show-current"], child)).toBe("pinned-work"); + // No origin/pinned-work exists β€” upstream is best-effort, not a failure. + expect(api.embedded.registry.getBranch("tests", fresh)).toBe("pinned-work"); + }); + + it("round-trips the branch record β†’ export β†’ restore --from on a second machine", () => { + // Obscured name (no convention) + ambiguous inference (two branches carry + // the pin): only the manifest can supply BOTH the URL and the branch. + const { work, parentBare, childBare, childSha } = makeParent({ gitlinkPath: "tests", childBareName: "secret-xyz" }); + git(["push", "origin", "main:dev"], path.join(work, "src-secret-xyz")); + + // Machine A: link records url + branch; export serializes both. + const machineA = freshClone(parentBare); + process.chdir(machineA); + vi.spyOn(process, "exit").mockImplementation((code) => { + throw new Error(`process.exit(${code})`); + }); + api.cli.link.run("tests", childBare); + process.chdir(originalCwd); + + const entries = api.embedded.registry.entries(machineA); + expect(entries).toEqual([{ path: "tests", url: childBare, branch: "main" }]); + const manifestFile = path.join(mkTmp(), "children.json"); + fs.writeFileSync(manifestFile, api.embedded.manifest.serialize(api.embedded.manifest.build(entries))); + + // Machine B: restore --from puts the child ON the manifest's branch. + const machineB = freshClone(parentBare); + const { results, exitCode } = api.embedded.restore({ cwd: machineB, from: manifestFile }); + expect(exitCode).toBe(0); + expect(results[0].outcome).toBe("restored"); + expect(results[0].branch).toBe("main"); + + const child = path.join(machineB, "tests"); + expect(git(["rev-parse", "HEAD"], child)).toBe(childSha); + expect(git(["branch", "--show-current"], child)).toBe("main"); + expect(api.embedded.registry.getBranch("tests", machineB)).toBe("main"); + }); +}); + +describe("api.embedded.sync (day-2 pin sync)", () => { + it("fast-forwards the registered branch to a moved pin (fetching the pin first)", () => { + const { work, parentBare } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + api.embedded.restore({ cwd: fresh }); // child on main @ childSha, branch registered + + const sha2 = advanceChild(work, "tests", "v2"); + bumpPin(fresh, "tests", sha2); + + const { results, exitCode } = api.embedded.sync({ cwd: fresh }); + expect(exitCode).toBe(0); + expect(results[0].outcome).toBe("synced"); + expect(results[0].branch).toBe("main"); + + const child = path.join(fresh, "tests"); + expect(git(["rev-parse", "HEAD"], child)).toBe(sha2); + expect(git(["branch", "--show-current"], child)).toBe("main"); + expect(git(["rev-parse", "--abbrev-ref", "main@{upstream}"], child)).toBe("origin/main"); + + // Idempotent: a second sync is a no-op. + const again = api.embedded.sync({ cwd: fresh }); + expect(again.results[0].outcome).toBe("in-sync"); + expect(again.exitCode).toBe(0); + }); + + it("dry-run reports the move without fetching or touching the child", () => { + const { work, parentBare, childSha } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + api.embedded.restore({ cwd: fresh }); + + const sha2 = advanceChild(work, "tests", "v2"); + bumpPin(fresh, "tests", sha2); + + const { results, exitCode } = api.embedded.sync({ cwd: fresh, dryRun: true }); + expect(exitCode).toBe(0); + expect(results[0].outcome).toBe("synced"); + expect(results[0].dryRun).toBe(true); + + const child = path.join(fresh, "tests"); + expect(git(["rev-parse", "HEAD"], child)).toBe(childSha); // unmoved + // No fetch happened β€” the new pin is still absent from the object store. + const probe = spawnSync("git", ["cat-file", "-e", `${sha2}^{commit}`], { cwd: child }); + expect(probe.status).not.toBe(0); + }); + + it("leaves a registered branch with commits beyond the pin alone (your work)", () => { + const { parentBare, childSha } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + api.embedded.restore({ cwd: fresh }); + + const child = path.join(fresh, "tests"); + fs.writeFileSync(path.join(child, "wip.txt"), "local work"); + git(["add", "."], child); + git(["commit", "-m", "local work beyond the pin"], child); + const localSha = git(["rev-parse", "HEAD"], child); + expect(localSha).not.toBe(childSha); + + const { results, exitCode } = api.embedded.sync({ cwd: fresh }); + expect(exitCode).toBe(0); + expect(results[0].outcome).toBe("ahead"); + expect(results[0].note).toMatch(/beyond the pin.*your work/); + expect(git(["rev-parse", "HEAD"], child)).toBe(localSha); // untouched + }); + + it("leaves a dirty child alone", () => { + const { work, parentBare, childSha } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + api.embedded.restore({ cwd: fresh }); + + const sha2 = advanceChild(work, "tests", "v2"); + bumpPin(fresh, "tests", sha2); + const child = path.join(fresh, "tests"); + fs.writeFileSync(path.join(child, "uncommitted.txt"), "precious"); + + const { results, exitCode } = api.embedded.sync({ cwd: fresh }); + expect(exitCode).toBe(0); + expect(results[0].outcome).toBe("dirty"); + expect(git(["rev-parse", "HEAD"], child)).toBe(childSha); // unmoved + expect(fs.readFileSync(path.join(child, "uncommitted.txt"), "utf8")).toBe("precious"); + }); + + it("snaps a clean, detached child to the moved pin (staying detached)", () => { + const { work, parentBare } = makeParent({ gitlinkPath: "tests" }); + // Ambiguous inference β†’ restore leaves the child detached, no branch registered. + git(["push", "origin", "main:dev"], path.join(work, "src-tests")); + const fresh = freshClone(parentBare); + api.embedded.restore({ cwd: fresh }); + + const sha2 = advanceChild(work, "tests", "v2"); + bumpPin(fresh, "tests", sha2); + + const { results, exitCode } = api.embedded.sync({ cwd: fresh }); + expect(exitCode).toBe(0); + expect(results[0].outcome).toBe("synced"); + expect(results[0].branch).toBeNull(); + + const child = path.join(fresh, "tests"); + expect(git(["rev-parse", "HEAD"], child)).toBe(sha2); + expect(git(["branch", "--show-current"], child)).toBe(""); // still detached + }); + + it("leaves a child on an unregistered branch alone", () => { + const { work, parentBare, childSha } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + api.embedded.restore({ cwd: fresh }); // registers "main" + + const child = path.join(fresh, "tests"); + git(["checkout", "-b", "feature"], child); + const sha2 = advanceChild(work, "tests", "v2"); + bumpPin(fresh, "tests", sha2); + + const { results, exitCode } = api.embedded.sync({ cwd: fresh }); + expect(exitCode).toBe(0); + expect(results[0].outcome).toBe("unregistered-branch"); + expect(results[0].note).toMatch(/'feature'.*left alone/); + expect(git(["rev-parse", "HEAD"], child)).toBe(childSha); // unmoved + expect(git(["branch", "--show-current"], child)).toBe("feature"); + }); + + it("reports pin-unavailable (non-zero) when one fetch cannot find the pin", () => { + const { work, parentBare, childSha } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + api.embedded.restore({ cwd: fresh }); + + // A pin that exists nowhere the child can fetch from (never pushed). + const ghostSha = advanceChild(work, "tests", "ghost", { push: false }); + bumpPin(fresh, "tests", ghostSha); + + const { results, exitCode } = api.embedded.sync({ cwd: fresh }); + expect(exitCode).toBe(1); + expect(results[0].outcome).toBe("pin-unavailable"); + expect(results[0].note).toMatch(/not found at origin/); + expect(git(["rev-parse", "HEAD"], path.join(fresh, "tests"))).toBe(childSha); // unmoved + }); + + it("reports no-repo only for an explicitly requested absent child", () => { + const { parentBare } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); // child never restored + + // Unfiltered: an absent child is restore's job β€” silently ignored. + const all = api.embedded.sync({ cwd: fresh }); + expect(all.results).toHaveLength(0); + expect(all.exitCode).toBe(0); + + // Explicitly requested: reported, but not a sync failure. + const asked = api.embedded.sync({ cwd: fresh, paths: ["tests"] }); + expect(asked.results[0].outcome).toBe("no-repo"); + expect(asked.exitCode).toBe(0); + }); + + it("reports sync-failed (non-zero) when reading the child's HEAD fails", () => { + const { parentBare } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + api.embedded.restore({ cwd: fresh }); + const child = path.join(fresh, "tests"); + // Point HEAD at a ref that does not exist so `git rev-parse HEAD` errors β€” + // a git failure, not "uncommitted changes" and not "not at pin". + fs.writeFileSync(path.join(child, ".git", "HEAD"), "ref: refs/heads/corrupt-gone"); + const { results, exitCode } = api.embedded.sync({ cwd: fresh }); + expect(results[0].outcome).toBe("sync-failed"); + expect(exitCode).toBe(1); + }); + + it("reports sync-failed for a git status failure instead of mislabeling it dirty", () => { + const { work, parentBare } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + api.embedded.restore({ cwd: fresh }); + const sha2 = advanceChild(work, "tests", "v2"); + bumpPin(fresh, "tests", sha2); // HEAD != pin, so status is consulted + const child = path.join(fresh, "tests"); + // Corrupt the index so `git status` errors while HEAD still reads fine. + fs.writeFileSync(path.join(child, ".git", "index"), "not a valid git index"); + const { results, exitCode } = api.embedded.sync({ cwd: fresh }); + expect(results[0].outcome).toBe("sync-failed"); + expect(exitCode).toBe(1); + }); + + it.skipIf(!canSymlink)("refuses a symlinked gitlink path (sync-failed), never running git through the link", () => { + const { parentBare, childBare } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + // Replace the materialized gitlink dir with a symlink to a repo OUTSIDE the + // parent worktree β€” sync must refuse, not fetch/checkout out there. + const outside = path.join(mkTmp(), "outside-child"); + git(["clone", "--quiet", childBare, outside]); + fs.rmdirSync(path.join(fresh, "tests")); + fs.symlinkSync(outside, path.join(fresh, "tests"), "dir"); + + const { results, exitCode } = api.embedded.sync({ cwd: fresh }); + expect(results[0].outcome).toBe("sync-failed"); + expect(results[0].note).toMatch(/symbolic link.*refusing/); + expect(exitCode).toBe(1); + expect(fs.lstatSync(path.join(fresh, "tests")).isSymbolicLink()).toBe(true); + }); + + it("reports sync-failed (not pin-unavailable) when the fallback fetch itself fails", () => { + const { work, parentBare, childSha } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + api.embedded.restore({ cwd: fresh }); + const child = path.join(fresh, "tests"); + // Break the child's origin so the fallback fetch errors out. + git(["remote", "set-url", "origin", path.join(mkTmp(), "gone.git")], child); + // A pin absent locally forces the fetch path. + const ghostSha = advanceChild(work, "tests", "ghost", { push: false }); + bumpPin(fresh, "tests", ghostSha); + + const { results, exitCode } = api.embedded.sync({ cwd: fresh }); + expect(results[0].outcome).toBe("sync-failed"); + expect(results[0].note).toMatch(/fetch origin failed/); + expect(exitCode).toBe(1); + expect(git(["rev-parse", "HEAD"], child)).toBe(childSha); // unmoved + }); +}); + +describe("filter-path normalization (review round 6)", () => { + it("--skip and paths filters accept './x', 'x/', and backslash spellings", () => { + const { parentBare } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + // skip spelled './tests' must actually skip (previously a silent no-match). + const skipped = api.embedded.restore({ cwd: fresh, skip: ["./tests"] }); + expect(skipped.results[0].outcome).toBe("skipped"); + expect(skipped.exitCode).toBe(0); + // paths filter spelled 'tests/' must select the gitlink (dry-run). + const wanted = api.embedded.restore({ cwd: fresh, paths: ["tests/"], dryRun: true }); + expect(wanted.results).toHaveLength(1); + expect(wanted.results[0].outcome).toBe("restored"); + // backslash spelling normalizes too. + const bs = api.embedded.restore({ cwd: fresh, skip: ["tests\\"], dryRun: true }); + expect(bs.results[0].outcome).toBe("skipped"); + }); +}); diff --git a/tests/hook-guards.test.mjs b/tests/hook-guards.test.mjs new file mode 100644 index 0000000..be3fd9d --- /dev/null +++ b/tests/hook-guards.test.mjs @@ -0,0 +1,422 @@ +/** + * @Project: @cldmv/git-embedded + * @Filename: /tests/hook-guards.test.mjs + * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. + * + * Behavior tests for the two guard hooks, driven through REAL git operations + * with the hooks installed into the parent's .git/hooks: + * + * - reference-transaction (embedded.guard = precise | strict | off): which + * HEAD moves are allowed/blocked given each child's dirty state and the + * pins in the NEW commit. Covers the plumbing fact that a plain commit + * emits a HEAD transaction line, the precise rule (dirty + would-re-pin), + * strict's all-clean + pins-current-on-append policy, and the drifted-child + * hole a naive pin-delta rule would miss. + * + * - pre-push (embedded.pushRecurse = check | on-demand | off): parent pushes + * are rejected while a newly-pinned child commit is unreachable from the + * child's origin, allowed once the child is pushed (on-demand publishes the + * child's branch to do that automatically), and unrelated (pin-less) pushes + * from a children-less clone stay allowed. + */ + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const hooksSrc = path.join(here, "..", "hooks"); + +const tmpRoots = []; +function mkTmp() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "git-embedded-guards-")); + tmpRoots.push(dir); + return dir; +} + +function git(args, cwd) { + const res = spawnSync("git", args, { cwd, encoding: "utf8" }); + if (res.status !== 0) throw new Error(`git ${args.join(" ")} (cwd=${cwd}) failed: ${res.stderr || res.stdout}`); + return (res.stdout || "").trim(); +} + +/** Like git() but returns { status, stderr } for operations expected to be blocked. */ +function gitTry(args, cwd) { + const res = spawnSync("git", args, { cwd, encoding: "utf8" }); + return { status: res.status ?? 1, stderr: res.stderr || "", stdout: res.stdout || "" }; +} + +function installHook(repoDir, name) { + const dest = path.join(repoDir, ".git", "hooks", name); + fs.mkdirSync(path.dirname(dest), { recursive: true }); + fs.copyFileSync(path.join(hooksSrc, name), dest); + fs.chmodSync(dest, 0o755); +} + +/** + * Parent repo with one embedded child at `tests`, pushed bares for both, and + * the requested hooks installed. Child working copy inside the parent is a + * clone of the child bare (origin wired), attached to main at c1. + */ +function makeGuardedParent({ hooks = [], childPath = "tests" } = {}) { + const work = mkTmp(); + const childBare = path.join(work, "child.git"); + git(["init", "--bare", "-b", "main", childBare]); + const childSeed = path.join(work, "child-seed"); + git(["init", "-b", "main", childSeed]); + fs.writeFileSync(path.join(childSeed, "spec.txt"), "c1"); + git(["add", "."], childSeed); + git(["commit", "-m", "c1"], childSeed); + git(["remote", "add", "origin", childBare], childSeed); + git(["push", "--quiet", "origin", "main"], childSeed); + + const parentBare = path.join(work, "parent.git"); + git(["init", "--bare", "-b", "main", parentBare]); + const parent = path.join(work, "parent"); + git(["init", "-b", "main", parent]); + fs.writeFileSync(path.join(parent, "README.md"), "parent"); + git(["add", "."], parent); + git(["commit", "-m", "parent init"], parent); + git(["clone", "--quiet", childBare, path.join(parent, childPath)], parent); + git(["add", childPath], parent); + git(["commit", "-m", `embed ${childPath}`], parent); + git(["remote", "add", "origin", parentBare], parent); + + for (const h of hooks) installHook(parent, h); + const child = path.join(parent, childPath); + return { work, parent, parentBare, child, childBare, childPath }; +} + +/** Commit inside the child (advances its HEAD; keeps it clean). */ +function childCommit(child, marker) { + fs.writeFileSync(path.join(child, "spec.txt"), marker); + git(["add", "."], child); + git(["commit", "-m", marker], child); + return git(["rev-parse", "HEAD"], child); +} + +// "Dirty" per the hooks' diff-index semantics = a MODIFIED TRACKED file. +// (Untracked files never count β€” same as the original guard's behavior.) +function dirtyChild(child) { + fs.writeFileSync(path.join(child, "spec.txt"), "UNCOMMITTED EDIT"); +} +function cleanChild(child) { + git(["checkout", "--", "spec.txt"], child); +} + +let originalEnv; +beforeEach(() => { + originalEnv = { ...process.env }; + // Hermetic git: no host/global config (no global hooksPath dispatcher, no + // signing), a fixed identity. + process.env.GIT_CONFIG_GLOBAL = os.platform() === "win32" ? "NUL" : "/dev/null"; + process.env.GIT_CONFIG_SYSTEM = os.platform() === "win32" ? "NUL" : "/dev/null"; + process.env.GIT_AUTHOR_NAME = "test"; + process.env.GIT_AUTHOR_EMAIL = "test@example.com"; + process.env.GIT_COMMITTER_NAME = "test"; + process.env.GIT_COMMITTER_EMAIL = "test@example.com"; +}); +afterEach(() => { + process.env = originalEnv; + while (tmpRoots.length) { + const d = tmpRoots.pop(); + try { + fs.rmSync(d, { recursive: true, force: true }); + } catch { + /* ignore */ + } + } +}); + +describe.skipIf(process.platform === "win32")("reference-transaction guard modes", () => { + it("precise (default): a parent commit passes while a child is dirty AT its pin", () => { + const { parent, child } = makeGuardedParent({ hooks: ["reference-transaction"] }); + dirtyChild(child); + fs.writeFileSync(path.join(parent, "README.md"), "v2"); + git(["add", "README.md"], parent); + git(["commit", "-m", "docs"], parent); // would throw if blocked + expect(git(["log", "--oneline", "-1"], parent)).toContain("docs"); + }); + + it("precise: a checkout that would re-pin a dirty child is blocked; the same checkout with the child clean passes", () => { + const { parent, child } = makeGuardedParent({ hooks: ["reference-transaction"] }); + const commitA = git(["rev-parse", "HEAD"], parent); + const c2 = childCommit(child, "c2"); + git(["add", "tests"], parent); + git(["commit", "-m", "bump pin to c2"], parent); + + dirtyChild(child); // child HEAD c2; commitA pins c1 β†’ re-pin + dirty + const blocked = gitTry(["checkout", "--quiet", commitA], parent); + expect(blocked.status).not.toBe(0); + expect(blocked.stderr).toMatch(/would re-pin/); + expect(git(["rev-parse", "HEAD"], child)).toBe(c2); // untouched + + cleanChild(child); + const ok = gitTry(["checkout", "--quiet", commitA], parent); + expect(ok.status).toBe(0); + }); + + it("precise: a checkout whose pin equals the dirty child's HEAD passes (sync would no-op)", () => { + const { parent, child } = makeGuardedParent({ hooks: ["reference-transaction"] }); + fs.writeFileSync(path.join(parent, "README.md"), "v2"); + git(["add", "README.md"], parent); + git(["commit", "-m", "docs only"], parent); // same pin as previous commit + dirtyChild(child); // child at c1 == pin in BOTH commits + const ok = gitTry(["checkout", "--quiet", "HEAD~1"], parent); + expect(ok.status).toBe(0); + }); + + it("precise: catches the DRIFTED dirty child even when the pin is unchanged across the move", () => { + const { parent, child } = makeGuardedParent({ hooks: ["reference-transaction"] }); + fs.writeFileSync(path.join(parent, "README.md"), "v2"); + git(["add", "README.md"], parent); + git(["commit", "-m", "docs only"], parent); // pin still c1 in both commits + childCommit(child, "c2"); // drift: child HEAD c2, pin (both commits) c1 + dirtyChild(child); + // pin-delta between the two parent commits is ZERO β€” a naive rule allows + // this; the sync would still try to move the dirty child back to c1. + const blocked = gitTry(["checkout", "--quiet", "HEAD~1"], parent); + expect(blocked.status).not.toBe(0); + expect(blocked.stderr).toMatch(/would re-pin/); + }); + + it("strict: any dirty child blocks a parent commit", () => { + const { parent, child } = makeGuardedParent({ hooks: ["reference-transaction"] }); + git(["config", "--local", "embedded.guard", "strict"], parent); + dirtyChild(child); + fs.writeFileSync(path.join(parent, "README.md"), "v2"); + git(["add", "README.md"], parent); + const blocked = gitTry(["commit", "-m", "docs"], parent); + expect(blocked.status).not.toBe(0); + expect(blocked.stderr).toMatch(/uncommitted changes/); + }); + + it("strict: a clean child with a STALE pin blocks a parent commit until the pin is recorded", () => { + const { parent, child } = makeGuardedParent({ hooks: ["reference-transaction"] }); + git(["config", "--local", "embedded.guard", "strict"], parent); + childCommit(child, "c2"); // clean, but pin (c1) is now stale + fs.writeFileSync(path.join(parent, "README.md"), "v2"); + git(["add", "README.md"], parent); + const blocked = gitTry(["commit", "-m", "docs without pin bump"], parent); + expect(blocked.status).not.toBe(0); + expect(blocked.stderr).toMatch(/not synced/); + + git(["add", "tests"], parent); // record the pin β†’ now current + git(["commit", "-m", "docs + pin bump"], parent); + expect(git(["log", "--oneline", "-1"], parent)).toContain("pin bump"); + }); + + it("strict: a jump (checkout) with all children clean passes even though pins differ", () => { + const { parent, child } = makeGuardedParent({ hooks: ["reference-transaction"] }); + const commitA = git(["rev-parse", "HEAD"], parent); + childCommit(child, "c2"); + git(["add", "tests"], parent); + git(["commit", "-m", "bump"], parent); + git(["config", "--local", "embedded.guard", "strict"], parent); + // commitA pins c1, child HEAD is c2 β€” clean, and a checkout is a jump, + // so the pins-current rule does not apply. + const ok = gitTry(["checkout", "--quiet", commitA], parent); + expect(ok.status).toBe(0); + }); + + it("off: dirty + drifted child blocks nothing", () => { + const { parent, child } = makeGuardedParent({ hooks: ["reference-transaction"] }); + git(["config", "--local", "embedded.guard", "off"], parent); + childCommit(child, "c2"); + dirtyChild(child); + fs.writeFileSync(path.join(parent, "README.md"), "v2"); + git(["add", "README.md"], parent); + git(["commit", "-m", "docs"], parent); + const ok = gitTry(["checkout", "--quiet", "HEAD~1"], parent); + expect(ok.status).toBe(0); + }); + + it("precise: a spaced-path child that is dirty and would be re-pinned is blocked", () => { + const { parent, child } = makeGuardedParent({ hooks: ["reference-transaction"], childPath: "my tests" }); + const commitA = git(["rev-parse", "HEAD"], parent); + childCommit(child, "c2"); + git(["add", "my tests"], parent); + git(["commit", "-m", "bump pin to c2"], parent); + dirtyChild(child); // child at c2; commitA pins c1 β†’ re-pin + dirty + const blocked = gitTry(["checkout", "--quiet", commitA], parent); + expect(blocked.status).not.toBe(0); + expect(blocked.stderr).toMatch(/would re-pin/); + expect(blocked.stderr).toContain("my tests"); // full path, not split on the space + }); + + it("strict: a newly-initialized (unborn) child is skipped, not blocked", () => { + const { parent, child } = makeGuardedParent({ hooks: ["reference-transaction"] }); + git(["config", "--local", "embedded.guard", "strict"], parent); + // Re-init the child so it has a valid but UNBORN HEAD (no commits yet). + fs.rmSync(path.join(child, ".git"), { recursive: true, force: true }); + git(["init", "-b", "main", child]); + fs.writeFileSync(path.join(parent, "README.md"), "v2"); + git(["add", "README.md"], parent); + git(["commit", "-m", "docs"], parent); // would throw if blocked + expect(git(["log", "--oneline", "-1"], parent)).toContain("docs"); + }); + + it("strict: a child with an unreadable/corrupt HEAD fails closed", () => { + const { parent, child } = makeGuardedParent({ hooks: ["reference-transaction"] }); + git(["config", "--local", "embedded.guard", "strict"], parent); + // Corrupt HEAD so neither rev-parse nor symbolic-ref can resolve it. + fs.writeFileSync(path.join(child, ".git", "HEAD"), "not a ref and not a sha\n"); + fs.writeFileSync(path.join(parent, "README.md"), "v2"); + git(["add", "README.md"], parent); + const blocked = gitTry(["commit", "-m", "docs"], parent); + expect(blocked.status).not.toBe(0); + expect(blocked.stderr).toMatch(/cannot read HEAD/); + }); + + it("precise: a child with an unreadable HEAD is skipped (does not block)", () => { + const { parent, child } = makeGuardedParent({ hooks: ["reference-transaction"] }); + // precise is the default; corrupt the child HEAD. + fs.writeFileSync(path.join(child, ".git", "HEAD"), "not a ref and not a sha\n"); + fs.writeFileSync(path.join(parent, "README.md"), "v2"); + git(["add", "README.md"], parent); + git(["commit", "-m", "docs"], parent); // would throw if blocked + expect(git(["log", "--oneline", "-1"], parent)).toContain("docs"); + }); +}); + +describe.skipIf(process.platform === "win32")("pre-push pin-publication check", () => { + it("check (default): pushing a parent whose new pin IS on the child's origin passes", () => { + const { parent } = makeGuardedParent({ hooks: ["pre-push"] }); + git(["push", "--quiet", "origin", "main"], parent); // initial: pin c1 is on child origin + expect(git(["ls-remote", "origin", "main"], parent)).not.toBe(""); + }); + + it("check: a parent pinning a committed-but-UNPUSHED child commit is rejected, then passes after the child pushes", () => { + const { parent, child } = makeGuardedParent({ hooks: ["pre-push"] }); + git(["push", "--quiet", "origin", "main"], parent); + + childCommit(child, "c2"); // NOT pushed to child origin + git(["add", "tests"], parent); + git(["commit", "-m", "bump pin to unpublished c2"], parent); + + const blocked = gitTry(["push", "--quiet", "origin", "main"], parent); + expect(blocked.status).not.toBe(0); + expect(blocked.stderr).toMatch(/not on that child's origin/); + + git(["push", "--quiet", "origin", "main"], child); // publish the child + git(["push", "--quiet", "origin", "main"], parent); // now passes + const remoteTip = git(["ls-remote", "origin", "main"], parent).split(/\s/)[0]; + expect(remoteTip).toBe(git(["rev-parse", "HEAD"], parent)); + }); + + it("off: the same unpublished pin pushes without verification", () => { + const { parent, child } = makeGuardedParent({ hooks: ["pre-push"] }); + git(["config", "--local", "embedded.pushRecurse", "off"], parent); + git(["push", "--quiet", "origin", "main"], parent); + childCommit(child, "c2"); + git(["add", "tests"], parent); + git(["commit", "-m", "bump"], parent); + git(["push", "--quiet", "origin", "main"], parent); // would throw if blocked + }); + + it("a clone WITHOUT restored children can push commits that touch no pin", () => { + const { parent, parentBare } = makeGuardedParent({ hooks: [] }); + git(["push", "--quiet", "origin", "main"], parent); + const bareClone = path.join(mkTmp(), "clone"); + git(["clone", "--quiet", parentBare, bareClone]); + installHook(bareClone, "pre-push"); // gitlink dir is empty β€” no child repo + fs.writeFileSync(path.join(bareClone, "README.md"), "docs from machine B"); + git(["add", "README.md"], bareClone); + git(["commit", "-m", "docs"], bareClone); + git(["push", "--quiet", "origin", "main"], bareClone); // would throw if blocked + }); + + it("a pin CHANGE for a child that is not present locally is rejected (cannot verify)", () => { + const { parent, parentBare, child } = makeGuardedParent({ hooks: [] }); + git(["push", "--quiet", "origin", "main"], parent); + const c2 = childCommit(child, "c2"); + git(["push", "--quiet", "origin", "main"], child); // even published β€” can't VERIFY locally + const bareClone = path.join(mkTmp(), "clone"); + git(["clone", "--quiet", parentBare, bareClone]); + installHook(bareClone, "pre-push"); + // Hand-craft a pin bump without a child repo present. + git(["update-index", "--add", "--cacheinfo", `160000,${c2},tests`], bareClone); + git(["commit", "-m", "blind pin bump"], bareClone); + const blocked = gitTry(["push", "--quiet", "origin", "main"], bareClone); + expect(blocked.status).not.toBe(0); + expect(blocked.stderr).toMatch(/not present/); + }); + + it("on-demand: an unpublished pin is auto-published by pushing the child branch, then the parent push passes", () => { + const { parent, child } = makeGuardedParent({ hooks: ["pre-push"] }); + git(["config", "--local", "embedded.pushRecurse", "on-demand"], parent); + git(["push", "--quiet", "origin", "main"], parent); // initial: pin c1 already published + + const c2 = childCommit(child, "c2"); // committed but NOT pushed to the child's origin + git(["add", "tests"], parent); + git(["commit", "-m", "bump pin to c2 (unpublished)"], parent); + + // on-demand publishes the child's current branch (main, which contains c2) + // as a side effect, so the parent push is then allowed. + git(["push", "--quiet", "origin", "main"], parent); // would throw if blocked + + // The child's c2 was pushed to its origin by the hook. + expect(git(["ls-remote", "origin", "main"], child).split(/\s/)[0]).toBe(c2); + // And the parent push landed. + expect(git(["ls-remote", "origin", "main"], parent).split(/\s/)[0]).toBe(git(["rev-parse", "HEAD"], parent)); + }); + + it("check: a child whose gitlink path contains spaces is verified correctly (published pin passes)", () => { + const { parent } = makeGuardedParent({ hooks: ["pre-push"], childPath: "my tests" }); + // Pin c1 is already on the child's origin. The old path-first serialization + // misparsed "my tests" into path "my" and wrongly rejected this push. + git(["push", "--quiet", "origin", "main"], parent); // would throw if blocked + expect(git(["ls-remote", "origin", "main"], parent)).not.toBe(""); + }); + + it("check: an unpublished pin for a spaced-path child is rejected naming the full path", () => { + const { parent, child } = makeGuardedParent({ hooks: ["pre-push"], childPath: "my tests" }); + git(["push", "--quiet", "origin", "main"], parent); + childCommit(child, "c2"); // committed, NOT pushed to the child's origin + git(["add", "my tests"], parent); + git(["commit", "-m", "bump pin to unpublished c2"], parent); + const blocked = gitTry(["push", "--quiet", "origin", "main"], parent); + expect(blocked.status).not.toBe(0); + // Old bug: misparsed to path "my" β†’ "not present"; fixed: real path + cause. + expect(blocked.stderr).toMatch(/not on that child's origin/); + expect(blocked.stderr).toContain("my tests"); + }); + + it("check: a child whose gitlink path STARTS with a space passes when its pin is published (leading-space edge)", () => { + const { parent } = makeGuardedParent({ hooks: ["pre-push"], childPath: " leading" }); + // Pin c1 is already on the child's origin, so this push must pass. A plain + // `read -r pin path` re-read strips the LEADING space, so verify_pin would + // look up "leading" (which doesn't exist) and falsely reject; the whole-line + // read preserves " leading" and the published pin verifies. + git(["push", "--quiet", "origin", "main"], parent); // would throw if blocked + expect(git(["ls-remote", "origin", "main"], parent)).not.toBe(""); + }); + + it("check: a non-fast-forward push re-verifies the whole tip (a pin unchanged in the range but now unreachable is caught)", () => { + const { parent, child } = makeGuardedParent({ hooks: ["pre-push"] }); + const c1 = git(["rev-parse", "HEAD"], child); + // Publish c2, pin it, and land it on the parent's origin (verified new-ref push). + childCommit(child, "c2"); + git(["push", "--quiet", "origin", "main"], child); // publish c2 + git(["add", "tests"], parent); + git(["commit", "-m", "M: pin c2"], parent); + const M = git(["rev-parse", "HEAD"], parent); + fs.writeFileSync(path.join(parent, "README.md"), "y"); + git(["add", "README.md"], parent); + git(["commit", "-m", "Y"], parent); // P1 = M -> Y (tests@c2, verified) + git(["push", "--quiet", "origin", "main"], parent); + // The child's origin now loses c2 (rewound to c1) β€” c2 is unreachable there again. + git(["push", "--quiet", "--force", "origin", `${c1}:main`], child); + // Diverge the parent from M with a commit that does NOT touch the pin. + git(["reset", "--hard", M], parent); + git(["commit", "--allow-empty", "-m", "Z: diverged, pin unchanged"], parent); // P2 = M -> Z + // Non-fast-forward push: tests@c2 is unchanged in P1..P2 (so the diff-only pass + // misses it) and no longer on the child's origin β€” the full-tip pass must reject. + const blocked = gitTry(["push", "--quiet", "--force", "origin", "main"], parent); + expect(blocked.status).not.toBe(0); + expect(blocked.stderr).toMatch(/not on that child's origin/); + }); +}); diff --git a/tests/install-hooks.test.mjs b/tests/install-hooks.test.mjs index 93bfbf6..2b3e558 100644 --- a/tests/install-hooks.test.mjs +++ b/tests/install-hooks.test.mjs @@ -39,7 +39,7 @@ beforeAll(async () => { }); describe("api.install.hooks", () => { - it("installs the four package hooks and skips foreign existing files", async () => { + it("installs the package hooks and skips foreign existing files", async () => { const gitDir = mkTmp(); fs.mkdirSync(path.join(gitDir, "hooks")); // Pre-existing foreign hook should be left alone. @@ -51,11 +51,12 @@ describe("api.install.hooks", () => { expect(installed).toContain("post-merge"); expect(installed).toContain("post-rewrite"); expect(installed).toContain("reference-transaction"); + expect(installed).toContain("pre-push"); expect(installed).not.toContain("post-checkout"); const skipped = Array.from(out.skipped).map((s) => s.name); expect(skipped).toContain("post-checkout"); - for (const name of ["post-merge", "post-rewrite", "reference-transaction"]) { + for (const name of ["post-merge", "post-rewrite", "reference-transaction", "pre-push"]) { const body = fs.readFileSync(path.join(gitDir, "hooks", name), "utf8"); expect(body.startsWith("#!/usr/bin/env bash")).toBe(true); expect(body).toContain("git-embedded"); @@ -77,7 +78,7 @@ describe("api.install.hooks", () => { await api.install.hooks("install", gitDir); const entries = await api.log.read(); const ours = entries.filter((e) => e.op === "install-repo-hook" && e.path.startsWith(gitDir)); - expect(ours.length).toBe(4); + expect(ours.length).toBe(5); expect(fs.existsSync(api.log.path())).toBe(true); }); }); @@ -89,7 +90,7 @@ describe("api.install.hooks uninstall", () => { fs.writeFileSync(path.join(gitDir, "hooks", "pre-commit"), "#!/bin/sh\necho foreign\n"); const out = await api.install.hooks("uninstall", gitDir); const removed = Array.from(out.removed); - for (const hook of ["post-checkout", "post-merge", "post-rewrite", "reference-transaction"]) { + for (const hook of ["post-checkout", "post-merge", "post-rewrite", "reference-transaction", "pre-push"]) { expect(removed).toContain(hook); } expect(fs.existsSync(path.join(gitDir, "hooks", "pre-commit"))).toBe(true);