diff --git a/.configs/vitest.config.mjs b/.configs/vitest.config.mjs index 05398ed..74e0c46 100644 --- a/.configs/vitest.config.mjs +++ b/.configs/vitest.config.mjs @@ -13,10 +13,17 @@ export default defineConfig({ exclude: ["node_modules", "reference/**"], environment: "node", testTimeout: 30000, + server: { deps: { inline: [/@cldmv\/slothlet/] } }, coverage: { provider: "v8", include: ["src/**"], - exclude: ["**/*.json", "tests/**"], + exclude: [ + "**/*.json", + "tests/**", + // Windows-only elevation helpers — cannot execute on the Linux coverage runner. + "src/api/link/elevate-windows.mjs", + "src/lib/elevate-windows-child.mjs" + ], reporter: ["text", "html", "json-summary", "json"] } } diff --git a/.github/workflows/branch-retention.yml b/.github/workflows/branch-retention.yml index 14df9e4..a461ae9 100644 --- a/.github/workflows/branch-retention.yml +++ b/.github/workflows/branch-retention.yml @@ -1,6 +1,6 @@ # -# @Project: @cldmv/.github -# @Filename: /examples/individual-repo-workflows/automation/branch-retention.yml +# @Project: @cldmv/git-embedded +# @Filename: /.github/workflows/branch-retention.yml # @Date: 2026-05-20 00:00:00 -07:00 (1779606000) # @Author: Nate Corcoran # @Email: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 51c66eb..49dad7c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,6 @@ # -# @Project: @cldmv/.github -# @Filename: /examples/individual-repo-workflows/core-cicd/ci.yml +# @Project: @cldmv/git-embedded +# @Filename: /.github/workflows/ci.yml # @Date: 2026-05-20 00:00:00 -07:00 (1779606000) # @Author: Nate Corcoran # @Email: @@ -218,8 +218,14 @@ jobs: upload_coverage_artifact: ${{ github.event.inputs.upload_coverage_artifact != 'false' }} # ── Type check (runs inside the coverage-badge job) ──────────────────── + # Skipped deliberately: git-embedded is a dynamic slothlet-composed API + # (self.* / context.* resolved at runtime). tsc can't statically type that + # surface, slothlet's typegen only emits an all-`any` structural interface + # (no real types), and a checkJs pass is ~260 untypeable dynamic-API errors — + # there is no meaningful JS type-check to run. ESLint is the static-analysis + # net. (Investigated 2026-07-19; revisit if the API gains real generated types.) type_check_command: ${{ github.event.inputs.type_check_command || 'npm run test:types' }} - skip_type_check: ${{ github.event.inputs.skip_type_check == 'true' }} + skip_type_check: true # ── PR coverage badge ───────────────────────────────────────────────── # Injects a Shields.io badge + breakdown table directly into the PR body diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml new file mode 100644 index 0000000..8ea1b5a --- /dev/null +++ b/.github/workflows/cla.yml @@ -0,0 +1,60 @@ +# +# @Project: @cldmv/git-embedded +# @Filename: /.github/workflows/cla.yml +# @Date: 2026-07-19 00:00:00 -07:00 (1784523600) +# @Author: Nate Corcoran +# @Email: +# @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. +# + +# Individual repo: .github/workflows/cla.yml +# +# Per-CLA-version signing with per-repo override support. Each commit author +# must either: +# - Be in the org (silent pass via /orgs/CLDMV/members lookup) +# - Be in the exempt-bots list +# - Already have a signature record at the active (scope, version) in the +# central ledger repo (default: CLDMV/.cla-signatures) +# - Reply on this PR with the exact required text +# +# Default vs. override scope: +# - DEFAULT (this repo has NO root-level CLA.md): the bot uses the org-wide +# CLA at cla-versions/v.md in the ledger. Signing once covers every +# CLDMV repo that uses the default until the major.minor is bumped. +# - OVERRIDE (this repo HAS a root-level CLA.md): the bot enforces the +# consumer-repo text and reads the version from its header. Signatures +# live under signatures//overrides///v/ and +# are scoped to this repo only. +# +# Required setup: +# - Bot App must have `Organization permissions → Members: read` for the +# org-member exemption. +# - Bot App must have `Repository contents: write` on the ledger repo. +# - Optional `CLDMV_CLA_BOT_APP_CLIENT_ID` / `CLDMV_CLA_BOT_APP_PRIVATE_KEY` +# org secrets override the general bot identity for CLA actions only. +name: 📜 CLA + +on: + pull_request_target: + types: [opened, synchronize, reopened, ready_for_review] + issue_comment: + types: [created] + +permissions: + contents: read + pull-requests: write + statuses: write + issues: write + +jobs: + cla: + uses: CLDMV/.github/.github/workflows/reusable-cla.yml@v4 + with: + cla_version: "1.0" + secrets: + BOT_APP_CLIENT_ID: ${{ secrets.CLDMV_BOT_APP_CLIENT_ID }} + BOT_APP_PRIVATE_KEY: ${{ secrets.CLDMV_BOT_APP_PRIVATE_KEY }} + CLA_BOT_APP_CLIENT_ID: ${{ secrets.CLDMV_CLA_BOT_APP_CLIENT_ID }} + CLA_BOT_APP_PRIVATE_KEY: ${{ secrets.CLDMV_CLA_BOT_APP_PRIVATE_KEY }} + TAGGER_NAME: ${{ secrets.CLDMV_BOT_NAME }} + TAGGER_EMAIL: ${{ secrets.CLDMV_BOT_EMAIL }} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index faed0f4..69e7f17 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -1,6 +1,6 @@ # -# @Project: @cldmv/.github -# @Filename: /examples/individual-repo-workflows/security/codeql.yml +# @Project: @cldmv/git-embedded +# @Filename: /.github/workflows/codeql.yml # @Date: 2026-05-20 00:00:00 -07:00 (1779606000) # @Author: Nate Corcoran # @Email: diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml index 654e8a3..d68cf6a 100644 --- a/.github/workflows/dependabot-auto-merge.yml +++ b/.github/workflows/dependabot-auto-merge.yml @@ -1,6 +1,6 @@ # -# @Project: @cldmv/.github -# @Filename: /examples/individual-repo-workflows/automation/dependabot-auto-merge.yml +# @Project: @cldmv/git-embedded +# @Filename: /.github/workflows/dependabot-auto-merge.yml # @Date: 2026-05-20 00:00:00 -07:00 (1779606000) # @Author: Nate Corcoran # @Email: diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 82a55c9..b69898a 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -1,6 +1,6 @@ # -# @Project: @cldmv/.github -# @Filename: /examples/individual-repo-workflows/security/dependency-review.yml +# @Project: @cldmv/git-embedded +# @Filename: /.github/workflows/dependency-review.yml # @Date: 2026-05-20 00:00:00 -07:00 (1779606000) # @Author: Nate Corcoran # @Email: diff --git a/.github/workflows/hotfix-redirector.yml b/.github/workflows/hotfix-redirector.yml index c45bdbd..79383d5 100644 --- a/.github/workflows/hotfix-redirector.yml +++ b/.github/workflows/hotfix-redirector.yml @@ -1,6 +1,6 @@ # -# @Project: @cldmv/.github -# @Filename: /examples/individual-repo-workflows/release-flow-v4/hotfix-redirector.yml +# @Project: @cldmv/git-embedded +# @Filename: /.github/workflows/hotfix-redirector.yml # @Date: 2026-05-22 00:00:00 -07:00 (1779778800) # @Author: Nate Corcoran # @Email: diff --git a/.github/workflows/hotfixes-release.yml b/.github/workflows/hotfixes-release.yml index eee6f79..d7cfeea 100644 --- a/.github/workflows/hotfixes-release.yml +++ b/.github/workflows/hotfixes-release.yml @@ -1,6 +1,6 @@ # -# @Project: @cldmv/.github -# @Filename: /examples/individual-repo-workflows/release-flow-v4/hotfixes-release.yml +# @Project: @cldmv/git-embedded +# @Filename: /.github/workflows/hotfixes-release.yml # @Date: 2026-05-22 00:00:00 -07:00 (1779778800) # @Author: Nate Corcoran # @Email: diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 3e8b8f0..72cbbbc 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -1,6 +1,6 @@ # -# @Project: @cldmv/.github -# @Filename: /examples/individual-repo-workflows/automation/labeler.yml +# @Project: @cldmv/git-embedded +# @Filename: /.github/workflows/labeler.yml # @Date: 2026-05-20 00:00:00 -07:00 (1779606000) # @Author: Nate Corcoran # @Email: diff --git a/.github/workflows/master-commit-audit.yml b/.github/workflows/master-commit-audit.yml index 4be375f..ad6b2e8 100644 --- a/.github/workflows/master-commit-audit.yml +++ b/.github/workflows/master-commit-audit.yml @@ -1,6 +1,6 @@ # -# @Project: @cldmv/.github -# @Filename: /examples/individual-repo-workflows/release-companions/master-commit-audit.yml +# @Project: @cldmv/git-embedded +# @Filename: /.github/workflows/master-commit-audit.yml # @Date: 2026-05-20 00:00:00 -07:00 (1779606000) # @Author: Nate Corcoran # @Email: diff --git a/.github/workflows/next-release.yml b/.github/workflows/next-release.yml index 915b5d7..bf0c701 100644 --- a/.github/workflows/next-release.yml +++ b/.github/workflows/next-release.yml @@ -1,6 +1,6 @@ # -# @Project: @cldmv/.github -# @Filename: /examples/individual-repo-workflows/release-flow-v4/next-release.yml +# @Project: @cldmv/git-embedded +# @Filename: /.github/workflows/next-release.yml # @Date: 2026-05-22 00:00:00 -07:00 (1779778800) # @Author: Nate Corcoran # @Email: diff --git a/.github/workflows/next-reset.yml b/.github/workflows/next-reset.yml index d47f046..f19a665 100644 --- a/.github/workflows/next-reset.yml +++ b/.github/workflows/next-reset.yml @@ -1,6 +1,6 @@ # -# @Project: @cldmv/.github -# @Filename: /examples/individual-repo-workflows/release-flow-v4/next-reset.yml +# @Project: @cldmv/git-embedded +# @Filename: /.github/workflows/next-reset.yml # @Date: 2026-05-22 00:00:00 -07:00 (1779778800) # @Author: Nate Corcoran # @Email: diff --git a/.github/workflows/pr-title-normalizer.yml b/.github/workflows/pr-title-normalizer.yml index 25c8d85..f7dd668 100644 --- a/.github/workflows/pr-title-normalizer.yml +++ b/.github/workflows/pr-title-normalizer.yml @@ -1,6 +1,6 @@ # -# @Project: @cldmv/.github -# @Filename: /examples/individual-repo-workflows/release-flow-v4/pr-title-normalizer.yml +# @Project: @cldmv/git-embedded +# @Filename: /.github/workflows/pr-title-normalizer.yml # @Date: 2026-05-22 00:00:00 -07:00 (1779778800) # @Author: Nate Corcoran # @Email: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index a214219..9914a0b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,6 +1,6 @@ # -# @Project: @cldmv/.github -# @Filename: /examples/individual-repo-workflows/core-cicd/publish.yml +# @Project: @cldmv/git-embedded +# @Filename: /.github/workflows/publish.yml # @Date: 2026-05-20 00:00:00 -07:00 (1779606000) # @Author: Nate Corcoran # @Email: diff --git a/.github/workflows/release-notify.yml b/.github/workflows/release-notify.yml new file mode 100644 index 0000000..8956a1e --- /dev/null +++ b/.github/workflows/release-notify.yml @@ -0,0 +1,37 @@ +# +# @Project: @cldmv/git-embedded +# @Filename: /.github/workflows/release-notify.yml +# @Date: 2026-07-19 00:00:00 -07:00 (1784523600) +# @Author: Nate Corcoran +# @Email: +# @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. +# + +# Individual repo: .github/workflows/release-notify.yml +# +# Fires on `release: published` and dispatches the release announcement to +# any enabled webhook. No config file — each channel is just a secret: +# +# DISCORD_RELEASES_PUBLIC_WEBHOOK / DISCORD_RELEASES_PRIVATE_WEBHOOK +# SLACK_RELEASES_PUBLIC_WEBHOOK / SLACK_RELEASES_PRIVATE_WEBHOOK +# GENERIC_RELEASES_PUBLIC_WEBHOOK / GENERIC_RELEASES_PRIVATE_WEBHOOK +# +# Visibility is determined automatically from the repo: GitHub `public` → +# PUBLIC, `private` or `internal` → PRIVATE. Set the org-level secret in +# CLDMV for the default URL; set a repo-level secret with the same name to +# override (or to an empty string to mute that channel for this repo). +name: 📣 Release Notify + +on: + release: + types: [published] + +permissions: + contents: read + +jobs: + notify: + # Defensive: skip untagged releases (mirrors Batch 1.2's filter) + if: github.event.release.tag_name != '' + uses: CLDMV/.github/.github/workflows/reusable-release-notifier.yml@v4 + secrets: inherit diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml new file mode 100644 index 0000000..cb99eaf --- /dev/null +++ b/.github/workflows/scorecard.yml @@ -0,0 +1,61 @@ +# +# @Project: @cldmv/git-embedded +# @Filename: /.github/workflows/scorecard.yml +# @Date: 2026-07-19 00:00:00 -07:00 (1784523600) +# @Author: Nate Corcoran +# @Email: +# @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. +# + +# Individual repo: .github/workflows/scorecard.yml +# +# OpenSSF Scorecard — scans the repo against ~18 security best-practice checks +# and produces a 0-10 score. Thin caller: the steps and the SHA-pinned +# scorecard-action version live in reusable-scorecard.yml@v4, so the action +# version can't drift in this copy (it just calls the org reusable). Triggers +# stay here, per OpenSSF's recommended setup. +# +# NOTE: this MUST stay a thin caller. OSSF Scorecard's publish step verifies +# the analysis job and allows only a fixed set of steps; the inline form used +# our checkout-code composite, which trips "job has unallowed step" -> publish +# HTTP 400. The reusable uses actions/checkout directly, which passes. +name: 🔬 OpenSSF Scorecard + +on: + branch_protection_rule: + schedule: + - cron: "32 7 * * 1" # weekly Monday 07:32 UTC + push: + branches: [master, main] + workflow_dispatch: + +# Caller must grant what the reusable needs — notably id-token: write for the +# OpenSSF transparency-log publish. +# +# No workflow-level `permissions:` here — grant on the `analyze` job below +# instead. scorecard-action's publish step verifies that write permissions +# were granted JOB-scoped, not workflow-wide (matching OSSF's own example: +# https://github.com/ossf/scorecard-action#example-workflow). A workflow-level +# grant satisfies GitHub's own reusable-workflow permission rules fine, but +# still trips scorecard-action's own check — the rejection ("workflow +# verification failed: global perm is set to write: permission for X is set +# to write") means "granted globally," not "forbidden." +# +# Do NOT add security-events: write here while publish_results: true below. +# scorecard-action's publish step rejects submissions from a workflow whose +# token has security-events write access (it verifies the caller can't have +# tampered with results before they hit the public transparency log). That +# trade-off means the reusable's own SARIF-to-Security-tab upload step has no +# permission to run in this configuration; the public OpenSSF badge is the +# thing actually enabled here, so this repo takes that trade-off. Only add +# security-events: write back (job-scoped) if publish_results is set to false +# instead. +jobs: + analyze: + permissions: + id-token: write + contents: read + actions: read + uses: CLDMV/.github/.github/workflows/reusable-scorecard.yml@v4 + with: + publish_results: true # set false for private repos / to skip the public badge diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index bdcabbd..989998e 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -1,6 +1,6 @@ # -# @Project: @cldmv/.github -# @Filename: /examples/individual-repo-workflows/automation/stale.yml +# @Project: @cldmv/git-embedded +# @Filename: /.github/workflows/stale.yml # @Date: 2026-05-20 00:00:00 -07:00 (1779606000) # @Author: Nate Corcoran # @Email: diff --git a/.github/workflows/tag-health.yml b/.github/workflows/tag-health.yml index 80701a5..cccf512 100644 --- a/.github/workflows/tag-health.yml +++ b/.github/workflows/tag-health.yml @@ -1,6 +1,6 @@ # -# @Project: @cldmv/.github -# @Filename: /examples/individual-repo-workflows/release-companions/tag-health.yml +# @Project: @cldmv/git-embedded +# @Filename: /.github/workflows/tag-health.yml # @Date: 2026-05-20 00:00:00 -07:00 (1779606000) # @Author: Nate Corcoran # @Email: diff --git a/.github/workflows/update-major-version-tags.yml b/.github/workflows/update-major-version-tags.yml index cabcb26..aa0b7bc 100644 --- a/.github/workflows/update-major-version-tags.yml +++ b/.github/workflows/update-major-version-tags.yml @@ -1,6 +1,6 @@ # -# @Project: @cldmv/.github -# @Filename: /examples/individual-repo-workflows/core-cicd/update-major-version-tags.yml +# @Project: @cldmv/git-embedded +# @Filename: /.github/workflows/update-major-version-tags.yml # @Date: 2026-05-20 00:00:00 -07:00 (1779606000) # @Author: Nate Corcoran # @Email: diff --git a/.github/workflows/v4-bootstrap.yml b/.github/workflows/v4-bootstrap.yml index d8c86c2..095200d 100644 --- a/.github/workflows/v4-bootstrap.yml +++ b/.github/workflows/v4-bootstrap.yml @@ -1,6 +1,6 @@ # -# @Project: @cldmv/.github -# @Filename: /examples/individual-repo-workflows/release-flow-v4/v4-bootstrap.yml +# @Project: @cldmv/git-embedded +# @Filename: /.github/workflows/v4-bootstrap.yml # @Date: 2026-05-26 00:00:00 -07:00 (1780124400) # @Author: Nate Corcoran # @Email: diff --git a/.github/workflows/welcome.yml b/.github/workflows/welcome.yml index b414467..e45d095 100644 --- a/.github/workflows/welcome.yml +++ b/.github/workflows/welcome.yml @@ -1,6 +1,6 @@ # -# @Project: @cldmv/.github -# @Filename: /examples/individual-repo-workflows/automation/welcome.yml +# @Project: @cldmv/git-embedded +# @Filename: /.github/workflows/welcome.yml # @Date: 2026-05-20 00:00:00 -07:00 (1779606000) # @Author: Nate Corcoran # @Email: diff --git a/package-lock.json b/package-lock.json index 2fa3df0..3d641db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@cldmv/git-embedded", - "version": "1.1.0", + "version": "1.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@cldmv/git-embedded", - "version": "1.1.0", + "version": "1.1.1", "license": "Apache-2.0", "dependencies": { "@cldmv/slothlet": "^3.7.0", @@ -20,6 +20,7 @@ "git-embedded": "bin/git-embedded.mjs" }, "devDependencies": { + "@cldmv/vitest-runner": "^1.2.0", "@eslint/js": "^9.18.0", "@eslint/json": "^0.10.0", "@eslint/markdown": "^6.2.2", @@ -128,6 +129,25 @@ } } }, + "node_modules/@cldmv/vitest-runner": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@cldmv/vitest-runner/-/vitest-runner-1.2.0.tgz", + "integrity": "sha512-RhmXwFNB68OsgnIFSoQeTWgqEAZt/A+MYfc9lf2JdCUIRhzq2Z3gVeuw1pYOV0LihqfPWRNSmaVVDEEQKa93Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.4.1" + }, + "bin": { + "vitest-runner": "bin/vitest-runner.mjs" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "vitest": ">=1.0.0" + } + }, "node_modules/@cldmv/wisp": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@cldmv/wisp/-/wisp-1.0.1.tgz", diff --git a/package.json b/package.json index bdfb869..a277270 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@cldmv/git-embedded", - "version": "1.1.0", + "version": "1.1.1", "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", @@ -67,9 +67,11 @@ }, "scripts": { "start": "node bin/git-embedded.mjs", - "test": "vitest run --config .configs/vitest.config.mjs", + "build:ci": "echo '✓ no build step'", + "test": "node tests/run-vitest.mjs", "test:watch": "vitest --config .configs/vitest.config.mjs", - "coverage": "vitest run --coverage --config .configs/vitest.config.mjs", + "coverage": "node tests/run-vitest.mjs --coverage-quiet", + "ci:coverage": "npm run coverage", "lint": "eslint --config .configs/eslint.config.mjs .", "lint:fix": "eslint --config .configs/eslint.config.mjs . --fix", "format": "prettier --config .configs/.prettierrc --write .", @@ -84,6 +86,7 @@ "marked-terminal": "^7.3.0" }, "devDependencies": { + "@cldmv/vitest-runner": "^1.2.0", "@eslint/js": "^9.18.0", "@eslint/json": "^0.10.0", "@eslint/markdown": "^6.2.2", diff --git a/src/api/cli/init.mjs b/src/api/cli/init.mjs index 3be85db..c6cf1ed 100644 --- a/src/api/cli/init.mjs +++ b/src/api/cli/init.mjs @@ -19,6 +19,7 @@ export async function run(opts = {}) { if (cfg.status === 0) { self.report.success("Silenced 'embedded git repository' advice (git config advice.addEmbeddedRepo=false)."); } else { + /* v8 ignore next -- git normally writes config failures to stderr; the `|| stdout` fallback covers an empty-stderr failure (e.g. signal kill) — real, just not reproducible in the suite */ self.report.warn(`Could not set git config advice.addEmbeddedRepo: ${cfg.stderr || cfg.stdout}`); } } diff --git a/src/api/cli/install-hooks.mjs b/src/api/cli/install-hooks.mjs index 077e164..656d5a2 100644 --- a/src/api/cli/install-hooks.mjs +++ b/src/api/cli/install-hooks.mjs @@ -105,6 +105,7 @@ async function bootstrapAndInstall(result, opts) { if (out.fallbackToCopy.length > 0) self.report.warn(`Filesystem fallback to copy for ${out.fallbackToCopy.length} entries`); const gitConfig = context.spawnSync("git", ["config", "--global", "core.hooksPath", dir], { encoding: "utf8" }); if (gitConfig.status !== 0) { + /* v8 ignore next -- git normally writes config failures to stderr; the `|| stdout` fallback covers an empty-stderr failure (e.g. signal kill) — real, just not reproducible in the suite */ self.report.error(`git config --global core.hooksPath failed: ${gitConfig.stderr || gitConfig.stdout}`); process.exit(1); } diff --git a/src/api/cli/link.mjs b/src/api/cli/link.mjs index d535084..8b01f14 100644 --- a/src/api/cli/link.mjs +++ b/src/api/cli/link.mjs @@ -66,12 +66,14 @@ export function run(localPath, remoteUrl) { const clone = spawnSync("git", ["clone", "--", remoteUrl, localPath], { stdio: "inherit" }); if (clone.status !== 0) { self.report.error(`git clone exited with status ${clone.status}`); + /* v8 ignore next -- spawnSync returns a null status on spawn failure (git not on PATH) or signal termination — the real case `|| 1` guards — but the suite always has git present, so it can't be reproduced here (ignored, not tested) */ process.exit(clone.status || 1); } const add = spawnSync("git", ["add", "--", localPath], { stdio: "inherit" }); if (add.status !== 0) { self.report.error(`git add ${localPath} exited with status ${add.status}`); + /* v8 ignore next -- spawnSync returns a null status on spawn failure (git not on PATH) or signal termination — the real case `|| 1` guards — but the suite always has git present, so it can't be reproduced here (ignored, not tested) */ process.exit(add.status || 1); } diff --git a/src/api/commander/custom-help.mjs b/src/api/commander/custom-help.mjs index 743127c..43bf2cc 100644 --- a/src/api/commander/custom-help.mjs +++ b/src/api/commander/custom-help.mjs @@ -100,8 +100,11 @@ export function makeCustomHelp(HelpClass, deps) { for (const ex of examples) { const colored = ex.replace(argPattern, (match, p1, p2, p3, p4) => { if (p2) return color(chalk.magenta, match); + /* v8 ignore else -- defensive: argPattern's two alternatives each + require 1+ chars in their capture group, so a successful match + always sets p2 or p4; the else has no reachable real input. */ if (p4) return color(chalk.yellow, match); - return match; + else return match; }); output.push(` ${colored}`); } @@ -197,6 +200,9 @@ function getFullCommandChain(cmd) { function wrapTextWithHangingIndent(text, indent, label, labelColor, width) { const pad = " ".repeat(indent); const prefix = "- "; + /* v8 ignore next -- defensive: both call sites (Aliases/Description below) + always pass a non-empty literal label, and this private helper has no + other caller, so the empty-label fallback has no reachable real input. */ const labelStr = label ? label + ": " : ""; const hangingPad = pad + " ".repeat(prefix.length + labelStr.length); const maxWidth = (width || process.stdout.columns || 80) - (pad.length + prefix.length + labelStr.length); diff --git a/src/api/embedded/gitlinks.mjs b/src/api/embedded/gitlinks.mjs index 0e7264c..409ef49 100644 --- a/src/api/embedded/gitlinks.mjs +++ b/src/api/embedded/gitlinks.mjs @@ -29,8 +29,13 @@ export default function gitlinks(cwd = process.cwd()) { if (!line) continue; // SP SP TAB const tab = line.indexOf("\t"); + /* v8 ignore next -- defensive: `git ls-tree -r HEAD` always emits + ` SP SP TAB `, so a non-empty line with no tab is + unreachable (the empty-line case is already handled above). */ if (tab < 0) continue; const meta = line.slice(0, tab).split(/\s+/); + /* v8 ignore next -- defensive: the pre-tab field is always exactly + ` ` (3 tokens) for `-r HEAD`, so fewer than 3 is unreachable. */ if (meta.length < 3) continue; const [mode, type, sha] = meta; if (mode !== "160000" || type !== "commit") continue; diff --git a/src/api/embedded/restore.mjs b/src/api/embedded/restore.mjs index b50d85b..bdb8545 100644 --- a/src/api/embedded/restore.mjs +++ b/src/api/embedded/restore.mjs @@ -99,6 +99,7 @@ export default function restore(opts = {}) { // assumed absent — otherwise we could clone into, and later removeClone // against, a pre-existing path we can't even stat. if (err.code !== "ENOENT") { + /* v8 ignore next -- defensive: an fs error object always carries a `.code`, so the `|| err.message` fallback is unreachable. */ results.push({ ...record, outcome: "unresolved", note: `target unreadable (${err.code || err.message}) — refusing to touch it` }); continue; } @@ -125,6 +126,7 @@ export default function restore(opts = {}) { try { if (fs.readdirSync(absChild).length > 0) refuse = "target directory is not empty"; } catch (err) { + /* v8 ignore next -- defensive: an fs error object always carries a `.code`, so the `|| err.message` fallback is unreachable. */ refuse = `target unreadable (${err.code || err.message})`; } } @@ -166,6 +168,7 @@ export default function restore(opts = {}) { const clone = git(["clone", "--quiet", "--", resolved.url, absChild], { cwd: root }); if (clone.code !== 0) { if (fs.existsSync(absChild)) removeClone(absChild, existedBefore); + /* v8 ignore next -- git normally writes to stderr on a clone failure; the empty-stderr `|| exit N` fallback covers a stderr-less failure (e.g. signal kill) — real, just not reproducible in the suite */ results.push({ ...record, outcome: "unresolved", note: `clone failed: ${clone.stderr || `exit ${clone.code}`}` }); continue; } @@ -177,6 +180,9 @@ export default function restore(opts = {}) { let fetchErr = null; if (!present) { const fetch = git(["-C", absChild, "fetch", "--quiet", "origin"]); + /* v8 ignore next -- the clone above just succeeded from this same origin (git + stores it as an absolute path), so the follow-up fetch normally succeeds; a + mid-call network failure (remote unreachable) is real but not reproducible in the suite. */ if (fetch.code !== 0) fetchErr = fetch.stderr || `git fetch exited ${fetch.code}`; present = git(["-C", absChild, "cat-file", "-e", `${sha}^{commit}`]).code === 0; } @@ -185,7 +191,8 @@ export default function restore(opts = {}) { // 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})` + ? /* v8 ignore next -- fetchErr is set only by the fetch-failure path above — a mid-call network failure, real but not reproducible in the suite */ + `fetch from ${resolved.source} repo failed (${fetchErr})` : `pinned ${sha.slice(0, 12)} absent in ${resolved.source} repo`; results.push({ ...record, @@ -208,6 +215,9 @@ export default function restore(opts = {}) { record.branch = attached ? branch : null; if (!attached) { const checkout = git(["-C", absChild, "checkout", "--quiet", "--detach", sha]); + /* v8 ignore start -- the pin was just SHA-verified present in this fresh clone, + so the detached checkout normally succeeds; an I/O error or mid-call corruption + is real but not reproducible in the suite. */ if (checkout.code !== 0) { removeClone(absChild, existedBefore); results.push({ @@ -217,6 +227,7 @@ export default function restore(opts = {}) { }); continue; } + /* v8 ignore stop */ } // Persist the resolved URL (and the branch the child ended on) so day-2 diff --git a/src/api/embedded/sync.mjs b/src/api/embedded/sync.mjs index 41053c2..2132718 100644 --- a/src/api/embedded/sync.mjs +++ b/src/api/embedded/sync.mjs @@ -2,6 +2,9 @@ import { self, context } from "@cldmv/slothlet/runtime"; function git(args, opts = {}) { const res = context.spawnSync("git", args, { encoding: "utf8", ...opts }); + /* v8 ignore next -- res.status is null on spawn failure (git not on PATH) or signal kill — + the real case `?? 1` maps to exit 1. git() runs after gitlinks() so git is normally + spawnable, but a later spawn can still fail; it just isn't reproducible in the suite. */ return { code: res.status ?? 1, stdout: (res.stdout || "").trim(), stderr: (res.stderr || "").trim() }; } @@ -84,6 +87,7 @@ export default function sync(opts = {}) { // on an existing path) is a real failure, not an absent child — surface // it rather than silently proceeding. if (err.code !== "ENOENT") { + /* v8 ignore next -- defensive: an fs error object always carries a `.code`, so the `|| err.message` fallback is unreachable. */ results.push({ ...record, outcome: "sync-failed", note: `gitlink path unreadable (${err.code || err.message})` }); continue; } @@ -106,6 +110,7 @@ export default function sync(opts = {}) { results.push({ ...record, outcome: "sync-failed", + /* v8 ignore next -- git normally writes to stderr on a rev-parse failure; the empty-stderr `|| exit N` fallback covers a stderr-less failure (e.g. signal kill) — real, just not reproducible in the suite */ note: `could not read HEAD: ${headRes.stderr || `git rev-parse exited ${headRes.code}`}` }); continue; @@ -121,6 +126,7 @@ export default function sync(opts = {}) { // non-zero and stderr surfaces, instead of mislabeling it dirty. const status = git(["-C", absChild, "status", "--porcelain"]); if (status.code !== 0) { + /* v8 ignore next -- git normally writes to stderr on a status failure; the empty-stderr `|| exit N` fallback covers a stderr-less failure (e.g. signal kill) — real, just not reproducible in the suite */ results.push({ ...record, outcome: "sync-failed", note: `git status failed: ${status.stderr || `exit ${status.code}`}` }); continue; } @@ -138,6 +144,7 @@ export default function sync(opts = {}) { 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. + /* v8 ignore next -- git normally writes to stderr on a fetch failure; the empty-stderr `|| exit N` fallback covers a stderr-less failure (e.g. signal kill) — real, just not reproducible in the suite */ results.push({ ...record, outcome: "sync-failed", note: `git fetch origin failed: ${fetch.stderr || `exit ${fetch.code}`}` }); continue; } @@ -150,6 +157,10 @@ export default function sync(opts = {}) { 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"]); + /* v8 ignore start -- `git branch --show-current` normally succeeds here — HEAD + (rev-parse), the worktree (status), and the pin (cat-file) already succeeded, and + it neither locks the index nor inflates objects (an index.lock leaves it exit 0). A + hard failure (I/O error, signal kill) is real but not reproducible in the suite. */ if (branchRes.code !== 0) { results.push({ ...record, @@ -158,6 +169,7 @@ export default function sync(opts = {}) { }); continue; } + /* v8 ignore stop */ const branch = branchRes.stdout || null; const registered = self.embedded.registry.getBranch(childPath, root); @@ -190,6 +202,7 @@ export default function sync(opts = {}) { ...record, branch, outcome: "sync-failed", + /* v8 ignore next -- git normally writes to stderr on a merge-base error; the empty-stderr `|| exit N` fallback covers a stderr-less failure (e.g. signal kill) — real, just not reproducible in the suite */ note: `could not test ancestry: ${anc.stderr || `merge-base --is-ancestor exited ${anc.code}`}` }); continue; @@ -229,6 +242,7 @@ export default function sync(opts = {}) { results.push({ ...record, outcome: "sync-failed", + /* v8 ignore next -- git normally writes to stderr on a checkout failure; the empty-stderr `|| exit N` fallback covers a stderr-less failure (e.g. signal kill) — real, just not reproducible in the suite */ note: `could not check out ${sha.slice(0, 12)}: ${checkout.stderr || `git checkout exited ${checkout.code}`}` }); continue; diff --git a/src/api/git.mjs b/src/api/git.mjs index 257c488..9bcef4d 100644 --- a/src/api/git.mjs +++ b/src/api/git.mjs @@ -34,6 +34,9 @@ export function getEffectiveHooksPath(cwd = process.cwd()) { const { path, os } = context; const res = context.spawnSync("git", ["config", "--get", "core.hooksPath"], { cwd, encoding: "utf8" }); if (res.status !== 0) return null; + /* v8 ignore next -- defensive: a successful `git config --get` (status 0) always + writes the value plus a trailing newline, so res.stdout is never falsy here; + real git can't produce this fallback (verified empirically). */ const raw = (res.stdout || "").trim(); if (!raw) return null; const expanded = raw.startsWith("~") ? path.join(os.homedir(), raw.slice(1)) : raw; diff --git a/src/api/report.mjs b/src/api/report.mjs index b5635c3..e8169ef 100644 --- a/src/api/report.mjs +++ b/src/api/report.mjs @@ -63,6 +63,9 @@ export function detectionHeader(result) { export function message(kind) { const body = self.messages.load(kind); const rendered = context.renderMarkdown(body); + /* v8 ignore next -- every messages/*.md file (plus empty/whitespace-only input) renders + with a trailing newline (verified), so the append branch isn't reached by the current + message set; marked doesn't guarantee a trailing newline universally, so the guard stays. */ process.stdout.write(rendered.endsWith("\n") ? rendered : rendered + "\n"); } diff --git a/tests/cli-coverage.test.mjs b/tests/cli-coverage.test.mjs new file mode 100644 index 0000000..1584c63 --- /dev/null +++ b/tests/cli-coverage.test.mjs @@ -0,0 +1,788 @@ +/** + * @Project: @cldmv/git-embedded + * @Filename: /tests/cli-coverage.test.mjs + * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. + * + * Gap-closing behavior tests for the CLI wrapper commands (src/api/cli/*.mjs), + * driven through the composed slothlet api against REAL temp git repos in the + * same house style as cli-hooks.test.mjs / cli-provisioning.test.mjs. Each test + * targets an uncovered path the existing suites do not exercise: + * + * - link: full command coverage (blocksClone refusals, clone/add + * failures, outside-worktree guards, the non-repo add path). + * - install-hooks: the switch default, the no-gitDir + all-skipped install + * paths, heal/bootstrap copy-fallback, the git-config + * failure, the CancelledByUser + re-throw catch arms, and + * the "no git repo" post-bootstrap branch. + * - init: the git-config failure warn arm. + * - export: the missing-exclude catch, the no-trailing-newline prefix, + * and the non-repo root fallback. + * - install-template: the nothing-installed (all-skipped) branch. + * - record/restore/sync: the no-branch / no-note LABEL arms and the + * unknown-outcome LABEL fallbacks. + * + * Temp git repos live under the repo's own tmp/ (never the system /tmp), and a + * GIT_CEILING so a non-repo temp dir there is genuinely seen as a non-repo. + */ + +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 { fileURLToPath } from "node:url"; +import { getApi } from "./_setup.mjs"; +import { CancelledByUser } from "../src/api/link/batch.mjs"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const packageRoot = path.resolve(here, ".."); +// All scratch git repos live under the repo's tmp/ (gitignored), never /tmp. +const WORK_ROOT = path.join(packageRoot, "tmp", "cli-cov-work"); +fs.mkdirSync(WORK_ROOT, { recursive: true }); + +const PACKAGE_HOOKS = ["post-checkout", "post-merge", "post-rewrite", "reference-transaction", "pre-push"]; +const REQUIRED_HOOKS = ["post-checkout", "post-merge", "post-rewrite", "reference-transaction"]; + +// A canonical chaining dispatcher body (matches the classifier's chain check). +const CHAINING_DISPATCHER = `#!/bin/sh +# git-embedded-compatible dispatcher +hook=$(basename "$0") +git_dir=$(git rev-parse --absolute-git-dir 2>/dev/null) || exit 0 +repo_hook="$git_dir/hooks/$hook" +if [ -x "$repo_hook" ] && [ "$repo_hook" != "$0" ]; then + exec "$repo_hook" "$@" +fi +exit 0 +`; + +const tmpRoots = []; +function mkTmp(prefix = "wt-") { + const dir = fs.mkdtempSync(path.join(WORK_ROOT, prefix)); + tmpRoots.push(dir); + return dir; +} + +// Whether this environment can CREATE symlinks (skips the symlink-dependent cases +// on a host that denies creation, e.g. Windows without Developer Mode). +const canSymlink = (() => { + let dir = null; + try { + dir = fs.mkdtempSync(path.join(WORK_ROOT, "symlink-probe-")); + fs.symlinkSync(dir, path.join(dir, "probe"), "dir"); + return true; + } catch { + return false; + } finally { + 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(); +} + +/** A plain git repo with one commit. Returns { repo, gitDir }. */ +function makeRepo() { + const repo = path.join(mkTmp(), "repo"); + git(["init", "-b", "main", repo]); + fs.writeFileSync(path.join(repo, "README.md"), "hi"); + git(["add", "."], repo); + git(["commit", "-m", "init"], repo); + return { repo, gitDir: path.join(repo, ".git") }; +} + +/** A bare child repo carrying one commit; returns its path (usable as a clone URL). */ +function makeBareWithCommit(marker = "child") { + const root = mkTmp("child-"); + const bare = path.join(root, "child.git"); + git(["init", "--bare", "-b", "main", bare]); + const src = path.join(root, "src"); + git(["init", "-b", "main", src]); + fs.writeFileSync(path.join(src, "spec.txt"), marker); + git(["add", "."], src); + git(["commit", "-m", "init"], src); + git(["remote", "add", "origin", bare], src); + git(["push", "origin", "main"], src); + return bare; +} + +/** Build a dispatcher dir: a chaining `_dispatch` plus a symlink for each name. */ +function makeDispatcherDir(linked) { + const dir = path.join(mkTmp("disp-"), "hooks"); + fs.mkdirSync(dir, { recursive: true }); + const dispatch = path.join(dir, "_dispatch"); + fs.writeFileSync(dispatch, CHAINING_DISPATCHER); + fs.chmodSync(dispatch, 0o755); + for (const name of linked) fs.symlinkSync(dispatch, path.join(dir, name)); + return { dir, dispatch }; +} + +// ---- child-in-parent fixtures (mirrors cli-provisioning.test.mjs) -------- + +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 }; +} + +function makeParent({ gitlinkPath = "tests" } = {}) { + const work = mkTmp("parent-"); + const remotes = path.join(work, "remotes"); + fs.mkdirSync(remotes, { recursive: true }); + const bareName = gitlinkPath.split("/").pop(); + const child = makeChildBare(work, remotes, bareName, "child"); + + 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-"), "clone"); + git(["clone", "--quiet", parentBare, dir]); + return dir; +} + +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); +} + +function bumpPin(parentDir, childPath, sha) { + git(["update-index", "--cacheinfo", `160000,${sha},${childPath}`], parentDir); + git(["commit", "-m", `bump ${childPath} pin`], parentDir); +} + +// ---- output + process.exit capture --------------------------------------- + +let logLines; +let errLines; +let stdoutChunks; +let readonlyDirs; // dirs chmod'd unreadable in a test; restored before cleanup + +const stripAnsi = (s) => String(s).replace(new RegExp(String.fromCharCode(27) + "\\[[0-9;]*m", "g"), ""); +const logText = () => logLines.map(stripAnsi).join("\n"); +const errText = () => errLines.map(stripAnsi).join("\n"); +const outText = () => stdoutChunks.map(stripAnsi).join(""); + +function resetOutput() { + logLines.length = 0; + errLines.length = 0; + stdoutChunks.length = 0; +} + +/** + * Run a sync CLI wrapper (link/restore/record/export/sync). These end with + * process.exit(code) (mocked to throw) on some paths; translate that back into + * the returned exit code. A path that returns normally yields null. A non-exit + * throw (a real error) propagates. + */ +function runCli(fn) { + try { + fn(); + } catch (err) { + const m = /process\.exit\((-?\d+)\)/.exec(String(err && err.message)); + if (!m) throw err; + return Number(m[1]); + } + return null; +} + +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"; + // A non-repo temp dir under the repo's tmp/ must NOT resolve up to the + // enclosing worktree — stop git's discovery at the work root. + process.env.GIT_CEILING_DIRECTORIES = WORK_ROOT; + // Redirect XDG so the transaction log + default dispatcher dir land in temp. + process.env.XDG_STATE_HOME = mkTmp("state-"); + process.env.XDG_CONFIG_HOME = mkTmp("config-"); + + logLines = []; + errLines = []; + stdoutChunks = []; + readonlyDirs = []; + vi.spyOn(console, "log").mockImplementation((...a) => logLines.push(a.map(String).join(" "))); + vi.spyOn(console, "error").mockImplementation((...a) => errLines.push(a.map(String).join(" "))); + vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { + stdoutChunks.push(typeof chunk === "string" ? chunk : chunk.toString("utf8")); + return true; + }); + vi.spyOn(process, "exit").mockImplementation((code) => { + throw new Error(`process.exit(${code})`); + }); +}); + +afterEach(() => { + try { + process.chdir(originalCwd); + } catch { + // ignore + } + for (const d of readonlyDirs) { + try { + fs.chmodSync(d, 0o755); + } 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(); +}); + +// ========================================================================== +// link +// ========================================================================== +describe("api.cli.link.run", () => { + it("clones a missing target, stages the gitlink, and records the URL", () => { + const { repo } = makeRepo(); + const bare = makeBareWithCommit(); + process.chdir(repo); + + api.cli.link.run("tests", bare); // happy path: no process.exit + + expect(logText()).toContain(`Cloning ${bare} into tests`); + expect(logText()).toContain("Staged gitlink at tests"); + expect(fs.existsSync(path.join(repo, "tests", ".git"))).toBe(true); + // Staged as a gitlink (mode 160000) and recorded in the local registry. + expect(git(["ls-files", "--stage", "tests"], repo)).toMatch(/^160000 /); + expect(api.embedded.registry.getUrl("tests", repo)).toBe(bare); + }); + + it("clones into a pre-existing EMPTY directory (an accepted target)", () => { + const { repo } = makeRepo(); + const bare = makeBareWithCommit(); + fs.mkdirSync(path.join(repo, "empty")); + process.chdir(repo); + + api.cli.link.run("empty", bare); + expect(fs.existsSync(path.join(repo, "empty", ".git"))).toBe(true); + }); + + it("refuses a non-empty directory target and exits 2", () => { + const { repo } = makeRepo(); + const bare = makeBareWithCommit(); + fs.mkdirSync(path.join(repo, "busy")); + fs.writeFileSync(path.join(repo, "busy", "f"), "x"); + process.chdir(repo); + + const code = runCli(() => api.cli.link.run("busy", bare)); + expect(code).toBe(2); + expect(errText()).toContain("busy exists and is not an empty directory"); + }); + + it("refuses a plain-file target and exits 2", () => { + const { repo } = makeRepo(); + const bare = makeBareWithCommit(); + fs.writeFileSync(path.join(repo, "afile"), "x"); + process.chdir(repo); + + const code = runCli(() => api.cli.link.run("afile", bare)); + expect(code).toBe(2); + expect(errText()).toContain("afile exists and is not an empty directory"); + }); + + it.skipIf(!canSymlink)("refuses a symlink target and exits 2", () => { + const { repo } = makeRepo(); + const bare = makeBareWithCommit(); + fs.symlinkSync(mkTmp("linktgt-"), path.join(repo, "alink"), "dir"); + process.chdir(repo); + + const code = runCli(() => api.cli.link.run("alink", bare)); + expect(code).toBe(2); + expect(errText()).toContain("alink exists and is not an empty directory"); + }); + + it("refuses an unreadable directory target and exits 2 (readdir throws)", () => { + const { repo } = makeRepo(); + const bare = makeBareWithCommit(); + const noread = path.join(repo, "noread"); + fs.mkdirSync(noread); + fs.chmodSync(noread, 0o000); + readonlyDirs.push(noread); + process.chdir(repo); + + const code = runCli(() => api.cli.link.run("noread", bare)); + expect(code).toBe(2); + expect(errText()).toContain("noread exists and is not an empty directory"); + }); + + it("refuses a target outside the worktree ('..') and exits 2", () => { + const { repo } = makeRepo(); + const bare = makeBareWithCommit(); + process.chdir(repo); + + const code = runCli(() => api.cli.link.run("../outside", bare)); + expect(code).toBe(2); + expect(errText()).toContain("outside the repository worktree"); + }); + + it("refuses the repo root itself ('.') and exits 2", () => { + const { repo } = makeRepo(); + const bare = makeBareWithCommit(); + process.chdir(repo); + + const code = runCli(() => api.cli.link.run(".", bare)); + expect(code).toBe(2); + expect(errText()).toContain("outside the repository worktree"); + }); + + it("exits with git's status when the clone fails", () => { + const { repo } = makeRepo(); + process.chdir(repo); + const nonexistent = path.join(mkTmp("bad-"), "does-not-exist.git"); + + const code = runCli(() => api.cli.link.run("newchild", nonexistent)); + expect(code).toBe(128); // git clone of a missing repo exits 128 + expect(errText()).toContain("git clone exited with status 128"); + expect(fs.existsSync(path.join(repo, "newchild", ".git"))).toBe(false); + }); + + it("outside a repo: root falls back to cwd and the git add fails", () => { + // getRepoRoot() is null here (ceiling stops discovery), so root=cwd; the + // clone still succeeds (it makes its own repo) but the follow-up git add + // has no parent repo to stage into. + const nonrepo = mkTmp("nonrepo-"); + const bare = makeBareWithCommit(); + process.chdir(nonrepo); + + const code = runCli(() => api.cli.link.run("child", bare)); + expect(code).toBe(128); + expect(errText()).toContain("git add child exited with status 128"); + // The clone itself happened (proves we got past the clone step). + expect(fs.existsSync(path.join(nonrepo, "child", ".git"))).toBe(true); + }); +}); + +// ========================================================================== +// install-hooks +// ========================================================================== +describe("api.cli.installHooks — uncovered action/branch paths", () => { + it("refuses an unknown detection action and exits 2", async () => { + const { repo } = makeRepo(); + process.chdir(repo); + vi.spyOn(api.detect, "run").mockReturnValue({ + action: "totally-bogus", + kind: "none", + paths: {}, + signals: { hooksPathScopes: { system: null, global: null, local: null }, initTemplateDir: null } + }); + + await expect(api.cli.installHooks.run({})).rejects.toThrow(/process\.exit\(2\)/); + expect(errText()).toContain("Unknown detection action: totally-bogus"); + }); + + it.skipIf(!canSymlink)("install action outside a git repo: refuses (no gitDir) and exits 2", async () => { + const { dir } = makeDispatcherDir(REQUIRED_HOOKS); // canonical-complete + const globalCfg = path.join(mkTmp("gcfg-"), "gitconfig"); + process.env.GIT_CONFIG_GLOBAL = globalCfg; + git(["config", "--global", "core.hooksPath", dir], undefined); + process.chdir(mkTmp("nonrepo-")); // canonical dispatcher is global, but no repo here + + await expect(api.cli.installHooks.run({})).rejects.toThrow(/process\.exit\(2\)/); + expect(errText()).toContain("Not inside a git repository"); + }); + + it.skipIf(!canSymlink)("install action with all hooks foreign installs nothing (no success line)", async () => { + const { dir } = makeDispatcherDir(REQUIRED_HOOKS); + const { repo, gitDir } = makeRepo(); + const hooksDir = path.join(gitDir, "hooks"); + fs.mkdirSync(hooksDir, { recursive: true }); + for (const name of PACKAGE_HOOKS) fs.writeFileSync(path.join(hooksDir, name), "#!/bin/sh\necho foreign\n"); + git(["config", "--local", "core.hooksPath", dir], repo); + process.chdir(repo); + + await api.cli.installHooks.run({}); + expect(logText()).not.toContain("Installed per-repo hooks"); + for (const name of PACKAGE_HOOKS) expect(logText()).toContain(`Skipped ${name}`); + }); + + it.skipIf(!canSymlink)("heal-then-install: warns on a filesystem copy fallback", async () => { + // A required entry that already exists as a PLAIN FILE is classified + // missing; healing it hits EEXIST on the symlink and falls back to copy. + const dir = path.join(mkTmp("disp-"), "hooks"); + fs.mkdirSync(dir, { recursive: true }); + const dispatch = path.join(dir, "_dispatch"); + fs.writeFileSync(dispatch, CHAINING_DISPATCHER); + fs.chmodSync(dispatch, 0o755); + fs.symlinkSync(dispatch, path.join(dir, "post-checkout")); + fs.symlinkSync(dispatch, path.join(dir, "post-merge")); + fs.writeFileSync(path.join(dir, "post-rewrite"), "#!/bin/sh\necho stale\n"); // plain file → missing + // reference-transaction absent → healed as a fresh symlink + + const { repo } = makeRepo(); + git(["config", "--local", "core.hooksPath", dir], repo); + process.chdir(repo); + + await api.cli.installHooks.run({ yes: true }); + expect(logText()).toMatch(/Healed \d+ entries/); + expect(logText()).toContain("Filesystem fallback to copy for 1 entries"); + }); + + it.skipIf(!canSymlink)("heal-then-install: CancelledByUser aborts with exit 2", async () => { + const { dir } = makeDispatcherDir(["post-checkout", "post-merge"]); // missing two required + const { repo } = makeRepo(); + git(["config", "--local", "core.hooksPath", dir], repo); + process.chdir(repo); + vi.spyOn(api.install, "dispatcher").mockImplementation(() => { + throw new CancelledByUser("symlink batch cancelled"); + }); + + await expect(api.cli.installHooks.run({ yes: true, noSymlinks: true })).rejects.toThrow(/process\.exit\(2\)/); + expect(errText()).toContain("symlink batch cancelled"); + expect(logText()).toContain("re-run with --no-symlinks"); + }); + + it.skipIf(!canSymlink)("heal-then-install: a non-cancel error propagates (re-thrown)", async () => { + const { dir } = makeDispatcherDir(["post-checkout", "post-merge"]); + const { repo } = makeRepo(); + git(["config", "--local", "core.hooksPath", dir], repo); + process.chdir(repo); + vi.spyOn(api.install, "dispatcher").mockImplementation(() => { + throw new Error("heal-boom"); + }); + + await expect(api.cli.installHooks.run({ yes: true })).rejects.toThrow(/heal-boom/); + }); + + it.skipIf(!canSymlink)("bootstrap: reports git-config failure and exits 1", async () => { + const { repo } = makeRepo(); + const dispatcherDir = path.join(mkTmp("disp-"), "global-hooks"); + // GIT_CONFIG_GLOBAL under a missing parent dir → `git config --global` fails. + process.env.GIT_CONFIG_GLOBAL = path.join(mkTmp("nogdir-"), "no-such-dir", "gitconfig"); + process.chdir(repo); + + await expect(api.cli.installHooks.run({ yes: true, dispatcherDir })).rejects.toThrow(/process\.exit\(1\)/); + expect(errText()).toContain("git config --global core.hooksPath failed"); + }); + + it("bootstrap: CancelledByUser aborts with exit 2", async () => { + const { repo } = makeRepo(); + const dispatcherDir = path.join(mkTmp("disp-"), "global-hooks"); + process.chdir(repo); + vi.spyOn(api.install, "dispatcher").mockImplementation(() => { + throw new CancelledByUser("bootstrap cancelled"); + }); + + await expect(api.cli.installHooks.run({ yes: true, noSymlinks: true, dispatcherDir })).rejects.toThrow(/process\.exit\(2\)/); + expect(errText()).toContain("bootstrap cancelled"); + expect(logText()).toContain("re-run with --no-symlinks"); + }); + + it("bootstrap: a non-cancel error propagates (re-thrown)", async () => { + const { repo } = makeRepo(); + const dispatcherDir = path.join(mkTmp("disp-"), "global-hooks"); + process.chdir(repo); + vi.spyOn(api.install, "dispatcher").mockImplementation(() => { + throw new Error("bootstrap-boom"); + }); + + await expect(api.cli.installHooks.run({ yes: true, dispatcherDir })).rejects.toThrow(/bootstrap-boom/); + }); + + it("bootstrap: warns on a filesystem copy fallback, then sets global config", async () => { + const { repo } = makeRepo(); + const dispatcherDir = path.join(mkTmp("disp-"), "global-hooks"); + const globalCfg = path.join(mkTmp("gcfg-"), "gitconfig"); + process.env.GIT_CONFIG_GLOBAL = globalCfg; // writable → git config succeeds + process.chdir(repo); + vi.spyOn(api.install, "dispatcher").mockReturnValue({ + dispatcherPath: path.join(dispatcherDir, "_dispatch"), + created: [{ source: path.join(dispatcherDir, "post-commit"), mechanism: "copy" }], + fallbackToCopy: [path.join(dispatcherDir, "post-commit")] + }); + + await api.cli.installHooks.run({ yes: true, dispatcherDir }); + expect(logText()).toContain("Filesystem fallback to copy for 1 entries"); + expect(logText()).toContain(`Set git config --global core.hooksPath ${dispatcherDir}`); + }); + + it("suggest-dispatcher declined outside a repo: installs nothing and returns", async () => { + process.chdir(mkTmp("nonrepo-")); // non-TTY → declined, and no gitDir to fall back to + await api.cli.installHooks.run({}); + expect(logText()).toContain("Dispatcher install declined"); + expect(logText()).not.toContain("Installed per-repo hooks"); + }); + + it.skipIf(!canSymlink)("bootstrap --yes outside a repo: warns it skipped per-repo install", async () => { + const dispatcherDir = path.join(mkTmp("disp-"), "global-hooks"); + const globalCfg = path.join(mkTmp("gcfg-"), "gitconfig"); + process.env.GIT_CONFIG_GLOBAL = globalCfg; + process.chdir(mkTmp("nonrepo-")); + + await api.cli.installHooks.run({ yes: true, dispatcherDir }); + expect(logText()).toContain("Dispatcher installed at"); + expect(logText()).toContain("Not inside a git repo — skipping per-repo hook install."); + }); +}); + +// ========================================================================== +// init +// ========================================================================== +describe("api.cli.init.run", () => { + it("warns when the advice git-config write fails (outside a repo)", async () => { + // install-hooks declines the dispatcher (non-TTY, no gitDir) and returns; + // then `git config advice.addEmbeddedRepo false` fails (not in a repo). + process.chdir(mkTmp("nonrepo-")); + await api.cli.init.run({}); + expect(logText()).toContain("Could not set git config advice.addEmbeddedRepo"); + }); +}); + +// ========================================================================== +// export +// ========================================================================== +describe("api.cli.export.run — uncovered paths", () => { + it("adds to a missing .git/info/exclude (readFile catch)", () => { + const { parentBare } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + process.chdir(fresh); + runCli(() => api.cli.restore.run([], {})); + fs.rmSync(path.join(fresh, ".git", "info", "exclude"), { force: true }); + resetOutput(); + + runCli(() => api.cli.export.run({ o: "children.json" })); + expect(logText()).toContain("added children.json to .git/info/exclude"); + expect(fs.readFileSync(path.join(fresh, ".git", "info", "exclude"), "utf8")).toContain("children.json"); + }); + + it("prepends a newline when the existing exclude has no trailing newline", () => { + const { parentBare } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + process.chdir(fresh); + runCli(() => api.cli.restore.run([], {})); + const excludeFile = path.join(fresh, ".git", "info", "exclude"); + fs.mkdirSync(path.dirname(excludeFile), { recursive: true }); + fs.writeFileSync(excludeFile, "existing-pattern"); // NO trailing newline + resetOutput(); + + runCli(() => api.cli.export.run({ o: "children.json" })); + expect(fs.readFileSync(excludeFile, "utf8")).toBe("existing-pattern\nchildren.json\n"); + }); + + it("outside a repo: root falls back to cwd and an empty manifest goes to stdout", () => { + process.chdir(mkTmp("nonrepo-")); + const code = runCli(() => api.cli.export.run({})); + expect(code).toBeNull(); + const parsed = JSON.parse(outText()); + expect(parsed.version).toBe(1); + expect(parsed.children).toEqual({}); + }); +}); + +// ========================================================================== +// install-template +// ========================================================================== +describe("api.cli.installTemplate.run", () => { + it("installs nothing (no success line) when every template hook is foreign", async () => { + const templateDir = path.join(mkTmp("tmpl-"), "template"); + const hooksDir = path.join(templateDir, "hooks"); + fs.mkdirSync(hooksDir, { recursive: true }); + for (const name of PACKAGE_HOOKS) fs.writeFileSync(path.join(hooksDir, name), "#!/bin/sh\necho foreign\n"); + + await api.cli.installTemplate.run({ templateDir, yes: true }); + expect(logText()).not.toContain("Installed template hooks"); + for (const name of PACKAGE_HOOKS) expect(logText()).toContain(`Skipped ${name}`); + }); +}); + +// ========================================================================== +// record +// ========================================================================== +describe("api.cli.record.run — uncovered LABEL arms", () => { + it("records a detached child with no branch suffix", () => { + const { parentBare, childBare } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + process.chdir(fresh); + runCli(() => api.cli.restore.run([], {})); + git(["checkout", "--detach"], path.join(fresh, "tests")); // detached → no current branch + resetOutput(); + + runCli(() => api.cli.record.run([])); + const line = logText() + .split("\n") + .find((l) => l.includes("tests →")); + expect(line).toContain(`tests → ${childBare}`); + expect(line).not.toContain("("); // no "(branch)" suffix when detached + }); + + it("renders an unknown outcome through the LABEL fallback", () => { + const { parentBare } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + process.chdir(fresh); + vi.spyOn(api.embedded, "record").mockReturnValue({ results: [{ path: "weirdo", outcome: "surprise" }] }); + + runCli(() => api.cli.record.run([])); + expect(logText()).toContain("weirdo: surprise"); + }); +}); + +// ========================================================================== +// restore +// ========================================================================== +describe("api.cli.restore.run — uncovered LABEL arms", () => { + it("renders note-less unresolved/pinned-mismatch and the unknown-outcome fallback", () => { + const { parentBare } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + process.chdir(fresh); + vi.spyOn(api.embedded, "restore").mockReturnValue({ + results: [ + { path: "a", outcome: "unresolved", note: null }, + { path: "b", outcome: "pinned-mismatch", note: null }, + { path: "c", outcome: "mystery" } + ], + exitCode: 1 + }); + + const code = runCli(() => api.cli.restore.run([], {})); + expect(code).toBe(1); + expect(errText()).toContain("a unresolved"); + expect(errText()).toContain("b pinned-mismatch"); + expect(logText()).toContain("c: mystery"); + // The note-less arms print no " — " detail. + expect(errText()).not.toContain("a unresolved —"); + expect(errText()).not.toContain("b pinned-mismatch —"); + }); +}); + +// ========================================================================== +// sync +// ========================================================================== +describe("api.cli.sync.run — uncovered outcome/LABEL arms", () => { + it("leaves an 'ahead' child alone (commits beyond the pin)", () => { + const { parentBare } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + process.chdir(fresh); + runCli(() => api.cli.restore.run([], {})); + // Advance the CHILD's registered branch past the pin (do not move the pin). + const child = path.join(fresh, "tests"); + fs.writeFileSync(path.join(child, "ahead.txt"), "ahead"); + git(["add", "."], child); + git(["commit", "-m", "child ahead"], child); + resetOutput(); + + const code = runCli(() => api.cli.sync.run([], {})); + expect(code).toBe(0); + expect(logText()).toContain("commits beyond the pin"); + expect(logText()).toContain("0 synced, 0 unchanged, 1 left alone, 0 failed."); + }); + + it("leaves an unregistered-branch child alone", () => { + const { work, parentBare } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + process.chdir(fresh); + runCli(() => api.cli.restore.run([], {})); // registers branch main + const sha2 = advanceChild(work, "tests", "v2"); + bumpPin(fresh, "tests", sha2); + git(["checkout", "-b", "feature"], path.join(fresh, "tests")); // now on an unregistered branch + resetOutput(); + + const code = runCli(() => api.cli.sync.run([], {})); + expect(code).toBe(0); + expect(logText()).toContain("unregistered branch 'feature'"); + expect(logText()).toContain("1 left alone"); + }); + + it.skipIf(!canSymlink)("reports sync-failed for a symlinked gitlink path and exits 1", () => { + const { parentBare } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + process.chdir(fresh); + runCli(() => api.cli.restore.run([], {})); + const child = path.join(fresh, "tests"); + fs.rmSync(child, { recursive: true, force: true }); + fs.symlinkSync(mkTmp("linktgt-"), child, "dir"); // gitlink path is now a symlink + resetOutput(); + + const code = runCli(() => api.cli.sync.run([], {})); + expect(code).toBe(1); + expect(errText()).toContain("tests sync-failed"); + expect(errText()).toContain("symbolic link"); + }); + + it("snaps a detached child to the moved pin (detached synced)", () => { + const { work, parentBare } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + process.chdir(fresh); + runCli(() => api.cli.restore.run([], {})); + const sha2 = advanceChild(work, "tests", "v2"); + bumpPin(fresh, "tests", sha2); + git(["checkout", "--detach"], path.join(fresh, "tests")); // detached at old pin + resetOutput(); + + const code = runCli(() => api.cli.sync.run([], {})); + expect(code).toBe(0); + expect(logText()).toContain("synced tests"); + expect(logText()).toContain("(detached)"); + expect(git(["rev-parse", "HEAD"], path.join(fresh, "tests"))).toBe(sha2); + }); + + it("renders note-less pin-unavailable/sync-failed and the unknown-outcome fallback", () => { + const { parentBare } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + process.chdir(fresh); + vi.spyOn(api.embedded, "sync").mockReturnValue({ + results: [ + { path: "a", outcome: "pin-unavailable", note: null }, + { path: "b", outcome: "sync-failed", note: null }, + { path: "c", outcome: "mystery" } + ], + exitCode: 1 + }); + + const code = runCli(() => api.cli.sync.run([], {})); + expect(code).toBe(1); + expect(errText()).toContain("a pin-unavailable"); + expect(errText()).toContain("b sync-failed"); + expect(logText()).toContain("c: mystery"); + expect(errText()).not.toContain("a pin-unavailable —"); + expect(errText()).not.toContain("b sync-failed —"); + }); +}); diff --git a/tests/cli-hooks.test.mjs b/tests/cli-hooks.test.mjs new file mode 100644 index 0000000..ecaa956 --- /dev/null +++ b/tests/cli-hooks.test.mjs @@ -0,0 +1,480 @@ +/** + * @Project: @cldmv/git-embedded + * @Filename: /tests/cli-hooks.test.mjs + * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. + * + * Behavior tests for the CLI wrapper commands that manage git hooks and print + * misc info, driven through the composed slothlet api against REAL temp git + * repos (per the house style). Covers: + * + * - install-hooks: the detection-driven switch (refuse / suggest-dispatcher / + * heal-then-install / install), the per-repo install (owned-copy + foreign + * skip), the dispatcher bootstrap (+ global core.hooksPath), and the heal. + * - uninstall-hooks: removes only git-embedded-owned hooks, keeps foreign ones, + * reports "none found", and refuses outside a repo. + * - install-template: templateDir resolution, confirm gate, --force overwrite. + * - print-hook-script: known-name passthrough to stdout + unknown-name refusal. + * - version / doctor / init: the small wrappers around package.json, detection, + * and the install-hooks + advice-silencing composition. + */ + +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 { fileURLToPath } from "node:url"; +import { getApi } from "./_setup.mjs"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const packageRoot = path.resolve(here, ".."); +const hooksSrcDir = path.join(packageRoot, "hooks"); + +// The five hook names git-embedded owns, and where each one's body comes from. +const PACKAGE_HOOKS = ["post-checkout", "post-merge", "post-rewrite", "reference-transaction", "pre-push"]; +const REQUIRED_HOOKS = ["post-checkout", "post-merge", "post-rewrite", "reference-transaction"]; +const STANDARD_HOOK_NAMES = [ + "applypatch-msg", + "commit-msg", + "post-applypatch", + "post-checkout", + "post-commit", + "post-merge", + "post-rewrite", + "pre-applypatch", + "pre-auto-gc", + "pre-commit", + "pre-merge-commit", + "pre-push", + "pre-rebase", + "prepare-commit-msg", + "reference-transaction" +]; + +// A canonical chaining dispatcher body (matches the classifier's chain check). +const CHAINING_DISPATCHER = `#!/bin/sh +# git-embedded-compatible dispatcher +hook=$(basename "$0") +git_dir=$(git rev-parse --absolute-git-dir 2>/dev/null) || exit 0 +repo_hook="$git_dir/hooks/$hook" +if [ -x "$repo_hook" ] && [ "$repo_hook" != "$0" ]; then + exec "$repo_hook" "$@" +fi +exit 0 +`; + +const tmpRoots = []; +function mkTmp(prefix = "git-embedded-cli-") { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tmpRoots.push(dir); + return dir; +} + +// Whether this environment can CREATE symlinks. The dispatcher fixtures + the +// bootstrap/heal link mechanisms need them; on Windows without Developer Mode +// creation is denied, so those cases skip (the copy-based paths still run). +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 { + 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(); +} + +/** A plain git repo with one commit. Returns { repo, gitDir }. */ +function makeRepo() { + const repo = path.join(mkTmp(), "repo"); + git(["init", "-b", "main", repo]); + fs.writeFileSync(path.join(repo, "README.md"), "hi"); + git(["add", "."], repo); + git(["commit", "-m", "init"], repo); + return { repo, gitDir: path.join(repo, ".git") }; +} + +/** Plant a Husky signature at a repo root so detect.run classifies it foreign. */ +function plantHusky(repo) { + fs.mkdirSync(path.join(repo, ".husky")); + fs.writeFileSync( + path.join(repo, "package.json"), + JSON.stringify({ scripts: { prepare: "husky" }, devDependencies: { husky: "^9.0.0" } }) + ); +} + +/** + * Build a dispatcher directory: a chaining `_dispatch` plus a symlink for each + * name in `linked`. With all REQUIRED_HOOKS linked it classifies + * canonical-complete; with a subset it classifies missing-symlinks. + */ +function makeDispatcherDir(linked) { + const dir = path.join(mkTmp("git-embedded-disp-"), "hooks"); + fs.mkdirSync(dir, { recursive: true }); + const dispatch = path.join(dir, "_dispatch"); + fs.writeFileSync(dispatch, CHAINING_DISPATCHER); + fs.chmodSync(dispatch, 0o755); + for (const name of linked) fs.symlinkSync(dispatch, path.join(dir, name)); + return { dir, dispatch }; +} + +function stripAnsi(s) { + // eslint-disable-next-line no-control-regex + return String(s).replace(/\[[0-9;]*m/g, ""); +} + +/** Silence + capture console.log / console.error / stdout into one line array. */ +function capture() { + const out = []; + const push = (s) => out.push(stripAnsi(s)); + vi.spyOn(console, "log").mockImplementation((...a) => push(a.join(" "))); + vi.spyOn(console, "error").mockImplementation((...a) => push(a.join(" "))); + vi.spyOn(process.stdout, "write").mockImplementation((c) => { + push(typeof c === "string" ? c : c.toString("utf8")); + return true; + }); + return out; +} + +/** Capture raw stdout bytes verbatim (no ANSI stripping) for exact compares. */ +function captureStdoutRaw() { + const chunks = []; + vi.spyOn(process.stdout, "write").mockImplementation((c) => { + chunks.push(typeof c === "string" ? c : c.toString("utf8")); + return true; + }); + return chunks; +} + +function mockExit() { + return vi.spyOn(process, "exit").mockImplementation((code) => { + throw new Error(`process.exit(${code})`); + }); +} + +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"; + // Redirect XDG so the transaction log + any default dispatcher dir land in + // throwaway temp dirs, never the real ~/.local/state or ~/.config. + process.env.XDG_STATE_HOME = mkTmp("git-embedded-state-"); + process.env.XDG_CONFIG_HOME = mkTmp("git-embedded-config-"); +}); + +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(); +}); + +function assertOwnedHooksInstalled(hooksDir) { + for (const name of PACKAGE_HOOKS) { + const p = path.join(hooksDir, name); + expect(fs.existsSync(p)).toBe(true); + expect(fs.readFileSync(p, "utf8")).toContain("git-embedded"); + } +} + +describe("api.cli.installHooks", () => { + it("refuses over a foreign manager (husky) and exits 2 without installing", async () => { + const { repo, gitDir } = makeRepo(); + plantHusky(repo); + process.chdir(repo); + capture(); + mockExit(); + await expect(api.cli.installHooks.run({})).rejects.toThrow(/process\.exit\(2\)/); + // Refuse happens before any install — no package hook was planted. + expect(fs.existsSync(path.join(gitDir, "hooks", "post-checkout"))).toBe(false); + }); + + it("suggest-dispatcher, declined (non-TTY): falls back to a per-repo install", async () => { + const { repo, gitDir } = makeRepo(); + process.chdir(repo); + const out = capture(); + await api.cli.installHooks.run({}); + const text = out.join("\n"); + expect(text).toContain("Dispatcher install declined"); + expect(text).toContain("Installed per-repo hooks"); + assertOwnedHooksInstalled(path.join(gitDir, "hooks")); + }); + + it("per-repo install skips a pre-existing hook that git-embedded does not own", async () => { + const { repo, gitDir } = makeRepo(); + const foreign = path.join(gitDir, "hooks", "post-checkout"); + fs.writeFileSync(foreign, "#!/bin/sh\necho not ours\n"); + process.chdir(repo); + const out = capture(); + await api.cli.installHooks.run({}); + const text = out.join("\n"); + expect(text).toContain("Skipped post-checkout"); + // The foreign file is preserved; the other four owned hooks still install. + expect(fs.readFileSync(foreign, "utf8")).toBe("#!/bin/sh\necho not ours\n"); + for (const name of PACKAGE_HOOKS.filter((n) => n !== "post-checkout")) { + expect(fs.readFileSync(path.join(gitDir, "hooks", name), "utf8")).toContain("git-embedded"); + } + }); + + it.skipIf(!canSymlink)( + "suggest-dispatcher, --yes: bootstraps a dispatcher, sets global core.hooksPath, then installs per-repo", + async () => { + const { repo, gitDir } = makeRepo(); + const dispatcherDir = path.join(mkTmp(), "global-hooks"); + const globalCfg = path.join(mkTmp(), "gitconfig"); + process.env.GIT_CONFIG_GLOBAL = globalCfg; // writable global so `git config --global` succeeds + process.chdir(repo); + const out = capture(); + + await api.cli.installHooks.run({ yes: true, dispatcherDir }); + + // Dispatcher script + a link for every standard hook name were created. + expect(fs.existsSync(path.join(dispatcherDir, "_dispatch"))).toBe(true); + for (const name of STANDARD_HOOK_NAMES) { + const linkPath = path.join(dispatcherDir, name); + expect(fs.lstatSync(linkPath).isSymbolicLink()).toBe(true); + expect(path.resolve(fs.readlinkSync(linkPath))).toBe(path.join(dispatcherDir, "_dispatch")); + } + // Global core.hooksPath now points at the new dispatcher dir. + const g = spawnSync("git", ["config", "--global", "--get", "core.hooksPath"], { + encoding: "utf8", + env: { ...process.env, GIT_CONFIG_GLOBAL: globalCfg } + }); + expect((g.stdout || "").trim()).toBe(dispatcherDir); + // And the per-repo hooks landed too. + assertOwnedHooksInstalled(path.join(gitDir, "hooks")); + expect(out.join("\n")).toContain("Dispatcher installed at"); + } + ); + + it.skipIf(!canSymlink)("heal-then-install, --yes: adds the missing dispatcher entries then installs per-repo", async () => { + const { repo, gitDir } = makeRepo(); + // Dispatcher present but missing two required entries → heal-then-install. + const { dir: dispatcherDir, dispatch } = makeDispatcherDir(["post-checkout", "post-merge"]); + git(["config", "--local", "core.hooksPath", dispatcherDir], repo); + process.chdir(repo); + const out = capture(); + + await api.cli.installHooks.run({ yes: true }); + + for (const name of ["post-rewrite", "reference-transaction"]) { + const linkPath = path.join(dispatcherDir, name); + expect(fs.lstatSync(linkPath).isSymbolicLink()).toBe(true); + expect(path.resolve(fs.readlinkSync(linkPath))).toBe(dispatch); + } + assertOwnedHooksInstalled(path.join(gitDir, "hooks")); + expect(out.join("\n")).toMatch(/Healed \d+ entries/); + }); + + it.skipIf(!canSymlink)("heal-then-install, declined: warns and exits 2 (adds nothing)", async () => { + const { repo } = makeRepo(); + const { dir: dispatcherDir } = makeDispatcherDir(["post-checkout", "post-merge"]); + git(["config", "--local", "core.hooksPath", dispatcherDir], repo); + process.chdir(repo); + capture(); + mockExit(); + await expect(api.cli.installHooks.run({})).rejects.toThrow(/process\.exit\(2\)/); + expect(fs.existsSync(path.join(dispatcherDir, "post-rewrite"))).toBe(false); + }); + + it.skipIf(!canSymlink)("install (canonical-complete dispatcher present): installs per-repo hooks without prompting", async () => { + const { repo, gitDir } = makeRepo(); + const { dir: dispatcherDir } = makeDispatcherDir(REQUIRED_HOOKS); + git(["config", "--local", "core.hooksPath", dispatcherDir], repo); + process.chdir(repo); + const out = capture(); + await api.cli.installHooks.run({}); + assertOwnedHooksInstalled(path.join(gitDir, "hooks")); + expect(out.join("\n")).toContain("Installed per-repo hooks"); + }); +}); + +describe("api.cli.uninstallHooks", () => { + it("removes only git-embedded-owned hooks and keeps a foreign one", async () => { + const { repo, gitDir } = makeRepo(); + const hooksDir = path.join(gitDir, "hooks"); + api.install.hooks("install", gitDir); // plant all five owned hooks + // Overwrite one with foreign content so uninstall must keep it. + fs.writeFileSync(path.join(hooksDir, "pre-push"), "#!/bin/sh\necho foreign pre-push\n"); + process.chdir(repo); + const out = capture(); + + await api.cli.uninstallHooks.run(); + + for (const name of REQUIRED_HOOKS) { + expect(fs.existsSync(path.join(hooksDir, name))).toBe(false); + } + expect(fs.readFileSync(path.join(hooksDir, "pre-push"), "utf8")).toBe("#!/bin/sh\necho foreign pre-push\n"); + const text = out.join("\n"); + expect(text).toContain("Removed per-repo hooks"); + expect(text).toContain("Left pre-push in place"); + }); + + it("reports nothing to do when no git-embedded hooks are present", async () => { + const { repo } = makeRepo(); + process.chdir(repo); + const out = capture(); + await api.cli.uninstallHooks.run(); + expect(out.join("\n")).toContain("No git-embedded hooks found"); + }); + + it("refuses outside a git repository and exits 2", async () => { + const notARepo = mkTmp(); + process.chdir(notARepo); + capture(); + mockExit(); + await expect(api.cli.uninstallHooks.run()).rejects.toThrow(/process\.exit\(2\)/); + }); +}); + +describe("api.cli.installTemplate", () => { + it("errors and exits 2 when no template dir is configured or passed", async () => { + process.chdir(mkTmp()); + capture(); + mockExit(); + await expect(api.cli.installTemplate.run({})).rejects.toThrow(/process\.exit\(2\)/); + }); + + it("--yes installs the packaged hooks into /hooks", async () => { + const templateDir = path.join(mkTmp(), "template"); + const out = capture(); + await api.cli.installTemplate.run({ templateDir, yes: true }); + assertOwnedHooksInstalled(path.join(templateDir, "hooks")); + expect(out.join("\n")).toContain("Installed template hooks"); + }); + + it("declined (non-TTY): installs nothing", async () => { + const templateDir = path.join(mkTmp(), "template"); + const out = capture(); + await api.cli.installTemplate.run({ templateDir }); + expect(fs.existsSync(path.join(templateDir, "hooks", "post-checkout"))).toBe(false); + expect(out.join("\n")).toContain("Declined"); + }); + + it("skips a foreign template hook without --force, overwrites it with --force", async () => { + const templateDir = path.join(mkTmp(), "template"); + const hooksDir = path.join(templateDir, "hooks"); + fs.mkdirSync(hooksDir, { recursive: true }); + const foreign = path.join(hooksDir, "post-checkout"); + fs.writeFileSync(foreign, "#!/bin/sh\necho foreign\n"); + + const out1 = capture(); + await api.cli.installTemplate.run({ templateDir, yes: true }); + expect(out1.join("\n")).toContain("Skipped post-checkout"); + expect(fs.readFileSync(foreign, "utf8")).toBe("#!/bin/sh\necho foreign\n"); + vi.restoreAllMocks(); + + const out2 = capture(); + await api.cli.installTemplate.run({ templateDir, yes: true, force: true }); + expect(fs.readFileSync(foreign, "utf8")).toContain("git-embedded"); + expect(out2.join("\n")).toContain("Installed template hooks"); + }); +}); + +describe("api.cli.printHookScript", () => { + it("prints the packaged script body verbatim for each known name", () => { + const cases = [ + ["post-checkout", "update-embedded-repos"], + ["post-merge", "update-embedded-repos"], + ["reference-transaction", "reference-transaction"], + ["pre-push", "pre-push"], + ["update-embedded-repos", "update-embedded-repos"], + ["_dispatch", "_dispatch.template"], + ["dispatcher", "_dispatch.template"] + ]; + for (const [name, sourceFile] of cases) { + const expected = fs.readFileSync(path.join(hooksSrcDir, sourceFile), "utf8"); + const chunks = captureStdoutRaw(); + api.cli.printHookScript.run(name); + expect(chunks.join("")).toBe(expected); + vi.restoreAllMocks(); + } + }); + + it("refuses an unknown hook name and exits 2", () => { + const out = capture(); + mockExit(); + expect(() => api.cli.printHookScript.run("not-a-hook")).toThrow(/process\.exit\(2\)/); + expect(out.join("\n")).toContain("Unknown hook script: not-a-hook"); + }); +}); + +describe("api.cli.version", () => { + it("prints the package name+version, node, and platform", () => { + const pkg = JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf8")); + const out = capture(); + api.cli.version.run(); + expect(out[0]).toBe(`${pkg.name} ${pkg.version}`); + expect(out).toContain(`node ${process.version.replace(/^v/, "")}`); + expect(out).toContain(`platform ${process.platform}`); + }); +}); + +describe("api.cli.doctor", () => { + it("reports 'No hook setup detected' for a plain repo and takes no action", async () => { + const { repo, gitDir } = makeRepo(); + process.chdir(repo); + const out = capture(); + await api.cli.doctor.run(); + const text = out.join("\n"); + expect(text).toContain("Detected: No hook setup detected"); + // Doctor is read-only — it must not have installed anything. + expect(fs.existsSync(path.join(gitDir, "hooks", "post-checkout"))).toBe(false); + }); + + it("classifies a husky repo as a foreign manager", async () => { + const { repo } = makeRepo(); + plantHusky(repo); + process.chdir(repo); + const out = capture(); + await api.cli.doctor.run(); + expect(out.join("\n")).toContain("Detected: Husky"); + }); +}); + +describe("api.cli.init", () => { + it("runs install-hooks then silences the embedded-repo advice", async () => { + const { repo, gitDir } = makeRepo(); + process.chdir(repo); + const out = capture(); + await api.cli.init.run({}); + // install-hooks ran (declined dispatcher → per-repo install). + assertOwnedHooksInstalled(path.join(gitDir, "hooks")); + // advice.addEmbeddedRepo was set false in the repo's local config. + expect(git(["config", "--get", "advice.addEmbeddedRepo"], repo)).toBe("false"); + expect(out.join("\n")).toContain("Silenced 'embedded git repository' advice"); + }); +}); diff --git a/tests/cli-provisioning.test.mjs b/tests/cli-provisioning.test.mjs new file mode 100644 index 0000000..af728ba --- /dev/null +++ b/tests/cli-provisioning.test.mjs @@ -0,0 +1,583 @@ +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"; + +// These exercise the CLI command wrappers (src/api/cli/{restore,record,export,sync}.mjs) +// against REAL temp git repos — the same fixture style as embedded-provisioning.test.mjs. +// Each wrapper reads process.cwd(), prints through self.report.* (console.log/error), and +// restore/sync end with process.exit(code); we chdir into the fixture, capture the output, +// and translate the process.exit into a return code. + +const tmpRoots = []; + +function mkTmp() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "git-embedded-cli-")); + 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(); +} + +/** + * Build a bare "child source" repo with one commit; return its bare path + SHA. + */ +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 one anonymous gitlink and push it to a bare. + * `childBareName` defaults to the gitlink basename (convention resolves); set it + * different to obscure the child so convention fails. + */ +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 }; +} + +/** + * A parent whose convention target (tests.git) is a DECOY with unrelated + * history, while the real pin lives in a differently-named bare convention never + * finds — the setup that makes restore end `pinned-mismatch`. + */ +function makeParentPinnedMismatch() { + const work = mkTmp(); + const remotes = path.join(work, "remotes"); + fs.mkdirSync(remotes, { recursive: true }); + + makeChildBare(work, remotes, "tests", "DECOY"); + 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); + return { parentBare }; +} + +function freshClone(parentBare) { + const dir = path.join(mkTmp(), "clone"); + git(["clone", "--quiet", parentBare, dir]); + return dir; +} + +/** A plain repo with one commit and NO gitlinks. */ +function makePlainRepo() { + const dir = path.join(mkTmp(), "plain"); + git(["init", "-b", "main", dir]); + fs.writeFileSync(path.join(dir, "README.md"), "plain"); + git(["add", "."], dir); + git(["commit", "-m", "init"], dir); + return dir; +} + +/** Advance the child source by a commit (pushed by default); return 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. */ +function bumpPin(parentDir, childPath, sha) { + git(["update-index", "--cacheinfo", `160000,${sha},${childPath}`], parentDir); + git(["commit", "-m", `bump ${childPath} pin`], parentDir); +} + +// ---- output + process.exit capture --------------------------------------- + +let logLines; +let errLines; +let stdoutChunks; + +const stripAnsi = (s) => String(s).replace(new RegExp(String.fromCharCode(27) + "\\[[0-9;]*m", "g"), ""); +const logText = () => logLines.map(stripAnsi).join("\n"); +const errText = () => errLines.map(stripAnsi).join("\n"); +const outText = () => stdoutChunks.map(stripAnsi).join(""); + +function resetOutput() { + logLines.length = 0; + errLines.length = 0; + stdoutChunks.length = 0; +} + +/** + * Run a CLI wrapper. restore/sync call process.exit(code) (mocked to throw); we + * translate that back into the returned exit code. record/export do not exit and + * return null. A non-exit throw (a real error) propagates. + */ +function runCli(fn) { + try { + fn(); + } catch (err) { + const m = /process\.exit\((-?\d+)\)/.exec(String(err && err.message)); + if (!m) throw err; + return Number(m[1]); + } + return null; +} + +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"; + + logLines = []; + errLines = []; + stdoutChunks = []; + vi.spyOn(console, "log").mockImplementation((...a) => { + logLines.push(a.map(String).join(" ")); + }); + vi.spyOn(console, "error").mockImplementation((...a) => { + errLines.push(a.map(String).join(" ")); + }); + vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { + stdoutChunks.push(String(chunk)); + return true; + }); + vi.spyOn(process, "exit").mockImplementation((code) => { + throw new Error(`process.exit(${code})`); + }); +}); + +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.cli.restore.run", () => { + it("reports 'No embedded gitlinks in HEAD.' and exits 0 for a repo with no gitlinks", () => { + const repo = makePlainRepo(); + process.chdir(repo); + const code = runCli(() => api.cli.restore.run([], {})); + expect(code).toBe(0); + expect(logText()).toContain("No embedded gitlinks in HEAD."); + }); + + it("restores a convention-resolvable child, prints the branch, and summarizes 1 restored", () => { + const { parentBare } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + process.chdir(fresh); + + const code = runCli(() => api.cli.restore.run([], {})); + expect(code).toBe(0); + expect(logText()).toContain("restored tests from convention"); + expect(logText()).toContain("on branch main"); + expect(logText()).toContain("1 restored, 0 unchanged, 0 failed."); + expect(fs.existsSync(path.join(fresh, "tests", ".git"))).toBe(true); + }); + + it("--dry-run reports 'would restore' with the resolvable summary and clones nothing", () => { + const { parentBare } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + process.chdir(fresh); + + const code = runCli(() => api.cli.restore.run([], { dryRun: true })); + expect(code).toBe(0); + expect(logText()).toContain("would restore tests from convention"); + expect(logText()).toContain("1 resolvable, 0 unchanged, 0 failed."); + // Nothing was cloned. + expect(fs.existsSync(path.join(fresh, "tests", ".git"))).toBe(false); + }); + + it("--skip (comma string) skips the child, warns, and counts it as skipped", () => { + const { parentBare } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + process.chdir(fresh); + + const code = runCli(() => api.cli.restore.run([], { skip: "tests" })); + expect(code).toBe(0); + expect(logText()).toContain("tests skipped"); + expect(logText()).toContain("0 restored, 0 unchanged, 1 skipped, 0 failed."); + expect(fs.existsSync(path.join(fresh, "tests", ".git"))).toBe(false); + }); + + it("--base derives the child URL and labels the source 'base'", () => { + const { parentBare, remotes } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + process.chdir(fresh); + + // base=/tests.git, the real bare; base beats convention. + const code = runCli(() => api.cli.restore.run([], { base: remotes })); + expect(code).toBe(0); + expect(logText()).toContain("restored tests from base"); + expect(fs.existsSync(path.join(fresh, "tests", ".git"))).toBe(true); + }); + + it("--from resolves an obscured child through a manifest (source 'manifest')", () => { + const { parentBare, childBare } = makeParent({ gitlinkPath: "tests", childBareName: "secret-xyz" }); + const fresh = freshClone(parentBare); + + const manifestFile = path.join(mkTmp(), "children.json"); + const manifest = api.embedded.manifest.build([{ path: "tests", url: childBare, branch: "main" }]); + fs.writeFileSync(manifestFile, api.embedded.manifest.serialize(manifest)); + + process.chdir(fresh); + const code = runCli(() => api.cli.restore.run([], { from: manifestFile })); + expect(code).toBe(0); + expect(logText()).toContain("restored tests from manifest"); + expect(logText()).toContain("on branch main"); + expect(fs.existsSync(path.join(fresh, "tests", ".git"))).toBe(true); + }); + + it("an unresolvable (obscured) child is an error line and exits 1", () => { + const { parentBare } = makeParent({ gitlinkPath: "tests", childBareName: "secret-xyz" }); + const fresh = freshClone(parentBare); + process.chdir(fresh); + + const code = runCli(() => api.cli.restore.run([], {})); + expect(code).toBe(1); + expect(errText()).toContain("tests unresolved"); + expect(logText()).toContain("0 restored, 0 unchanged, 1 failed."); + expect(fs.existsSync(path.join(fresh, "tests", ".git"))).toBe(false); + }); + + it("a pinned-mismatch (decoy sibling) is an error line and exits 1", () => { + const { parentBare } = makeParentPinnedMismatch(); + const fresh = freshClone(parentBare); + process.chdir(fresh); + + const code = runCli(() => api.cli.restore.run([], {})); + expect(code).toBe(1); + expect(errText()).toContain("tests pinned-mismatch"); + expect(logText()).toContain("0 restored, 0 unchanged, 1 failed."); + }); + + it("a second restore reports already-present (warn) and counts it as unchanged", () => { + const { parentBare } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + process.chdir(fresh); + + expect(runCli(() => api.cli.restore.run([], {}))).toBe(0); + resetOutput(); + + const code = runCli(() => api.cli.restore.run([], {})); + expect(code).toBe(0); + expect(logText()).toContain("tests already present"); + expect(logText()).toContain("0 restored, 1 unchanged, 0 failed."); + }); +}); + +describe("api.cli.record.run", () => { + it("reports nothing to record when no child is present on disk", () => { + const { parentBare } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); // gitlink present but child not restored + process.chdir(fresh); + + const code = runCli(() => api.cli.record.run([])); + expect(code).toBeNull(); // record never calls process.exit + expect(logText()).toContain("No embedded children present on disk to record."); + }); + + it("records a present child's origin URL + branch into the local registry", () => { + const { parentBare, childBare } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + process.chdir(fresh); + runCli(() => api.cli.restore.run([], {})); // child present, origin wired + resetOutput(); + + runCli(() => api.cli.record.run([])); + expect(logText()).toContain(`tests → ${childBare}`); + expect(logText()).toContain("(main)"); + expect(logText()).toContain("Recorded 1 of 1 into the local registry (not committed)."); + expect(api.embedded.registry.getUrl("tests", fresh)).toBe(childBare); + }); + + it("warns 'not present on disk' for an explicitly requested absent child", () => { + const { parentBare } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); // not restored + process.chdir(fresh); + + runCli(() => api.cli.record.run(["tests"])); + expect(logText()).toContain("tests not present on disk"); + expect(logText()).toContain("Recorded 0 of 1 into the local registry (not committed)."); + }); + + it("warns 'has no remote.origin.url' when a present child lost its origin", () => { + const { parentBare } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + process.chdir(fresh); + runCli(() => api.cli.restore.run([], {})); + // Strip the child's origin so recordOne finds .git but no URL. + git(["remote", "remove", "origin"], path.join(fresh, "tests")); + resetOutput(); + + runCli(() => api.cli.record.run([])); + expect(logText()).toContain("tests has no remote.origin.url"); + expect(logText()).toContain("Recorded 0 of 1 into the local registry (not committed)."); + }); +}); + +describe("api.cli.export.run", () => { + it("writes the manifest to stdout by default", () => { + const { parentBare, childBare } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + process.chdir(fresh); + runCli(() => api.cli.restore.run([], {})); // populates the registry + resetOutput(); + + const code = runCli(() => api.cli.export.run({})); + expect(code).toBeNull(); + const parsed = JSON.parse(outText()); + expect(parsed.version).toBe(1); + expect(parsed.children.tests.url).toBe(childBare); + expect(parsed.children.tests.branch).toBe("main"); + }); + + it("-o outside the worktree writes the file and does not touch git excludes", () => { + const { parentBare, childBare } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + process.chdir(fresh); + runCli(() => api.cli.restore.run([], {})); + resetOutput(); + + const outFile = path.join(mkTmp(), "sub", "children.json"); + runCli(() => api.cli.export.run({ o: outFile })); + + expect(logText()).toContain(`Wrote manifest to ${outFile}`); + expect(logText()).toContain("1 children"); + expect(logText()).toContain("do NOT commit"); + expect(logText()).not.toContain("exclude"); + + const parsed = JSON.parse(fs.readFileSync(outFile, "utf8")); + expect(parsed.children.tests.url).toBe(childBare); + }); + + it("-o inside the worktree excludes the file once, not twice on a re-export", () => { + const { parentBare } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + process.chdir(fresh); + runCli(() => api.cli.restore.run([], {})); + resetOutput(); + + // First export: file written under the worktree + added to info/exclude. + runCli(() => api.cli.export.run({ o: "children.json" })); + expect(logText()).toContain("added children.json to .git/info/exclude"); + const excludeFile = path.join(fresh, ".git", "info", "exclude"); + expect(fs.readFileSync(excludeFile, "utf8")).toContain("children.json"); + expect(fs.existsSync(path.join(fresh, "children.json"))).toBe(true); + resetOutput(); + + // Second export: already excluded, so no courtesy line is printed. + runCli(() => api.cli.export.run({ o: "children.json" })); + expect(logText()).toContain("Wrote manifest to"); + expect(logText()).not.toContain("added children.json"); + }); + + it("--scan records present children before serializing the manifest", () => { + const { parentBare, childBare } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + process.chdir(fresh); + runCli(() => api.cli.restore.run([], {})); + // Wipe the registry restore wrote, so only --scan can repopulate it. + git(["config", "--local", "--unset", "embedded.tests.url"], fresh); + git(["config", "--local", "--unset", "embedded.tests.branch"], fresh); + expect(api.embedded.registry.entries(fresh)).toEqual([]); + resetOutput(); + + const outFile = path.join(mkTmp(), "scanned.json"); + runCli(() => api.cli.export.run({ scan: true, o: outFile })); + + const parsed = JSON.parse(fs.readFileSync(outFile, "utf8")); + expect(parsed.children.tests.url).toBe(childBare); + }); +}); + +describe("api.cli.sync.run", () => { + it("reports nothing to sync and exits 0 when no child is present", () => { + const { parentBare } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); // never restored + process.chdir(fresh); + + const code = runCli(() => api.cli.sync.run([], {})); + expect(code).toBe(0); + expect(logText()).toContain("No embedded children present to sync."); + }); + + it("fast-forwards a moved pin, prints the branch, and summarizes 1 synced", () => { + const { work, parentBare } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + process.chdir(fresh); + runCli(() => api.cli.restore.run([], {})); + + const sha2 = advanceChild(work, "tests", "v2"); + bumpPin(fresh, "tests", sha2); + resetOutput(); + + const code = runCli(() => api.cli.sync.run([], {})); + expect(code).toBe(0); + expect(logText()).toContain(`synced tests → ${sha2.slice(0, 12)}`); + expect(logText()).toContain("(branch main)"); + expect(logText()).toContain("1 synced, 0 unchanged, 0 left alone, 0 failed."); + expect(git(["rev-parse", "HEAD"], path.join(fresh, "tests"))).toBe(sha2); + }); + + it("--dry-run reports 'would sync' with the syncable summary and moves nothing", () => { + const { work, parentBare, childSha } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + process.chdir(fresh); + runCli(() => api.cli.restore.run([], {})); + + const sha2 = advanceChild(work, "tests", "v2"); + bumpPin(fresh, "tests", sha2); + resetOutput(); + + const code = runCli(() => api.cli.sync.run([], { dryRun: true })); + expect(code).toBe(0); + expect(logText()).toContain("would sync tests"); + expect(logText()).toContain("1 syncable, 0 unchanged, 0 left alone, 0 failed."); + expect(git(["rev-parse", "HEAD"], path.join(fresh, "tests"))).toBe(childSha); // unmoved + }); + + it("reports in-sync (unchanged) when the child is already at the pin", () => { + const { parentBare } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + process.chdir(fresh); + runCli(() => api.cli.restore.run([], {})); + resetOutput(); + + const code = runCli(() => api.cli.sync.run([], {})); + expect(code).toBe(0); + expect(logText()).toContain("tests already at pin"); + expect(logText()).toContain("0 synced, 1 unchanged, 0 left alone, 0 failed."); + }); + + it("leaves a dirty child alone and counts it under 'left alone'", () => { + const { work, parentBare, childSha } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + process.chdir(fresh); + runCli(() => api.cli.restore.run([], {})); + + const sha2 = advanceChild(work, "tests", "v2"); + bumpPin(fresh, "tests", sha2); + fs.writeFileSync(path.join(fresh, "tests", "uncommitted.txt"), "precious"); + resetOutput(); + + const code = runCli(() => api.cli.sync.run([], {})); + expect(code).toBe(0); + expect(logText()).toContain("pin moved but child has uncommitted changes"); + expect(logText()).toContain("0 synced, 0 unchanged, 1 left alone, 0 failed."); + expect(git(["rev-parse", "HEAD"], path.join(fresh, "tests"))).toBe(childSha); // unmoved + }); + + it("reports pin-unavailable as an error and exits 1 when the pin cannot be fetched", () => { + const { work, parentBare, childSha } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + process.chdir(fresh); + runCli(() => api.cli.restore.run([], {})); + + // A pin that exists nowhere the child can fetch from (never pushed). + const ghostSha = advanceChild(work, "tests", "ghost", { push: false }); + bumpPin(fresh, "tests", ghostSha); + resetOutput(); + + const code = runCli(() => api.cli.sync.run([], {})); + expect(code).toBe(1); + expect(errText()).toContain("tests pin-unavailable"); + expect(errText()).toContain("not found at origin"); + expect(logText()).toContain("0 synced, 0 unchanged, 0 left alone, 1 failed."); + expect(git(["rev-parse", "HEAD"], path.join(fresh, "tests"))).toBe(childSha); // unmoved + }); + + it("--skip (comma string) skips the child and counts it as skipped", () => { + const { work, parentBare } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + process.chdir(fresh); + runCli(() => api.cli.restore.run([], {})); + + const sha2 = advanceChild(work, "tests", "v2"); + bumpPin(fresh, "tests", sha2); // there IS a move pending + resetOutput(); + + const code = runCli(() => api.cli.sync.run([], { skip: "tests" })); + expect(code).toBe(0); + expect(logText()).toContain("tests skipped"); + expect(logText()).toContain("0 synced, 0 unchanged, 0 left alone, 1 skipped, 0 failed."); + }); + + it("reports no-repo (warn) for an explicitly requested absent child", () => { + const { parentBare } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); // never restored + process.chdir(fresh); + + const code = runCli(() => api.cli.sync.run(["tests"], {})); + expect(code).toBe(0); + expect(logText()).toContain("tests not present on disk — run restore"); + expect(logText()).toContain("0 synced, 0 unchanged, 0 left alone, 1 skipped, 0 failed."); + expect(errText()).not.toContain("tests"); // no-repo is a warn, not an error + }); +}); diff --git a/tests/commander-help.test.mjs b/tests/commander-help.test.mjs new file mode 100644 index 0000000..5769c97 --- /dev/null +++ b/tests/commander-help.test.mjs @@ -0,0 +1,486 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import chalk from "chalk"; +import { Command, Help } from "commander"; +import { makeCustomHelp } from "../src/api/commander/custom-help.mjs"; +import customHelpDefault from "../src/api/commander/custom-help.mjs"; + +// This module is pure CLI-help formatting — it never shells out to git, so no +// temp git fixtures are needed. Every test builds a real commander `Command` +// tree and renders it through the CustomHelp the bin uses, asserting on the +// produced help text. The factory receives the real `chalk` singleton (as the +// bin does); tests that flip color modes restore `chalk.level` afterwards. + +const ESC = String.fromCharCode(27); // color codes start with the ANSI escape +const hasAnsi = (s) => s.includes(ESC); + +/** Fresh factory per call — cheap, and avoids sharing CustomHelp state. */ +function build() { + return makeCustomHelp(Help, { chalk }); +} + +/** A CustomHelp locked to "never" so rendered text is ANSI-free and easy to assert. */ +function plainHelp() { + return new (build().CustomHelp)({ colorMode: "never" }); +} + +let savedEnv; +let savedChalkLevel; +let savedColumns; + +beforeEach(() => { + // Baseline the color inputs the module reads so a test starts from "auto": + // initColorMode() consults NO_COLOR / FORCE_COLOR in the constructor. + savedEnv = { NO_COLOR: process.env.NO_COLOR, FORCE_COLOR: process.env.FORCE_COLOR }; + savedChalkLevel = chalk.level; + savedColumns = process.stdout.columns; + delete process.env.NO_COLOR; + delete process.env.FORCE_COLOR; +}); + +afterEach(() => { + if (savedEnv.NO_COLOR === undefined) delete process.env.NO_COLOR; + else process.env.NO_COLOR = savedEnv.NO_COLOR; + if (savedEnv.FORCE_COLOR === undefined) delete process.env.FORCE_COLOR; + else process.env.FORCE_COLOR = savedEnv.FORCE_COLOR; + chalk.level = savedChalkLevel; + try { + process.stdout.columns = savedColumns; + } catch { + // non-tty stdout may reject the assignment on some platforms + } +}); + +describe("makeCustomHelp (factory guardrails)", () => { + it("throws when chalk is not supplied", () => { + expect(() => makeCustomHelp(Help)).toThrow(/deps\.chalk is required/); + expect(() => makeCustomHelp(Help, {})).toThrow(/chalk/); + expect(() => makeCustomHelp(Help, { chalk: null })).toThrow(/chalk/); + }); + + it("returns a CustomHelp class and applyCustomHelpRecursive when chalk is supplied", () => { + const built = makeCustomHelp(Help, { chalk }); + expect(typeof built.CustomHelp).toBe("function"); + expect(built.CustomHelp.prototype).toBeInstanceOf(Help); + expect(typeof built.applyCustomHelpRecursive).toBe("function"); + }); + + it("exposes makeCustomHelp on the default export", () => { + expect(typeof customHelpDefault.makeCustomHelp).toBe("function"); + expect(customHelpDefault.makeCustomHelp).toBe(makeCustomHelp); + }); +}); + +describe("CustomHelp constructor + color mode", () => { + it("defaults colorMode to 'auto' when no env and no opts", () => { + const { CustomHelp } = build(); + expect(new CustomHelp().colorMode).toBe("auto"); + }); + + it("honors an explicit string colorMode and ignores a non-string one", () => { + const { CustomHelp } = build(); + expect(new CustomHelp({ colorMode: "never" }).colorMode).toBe("never"); + expect(new CustomHelp({ colorMode: 123 }).colorMode).toBe("auto"); + }); + + it("initColorMode: NO_COLOR forces 'never' and drops chalk.level to 0", () => { + process.env.NO_COLOR = "1"; + const { CustomHelp } = build(); + const help = new CustomHelp(); + expect(help.colorMode).toBe("never"); + expect(chalk.level).toBe(0); + }); + + it("initColorMode: FORCE_COLOR forces 'always' and raises chalk.level to 3", () => { + process.env.FORCE_COLOR = "1"; + const { CustomHelp } = build(); + const help = new CustomHelp(); + expect(help.colorMode).toBe("always"); + expect(chalk.level).toBe(3); + }); + + it("setColorMode toggles chalk.level for 'always'/'never' and leaves it for 'auto'", () => { + const { CustomHelp } = build(); + const help = new CustomHelp({ colorMode: "auto" }); + + help.setColorMode("always"); + expect(help.colorMode).toBe("always"); + expect(chalk.level).toBe(3); + + help.setColorMode("never"); + expect(help.colorMode).toBe("never"); + expect(chalk.level).toBe(0); + + // "auto" updates the mode but touches neither chalk branch. + help.setColorMode("auto"); + expect(help.colorMode).toBe("auto"); + expect(chalk.level).toBe(0); + }); +}); + +describe("CustomHelp.ensureHelpOption (static)", () => { + it("adds the -h/--help option when the command has none", () => { + const { CustomHelp } = build(); + const calls = []; + const cmd = { helpOption: (...a) => calls.push(a), options: [], commands: [] }; + CustomHelp.ensureHelpOption(cmd); + expect(calls).toHaveLength(1); + expect(calls[0][0]).toBe("-h, --help"); + expect(calls[0][1]).toBe("Show help for this command"); + }); + + it("skips adding help when a --help option already exists", () => { + const { CustomHelp } = build(); + let called = 0; + const cmd = { + helpOption: () => { + called++; + }, + options: [{ long: "--help" }], + commands: [] + }; + CustomHelp.ensureHelpOption(cmd); + expect(called).toBe(0); + }); + + it("recurses into subcommands", () => { + const { CustomHelp } = build(); + let childCalled = 0; + const child = { + helpOption: () => { + childCalled++; + }, + options: [], + commands: [] + }; + const root = { helpOption: () => {}, options: [], commands: [child] }; + CustomHelp.ensureHelpOption(root); + expect(childCalled).toBe(1); + }); + + it("tolerates a command with no helpOption function", () => { + const { CustomHelp } = build(); + const cmd = { options: [], commands: [] }; + expect(() => CustomHelp.ensureHelpOption(cmd)).not.toThrow(); + }); +}); + +describe("formatHelp — usage line", () => { + it("renders required args as and optional as [name], with a full command chain", () => { + const help = plainHelp(); + const program = new Command("git-embedded"); + const restore = program.command("restore"); + const inner = restore.command("inner"); + inner.argument("", "the child").argument("[extra]"); + const usage = help.formatHelp(inner, help).split("\n")[0]; + expect(usage).toContain("Usage: git-embedded restore inner"); + expect(usage).toContain(""); + expect(usage).toContain("[extra]"); + }); + + it("appends [options] only when a non-help option exists", () => { + const help = plainHelp(); + + const bare = new Command("bare"); + const bareUsage = help.formatHelp(bare, help).split("\n")[0]; + expect(bareUsage).toBe("Usage: bare"); + expect(bareUsage).not.toContain("[options]"); + + const withOpt = new Command("withopt"); + withOpt.option("--verbose", "be loud"); + const withUsage = help.formatHelp(withOpt, help).split("\n")[0]; + expect(withUsage).toContain("[options]"); + }); +}); + +describe("formatHelp — sections", () => { + it("renders the Description section when present and omits it otherwise", () => { + const help = plainHelp(); + + const described = new Command("described"); + described.description("Does a described thing."); + const out = help.formatHelp(described, help); + expect(out).toContain("Description:"); + expect(out).toContain("Does a described thing."); + + const bare = new Command("bare"); + expect(help.formatHelp(bare, help)).not.toContain("Description:"); + }); + + it("renders the command's own Aliases section, skipping it for the 'help' command", () => { + const help = plainHelp(); + + const build = new Command("build"); + build._aliases = ["b", "bld"]; + const buildOut = help.formatHelp(build, help); + const lines = buildOut.split("\n"); + expect(buildOut).toContain("Aliases:"); + expect(lines).toContain(" b"); + expect(lines).toContain(" bld"); + + // A command literally named "help" suppresses its Aliases section. + const helpCmd = new Command("help"); + helpCmd._aliases = ["h"]; + expect(help.formatHelp(helpCmd, help)).not.toContain("Aliases:"); + + // No aliases at all → no section. + expect(help.formatHelp(new Command("plain"), help)).not.toContain("Aliases:"); + }); + + it("uses 'Commands:' at the top level and 'Sub Commands:' for a nested command", () => { + const help = plainHelp(); + const program = new Command("git-embedded"); + const restore = program.command("restore"); + restore.command("inner"); + + const top = help.formatHelp(program, help); + expect(top).toContain("Commands:"); + expect(top).not.toContain("Sub Commands:"); + + const nested = help.formatHelp(restore, help); + expect(nested).toContain("Sub Commands:"); + }); + + it("aligns the Options description column via padEnd", () => { + const help = plainHelp(); + const cmd = new Command("prog"); + cmd.option("-a, --alpha", "first option"); + cmd.option("--beta-long-flag ", "second option"); + const out = help.formatHelp(cmd, help); + expect(out).toContain("Options:"); + const lines = out.split("\n"); + const alpha = lines.find((l) => l.includes("--alpha")); + const beta = lines.find((l) => l.includes("--beta-long-flag")); + expect(alpha).toBeTruthy(); + expect(beta).toBeTruthy(); + // The short term is padded so both descriptions start at the same column. + expect(alpha.indexOf("first option")).toBe(beta.indexOf("second option")); + }); + + it("renders the Arguments section with and without per-argument descriptions", () => { + const help = plainHelp(); + const cmd = new Command("args"); + cmd.argument("", "required one"); + cmd.argument("[opt]"); // no description + const out = help.formatHelp(cmd, help); + expect(out).toContain("Arguments:"); + const lines = out.split("\n").map((l) => l.trim()); + expect(lines).toContain(" required one"); + expect(lines).toContain("[opt]"); + }); + + it("renders the Examples section with the example text intact", () => { + const help = plainHelp(); + const cmd = new Command("ex"); + cmd._exampleList = ["$ ex --flag "]; + const out = help.formatHelp(cmd, help); + expect(out).toContain("Examples:"); + expect(out).toContain("$ ex --flag "); + }); + + it("always ends with the trailing 'For more information' footer", () => { + const help = plainHelp(); + const out = help.formatHelp(new Command("bare"), help); + expect(out.trimEnd().endsWith("For more information, use a command with help, --help, or -h.")).toBe(true); + }); +}); + +describe("formatHelp — color modes", () => { + it("colorMode 'never' emits no ANSI and leaves arg markers as plain text", () => { + const help = plainHelp(); + const cmd = new Command("prog"); + cmd.argument("", "r").argument("[opt]", "o"); + cmd._exampleList = ["$ prog [opt]"]; + const out = help.formatHelp(cmd, help); + expect(hasAnsi(out)).toBe(false); + expect(out).toContain(""); + expect(out).toContain("[opt]"); + }); + + it("colorMode 'always' emits ANSI color codes and a longer string than 'never'", () => { + const { CustomHelp } = build(); + const cmd = new Command("prog"); + cmd.argument("", "r").argument("[opt]", "o"); + cmd._exampleList = ["$ prog [opt]"]; + + const never = new CustomHelp({ colorMode: "never" }); + const plain = never.formatHelp(cmd, never); + + const always = new CustomHelp({ colorMode: "always" }); + always.setColorMode("always"); // raise chalk.level so codes are actually emitted + const colored = always.formatHelp(cmd, always); + + expect(hasAnsi(colored)).toBe(true); + expect(hasAnsi(plain)).toBe(false); + expect(colored.length).toBeGreaterThan(plain.length); + }); +}); + +describe("printCommands (via a parent's help)", () => { + it("renders subcommand terms with [options], args, variadic markers, and deeper nesting", () => { + const help = plainHelp(); + const program = new Command("git-embedded"); + const restore = program.command("restore").option("--dry-run", "plan only"); + const inner = restore.command("inner"); + inner.option("--flag", "f").argument("", "r").argument("[optional...]", "o"); + program.command("plain"); // no options → term must omit [options] + + const out = help.formatHelp(program, help); + expect(out).toMatch(/restore \[options\]/); + expect(out).toContain("inner [options] [optional...]"); + + const lines = out.split("\n"); + const lead = (s) => s.match(/^\s*/)[0].length; + const restoreLine = lines.find((l) => l.includes("restore [options]")); + const innerLine = lines.find((l) => l.includes("inner [options]")); + expect(lead(innerLine)).toBeGreaterThan(lead(restoreLine)); + + const plainLine = lines.find((l) => l.trim() === "plain"); + expect(plainLine).toBeTruthy(); + expect(plainLine).not.toContain("[options]"); + }); + + it("filters an alias equal to the command name and accepts a string _aliases value", () => { + const help = plainHelp(); + process.stdout.columns = 200; // keep alias lines on one line for exact matching + const program = new Command("root"); + + const subA = program.command("subA"); + subA._aliases = ["a1", "subA", "a2"]; // array; own-name entry filtered out + + const subB = program.command("subB"); + subB._aliases = "b1"; // string, differs from name + + const subC = program.command("subC"); + subC._aliases = "subC"; // string equal to name → produces no alias line + + program.command("subD"); // default (empty) aliases → no alias line + + const out = help.formatHelp(program, help); + const aliasLines = out.split("\n").filter((l) => l.includes("- Aliases:")); + expect(aliasLines).toHaveLength(2); + expect(out).toContain("- Aliases: a1, a2"); + expect(out).toContain("- Aliases: b1"); + }); + + it("wraps a long subcommand description with a hanging indent", () => { + const help = plainHelp(); + process.stdout.columns = 40; // force the description to wrap + const program = new Command("root"); + program + .command("deploy") + .description( + "This is a deliberately long subcommand description that must wrap across several lines to exercise the hanging indent code path fully and thoroughly." + ); + + const out = help.formatHelp(program, help); + const lines = out.split("\n"); + const descIdx = lines.findIndex((l) => l.includes("- Description:")); + expect(descIdx).toBeGreaterThan(-1); + + // The line after the label is an indented continuation (not a new "- " bullet). + const continuation = lines[descIdx + 1]; + expect(continuation.trim().length).toBeGreaterThan(0); + expect(continuation.startsWith(" ")).toBe(true); + expect(continuation.trimStart().startsWith("- ")).toBe(false); + + // Words survive the wrap unbroken. + expect(out).toContain("deliberately"); + expect(out).toContain("thoroughly"); + }); +}); + +describe("collectExamples (via help)", () => { + it("aggregates examples from the whole tree at the top level", () => { + const help = plainHelp(); + const program = new Command("root"); + program._exampleList = ["$ root top"]; + program.command("a")._exampleList = ["$ root a"]; + program.command("b")._exampleList = ["$ root b"]; + + const out = help.formatHelp(program, help); + expect(out).toContain("$ root top"); + expect(out).toContain("$ root a"); + expect(out).toContain("$ root b"); + }); + + it("caps the top-level example list at five entries", () => { + const help = plainHelp(); + const program = new Command("root"); + program._exampleList = ["$ e1", "$ e2", "$ e3"]; + program.command("a")._exampleList = ["$ e4", "$ e5"]; + program.command("b")._exampleList = ["$ e6", "$ e7"]; + + const out = help.formatHelp(program, help); + const exampleLines = out.split("\n").filter((l) => /^\s+\$ e\d/.test(l)); + expect(exampleLines).toHaveLength(5); + }); + + it("synthesizes a '$ chain sub ' example for a nested command, deduping by name", () => { + const help = plainHelp(); + + // Own example does not mention the sub → synthetic example is added. + const p1 = new Command("git-embedded"); + const restore1 = p1.command("restore"); + restore1._exampleList = ["$ git-embedded restore"]; + restore1.command("inner").argument("", "n").argument("[extra]"); + const out1 = help.formatHelp(restore1, help); + expect(out1).toContain("$ git-embedded restore"); + expect(out1).toContain("$ git-embedded restore inner [extra]"); + + // Own example already mentions the sub → synthetic example is suppressed. + const p2 = new Command("git-embedded"); + const restore2 = p2.command("restore"); + restore2._exampleList = ["$ git-embedded restore inner --now"]; + restore2.command("inner").argument("", "n"); + const out2 = help.formatHelp(restore2, help); + expect(out2).toContain("$ git-embedded restore inner --now"); + expect(out2).not.toContain("$ git-embedded restore inner "); + }); +}); + +describe("applyCustomHelpRecursive (end-to-end via helpInformation)", () => { + it("wires createHelp, showHelpAfterError, and the help option across the tree", () => { + const { CustomHelp, applyCustomHelpRecursive } = build(); + const program = new Command(); + program + .name("git-embedded") + .description("Root desc") + .configureOutput({ writeErr: () => {} }); + const sub = program.command("restore").description("Restore desc"); + + applyCustomHelpRecursive(program); + + expect(program._showHelpAfterError).toBe(true); + expect(sub._showHelpAfterError).toBe(true); + expect(typeof program.createHelp).toBe("function"); + expect(program.createHelp()).toBeInstanceOf(CustomHelp); + + const info = program.helpInformation(); + expect(info).toContain("Usage: git-embedded"); + expect(info).toContain("Root desc"); + // ensureHelpOption installed our custom help description. + expect(info).toContain("Show help for this command"); + expect(info).toContain("For more information, use a command with help, --help, or -h."); + + // Recursion reached the subcommand: its help renders in the custom format too. + const subInfo = sub.helpInformation(); + expect(subInfo).toContain("Usage: git-embedded restore"); + expect(subInfo).toContain("For more information, use a command with help, --help, or -h."); + }); + + it("tolerates a command-like object without showHelpAfterError", () => { + const { CustomHelp, applyCustomHelpRecursive } = build(); + // A minimal duck-typed command (e.g. a plugin-provided one) that lacks + // showHelpAfterError must not fault the guarded call. + const calls = []; + const cmd = { + helpOption: (...a) => calls.push(a), + options: [], + commands: [] + }; + expect(() => applyCustomHelpRecursive(cmd)).not.toThrow(); + expect(typeof cmd.createHelp).toBe("function"); + expect(cmd.createHelp()).toBeInstanceOf(CustomHelp); + expect(calls).toHaveLength(1); // ensureHelpOption still ran + }); +}); diff --git a/tests/detect-coverage.test.mjs b/tests/detect-coverage.test.mjs new file mode 100644 index 0000000..dfcf37c --- /dev/null +++ b/tests/detect-coverage.test.mjs @@ -0,0 +1,386 @@ +/** + * @Project: @cldmv/git-embedded + * @Filename: /tests/detect-coverage.test.mjs + * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. + * + * Targeted coverage-closing tests for src/api/detect/*. tests/detect-hooks.test.mjs, + * tests/detect-foreign.test.mjs, and tests/dispatcher-classify.test.mjs cover the + * baseline detection patterns; this file adds only the edge cases those don't + * reach, closing dispatcher.mjs, husky.mjs, pre-commit.mjs, lefthook.mjs, and + * run.mjs to 100% lines/statements/functions/branches: + * + * - src/api/detect/dispatcher.mjs — falsy `dir`, an unreadable dir, a + * directory-shaped `_dispatch` (readFileSync EISDIR), relative symlink + * targets, the copy-cluster hashing loop's skip/unreadable/losing-bucket + * branches, the all-different-content (no cluster) case, a dotted + * non-hook-only dir, a symlink pointing at an unrelated decoy file, and the + * TOCTOU-style fs-race branches (lstatSync/readlinkSync/statSync/ + * realpathSync throwing after an earlier check already confirmed the path) + * simulated via targeted fs spies since a real filesystem race can't be + * fabricated deterministically. + * - src/api/detect/husky.mjs — falsy repoRoot, a malformed package.json + * (wispSync throws), and the dependencies-only husky fallback. + * - src/api/detect/pre-commit.mjs — falsy gitDir, a gitDir with no hooks + * subdir, and the readHead catch (a subdirectory entry in hooks/). + * - src/api/detect/lefthook.mjs — the readHead catch (a subdirectory entry + * in hooks/). + * - src/api/detect/run.mjs — the `effectiveHooksPath || systemPath` + * fallback, triggered by a cwd that doesn't exist on disk (so + * getEffectiveHooksPath's git spawn fails) while the system hooksPath + * scope is still readable (getAllHooksPathScopes ignores its cwd + * argument and reads from the real process cwd). + * + * Scratch fixtures live under this repo's tmp/ (never the system /tmp), are + * tracked per-test, and are removed in afterEach/afterAll. + */ + +import { afterAll, 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 { fileURLToPath } from "node:url"; +import { getApi } from "./_setup.mjs"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const packageRoot = path.resolve(here, ".."); +const scratchRoot = path.join(packageRoot, "tmp", "detect-coverage"); +fs.mkdirSync(scratchRoot, { recursive: true }); + +const tmpRoots = []; +function mkTmp(prefix = "case-") { + const dir = fs.mkdtempSync(path.join(scratchRoot, prefix)); + tmpRoots.push(dir); + return dir; +} + +function writeExecutable(p, body) { + fs.writeFileSync(p, body); + fs.chmodSync(p, 0o755); +} + +function writeGitConfig(body) { + const dir = mkTmp("cfg-"); + const f = path.join(dir, "config"); + fs.writeFileSync(f, body); + return f; +} + +// A chaining dispatcher body: the classifier recognizes the `exec "$repo_hook"` +// chain that marks a git-embedded-compatible dispatcher. +const CHAINING = `#!/bin/sh +# git-embedded-compatible dispatcher +hook=$(basename "$0") +git_dir=$(git rev-parse --absolute-git-dir 2>/dev/null) || exit 0 +repo_hook="$git_dir/hooks/$hook" +if [ -x "$repo_hook" ] && [ "$repo_hook" != "$0" ]; then + exec "$repo_hook" "$@" +fi +exit 0 +`; + +const REQUIRED_HOOKS = ["post-checkout", "post-merge", "post-rewrite", "reference-transaction"]; + +/** Identical CHAINING content at every required-hook name trips the + * classifier's copy-cluster detection without needing symlink rights. */ +function mkCanonicalHooksDir() { + const dir = mkTmp("canonical-hooks-"); + for (const h of REQUIRED_HOOKS) writeExecutable(path.join(dir, h), CHAINING); + return dir; +} + +let originalEnv; +let originalCwd; +beforeEach(() => { + originalEnv = { ...process.env }; + originalCwd = process.cwd(); + // Hermetic git: ignore host/global/system 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(() => { + vi.restoreAllMocks(); + try { + process.chdir(originalCwd); + } catch { + // ignore + } + process.env = originalEnv; + while (tmpRoots.length) { + const d = tmpRoots.pop(); + try { + fs.rmSync(d, { recursive: true, force: true }); + } catch { + // ignore + } + } +}); +afterAll(() => { + try { + fs.rmSync(scratchRoot, { recursive: true, force: true }); + } catch { + // ignore + } +}); + +let api; +beforeAll(async () => { + api = await getApi(); +}); + +describe.skipIf(process.platform === "win32")("api.detect.dispatcher (remaining coverage gaps)", () => { + it("returns empty for a falsy dir argument", () => { + expect(api.detect.dispatcher(null)).toEqual({ kind: "empty" }); + }); + + it("returns empty with a reason when the dir cannot be read (does not exist)", () => { + const parent = mkTmp("parent-"); + const missing = path.join(parent, "does-not-exist"); + const out = api.detect.dispatcher(missing); + expect(out.kind).toBe("empty"); + expect(out.reason).toBe("directory not readable"); + }); + + it("classifies a directory-shaped _dispatch as non-conforming (readFileSync EISDIR)", () => { + const dir = mkTmp("dispatch-is-dir-"); + fs.mkdirSync(path.join(dir, "_dispatch")); + const out = api.detect.dispatcher(dir); + expect(out.kind).toBe("dispatcher-non-conforming"); + expect(out.dispatcherPath).toBe(path.join(dir, "_dispatch")); + expect(out.reason).toBe("dispatcher does not chain to per-repo hooks"); + }); + + it("resolves relative symlink targets to a canonical-complete dispatcher", () => { + const dir = mkTmp("relative-symlinks-"); + writeExecutable(path.join(dir, "_dispatch"), CHAINING); + for (const hook of REQUIRED_HOOKS) { + fs.symlinkSync("_dispatch", path.join(dir, hook)); // relative target, not absolute + } + const out = api.detect.dispatcher(dir); + expect(out.kind).toBe("dispatcher-canonical-complete"); + expect(out.present.sort()).toEqual([...REQUIRED_HOOKS].sort()); + }); + + it("finds a qualifying copy-cluster while skipping a non-standard name, an unreadable file, and a too-small rival bucket", () => { + const dir = mkTmp("copy-cluster-"); + // Winning cluster: identical content at 3 real (non-symlink) files. + writeExecutable(path.join(dir, "post-checkout"), CHAINING); + writeExecutable(path.join(dir, "post-merge"), CHAINING); + writeExecutable(path.join(dir, "post-rewrite"), CHAINING); + // Rival, too-small (size 1) content bucket -- exercises the losing + // `names.length >= 3` branch in the best-bucket selection. + writeExecutable(path.join(dir, "commit-msg"), "#!/bin/sh\necho rival\n"); + // Unreadable standard-named file -- exercises the readFileSync catch + // (continue) in the copy-cluster hashing loop. + const unreadable = path.join(dir, "pre-push"); + writeExecutable(unreadable, "#!/bin/sh\necho unreadable\n"); + fs.chmodSync(unreadable, 0o000); + // Non-standard name -- exercises the "skip non-hook names" continue. + fs.writeFileSync(path.join(dir, "README.txt"), "not a hook\n"); + + try { + const out = api.detect.dispatcher(dir); + expect(out.kind).toBe("dispatcher-missing-symlinks"); + expect(["post-checkout", "post-merge", "post-rewrite"]).toContain(path.basename(out.dispatcherPath)); + expect(out.present.sort()).toEqual(["post-checkout", "post-merge", "post-rewrite"]); + expect(out.missing).toEqual(["reference-transaction"]); + } finally { + fs.chmodSync(unreadable, 0o755); // restore so afterEach cleanup can remove it + } + }); + + it("finds no qualifying copy-cluster when all standard-named files differ -> bare-githooks", () => { + const dir = mkTmp("no-cluster-"); + writeExecutable(path.join(dir, "post-checkout"), "#!/bin/sh\necho a\n"); + writeExecutable(path.join(dir, "post-merge"), "#!/bin/sh\necho b\n"); + writeExecutable(path.join(dir, "post-rewrite"), "#!/bin/sh\necho c\n"); + const out = api.detect.dispatcher(dir); + expect(out.kind).toBe("bare-githooks"); + }); + + it("returns empty when the only entries are non-hook, dotted names", () => { + const dir = mkTmp("readme-only-"); + fs.writeFileSync(path.join(dir, "README.md"), "nothing to see here\n"); + const out = api.detect.dispatcher(dir); + expect(out).toEqual({ kind: "empty" }); + }); + + it("treats a symlink pointing at an unrelated decoy file as missing, not present", () => { + const dir = mkTmp("decoy-symlink-"); + const dispatch = path.join(dir, "_dispatch"); + writeExecutable(dispatch, CHAINING); + const decoy = path.join(dir, "_decoy"); + writeExecutable(decoy, "#!/bin/sh\necho decoy\n"); + fs.symlinkSync(dispatch, path.join(dir, "post-checkout")); + fs.symlinkSync(dispatch, path.join(dir, "post-merge")); + fs.symlinkSync(dispatch, path.join(dir, "post-rewrite")); + fs.symlinkSync(decoy, path.join(dir, "reference-transaction")); + const out = api.detect.dispatcher(dir); + expect(out.kind).toBe("dispatcher-missing-symlinks"); + expect(out.present.sort()).toEqual(["post-checkout", "post-merge", "post-rewrite"]); + expect(out.missing).toEqual(["reference-transaction"]); + }); + + it("still classifies canonical-complete via raw-path fallback when statSync/realpathSync race on the dispatcher itself", () => { + const dir = mkTmp("dispatcher-stat-races-"); + const dispatch = path.join(dir, "_dispatch"); + writeExecutable(dispatch, CHAINING); + for (const hook of REQUIRED_HOOKS) { + fs.symlinkSync(dispatch, path.join(dir, hook)); // absolute target === dispatch + } + + const realStat = fs.statSync.bind(fs); + const realRealpath = fs.realpathSync.bind(fs); + // Simulate the dispatcher file vanishing between the earlier + // readFileSync (chain check) and these calls -- a TOCTOU race that + // can't be fabricated deterministically on a real filesystem. + vi.spyOn(fs, "statSync").mockImplementation((p, ...rest) => { + if (p === dispatch) throw new Error("simulated ENOENT: statSync race on dispatcher"); + return realStat(p, ...rest); + }); + vi.spyOn(fs, "realpathSync").mockImplementation((p, ...rest) => { + if (p === dispatch) throw new Error("simulated ENOENT: realpathSync race on dispatcher"); + return realRealpath(p, ...rest); + }); + + const out = api.detect.dispatcher(dir); + + expect(out.kind).toBe("dispatcher-canonical-complete"); + expect(out.present.sort()).toEqual([...REQUIRED_HOOKS].sort()); + }); + + it("keeps correct classification when lstatSync/readlinkSync race in the first inventory pass", () => { + const dir = mkTmp("first-pass-races-"); + const dispatch = path.join(dir, "_dispatch"); + writeExecutable(dispatch, CHAINING); + const raceLstatPath = path.join(dir, "post-rewrite"); + const raceReadlinkPath = path.join(dir, "post-merge"); + fs.symlinkSync(dispatch, path.join(dir, "post-checkout")); + fs.symlinkSync(dispatch, raceReadlinkPath); + fs.symlinkSync(dispatch, raceLstatPath); + fs.symlinkSync(dispatch, path.join(dir, "reference-transaction")); + + const realLstat = fs.lstatSync.bind(fs); + const realReadlink = fs.readlinkSync.bind(fs); + // Simulate entries vanishing between readdirSync and the per-entry + // lstat/readlink calls -- TOCTOU races that can't be fabricated + // deterministically on a real filesystem. + vi.spyOn(fs, "lstatSync").mockImplementation((p, ...rest) => { + if (p === raceLstatPath) throw new Error("simulated ENOENT: lstatSync race"); + return realLstat(p, ...rest); + }); + vi.spyOn(fs, "readlinkSync").mockImplementation((p, ...rest) => { + if (p === raceReadlinkPath) throw new Error("simulated ENOENT: readlinkSync race"); + return realReadlink(p, ...rest); + }); + + const out = api.detect.dispatcher(dir); + + // post-rewrite: lost in the first pass (lstatSync raced) but still + // resolves as present via the second pass's direct realpathSync check. + // post-merge: readlinkSync raced -> recorded as an unresolved (null) + // symlink target -> can't be confirmed present -> missing. + expect(out.kind).toBe("dispatcher-missing-symlinks"); + expect(out.present.sort()).toEqual(["post-checkout", "post-rewrite", "reference-transaction"]); + expect(out.missing).toEqual(["post-merge"]); + }); + + it("falls back to the raw path (and then to inode/copy-cluster checks) when realpathSync races on a plain-file required hook", () => { + const dir = mkTmp("hook-realpath-races-"); + const dispatch = path.join(dir, "_dispatch"); + writeExecutable(dispatch, CHAINING); + const decoyFile = path.join(dir, "post-checkout"); + writeExecutable(decoyFile, "#!/bin/sh\necho not-the-dispatcher\n"); + + const realRealpath = fs.realpathSync.bind(fs); + vi.spyOn(fs, "realpathSync").mockImplementation((p, ...rest) => { + if (p === decoyFile) throw new Error("simulated ENOENT: realpathSync race on hook file"); + return realRealpath(p, ...rest); + }); + + const out = api.detect.dispatcher(dir); + + expect(out.kind).toBe("dispatcher-missing-symlinks"); + expect(out.present).toEqual([]); + expect(out.missing.sort()).toEqual(["post-checkout", "post-merge", "post-rewrite", "reference-transaction"]); + }); +}); + +describe("api.detect.husky (remaining coverage gaps)", () => { + it("returns null for a falsy repoRoot", () => { + expect(api.detect.husky(null)).toBeNull(); + }); + + it("swallows a malformed package.json (wispSync throws) and reports null prepare/version", () => { + const root = mkTmp("husky-malformed-pkg-"); + fs.mkdirSync(path.join(root, ".husky")); + fs.writeFileSync(path.join(root, "package.json"), "this is not valid json {"); + const out = api.detect.husky(root); + expect(out).toEqual({ kind: "husky", dir: path.join(root, ".husky"), prepare: null, version: null }); + }); + + it("falls back to dependencies.husky when devDependencies has no husky key", () => { + const root = mkTmp("husky-deps-only-"); + fs.mkdirSync(path.join(root, ".husky")); + fs.writeFileSync(path.join(root, "package.json"), JSON.stringify({ dependencies: { husky: "^8.0.0" } })); + const out = api.detect.husky(root); + expect(out.version).toBe("^8.0.0"); + }); +}); + +describe("api.detect.preCommit (remaining coverage gaps)", () => { + it("returns null when gitDir is falsy and there is no config file", () => { + const root = mkTmp("precommit-no-gitdir-"); + expect(api.detect.preCommit(root, null)).toBeNull(); + }); + + it("returns null when gitDir is set but has no hooks subdirectory", () => { + const root = mkTmp("precommit-no-hooksdir-root-"); + const gitDir = mkTmp("precommit-no-hooksdir-gitdir-"); // no hooks/ subdir created + expect(api.detect.preCommit(root, gitDir)).toBeNull(); + }); + + it("exercises the readHead catch when the hooks dir contains only a subdirectory", () => { + const root = mkTmp("precommit-subdir-only-root-"); + const gitDir = mkTmp("precommit-subdir-only-gitdir-"); + const hooksDir = path.join(gitDir, "hooks"); + fs.mkdirSync(hooksDir); + fs.mkdirSync(path.join(hooksDir, "subdir")); // readFileSync on this throws (EISDIR) + expect(api.detect.preCommit(root, gitDir)).toBeNull(); + }); +}); + +describe("api.detect.lefthook (remaining coverage gaps)", () => { + it("exercises the readHead catch when the hooks dir contains only a subdirectory", () => { + const root = mkTmp("lefthook-subdir-only-root-"); + const gitDir = mkTmp("lefthook-subdir-only-gitdir-"); + const hooksDir = path.join(gitDir, "hooks"); + fs.mkdirSync(hooksDir); + fs.mkdirSync(path.join(hooksDir, "subdir")); // readFileSync on this throws (EISDIR) + expect(api.detect.lefthook(root, gitDir)).toBeNull(); + }); +}); + +describe.skipIf(process.platform === "win32")("api.detect.run (remaining coverage gaps)", () => { + it("falls back to systemPath when effectiveHooksPath resolution fails for a nonexistent cwd", () => { + const hooksDir = mkCanonicalHooksDir(); + process.env.GIT_CONFIG_SYSTEM = writeGitConfig(`[core]\n\thooksPath = ${hooksDir}\n`); + // getAllHooksPathScopes ignores its cwd argument and reads config from + // the real process cwd, so chdir here to a real, existing non-repo dir + // -- independent of the (nonexistent) cwd passed to detect.run below. + const nonRepo = mkTmp("run-fallback-norepo-"); + process.chdir(nonRepo); + // A cwd that was never created on disk: getEffectiveHooksPath's git + // spawn fails (bad cwd for spawnSync) and returns null, forcing the + // `effectiveHooksPath || systemPath` fallback to systemPath. + const fakeCwd = path.join(nonRepo, "does-not-exist-at-all"); + const out = api.detect.run(fakeCwd); + expect(out.paths.effectiveHooksPath).toBeNull(); + expect(out.kind).toBe("system-hookspath"); + expect(out.action).toBe("install"); + expect(out.subClassification.kind).toBe("dispatcher-canonical-complete"); + }); +}); diff --git a/tests/detect-hooks.test.mjs b/tests/detect-hooks.test.mjs new file mode 100644 index 0000000..93d8949 --- /dev/null +++ b/tests/detect-hooks.test.mjs @@ -0,0 +1,415 @@ +/** + * @Project: @cldmv/git-embedded + * @Filename: /tests/detect-hooks.test.mjs + * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. + * + * Coverage tests for the hook-manager detectors and the detect orchestrator: + * + * - src/api/detect/lefthook.mjs — config-name variants, the gitDir + * hooks-header scan (headerIn), and the null/no-match branches. + * - src/api/detect/pre-commit.mjs — the gitDir hooks-header scan and the + * null/no-match branches. + * - src/api/detect/simple-git-hooks.mjs — the package.json key path (with its + * parsed config), the standalone `.simple-git-hooks.json` path, and the + * wispSync-throws → pkg=null branch. + * - src/api/detect/run.mjs — the whole classifier: foreign-manager + * precedence (husky > lefthook > simple-git-hooks > pre-commit), effective + * core.hooksPath sub-classification (canonical / missing / non-conforming / + * bare / empty), system-scope hooksPath, init.templateDir fallback, and none. + * + * The detectors are pure-fs and are driven directly with fabricated + * repoRoot/gitDir args (matching tests/detect-foreign.test.mjs). run() shells + * out to real git, so it is driven against real temp repos with a hermetic git + * environment (matching tests/embedded-provisioning.test.mjs). + */ + +import { afterEach, beforeAll, 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 { getApi } from "./_setup.mjs"; + +const tmpRoots = []; +function mkTmp(prefix = "git-embedded-detect-") { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + 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(); +} + +/** git init a fresh repo in a temp dir and return its worktree path. */ +function mkGitRepo() { + const dir = mkTmp("git-embedded-repo-"); + git(["init", "-b", "main"], dir); + return dir; +} + +// A chaining dispatcher body: the classifier recognizes the `exec "$repo_hook"` +// chain that marks a git-embedded-compatible dispatcher. +const CHAINING = `#!/bin/sh +# git-embedded-compatible dispatcher +hook=$(basename "$0") +git_dir=$(git rev-parse --absolute-git-dir 2>/dev/null) || exit 0 +repo_hook="$git_dir/hooks/$hook" +if [ -x "$repo_hook" ] && [ "$repo_hook" != "$0" ]; then + exec "$repo_hook" "$@" +fi +exit 0 +`; + +// A dispatcher body that never chains to a per-repo hook → non-conforming. +const NONCHAINING = `#!/bin/sh +hook=$(basename "$0") +echo "non-conforming dispatcher for $hook" +exit 0 +`; + +const REQUIRED_HOOKS = ["post-checkout", "post-merge", "post-rewrite", "reference-transaction"]; + +function writeExecutable(p, body) { + fs.writeFileSync(p, body); + fs.chmodSync(p, 0o755); +} + +/** + * A hooks dir that classifies canonical-complete WITHOUT needing symlink + * rights: identical copies of the chaining body at every required-hook name + * trip the classifier's copy-cluster detection. + */ +function mkCanonicalHooksDir() { + const dir = mkTmp("git-embedded-hooks-"); + for (const h of REQUIRED_HOOKS) writeExecutable(path.join(dir, h), CHAINING); + return dir; +} + +/** Only 3 of the 4 required hooks present → dispatcher-missing-symlinks. */ +function mkMissingHooksDir() { + const dir = mkTmp("git-embedded-hooks-"); + for (const h of ["post-checkout", "post-merge", "post-rewrite"]) writeExecutable(path.join(dir, h), CHAINING); + return dir; +} + +/** A lone `_dispatch` that does not chain → dispatcher-non-conforming. */ +function mkNonConformingHooksDir() { + const dir = mkTmp("git-embedded-hooks-"); + writeExecutable(path.join(dir, "_dispatch"), NONCHAINING); + return dir; +} + +/** A single ordinary hook script, no dispatcher → bare-githooks. */ +function mkBareHooksDir() { + const dir = mkTmp("git-embedded-hooks-"); + writeExecutable(path.join(dir, "pre-commit"), "#!/bin/sh\necho hi\n"); + return dir; +} + +/** Write a git config file (for GIT_CONFIG_SYSTEM / GIT_CONFIG_GLOBAL). */ +function writeGitConfig(body) { + const dir = mkTmp("git-embedded-cfg-"); + const f = path.join(dir, "config"); + fs.writeFileSync(f, body); + return f; +} + +let originalEnv; +let originalCwd; +beforeEach(() => { + originalEnv = { ...process.env }; + originalCwd = process.cwd(); + // Hermetic git: ignore host/global/system 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; + 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.detect.lefthook (edge cases)", () => { + it("returns null for a falsy repoRoot", () => { + expect(api.detect.lefthook(null, null)).toBeNull(); + }); + + it("detects a non-primary config-name variant (.lefthook.yaml)", () => { + const root = mkTmp(); + fs.writeFileSync(path.join(root, ".lefthook.yaml"), "pre-commit:\n commands: {}\n"); + const out = api.detect.lefthook(root, null); + expect(out).not.toBeNull(); + expect(out.kind).toBe("lefthook"); + expect(out.configFile.endsWith(".lefthook.yaml")).toBe(true); + }); + + it("finds a lefthook header inside a gitDir hook when no config file exists", () => { + const root = mkTmp(); // no lefthook config here + const gitDir = mkTmp("git-embedded-gitdir-"); + const hooksDir = path.join(gitDir, "hooks"); + fs.mkdirSync(hooksDir); + // A subdir exercises readHead's catch (readFileSync on a dir throws). + fs.mkdirSync(path.join(hooksDir, "subdir")); + // A non-matching sibling exercises the head-does-not-match continue. + fs.writeFileSync(path.join(hooksDir, "commit-msg"), "#!/bin/sh\necho unrelated\n"); + fs.writeFileSync(path.join(hooksDir, "pre-commit"), "#!/bin/sh\n# generated by lefthook\nlefthook run pre-commit\n"); + + const out = api.detect.lefthook(root, gitDir); + expect(out).not.toBeNull(); + expect(out.kind).toBe("lefthook"); + expect(out.configFile).toBeNull(); + expect(out.headerIn).toBe(path.join(hooksDir, "pre-commit")); + }); + + it("returns null when the gitDir has a hooks dir but no lefthook header", () => { + const root = mkTmp(); + const gitDir = mkTmp("git-embedded-gitdir-"); + fs.mkdirSync(path.join(gitDir, "hooks")); + fs.writeFileSync(path.join(gitDir, "hooks", "pre-commit"), "#!/bin/sh\necho plain\n"); + expect(api.detect.lefthook(root, gitDir)).toBeNull(); + }); + + it("returns null when neither a config nor a gitDir hooks dir is present", () => { + const root = mkTmp(); // no config + const gitDir = mkTmp("git-embedded-gitdir-"); // no hooks/ subdir + expect(api.detect.lefthook(root, gitDir)).toBeNull(); + // And with no gitDir at all. + expect(api.detect.lefthook(root, null)).toBeNull(); + }); +}); + +describe("api.detect.preCommit (edge cases)", () => { + it("returns null for a falsy repoRoot", () => { + expect(api.detect.preCommit(null, null)).toBeNull(); + }); + + it("finds a pre-commit generated-header inside a gitDir hook when no config file exists", () => { + const root = mkTmp(); // no .pre-commit-config.yaml + const gitDir = mkTmp("git-embedded-gitdir-"); + const hooksDir = path.join(gitDir, "hooks"); + fs.mkdirSync(hooksDir); + fs.mkdirSync(path.join(hooksDir, "subdir")); // readHead catch + fs.writeFileSync(path.join(hooksDir, "commit-msg"), "#!/bin/sh\necho unrelated\n"); + fs.writeFileSync(path.join(hooksDir, "pre-commit"), "#!/usr/bin/env bash\n# File generated by pre-commit: https://pre-commit.com\n"); + + const out = api.detect.preCommit(root, gitDir); + expect(out).not.toBeNull(); + expect(out.kind).toBe("pre-commit"); + expect(out.configFile).toBeNull(); + expect(out.headerIn).toBe(path.join(hooksDir, "pre-commit")); + }); + + it("returns null when the gitDir hooks dir has no pre-commit header", () => { + const root = mkTmp(); + const gitDir = mkTmp("git-embedded-gitdir-"); + fs.mkdirSync(path.join(gitDir, "hooks")); + fs.writeFileSync(path.join(gitDir, "hooks", "pre-commit"), "#!/bin/sh\necho plain\n"); + expect(api.detect.preCommit(root, gitDir)).toBeNull(); + }); +}); + +describe("api.detect.simpleGitHooks (edge cases)", () => { + it("returns null for a falsy repoRoot", () => { + expect(api.detect.simpleGitHooks(null)).toBeNull(); + }); + + it("returns the parsed config from a package.json top-level key", () => { + const root = mkTmp(); + const cfg = { "pre-commit": "echo hi", "pre-push": "echo bye" }; + fs.writeFileSync(path.join(root, "package.json"), JSON.stringify({ "simple-git-hooks": cfg })); + const out = api.detect.simpleGitHooks(root); + expect(out).not.toBeNull(); + expect(out.kind).toBe("simple-git-hooks"); + expect(out.configIn).toBe("package.json"); + expect(out.config).toEqual(cfg); + }); + + it("detects a standalone .simple-git-hooks.json when there is no package.json", () => { + const root = mkTmp(); + const standalone = path.join(root, ".simple-git-hooks.json"); + fs.writeFileSync(standalone, JSON.stringify({ "pre-commit": "echo standalone" })); + const out = api.detect.simpleGitHooks(root); + expect(out).not.toBeNull(); + expect(out.kind).toBe("simple-git-hooks"); + expect(out.configIn).toBe(standalone); + }); + + it("swallows a malformed package.json (wispSync throws) and falls back to the standalone file", () => { + const root = mkTmp(); + fs.writeFileSync(path.join(root, "package.json"), "this is not valid json {"); + const standalone = path.join(root, ".simple-git-hooks.json"); + fs.writeFileSync(standalone, JSON.stringify({ "pre-commit": "echo x" })); + const out = api.detect.simpleGitHooks(root); + expect(out).not.toBeNull(); + expect(out.configIn).toBe(standalone); + }); + + it("returns null when a package.json has no key and there is no standalone file", () => { + const root = mkTmp(); + fs.writeFileSync(path.join(root, "package.json"), JSON.stringify({ name: "no-hooks-here" })); + expect(api.detect.simpleGitHooks(root)).toBeNull(); + }); + + it("returns null when a malformed package.json has no standalone fallback", () => { + const root = mkTmp(); + fs.writeFileSync(path.join(root, "package.json"), "not json"); + expect(api.detect.simpleGitHooks(root)).toBeNull(); + }); +}); + +describe.skipIf(process.platform === "win32")("api.detect.run (foreign-manager precedence)", () => { + it("classifies husky (highest precedence) and refuses", () => { + const repo = mkGitRepo(); + fs.mkdirSync(path.join(repo, ".husky")); + fs.writeFileSync( + path.join(repo, "package.json"), + JSON.stringify({ scripts: { prepare: "husky" }, devDependencies: { husky: "^9.0.0" } }) + ); + const out = api.detect.run(repo); + expect(out.kind).toBe("husky"); + expect(out.action).toBe("refuse"); + expect(out.foreign.kind).toBe("husky"); + }); + + it("classifies lefthook and refuses", () => { + const repo = mkGitRepo(); + fs.writeFileSync(path.join(repo, "lefthook.yml"), "pre-commit:\n commands: {}\n"); + const out = api.detect.run(repo); + expect(out.kind).toBe("lefthook"); + expect(out.action).toBe("refuse"); + expect(out.foreign.configFile.endsWith("lefthook.yml")).toBe(true); + }); + + it("classifies simple-git-hooks and refuses", () => { + const repo = mkGitRepo(); + fs.writeFileSync(path.join(repo, "package.json"), JSON.stringify({ "simple-git-hooks": { "pre-commit": "echo hi" } })); + const out = api.detect.run(repo); + expect(out.kind).toBe("simple-git-hooks"); + expect(out.action).toBe("refuse"); + expect(out.foreign.configIn).toBe("package.json"); + }); + + it("classifies pre-commit and refuses", () => { + const repo = mkGitRepo(); + fs.writeFileSync(path.join(repo, ".pre-commit-config.yaml"), "repos: []\n"); + const out = api.detect.run(repo); + expect(out.kind).toBe("pre-commit"); + expect(out.action).toBe("refuse"); + expect(out.foreign.configFile.endsWith(".pre-commit-config.yaml")).toBe(true); + }); +}); + +describe.skipIf(process.platform === "win32")("api.detect.run (effective core.hooksPath classification)", () => { + function repoWithHooksPath(hooksDir) { + const repo = mkGitRepo(); + git(["config", "--local", "core.hooksPath", hooksDir], repo); + return repo; + } + + it("routes a canonical-complete dispatcher to action install", () => { + const repo = repoWithHooksPath(mkCanonicalHooksDir()); + const out = api.detect.run(repo); + expect(out.kind).toBe("dispatcher-canonical-complete"); + expect(out.action).toBe("install"); + expect(out.dispatcher.kind).toBe("dispatcher-canonical-complete"); + }); + + it("routes a missing-symlinks dispatcher to action heal-then-install", () => { + const repo = repoWithHooksPath(mkMissingHooksDir()); + const out = api.detect.run(repo); + expect(out.kind).toBe("dispatcher-missing-symlinks"); + expect(out.action).toBe("heal-then-install"); + expect(Array.from(out.dispatcher.missing)).toContain("reference-transaction"); + }); + + it("routes a non-conforming dispatcher to action refuse", () => { + const repo = repoWithHooksPath(mkNonConformingHooksDir()); + const out = api.detect.run(repo); + expect(out.kind).toBe("dispatcher-non-conforming"); + expect(out.action).toBe("refuse"); + }); + + it("routes a bare githooks dir to action refuse", () => { + const repo = repoWithHooksPath(mkBareHooksDir()); + const out = api.detect.run(repo); + expect(out.kind).toBe("bare-githooks"); + expect(out.action).toBe("refuse"); + expect(out.bare.kind).toBe("bare-githooks"); + }); + + it("falls through to none when the hooksPath dir is empty", () => { + const repo = repoWithHooksPath(mkTmp("git-embedded-hooks-empty-")); + const out = api.detect.run(repo); + expect(out.kind).toBe("none"); + expect(out.action).toBe("suggest-dispatcher"); + }); +}); + +describe.skipIf(process.platform === "win32")("api.detect.run (fallbacks)", () => { + it("reports none for a plain repo with no manager, hooksPath, or template dir", () => { + const repo = mkGitRepo(); + const out = api.detect.run(repo); + expect(out.kind).toBe("none"); + expect(out.action).toBe("suggest-dispatcher"); + expect(out.paths.repoRoot).toBe(git(["rev-parse", "--show-toplevel"], repo)); + }); + + it("reports init-templatedir when a global init.templateDir with a hooks dir exists", () => { + const repo = mkGitRepo(); + const tmplDir = mkTmp("git-embedded-tmpl-"); + fs.mkdirSync(path.join(tmplDir, "hooks")); + process.env.GIT_CONFIG_GLOBAL = writeGitConfig(`[init]\n\ttemplateDir = ${tmplDir}\n`); + const out = api.detect.run(repo); + expect(out.kind).toBe("init-templatedir"); + expect(out.action).toBe("suggest-dispatcher"); + expect(out.templateDir).toBe(tmplDir); + }); + + it("reports system-hookspath install for a system-scope core.hooksPath to a canonical dispatcher", () => { + const hooksDir = mkCanonicalHooksDir(); + process.env.GIT_CONFIG_SYSTEM = writeGitConfig(`[core]\n\thooksPath = ${hooksDir}\n`); + // A non-repo cwd keeps the local scope empty so the system-only branch fires. + const nonRepo = mkTmp("git-embedded-norepo-"); + process.chdir(nonRepo); + const out = api.detect.run(nonRepo); + expect(out.kind).toBe("system-hookspath"); + expect(out.action).toBe("install"); + expect(out.subClassification.kind).toBe("dispatcher-canonical-complete"); + expect(out.dispatcher.kind).toBe("dispatcher-canonical-complete"); + }); + + it("reports system-hookspath refuse (dispatcher null) for a system-scope hooksPath to a bare dir", () => { + const hooksDir = mkBareHooksDir(); + process.env.GIT_CONFIG_SYSTEM = writeGitConfig(`[core]\n\thooksPath = ${hooksDir}\n`); + const nonRepo = mkTmp("git-embedded-norepo-"); + process.chdir(nonRepo); + const out = api.detect.run(nonRepo); + expect(out.kind).toBe("system-hookspath"); + expect(out.action).toBe("refuse"); + expect(out.subClassification.kind).toBe("bare-githooks"); + expect(out.dispatcher).toBeNull(); + }); +}); diff --git a/tests/embedded-coverage.test.mjs b/tests/embedded-coverage.test.mjs new file mode 100644 index 0000000..82c04c6 --- /dev/null +++ b/tests/embedded-coverage.test.mjs @@ -0,0 +1,570 @@ +/** + * Coverage closure for the embedded engine. These target the error/edge/defensive + * branches that embedded-provisioning.test.mjs and embedded-topup.test.mjs leave + * uncovered, against REAL temp git fixtures (house style — no over-mocking): + * + * - branch.mjs / gitlinks.mjs / registry.mjs — the `res.status ?? 1` spawn-failure + * fallback (git absent from PATH → null status), plus registry.entries()'s + * multi-line-config parse guards. + * - manifest.mjs — relative-path resolution, the missing-file null return, the + * invalid-JSON throw, and build()'s null/url-less entry drops. + * - resolve.mjs — conventionUrl basename of an empty path segment. + * - restore.mjs — paths-filter miss, non-ENOENT lstat (ENOTDIR/EACCES) refusals, + * the "nothing resolves" unresolved, and the attach-fail → detached fallback. + * - sync.mjs — non-repo cwd, skip, paths-filter miss, symlink-parent ENOTDIR, + * detached dry-run, the corrupt-ancestry sync-failed, and index-locked branch / + * detached checkout failures. + */ + +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 { fileURLToPath } from "node:url"; +import { getApi } from "./_setup.mjs"; + +// Scratch git fixtures live under the repo's gitignored tmp/ (never the system +// /tmp), and are torn down per-test in afterEach. Because tmp/ sits INSIDE this +// repo's worktree, a fixture that isn't itself a git repo would otherwise resolve +// the enclosing repo as its root; GIT_CEILING_DIRECTORIES (set in beforeEach) +// stops git's upward search at repoTmp so a non-repo fixture reads as non-repo, +// exactly as an out-of-tree /tmp fixture would. +const repoTmp = (() => { + const p = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "tmp"); + fs.mkdirSync(p, { recursive: true }); + return fs.realpathSync(p); +})(); + +const tmpRoots = []; + +function mkTmp() { + const dir = fs.mkdtempSync(path.join(repoTmp, "git-embedded-cov-")); + tmpRoots.push(dir); + return dir; +} + +/** Throwing git for fixture setup. */ +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(); +} + +const BOGUS_SHA = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"; + +// Whether emptying PATH makes a bare `git` unresolvable (spawnSync → null status). +// True on POSIX; the coverage runner is Linux. Guards the spawn-failure tests so +// they don't misfire on a platform that still resolves git.exe without PATH. +const gitVanishesWithoutPath = (() => { + const saved = process.env.PATH; + try { + process.env.PATH = ""; + return spawnSync("git", ["--version"], { encoding: "utf8" }).status === null; + } catch { + return false; + } finally { + process.env.PATH = saved; + } +})(); + +/** Run `fn` with an emptied PATH so every git spawn yields a null status. */ +function withBrokenPath(fn) { + const saved = process.env.PATH; + process.env.PATH = ""; + try { + return fn(); + } finally { + process.env.PATH = saved; + } +} + +/** + * Bare "child source" repo with one commit on `main` (pushed), under + * `remotes/.git`. Returns the bare path + pinned SHA. + */ +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); + return { bare, sha: git(["rev-parse", "HEAD"], src) }; +} + +/** + * Parent repo carrying an anonymous gitlink at `gitlinkPath`, pushed to a bare. + * `childBareName` obscures the child (defaults to the gitlink basename so + * convention resolves). + */ +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); + // Materialize intermediate dirs for a nested gitlink path. + fs.mkdirSync(path.dirname(path.join(parentSrc, gitlinkPath)), { recursive: true }); + 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, bareName, gitlinkPath }; +} + +function freshClone(parentBare) { + const dir = path.join(mkTmp(), "clone"); + git(["clone", "--quiet", parentBare, dir]); + return dir; +} + +/** Advance the child source by one commit (pushed by default). Returns new SHA. */ +function advanceChild(work, bareName, marker, { push = true } = {}) { + const src = path.join(work, `src-${bareName}`); + fs.writeFileSync(path.join(src, `${marker}.txt`), marker); + git(["add", "."], src); + git(["commit", "-m", `${bareName} ${marker}`], 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. */ +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(); + process.env.GIT_CONFIG_GLOBAL = os.platform() === "win32" ? "NUL" : "/dev/null"; + process.env.GIT_CONFIG_SYSTEM = os.platform() === "win32" ? "NUL" : "/dev/null"; + // Keep git from walking out of a non-repo fixture into this enclosing repo. + process.env.GIT_CEILING_DIRECTORIES = repoTmp; + 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 { + // Restore perms first — an EACCES-refusal test may have chmod 000'd a dir, + // which would otherwise block recursive removal. + try { + fs.chmodSync(d, 0o755); + } catch { + /* ignore */ + } + fs.rmSync(d, { recursive: true, force: true }); + } catch { + // ignore + } + } +}); + +let api; +beforeAll(async () => { + api = await getApi(); +}); + +// ─── branch.mjs / gitlinks.mjs / registry.mjs — spawn-failure fallback ────────── + +describe("spawn-failure fallback (git absent from PATH → null status)", () => { + it.skipIf(!gitVanishesWithoutPath)("branch.infer returns null when git cannot be spawned", () => { + const { work } = makeParent({ gitlinkPath: "tests" }); + const child = path.join(work, "src-tests"); + withBrokenPath(() => { + expect(api.embedded.branch.infer(child, BOGUS_SHA)).toBeNull(); + }); + }); + + it.skipIf(!gitVanishesWithoutPath)("gitlinks returns [] when git cannot be spawned", () => { + const { work } = makeParent({ gitlinkPath: "tests" }); + withBrokenPath(() => { + expect(api.embedded.gitlinks(path.join(work, "src-parent"))).toEqual([]); + }); + }); + + it.skipIf(!gitVanishesWithoutPath)("registry.getUrl returns null when git cannot be spawned", () => { + const { work } = makeParent({ gitlinkPath: "tests" }); + withBrokenPath(() => { + expect(api.embedded.registry.getUrl("tests", path.join(work, "src-parent"))).toBeNull(); + }); + }); + + it.skipIf(!gitVanishesWithoutPath)("restore is a clean no-op when git cannot be spawned", () => { + const { parentBare } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + const out = withBrokenPath(() => api.embedded.restore({ cwd: fresh })); + expect(out).toEqual({ results: [], exitCode: 0 }); + }); +}); + +// ─── registry.mjs — entries() parse guards for pathological config output ─────── + +describe("registry.entries parse guards (multi-line config values)", () => { + it("skips blank, space-less, and dot-less continuation lines without crashing", () => { + const root = mkTmp(); + git(["init", "-b", "main", root]); + // A url value whose text spans lines: `git config --get-regexp` emits it as a + // key line followed by raw continuation lines — a blank line (L74), a line + // with no space (L76), and a line whose first token has no dot (L84). The + // parser must skip all three and still return the well-formed entries. + api.embedded.registry.setUrl("alpha", "first line\n\nnospace\nhas space", root); + api.embedded.registry.setUrl("beta", "clean", root); + + const byPath = Object.fromEntries(api.embedded.registry.entries(root).map((e) => [e.path, e])); + expect(byPath.alpha).toEqual({ path: "alpha", url: "first line" }); + expect(byPath.beta).toEqual({ path: "beta", url: "clean" }); + }); +}); + +// ─── manifest.mjs ─────────────────────────────────────────────────────────────── + +describe("manifest.read edges", () => { + it("resolves a RELATIVE file against cwd and returns null when it is missing", () => { + const dir = mkTmp(); + expect(api.embedded.manifest.read("does-not-exist.json", dir)).toBeNull(); + }); + + it("throws a descriptive error for a file that exists but is not valid JSON", () => { + const dir = mkTmp(); + const bad = path.join(dir, "bad.json"); + fs.writeFileSync(bad, "{ this is not json "); + expect(() => api.embedded.manifest.read(bad)).toThrow(/is not valid JSON/); + }); +}); + +describe("manifest.build entry filtering", () => { + it("returns an empty children map for no entries at all", () => { + expect(api.embedded.manifest.build()).toEqual({ version: 1, children: {} }); + expect(api.embedded.manifest.build(null)).toEqual({ version: 1, children: {} }); + }); + + it("drops null and url-less entries, keeping only entries with a url", () => { + const manifest = api.embedded.manifest.build([ + null, + { path: "no-url" }, + { path: "keep", url: "ssh://h/keep.git", branch: "main" } + ]); + expect(Object.keys(manifest.children)).toEqual(["keep"]); + expect(manifest.children.keep).toEqual({ url: "ssh://h/keep.git", branch: "main" }); + }); +}); + +// ─── resolve.mjs ───────────────────────────────────────────────────────────────── + +describe("resolve.conventionUrl basename edge", () => { + it("yields an empty basename for a path that is only slashes", () => { + // childPath "" → split/filter → [] → basename falls back to String(childPath). + expect(api.embedded.resolve.conventionUrl("https://h/o/parent.git", "")).toBe("https://h/o/.git"); + }); +}); + +// ─── restore.mjs ───────────────────────────────────────────────────────────────── + +describe("restore edge branches", () => { + it("skips a gitlink not named by the paths filter (no results)", () => { + const { parentBare } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + const { results, exitCode } = api.embedded.restore({ cwd: fresh, paths: ["not-a-real-path"] }); + expect(results).toEqual([]); + expect(exitCode).toBe(0); + expect(fs.existsSync(path.join(fresh, "tests", ".git"))).toBe(false); + }); + + it("refuses a target whose parent path is a file (non-ENOENT lstat → ENOTDIR)", () => { + const { parentBare } = makeParent({ gitlinkPath: "vendor/lib" }); + const fresh = freshClone(parentBare); + // Replace the intermediate `vendor` directory with a regular file so + // lstat("vendor/lib") fails ENOTDIR — a non-ENOENT error that must be refused, + // not treated as "absent, clone will create it". + fs.rmSync(path.join(fresh, "vendor"), { recursive: true, force: true }); + fs.writeFileSync(path.join(fresh, "vendor"), "not a directory"); + + const { results, exitCode } = api.embedded.restore({ cwd: fresh }); + expect(results).toHaveLength(1); + expect(results[0].path).toBe("vendor/lib"); + expect(results[0].outcome).toBe("unresolved"); + expect(results[0].note).toMatch(/target unreadable \(ENOTDIR\).*refusing/); + expect(exitCode).toBe(1); + expect(fs.readFileSync(path.join(fresh, "vendor"), "utf8")).toBe("not a directory"); + }); + + it("refuses an unreadable (EACCES) materialized gitlink directory", () => { + const { parentBare } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + const target = path.join(fresh, "tests"); + // A directory git can lstat but the process cannot readdir → the emptiness + // probe throws EACCES and restore refuses rather than cloning into it. + fs.chmodSync(target, 0o000); + try { + const { results, exitCode } = api.embedded.restore({ cwd: fresh }); + expect(results[0].outcome).toBe("unresolved"); + expect(results[0].note).toMatch(/target unreadable \(EACCES\).*refusing/); + expect(exitCode).toBe(1); + expect(fs.existsSync(path.join(target, ".git"))).toBe(false); + } finally { + fs.chmodSync(target, 0o755); + } + }); + + it("reports unresolved when NO source can supply a URL (no origin, config, manifest, or base)", () => { + const { parentBare } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + // Drop the parent's origin so convention has nothing to derive from, and no + // registry/manifest/base is supplied → resolve returns a null url. + git(["remote", "remove", "origin"], fresh); + const { results, exitCode } = api.embedded.restore({ cwd: fresh }); + expect(results).toHaveLength(1); + expect(results[0].url).toBeNull(); + expect(results[0].source).toBeNull(); + expect(results[0].outcome).toBe("unresolved"); + expect(results[0].note).toMatch(/no URL from local config, manifest, --base, or convention/); + expect(exitCode).toBe(1); + }); + + it("deletes the whole clone on a pinned-mismatch when the target did not pre-exist", () => { + // Decoy at the convention target (tests.git) with unrelated history; the REAL + // pin lives in a differently-named bare convention never finds. + const work = mkTmp(); + const remotes = path.join(work, "remotes"); + fs.mkdirSync(remotes, { recursive: true }); + makeChildBare(work, remotes, "tests", "DECOY"); + 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); + // Remove the materialized empty dir so the clone target does NOT pre-exist — + // removeClone must then delete the whole directory it created (not just its + // contents) when the decoy clone fails SHA verification. + fs.rmSync(path.join(fresh, "tests"), { recursive: true, force: true }); + expect(fs.existsSync(path.join(fresh, "tests"))).toBe(false); + + 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 entirely — nothing left at the path. + expect(fs.existsSync(path.join(fresh, "tests"))).toBe(false); + }); + + it("does not attempt removeClone when a failed clone left no directory behind", () => { + const { parentBare } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + // Remove the materialized dir (target does NOT pre-exist) and point the clone + // at a nonexistent repo. git creates then removes the dest on failure, so the + // `if (fs.existsSync(absChild)) removeClone(...)` guard must take its false arm. + fs.rmSync(path.join(fresh, "tests"), { recursive: true, force: true }); + api.embedded.registry.setUrl("tests", path.join(mkTmp(), "nonexistent.git"), fresh); + + const { results, exitCode } = api.embedded.restore({ cwd: fresh }); + expect(results[0].outcome).toBe("unresolved"); + expect(results[0].note).toMatch(/clone failed/); + expect(exitCode).toBe(1); + expect(fs.existsSync(path.join(fresh, "tests"))).toBe(false); + }); + + it("falls back to a detached checkout when the registered branch name is invalid (attach fails)", () => { + const { parentBare, childSha } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + // A branch name git rejects for `checkout -B` (consecutive dots). Resolve/pin + // succeed, so restore must still land the child — detached — rather than fail. + api.embedded.registry.setBranch("tests", "bad..name", fresh); + + const { results, exitCode } = api.embedded.restore({ cwd: fresh }); + expect(exitCode).toBe(0); + expect(results[0].outcome).toBe("restored"); + expect(results[0].branch).toBeNull(); + expect(results[0].note).toMatch(/could not attach branch bad\.\.name; checked out detached/); + + const child = path.join(fresh, "tests"); + expect(git(["rev-parse", "HEAD"], child)).toBe(childSha); + expect(git(["branch", "--show-current"], child)).toBe(""); // detached + }); +}); + +// ─── sync.mjs ──────────────────────────────────────────────────────────────────── + +describe("sync edge branches", () => { + it("is a clean no-op when cwd is not a git repository", () => { + const notRepo = mkTmp(); + expect(api.embedded.sync({ cwd: notRepo })).toEqual({ results: [], exitCode: 0 }); + }); + + it("honors --skip (skipped child is not touched)", () => { + const { parentBare } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + const { results, exitCode } = api.embedded.sync({ cwd: fresh, skip: ["tests"] }); + expect(results).toHaveLength(1); + expect(results[0].outcome).toBe("skipped"); + expect(exitCode).toBe(0); + }); + + it("skips a gitlink not named by the paths filter (no results)", () => { + const { parentBare } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + expect(api.embedded.sync({ cwd: fresh, paths: ["not-a-real-path"] })).toEqual({ results: [], exitCode: 0 }); + }); + + it("reports sync-failed when the gitlink path's parent is a file (ENOTDIR)", () => { + const { parentBare } = makeParent({ gitlinkPath: "vendor/lib" }); + const fresh = freshClone(parentBare); + fs.rmSync(path.join(fresh, "vendor"), { recursive: true, force: true }); + fs.writeFileSync(path.join(fresh, "vendor"), "not a directory"); + + const { results, exitCode } = api.embedded.sync({ cwd: fresh }); + expect(results).toHaveLength(1); + expect(results[0].outcome).toBe("sync-failed"); + expect(results[0].note).toMatch(/gitlink path unreadable \(ENOTDIR\)/); + expect(exitCode).toBe(1); + }); + + it("treats a deleted materialized dir (lstat ENOENT) as an absent child, not a failure", () => { + const { parentBare } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + // Delete the materialized empty dir so lstat throws ENOENT — the catch must + // take its ENOENT arm and fall through to the no-repo handling, never mislabel + // it a sync-failed anomaly. + fs.rmSync(path.join(fresh, "tests"), { recursive: true, force: true }); + const { results, exitCode } = api.embedded.sync({ cwd: fresh, paths: ["tests"] }); + expect(results).toEqual([{ path: "tests", sha: expect.any(String), branch: null, note: "not present on disk — run restore", outcome: "no-repo" }]); + expect(exitCode).toBe(0); + }); + + it("dry-run snaps a detached child optimistically without moving it", () => { + const { work, parentBare, childSha } = 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, dryRun: true }); + expect(exitCode).toBe(0); + expect(results[0].outcome).toBe("synced"); + expect(results[0].dryRun).toBe(true); + expect(results[0].branch).toBeNull(); + + const child = path.join(fresh, "tests"); + expect(git(["rev-parse", "HEAD"], child)).toBe(childSha); // unmoved + expect(git(["branch", "--show-current"], child)).toBe(""); // still detached + }); + + it("reports sync-failed when ancestry cannot be tested (corrupt object graph)", () => { + const { work, parentBare } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + api.embedded.restore({ cwd: fresh }); // child on main @ c1 + + // Build a 3-commit chain c1→c2→c3; pin to c3. After making c3 locally + // available, CORRUPT c2's object so the `merge-base --is-ancestor c1 c3` + // walk (which passes through c2) errors hard (128) rather than returning a + // clean ancestor/not-ancestor answer. A deleted middle object would merely + // read as "not an ancestor" (exit 1); corruption is what forces the 128. + // HEAD (c1) and the pin (c3) stay intact so status/cat-file still pass. + const c2 = advanceChild(work, "tests", "v2"); + const c3 = advanceChild(work, "tests", "v3"); + bumpPin(fresh, "tests", c3); + const child = path.join(fresh, "tests"); + git(["fetch", "origin"], child); // c2 + c3 now present locally + const obj = path.join(child, ".git", "objects", c2.slice(0, 2), c2.slice(2)); + expect(fs.existsSync(obj)).toBe(true); // precondition: loose object + fs.chmodSync(obj, 0o644); // loose objects are read-only + fs.writeFileSync(obj, "GARBAGE-not-a-valid-zlib-object"); + + const { results, exitCode } = api.embedded.sync({ cwd: fresh }); + expect(results[0].outcome).toBe("sync-failed"); + expect(results[0].note).toMatch(/could not test ancestry/); + expect(exitCode).toBe(1); + }); + + it("reports sync-failed when moving the registered branch fails (index locked)", () => { + const { work, parentBare, childSha } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + api.embedded.restore({ cwd: fresh }); // registers "main" + + const sha2 = advanceChild(work, "tests", "v2"); + bumpPin(fresh, "tests", sha2); + const child = path.join(fresh, "tests"); + git(["fetch", "origin"], child); // make the pin present so no fetch is needed + // A stale index.lock makes the branch-moving `checkout -B` fail while HEAD / + // status / merge-base (which don't take the lock) still succeed. + const lock = path.join(child, ".git", "index.lock"); + fs.writeFileSync(lock, ""); + try { + const { results, exitCode } = api.embedded.sync({ cwd: fresh }); + expect(results[0].outcome).toBe("sync-failed"); + expect(results[0].branch).toBe("main"); + expect(results[0].note).toMatch(/could not move branch main/); + expect(exitCode).toBe(1); + expect(git(["rev-parse", "HEAD"], child)).toBe(childSha); // unmoved + } finally { + fs.rmSync(lock, { force: true }); + } + }); + + it("reports sync-failed when the detached checkout fails (index locked)", () => { + const { work, parentBare, childSha } = makeParent({ gitlinkPath: "tests" }); + git(["push", "origin", "main:dev"], path.join(work, "src-tests")); // ambiguous → detached + 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"); + git(["fetch", "origin"], child); + const lock = path.join(child, ".git", "index.lock"); + fs.writeFileSync(lock, ""); + try { + const { results, exitCode } = api.embedded.sync({ cwd: fresh }); + expect(results[0].outcome).toBe("sync-failed"); + expect(results[0].note).toMatch(/could not check out/); + expect(exitCode).toBe(1); + expect(git(["rev-parse", "HEAD"], child)).toBe(childSha); // unmoved + } finally { + fs.rmSync(lock, { force: true }); + } + }); +}); diff --git a/tests/embedded-topup.test.mjs b/tests/embedded-topup.test.mjs new file mode 100644 index 0000000..62380c7 --- /dev/null +++ b/tests/embedded-topup.test.mjs @@ -0,0 +1,396 @@ +/** + * Branch top-up coverage for the embedded engine's smaller modules. These + * exercise the error paths, ambiguous/edge inputs, and layer-precedence + * branches that embedded-provisioning.test.mjs leaves uncovered: + * + * - branch.mjs infer's "on no remote branch" (size 0) and git-error paths, + * and attach's checkout-failure + best-effort-upstream branches. + * - gitlinks.mjs the not-a-repo, empty-tree, and regular-file-only filters, + * plus a happy nested-path read. + * - record.mjs the paths-filter miss, absent-child (no-repo vs silent skip), + * and the non-repo getRepoRoot fallback. + * - registry.mjs entries-empty, recordOne no-repo/no-origin/detached, and the + * setter/getter false/null branches. + * - resolve.mjs conventionUrl null/no-delimiter edges and the full + * local-config > manifest > base > convention precedence chain. + * + * Direct api unit calls against REAL temp git fixtures, matching the house + * style (no over-mocking). + */ + +import { afterEach, beforeAll, 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 { getApi } from "./_setup.mjs"; + +const tmpRoots = []; + +function mkTmp() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "git-embedded-topup-")); + tmpRoots.push(dir); + return dir; +} + +/** Throwing git for fixture setup. */ +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(); +} + +/** Non-throwing git for operations expected to fail (upstream probes, etc.). */ +function gitTry(args, cwd) { + const res = spawnSync("git", args, { cwd, encoding: "utf8" }); + return { status: res.status ?? 1, stdout: (res.stdout || "").trim(), stderr: res.stderr || "" }; +} + +const BOGUS_SHA = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"; + +/** A bare repo with one commit on `main` (pushed) + its origin URL + tip sha. */ +function makeBare(marker = "c1") { + const work = mkTmp(); + const bare = path.join(work, "child.git"); + git(["init", "--bare", "-b", "main", bare]); + const seed = path.join(work, "seed"); + git(["init", "-b", "main", seed]); + fs.writeFileSync(path.join(seed, "spec.txt"), marker); + git(["add", "."], seed); + git(["commit", "-m", "c1"], seed); + git(["remote", "add", "origin", bare], seed); + git(["push", "--quiet", "origin", "main"], seed); + return { work, bare, seed, sha: git(["rev-parse", "HEAD"], seed) }; +} + +/** A working clone of a fresh bare (origin wired, on `main`). */ +function makeBareWithClone(marker = "c1") { + const { work, bare, seed, sha } = makeBare(marker); + const clone = path.join(work, "clone"); + git(["clone", "--quiet", bare, clone]); + return { work, bare, seed, clone, sha }; +} + +/** Init an empty parent repo and return its root (a real git repo, no children). */ +function initRepo() { + const root = mkTmp(); + git(["init", "-b", "main", root]); + return root; +} + +/** + * Parent repo embedding a child at each of `gitlinkPaths`, pushed to a bare. + * Returns the parent source dir (children present, committed), the parent bare, + * and per-path child bare + pinned sha. + */ +function makeParentWithChildren(gitlinkPaths = ["tests"]) { + const work = mkTmp(); + const remotes = path.join(work, "remotes"); + fs.mkdirSync(remotes, { recursive: true }); + + 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); + + const childBares = {}; + for (const gp of gitlinkPaths) { + const name = gp.split("/").pop(); + const bare = path.join(remotes, `${name}.git`); + git(["init", "--bare", "-b", "main", bare]); + const src = path.join(work, `src-${name}`); + git(["init", "-b", "main", src]); + fs.writeFileSync(path.join(src, "spec.txt"), name); + git(["add", "."], src); + git(["commit", "-m", `${name} init`], src); + git(["remote", "add", "origin", bare], src); + git(["push", "--quiet", "origin", "main"], src); + childBares[gp] = { bare, sha: git(["rev-parse", "HEAD"], src) }; + git(["clone", "--quiet", bare, path.join(parentSrc, gp)]); + git(["add", gp], parentSrc); + } + git(["commit", "-m", "embed children"], parentSrc); + git(["remote", "add", "origin", parentBare], parentSrc); + git(["push", "--quiet", "origin", "main"], parentSrc); + + return { work, parentBare, parentSrc, childBares }; +} + +function freshClone(parentBare) { + const dir = path.join(mkTmp(), "clone"); + git(["clone", "--quiet", parentBare, dir]); + return dir; +} + +let originalEnv; +beforeEach(() => { + originalEnv = { ...process.env }; + // 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(() => { + process.env = originalEnv; + 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.branch.infer", () => { + it("returns the single containing branch, then declines when the pin is ambiguous", () => { + const { work, clone, sha } = makeBareWithClone(); + // Exactly one origin branch contains the pin. + expect(api.embedded.branch.infer(clone, sha)).toBe("main"); + + // A second origin branch carrying the same tip makes inference ambiguous. + git(["push", "--quiet", "origin", "main:dev"], path.join(work, "seed")); + git(["fetch", "--quiet", "origin"], clone); + expect(api.embedded.branch.infer(clone, sha)).toBeNull(); + }); + + it("returns null when the pin is on NO remote branch (unpushed local commit)", () => { + const { clone } = makeBareWithClone(); + // A local commit ahead of origin/main → contained by no remote branch. + fs.writeFileSync(path.join(clone, "local.txt"), "wip"); + git(["add", "."], clone); + git(["commit", "-m", "local only"], clone); + const localSha = git(["rev-parse", "HEAD"], clone); + expect(git(["branch", "-r", "--contains", localSha], clone)).toBe(""); // precondition + expect(api.embedded.branch.infer(clone, localSha)).toBeNull(); + }); + + it("returns null when git itself errors (unknown commit)", () => { + const { clone } = makeBareWithClone(); + expect(api.embedded.branch.infer(clone, BOGUS_SHA)).toBeNull(); + }); +}); + +describe("api.embedded.branch.attach", () => { + it("returns false and moves nothing when the checkout fails (bad sha)", () => { + const { clone } = makeBareWithClone(); + expect(api.embedded.branch.attach(clone, "feature", BOGUS_SHA)).toBe(false); + // HEAD stayed put and no dangling branch was created. + expect(git(["branch", "--show-current"], clone)).toBe("main"); + expect(gitTry(["rev-parse", "--verify", "feature"], clone).status).not.toBe(0); + }); + + it("attaches to an existing branch and sets its upstream", () => { + const { clone, sha } = makeBareWithClone(); + expect(api.embedded.branch.attach(clone, "main", sha)).toBe(true); + expect(git(["rev-parse", "HEAD"], clone)).toBe(sha); + expect(git(["branch", "--show-current"], clone)).toBe("main"); + expect(git(["rev-parse", "--abbrev-ref", "main@{upstream}"], clone)).toBe("origin/main"); + }); + + it("succeeds (best-effort) even when no matching origin branch exists to track", () => { + const { clone, sha } = makeBareWithClone(); + // No origin/brandnew exists — the soft --set-upstream-to fails but attach + // must still report success with the local branch created at the pin. + expect(api.embedded.branch.attach(clone, "brandnew", sha)).toBe(true); + expect(git(["branch", "--show-current"], clone)).toBe("brandnew"); + expect(git(["rev-parse", "HEAD"], clone)).toBe(sha); + expect(gitTry(["rev-parse", "--abbrev-ref", "brandnew@{upstream}"], clone).status).not.toBe(0); + }); +}); + +describe("api.embedded.gitlinks", () => { + it("returns [] for a directory that is not a git repo", () => { + expect(api.embedded.gitlinks(mkTmp())).toEqual([]); + }); + + it("returns [] for a repo whose HEAD tree is empty (empty commit)", () => { + const repo = initRepo(); + git(["commit", "--allow-empty", "-m", "root"], repo); + expect(api.embedded.gitlinks(repo)).toEqual([]); + }); + + it("returns [] for a repo containing only regular files (no gitlinks)", () => { + const repo = initRepo(); + fs.writeFileSync(path.join(repo, "a.txt"), "x"); + fs.mkdirSync(path.join(repo, "sub")); + fs.writeFileSync(path.join(repo, "sub", "b.txt"), "y"); + git(["add", "."], repo); + git(["commit", "-m", "files"], repo); + expect(api.embedded.gitlinks(repo)).toEqual([]); + }); + + it("enumerates gitlink path + pinned sha, including a nested path", () => { + const { parentSrc, childBares } = makeParentWithChildren(["tests", "vendor/lib"]); + const links = api.embedded.gitlinks(parentSrc); + const byPath = Object.fromEntries(links.map((l) => [l.path, l.sha])); + expect(Object.keys(byPath).sort()).toEqual(["tests", "vendor/lib"]); + expect(byPath["tests"]).toBe(childBares["tests"].sha); + expect(byPath["vendor/lib"]).toBe(childBares["vendor/lib"].sha); + }); +}); + +describe("api.embedded.record (uncovered paths)", () => { + it("silently skips an absent child when no path filter is given", () => { + const { parentBare } = makeParentWithChildren(["tests"]); + const fresh = freshClone(parentBare); // child materialized empty, no .git + expect(fs.existsSync(path.join(fresh, "tests", ".git"))).toBe(false); + expect(api.embedded.record({ cwd: fresh }).results).toEqual([]); + }); + + it("reports no-repo for an explicitly requested absent child", () => { + const { parentBare } = makeParentWithChildren(["tests"]); + const fresh = freshClone(parentBare); + const { results } = api.embedded.record({ cwd: fresh, paths: ["tests"] }); + expect(results).toEqual([{ path: "tests", outcome: "no-repo" }]); + }); + + it("records nothing when the path filter matches no gitlink", () => { + const { parentBare } = makeParentWithChildren(["tests"]); + const fresh = freshClone(parentBare); + expect(api.embedded.record({ cwd: fresh, paths: ["does-not-exist"] }).results).toEqual([]); + }); + + it("falls back to cwd (records nothing) when cwd is not a git repo", () => { + const notRepo = mkTmp(); + expect(api.embedded.record({ cwd: notRepo }).results).toEqual([]); + }); +}); + +describe("api.embedded.registry", () => { + it("entries returns [] when no embedded.* keys are set", () => { + expect(api.embedded.registry.entries(initRepo())).toEqual([]); + }); + + it("entries parses url-only and url+branch subsections independently", () => { + const root = initRepo(); + api.embedded.registry.setUrl("alpha", "URL_A", root); + api.embedded.registry.setUrl("beta", "URL_B", root); + api.embedded.registry.setBranch("beta", "main", root); + const byPath = Object.fromEntries(api.embedded.registry.entries(root).map((e) => [e.path, e])); + expect(byPath.alpha).toEqual({ path: "alpha", url: "URL_A" }); + expect(byPath.beta).toEqual({ path: "beta", url: "URL_B", branch: "main" }); + }); + + it("setters/getters round-trip; unset reads are null and non-repo writes are false", () => { + const root = initRepo(); + expect(api.embedded.registry.getUrl("tests", root)).toBeNull(); // unset + expect(api.embedded.registry.getBranch("tests", root)).toBeNull(); // unset + expect(api.embedded.registry.setUrl("tests", "U", root)).toBe(true); + expect(api.embedded.registry.getUrl("tests", root)).toBe("U"); + expect(api.embedded.registry.setBranch("tests", "b", root)).toBe(true); + expect(api.embedded.registry.getBranch("tests", root)).toBe("b"); + + const notRepo = mkTmp(); + expect(api.embedded.registry.setUrl("tests", "U", notRepo)).toBe(false); + expect(api.embedded.registry.setBranch("tests", "b", notRepo)).toBe(false); + expect(api.embedded.registry.getUrl("tests", notRepo)).toBeNull(); + }); + + it("recordOne reports no-repo when the child has no .git", () => { + const root = initRepo(); + expect(api.embedded.registry.recordOne("tests", root)).toEqual({ path: "tests", outcome: "no-repo" }); + }); + + it("recordOne reports no-origin for a present child with no origin remote", () => { + const root = initRepo(); + const child = path.join(root, "tests"); + git(["init", "-b", "main", child]); + fs.writeFileSync(path.join(child, "f.txt"), "x"); + git(["add", "."], child); + git(["commit", "-m", "c"], child); + expect(api.embedded.registry.recordOne("tests", root)).toEqual({ path: "tests", outcome: "no-origin" }); + // Nothing was written to the parent registry on failure. + expect(api.embedded.registry.getUrl("tests", root)).toBeNull(); + }); + + it("recordOne records the url but leaves branch null for a detached-HEAD child", () => { + const root = initRepo(); + const { bare, sha } = makeBare(); + const child = path.join(root, "tests"); + git(["clone", "--quiet", bare, child]); + git(["checkout", "--quiet", "--detach", sha], child); // no symbolic-ref for HEAD + + const res = api.embedded.registry.recordOne("tests", root); + expect(res.outcome).toBe("recorded"); + expect(res.url).toBe(bare); + expect(res.branch).toBeNull(); + // url was persisted; branch was NOT (setBranch skipped for detached HEAD). + expect(api.embedded.registry.getUrl("tests", root)).toBe(bare); + expect(api.embedded.registry.getBranch("tests", root)).toBeNull(); + }); +}); + +describe("api.embedded.resolve.conventionUrl", () => { + const cu = (...a) => api.embedded.resolve.conventionUrl(...a); + + it("returns null with no parent origin", () => { + expect(cu(null, "tests")).toBeNull(); + }); + + it("returns null for an origin with neither a slash nor a colon", () => { + expect(cu("bareword", "tests")).toBeNull(); + }); + + it("splits a URL-style origin on the last slash (trailing slash trimmed, nested basename)", () => { + expect(cu("https://h/o/parent.git/", "vendor/foo")).toBe("https://h/o/foo.git"); + expect(cu("https://h/o/parent.git", "tests")).toBe("https://h/o/tests.git"); + }); + + it("splits an scp-style root origin (no slash) on the last colon", () => { + expect(cu("git@host:parent.git", "tests")).toBe("git@host:tests.git"); + }); +}); + +describe("api.embedded.resolve (layer precedence)", () => { + it("prefers local-config > manifest > base > convention, then reports nothing", () => { + const repo = initRepo(); + const manifest = { children: { tests: { url: "MANIFEST_URL" } } }; + const full = { cwd: repo, manifest, base: "https://base", parentOrigin: "https://h/o/parent.git" }; + + // Layer 1: local-config beats a supplied manifest/base/origin. + api.embedded.registry.setUrl("tests", "CFG_URL", repo); + expect(api.embedded.resolve("tests", full)).toEqual({ url: "CFG_URL", source: "local-config" }); + + // Layer 2: with config cleared, the manifest wins over base/convention. + git(["config", "--local", "--unset", "embedded.tests.url"], repo); + expect(api.embedded.resolve("tests", full)).toEqual({ url: "MANIFEST_URL", source: "manifest" }); + + // Layer 3: a manifest entry present but WITHOUT a url falls through to --base. + const baseOpts = { + cwd: repo, + manifest: { children: { tests: {} } }, + base: "https://base", + parentOrigin: "https://h/o/parent.git" + }; + expect(api.embedded.resolve("tests", baseOpts)).toEqual({ url: "https://base/tests.git", source: "base" }); + + // Layer 4: only the parent origin remains → convention sibling. + expect(api.embedded.resolve("tests", { cwd: repo, parentOrigin: "https://h/o/parent.git" })).toEqual({ + url: "https://h/o/tests.git", + source: "convention" + }); + + // Nothing resolves: no config, no manifest, no base, no origin. + expect(api.embedded.resolve("tests", { cwd: repo })).toEqual({ url: null, source: null }); + }); + + it("ignores a foreign/inherited manifest key (own-property check)", () => { + const repo = initRepo(); + // "constructor" is on Object.prototype but NOT an own key of children. + const manifest = { children: {} }; + expect(api.embedded.resolve("constructor", { cwd: repo, manifest })).toEqual({ url: null, source: null }); + }); +}); diff --git a/tests/helpers.test.mjs b/tests/helpers.test.mjs new file mode 100644 index 0000000..ddf6d44 --- /dev/null +++ b/tests/helpers.test.mjs @@ -0,0 +1,398 @@ +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"; + +/** + * Low-level helper coverage: src/api/git.mjs, paths.mjs, report.mjs, log.mjs, + * and messages/load.mjs. Exercised through the composed slothlet api (matching + * the house style) against real temp git repos and temp XDG state/config dirs. + */ + +const tmpRoots = []; + +function mkTmp() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "git-embedded-helpers-")); + 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(); +} + +/** A minimal real git repo (no commit needed for config / rev-parse reads). */ +function makeRepo() { + const dir = mkTmp(); + git(["init", "-q", "-b", "main", dir]); + return dir; +} + +/** Write a standalone git config file and return its path (for GIT_CONFIG_GLOBAL). */ +function writeConfigFile(body) { + const f = path.join(mkTmp(), "gitconfig"); + fs.writeFileSync(f, body); + return f; +} + +/** Run fn with process.cwd() temporarily switched — for helpers that read cwd. */ +function withCwd(dir, fn) { + const prev = process.cwd(); + process.chdir(dir); + try { + return fn(); + } finally { + process.chdir(prev); + } +} + +const stripAnsi = (s) => String(s).replace(new RegExp(String.fromCharCode(27) + "\\[[0-9;]*m", "g"), ""); + +let originalEnv; +let originalCwd; + +beforeEach(() => { + originalEnv = { ...process.env }; + originalCwd = process.cwd(); + // Hermetic git: ignore host/global/system 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"; + // Isolate the transaction-log location so log.append never touches real state. + const sd = mkTmp(); + process.env.XDG_STATE_HOME = sd; + if (process.platform === "win32") process.env.LOCALAPPDATA = sd; +}); + +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.git config + repo discovery", () => { + it("getConfig reads scoped and merged keys and returns null for an absent key", () => { + const repo = makeRepo(); + git(["config", "--local", "sample.key", "hello"], repo); + withCwd(repo, () => { + expect(api.git.getConfig("sample.key", "local")).toBe("hello"); + expect(api.git.getConfig("sample.key")).toBe("hello"); // merged, no scope arg + expect(api.git.getConfig("no.suchkey", "local")).toBeNull(); + expect(api.git.getConfig("no.suchkey")).toBeNull(); + }); + }); + + it("getConfig reads a global-scope key via GIT_CONFIG_GLOBAL", () => { + process.env.GIT_CONFIG_GLOBAL = writeConfigFile("[sample]\n\tkey = global-val\n"); + expect(api.git.getConfig("sample.key", "global")).toBe("global-val"); + expect(api.git.getConfig("absent.key", "global")).toBeNull(); + }); + + it("getRepoRoot / getGitDir resolve inside a repo and are null outside one", () => { + const repo = makeRepo(); + const root = api.git.getRepoRoot(repo); + expect(root).not.toBeNull(); + expect(fs.realpathSync(root)).toBe(fs.realpathSync(repo)); + + const gitDir = api.git.getGitDir(repo); + expect(gitDir).not.toBeNull(); + expect(path.basename(gitDir)).toBe(".git"); + expect(fs.existsSync(gitDir)).toBe(true); + + const nonRepo = mkTmp(); + expect(api.git.getRepoRoot(nonRepo)).toBeNull(); + expect(api.git.getGitDir(nonRepo)).toBeNull(); + }); + + it("getEffectiveHooksPath returns an absolute core.hooksPath unchanged", () => { + const repo = makeRepo(); + const abs = path.join(mkTmp(), "abs-hooks"); + git(["config", "--local", "core.hooksPath", abs], repo); + expect(api.git.getEffectiveHooksPath(repo)).toBe(abs); + }); + + it("getEffectiveHooksPath resolves a relative core.hooksPath against the repo root", () => { + const repo = makeRepo(); + git(["config", "--local", "core.hooksPath", "team-hooks"], repo); + expect(api.git.getEffectiveHooksPath(repo)).toBe(path.join(api.git.getRepoRoot(repo), "team-hooks")); + }); + + it("getEffectiveHooksPath expands a leading ~ against the home directory", () => { + const repo = makeRepo(); + git(["config", "--local", "core.hooksPath", "~/tilde-hooks"], repo); + expect(api.git.getEffectiveHooksPath(repo)).toBe(path.join(os.homedir(), "tilde-hooks")); + }); + + it("getEffectiveHooksPath returns null when core.hooksPath is unset or empty", () => { + const repo = makeRepo(); + expect(api.git.getEffectiveHooksPath(repo)).toBeNull(); // unset → git exits non-zero + git(["config", "--local", "core.hooksPath", ""], repo); + expect(api.git.getEffectiveHooksPath(repo)).toBeNull(); // present-but-empty → !raw guard + }); + + it("getEffectiveHooksPath resolves a relative path against cwd when not in a repo", () => { + const nonRepo = mkTmp(); + process.env.GIT_CONFIG_GLOBAL = writeConfigFile("[core]\n\thooksPath = rel-hooks\n"); + expect(api.git.getRepoRoot(nonRepo)).toBeNull(); // precondition: genuinely not a repo + expect(api.git.getEffectiveHooksPath(nonRepo)).toBe(path.resolve(nonRepo, "rel-hooks")); + }); + + it("getAllHooksPathScopes reports each scope independently", () => { + const repo = makeRepo(); + git(["config", "--local", "core.hooksPath", ".githooks"], repo); + process.env.GIT_CONFIG_GLOBAL = writeConfigFile("[core]\n\thooksPath = /global/hooks\n"); + withCwd(repo, () => { + const scopes = api.git.getAllHooksPathScopes(); + expect(scopes.local).toBe(".githooks"); + expect(scopes.global).toBe("/global/hooks"); + expect(scopes.system).toBeNull(); // GIT_CONFIG_SYSTEM=/dev/null + }); + }); + + it("getInitTemplateDir expands ~, passes an absolute path through, and is null when unset", () => { + // Unset: GIT_CONFIG_GLOBAL=/dev/null from beforeEach → no init.templateDir. + expect(api.git.getInitTemplateDir()).toBeNull(); + // Leading ~ expands against the home directory. + process.env.GIT_CONFIG_GLOBAL = writeConfigFile("[init]\n\ttemplateDir = ~/my-template\n"); + expect(api.git.getInitTemplateDir()).toBe(path.join(os.homedir(), "my-template")); + // Absolute path is returned verbatim. + process.env.GIT_CONFIG_GLOBAL = writeConfigFile("[init]\n\ttemplateDir = /opt/tpl\n"); + expect(api.git.getInitTemplateDir()).toBe("/opt/tpl"); + }); +}); + +describe("api.paths", () => { + it("packageRoot / hooksSourceDir / messagesDir resolve to real directories under the package", () => { + const root = api.paths.packageRoot(); + expect(typeof root).toBe("string"); + expect(fs.existsSync(root)).toBe(true); + expect(api.paths.hooksSourceDir()).toBe(path.join(root, "hooks")); + expect(api.paths.messagesDir()).toBe(path.join(root, "messages")); + expect(fs.existsSync(api.paths.hooksSourceDir())).toBe(true); + expect(fs.existsSync(api.paths.messagesDir())).toBe(true); + }); + + it.skipIf(process.platform === "win32")("stateDir uses XDG_STATE_HOME when set", () => { + const base = mkTmp(); + process.env.XDG_STATE_HOME = base; + expect(api.paths.stateDir()).toBe(path.join(base, "git-embedded")); + }); + + it.skipIf(process.platform === "win32")("stateDir falls back to ~/.local/state when XDG_STATE_HOME is unset", () => { + delete process.env.XDG_STATE_HOME; + expect(api.paths.stateDir()).toBe(path.join(os.homedir(), ".local", "state", "git-embedded")); + }); + + it.skipIf(process.platform === "win32")("stateDir treats an empty XDG_STATE_HOME as unset", () => { + process.env.XDG_STATE_HOME = ""; + expect(api.paths.stateDir()).toBe(path.join(os.homedir(), ".local", "state", "git-embedded")); + }); + + it.skipIf(process.platform === "win32")("transactionLogPath is stateDir/install.log", () => { + const base = mkTmp(); + process.env.XDG_STATE_HOME = base; + expect(api.paths.transactionLogPath()).toBe(path.join(base, "git-embedded", "install.log")); + }); + + it("defaultGlobalDispatcherDir uses XDG_CONFIG_HOME when set", () => { + const base = mkTmp(); + process.env.XDG_CONFIG_HOME = base; + expect(api.paths.defaultGlobalDispatcherDir()).toBe(path.join(base, "git", "hooks")); + }); + + it("defaultGlobalDispatcherDir falls back to ~/.config when XDG_CONFIG_HOME is unset", () => { + delete process.env.XDG_CONFIG_HOME; + expect(api.paths.defaultGlobalDispatcherDir()).toBe(path.join(os.homedir(), ".config", "git", "hooks")); + }); + + it("defaultGlobalDispatcherDir treats an empty XDG_CONFIG_HOME as unset", () => { + process.env.XDG_CONFIG_HOME = ""; + expect(api.paths.defaultGlobalDispatcherDir()).toBe(path.join(os.homedir(), ".config", "git", "hooks")); + }); +}); + +describe("api.report output helpers", () => { + it("success/warn/plain go to stdout and error goes to stderr, each with its glyph", () => { + const out = []; + const err = []; + vi.spyOn(console, "log").mockImplementation((...a) => out.push(a.map(String).join(" "))); + vi.spyOn(console, "error").mockImplementation((...a) => err.push(a.map(String).join(" "))); + + api.report.success("saved ok"); + api.report.warn("careful now"); + api.report.error("it broke"); + api.report.plain("just text"); + api.report.plain(); // default empty line + + const outJoined = out.map(stripAnsi).join("\n"); + const errJoined = err.map(stripAnsi).join("\n"); + expect(outJoined).toContain("✓ saved ok"); // ✓ + expect(outJoined).toContain("! careful now"); + expect(outJoined).toContain("just text"); + expect(out).toContain(""); // plain() with no arg logs the empty string + expect(errJoined).toContain("✗ it broke"); // ✗ + expect(outJoined).not.toContain("it broke"); // error must not leak to stdout + }); + + it("detectionHeader renders every populated field with a known-kind label", () => { + const out = []; + vi.spyOn(console, "log").mockImplementation((...a) => out.push(a.map(String).join(" "))); + + api.report.detectionHeader({ + kind: "husky", + paths: { repoRoot: "/r/root", gitDir: "/r/root/.git", effectiveHooksPath: "/r/hooks" }, + signals: { + hooksPathScopes: { system: "/sys", global: "/glob", local: null }, + initTemplateDir: "/tpl" + }, + dispatcher: { dispatcherPath: "/disp/_dispatch", missing: ["post-rewrite", "reference-transaction"] }, + foreign: { dir: "/foreign/dir", configFile: "/foreign/cfg.yml" }, + bare: { dir: "/bare/hooks" }, + subClassification: { dispatcherPath: "/sys/_dispatch" } + }); + + const text = stripAnsi(out.join("\n")); + expect(text).toContain("Detected: Husky"); // KIND_LABELS lookup + expect(text).toContain("Repo root"); + expect(text).toContain("/r/root"); + expect(text).toContain("Git dir"); + expect(text).toContain("/r/root/.git"); + expect(text).toContain("Effective core.hooksPath"); + expect(text).toContain("/r/hooks"); + expect(text).toContain("core.hooksPath scopes"); + expect(text).toContain("system=/sys"); + expect(text).toContain("global=/glob"); + expect(text).not.toContain("local="); // local was null → dropped from the parts + expect(text).toContain("init.templateDir"); + expect(text).toContain("/tpl"); + expect(text).toContain("Dispatcher"); + expect(text).toContain("/disp/_dispatch"); + expect(text).toContain("Missing entries"); + expect(text).toContain("post-rewrite, reference-transaction"); + expect(text).toContain("Tool directory"); + expect(text).toContain("/foreign/dir"); + expect(text).toContain("Config file"); + expect(text).toContain("/foreign/cfg.yml"); + expect(text).toContain("Hooks directory"); + expect(text).toContain("/bare/hooks"); + expect(text).toContain("System-path dispatcher"); + expect(text).toContain("/sys/_dispatch"); + }); + + it("detectionHeader falls back to the raw kind for an unknown label and prints no field lines", () => { + const out = []; + vi.spyOn(console, "log").mockImplementation((...a) => out.push(a.map(String).join(" "))); + api.report.detectionHeader({ kind: "mystery-kind" }); + const text = stripAnsi(out.join("\n")); + expect(text).toContain("Detected: mystery-kind"); // unknown kind → raw value + expect(text).not.toContain("Repo root"); + expect(text).not.toContain("Dispatcher"); + }); + + it("detectionHeader omits empty/null field values and all-null scope maps", () => { + const out = []; + vi.spyOn(console, "log").mockImplementation((...a) => out.push(a.map(String).join(" "))); + api.report.detectionHeader({ + kind: "none", + paths: { repoRoot: "", gitDir: null, effectiveHooksPath: undefined }, + signals: { hooksPathScopes: { system: null, global: null, local: null } }, + foreign: { dir: "" } + }); + const text = stripAnsi(out.join("\n")); + expect(text).toContain("Detected: No hook setup detected"); // KIND_LABELS.none + expect(text).not.toContain("Repo root"); // fmtKv("", …) → null → filtered + expect(text).not.toContain("Git dir"); + expect(text).not.toContain("Tool directory"); + expect(text).not.toContain("core.hooksPath scopes"); // parts empty → no line + }); + + it("message renders a known kind's markdown to stdout ending in a newline", () => { + const writes = []; + vi.spyOn(process.stdout, "write").mockImplementation((s) => { + writes.push(String(s)); + return true; + }); + api.report.message("none"); + const rendered = writes.join(""); + expect(rendered.length).toBeGreaterThan(0); + expect(rendered.endsWith("\n")).toBe(true); + }); + + it("message throws for an unknown kind (propagated from messages.load)", () => { + expect(() => api.report.message("does-not-exist")).toThrow(/Unknown message kind/); + }); +}); + +describe("api.log transaction log", () => { + it("append writes timestamped JSONL entries that read parses back in order", () => { + expect(api.log.read()).toEqual([]); // fresh state dir → no log yet + expect(fs.existsSync(api.log.path())).toBe(false); + + api.log.append({ op: "install-repo-hook", path: "/x/hooks/pre-push" }); + api.log.append({ op: "uninstall-repo-hook", path: "/x/hooks/pre-push" }); + + expect(fs.existsSync(api.log.path())).toBe(true); + const entries = api.log.read(); + expect(entries).toHaveLength(2); + expect(entries[0].op).toBe("install-repo-hook"); + expect(entries[1].op).toBe("uninstall-repo-hook"); + expect(entries[0].path).toBe("/x/hooks/pre-push"); + expect(typeof entries[0].ts).toBe("string"); + expect(entries[0].ts).toMatch(/^\d{4}-\d{2}-\d{2}T/); // ISO timestamp stamped by append + + // path() mirrors paths.transactionLogPath() and lives under the temp state dir. + expect(api.log.path()).toBe(api.paths.transactionLogPath()); + expect(api.log.path()).toBe(path.join(process.env.XDG_STATE_HOME, "git-embedded", "install.log")); + }); + + it("read skips malformed lines and returns only the valid JSON entries", () => { + const logPath = api.log.path(); + fs.mkdirSync(path.dirname(logPath), { recursive: true }); + fs.writeFileSync(logPath, [JSON.stringify({ op: "a" }), "this is not json {{{", "", JSON.stringify({ op: "b" })].join("\n") + "\n"); + const entries = api.log.read(); + expect(entries).toHaveLength(2); // blank + garbage lines dropped + expect(entries.map((e) => e.op)).toEqual(["a", "b"]); + }); + + it("read returns an empty array when the log file does not exist", () => { + expect(fs.existsSync(api.log.path())).toBe(false); + expect(api.log.read()).toEqual([]); + }); +}); + +describe("api.messages.load", () => { + it("returns the verbatim markdown body for a known kind", () => { + const body = api.messages.load("none"); + const onDisk = fs.readFileSync(path.join(api.paths.messagesDir(), "setup-none.md"), "utf8"); + expect(body).toBe(onDisk); + expect(body.length).toBeGreaterThan(0); + }); + + it("throws for an unknown kind", () => { + expect(() => api.messages.load("no-such-kind")).toThrow(/Unknown message kind: no-such-kind/); + }); +}); diff --git a/tests/install-link.test.mjs b/tests/install-link.test.mjs new file mode 100644 index 0000000..9540b8c --- /dev/null +++ b/tests/install-link.test.mjs @@ -0,0 +1,383 @@ +/** + * @Project: @cldmv/git-embedded + * @Filename: /tests/install-link.test.mjs + * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. + * + * Behavior tests for the install-dispatch + link-batch layer, driven through + * the composed slothlet api against REAL files in temp dirs: + * + * - api.install.dispatcher (bootstrap|heal): writes hooks/_dispatch from the + * packaged template and fans out links to the standard hook names; heal only + * adds the missing ones without rewriting the dispatcher; unknown op throws. + * - api.install.template: seeds a `git init` templateDir/hooks with the package + * hooks, honoring the foreign-hook skip and the --force override. + * - api.link.batch: symlink (default) / hardlink (noSymlinks) mechanisms, the + * overwrite pre-removal, the copy fallback when a symlink can't be made, and + * the throw paths when no mechanism succeeds. + * - api.link.copyExecutable: copy + +x bit, the overwrite pre-removal branch, + * and the overwrite:false branch. + * + * The Windows deferred-symlink → UAC-elevation path in link/batch.mjs is + * guarded by `process.platform === "win32"` and is not reachable on POSIX CI; + * it is not exercised here (see notes). + */ + +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { getApi } from "./_setup.mjs"; + +const isWin = process.platform === "win32"; +const tmpRoots = []; + +function mkTmp() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "git-embedded-instlink-")); + tmpRoots.push(dir); + return dir; +} + +let originalEnv; + +beforeEach(() => { + originalEnv = { ...process.env }; + // Redirect the append-only transaction log into a throwaway state dir so the + // install/heal ops here never touch the real ~/.local/state (or %LOCALAPPDATA%). + const stateDir = mkTmp(); + process.env.XDG_STATE_HOME = stateDir; + if (isWin) process.env.LOCALAPPDATA = stateDir; +}); + +afterEach(() => { + process.env = originalEnv; + while (tmpRoots.length) { + const d = tmpRoots.pop(); + try { + fs.rmSync(d, { recursive: true, force: true }); + } catch { + // ignore + } + } +}); + +let api; +beforeAll(async () => { + api = await getApi(); +}); + +const STANDARD_HOOK_NAMES = [ + "applypatch-msg", + "commit-msg", + "post-applypatch", + "post-checkout", + "post-commit", + "post-merge", + "post-rewrite", + "pre-applypatch", + "pre-auto-gc", + "pre-commit", + "pre-merge-commit", + "pre-push", + "pre-rebase", + "prepare-commit-msg", + "reference-transaction" +]; +const PACKAGE_HOOKS = ["post-checkout", "post-merge", "post-rewrite", "reference-transaction", "pre-push"]; + +function inode(p) { + const st = fs.statSync(p); + return `${st.dev}:${st.ino}`; +} + +describe("api.install.dispatcher (bootstrap)", () => { + it("writes _dispatch from the packaged template and links every standard hook name", () => { + const dir = path.join(mkTmp(), "hooks-out"); // not-yet-existing → exercises mkdirSync + const out = api.install.dispatcher("bootstrap", { dir }); + + const dispatcherPath = path.join(dir, "_dispatch"); + expect(out.dispatcherPath).toBe(dispatcherPath); + expect(fs.existsSync(dispatcherPath)).toBe(true); + + // Content came straight from hooks/_dispatch.template. + const body = fs.readFileSync(dispatcherPath, "utf8"); + expect(body).toContain("git-embedded"); + expect(body).toContain("chain to the repository's own hook"); + + // One created entry per standard hook name; none fell back to copy on POSIX. + expect(out.created).toHaveLength(STANDARD_HOOK_NAMES.length); + expect(out.created.map((c) => c.source).sort()).toEqual(STANDARD_HOOK_NAMES.map((n) => path.join(dir, n)).sort()); + for (const name of STANDARD_HOOK_NAMES) { + expect(fs.existsSync(path.join(dir, name))).toBe(true); + } + }); + + it.skipIf(isWin)("links the standard hooks as symlinks to _dispatch and marks _dispatch executable", () => { + const dir = path.join(mkTmp(), "hooks-out"); + const out = api.install.dispatcher("bootstrap", { dir }); + const dispatcherPath = path.join(dir, "_dispatch"); + + expect(out.fallbackToCopy).toEqual([]); + expect(new Set(out.created.map((c) => c.mechanism))).toEqual(new Set(["symlink"])); + + // Each hook name is a symlink resolving to the dispatcher script. + for (const name of STANDARD_HOOK_NAMES) { + const p = path.join(dir, name); + expect(fs.lstatSync(p).isSymbolicLink()).toBe(true); + expect(fs.realpathSync(p)).toBe(fs.realpathSync(dispatcherPath)); + } + // copyExecutable set the +x bit on the dispatcher. + expect(fs.statSync(dispatcherPath).mode & 0o111).not.toBe(0); + }); + + it("with noSymlinks fans out hardlinks that share the dispatcher's inode", () => { + const dir = path.join(mkTmp(), "hooks-out"); + const out = api.install.dispatcher("bootstrap", { dir }, { noSymlinks: true }); + const dispatcherPath = path.join(dir, "_dispatch"); + + expect(out.fallbackToCopy).toEqual([]); + expect(new Set(out.created.map((c) => c.mechanism))).toEqual(new Set(["hardlink"])); + + const dispatcherInode = inode(dispatcherPath); + for (const name of STANDARD_HOOK_NAMES) { + const p = path.join(dir, name); + expect(fs.lstatSync(p).isSymbolicLink()).toBe(false); + expect(inode(p)).toBe(dispatcherInode); + } + }); + + it("honors a hookNames override, linking only the requested names", () => { + const dir = path.join(mkTmp(), "hooks-out"); + const out = api.install.dispatcher("bootstrap", { dir }, { hookNames: ["pre-commit", "commit-msg"] }); + + expect(out.created).toHaveLength(2); + expect(out.created.map((c) => c.source).sort()).toEqual([path.join(dir, "commit-msg"), path.join(dir, "pre-commit")]); + expect(fs.existsSync(path.join(dir, "pre-commit"))).toBe(true); + expect(fs.existsSync(path.join(dir, "commit-msg"))).toBe(true); + // A name outside the override was never linked. + expect(fs.existsSync(path.join(dir, "post-checkout"))).toBe(false); + }); +}); + +describe("api.install.dispatcher (heal)", () => { + it("adds only the missing entries and leaves the dispatcher script + existing links intact", () => { + const dir = path.join(mkTmp(), "hooks-out"); + // Seed a partial dispatcher: _dispatch + a single pre-commit link. + api.install.dispatcher("bootstrap", { dir }, { hookNames: ["pre-commit"] }); + const dispatcherPath = path.join(dir, "_dispatch"); + const dispatcherBefore = fs.readFileSync(dispatcherPath, "utf8"); + const preCommitBefore = fs.existsSync(path.join(dir, "pre-commit")); + expect(preCommitBefore).toBe(true); + + const missing = ["post-checkout", "post-merge", "reference-transaction"]; + const out = api.install.dispatcher("heal", { dispatcherPath, missing }); + + expect(out.created.map((c) => c.source).sort()).toEqual(missing.map((n) => path.join(dir, n)).sort()); + for (const name of missing) { + const p = path.join(dir, name); + expect(fs.existsSync(p)).toBe(true); + if (!isWin) expect(fs.realpathSync(p)).toBe(fs.realpathSync(dispatcherPath)); + } + // Heal never rewrites the dispatcher body, and pre-existing links survive. + expect(fs.readFileSync(dispatcherPath, "utf8")).toBe(dispatcherBefore); + expect(fs.existsSync(path.join(dir, "pre-commit"))).toBe(true); + }); + + it("is a no-op when there is nothing missing (undefined missing list)", () => { + const dir = path.join(mkTmp(), "hooks-out"); + api.install.dispatcher("bootstrap", { dir }, { hookNames: ["pre-commit"] }); + const dispatcherPath = path.join(dir, "_dispatch"); + + const out = api.install.dispatcher("heal", { dispatcherPath }); + expect(out.created).toEqual([]); + expect(out.fallbackToCopy).toEqual([]); + }); +}); + +describe("api.install.dispatcher (guard)", () => { + it("throws on an unknown op", () => { + expect(() => api.install.dispatcher("frobnicate", {})).toThrow(/unknown op "frobnicate"/); + }); +}); + +describe("api.install.template", () => { + it("seeds a git-init templateDir/hooks with the package hooks", () => { + const templateDir = path.join(mkTmp(), "template"); // not-yet-existing → recursive mkdir + const out = api.install.template(templateDir); + + const installed = Array.from(out.installed); + for (const name of PACKAGE_HOOKS) expect(installed).toContain(name); + expect(out.skipped).toEqual([]); + + const hooksDir = path.join(templateDir, "hooks"); + for (const name of PACKAGE_HOOKS) { + const body = fs.readFileSync(path.join(hooksDir, name), "utf8"); + expect(body).toContain("git-embedded"); + if (!isWin) expect(fs.statSync(path.join(hooksDir, name)).mode & 0o111).not.toBe(0); + } + }); + + it("skips a pre-existing foreign hook, then overwrites it under --force", () => { + const templateDir = path.join(mkTmp(), "template"); + fs.mkdirSync(path.join(templateDir, "hooks"), { recursive: true }); + fs.writeFileSync(path.join(templateDir, "hooks", "pre-push"), "#!/bin/sh\necho foreign\n"); + + const out = api.install.template(templateDir); + // The foreign pre-push is refused; the other four still install. + expect(Array.from(out.installed)).not.toContain("pre-push"); + const skipped = Array.from(out.skipped); + expect(skipped.map((s) => s.name)).toContain("pre-push"); + expect(skipped.find((s) => s.name === "pre-push").reason).toMatch(/not owned by git-embedded/); + // And its bytes are untouched. + expect(fs.readFileSync(path.join(templateDir, "hooks", "pre-push"), "utf8")).toBe("#!/bin/sh\necho foreign\n"); + + // --force flows through to install.hooks and overwrites the foreign file. + const forced = api.install.template(templateDir, { force: true }); + expect(Array.from(forced.installed)).toContain("pre-push"); + expect(fs.readFileSync(path.join(templateDir, "hooks", "pre-push"), "utf8")).toContain("git-embedded"); + }); + + it("re-installs its own (git-embedded-owned) hooks on a second run without skipping", () => { + const templateDir = path.join(mkTmp(), "template"); + api.install.template(templateDir); + const second = api.install.template(templateDir); + // Owned hooks are recognized and overwritten, never skipped as foreign. + expect(Array.from(second.installed).sort()).toEqual([...PACKAGE_HOOKS].sort()); + expect(second.skipped).toEqual([]); + }); +}); + +describe("api.link.batch", () => { + // Build a real target file to link/copy from. + function makeTarget(content = "#!/bin/sh\necho TARGET\n") { + const t = path.join(mkTmp(), "target"); + fs.writeFileSync(t, content); + return t; + } + + it.skipIf(isWin)("creates a symlink per source (default mechanism) that resolves to the target", () => { + const target = makeTarget(); + const base = mkTmp(); + // Sources live under a not-yet-existing subdir → exercises ensureDir. + const sources = ["a", "b", "c"].map((n) => path.join(base, "nested", n)); + + const out = api.link.batch(target, sources); + + expect(out.created.map((c) => c.source)).toEqual(sources); + expect(new Set(out.created.map((c) => c.mechanism))).toEqual(new Set(["symlink"])); + expect(out.fallbackToCopy).toEqual([]); + for (const s of sources) { + expect(fs.lstatSync(s).isSymbolicLink()).toBe(true); + expect(fs.realpathSync(s)).toBe(fs.realpathSync(target)); + expect(fs.readFileSync(s, "utf8")).toBe("#!/bin/sh\necho TARGET\n"); + } + }); + + it("creates hardlinks under noSymlinks that share the target's inode", () => { + const target = makeTarget(); + const base = mkTmp(); + const sources = ["a", "b"].map((n) => path.join(base, "nested", n)); + + const out = api.link.batch(target, sources, { noSymlinks: true }); + + expect(new Set(out.created.map((c) => c.mechanism))).toEqual(new Set(["hardlink"])); + expect(out.fallbackToCopy).toEqual([]); + for (const s of sources) { + expect(fs.lstatSync(s).isSymbolicLink()).toBe(false); + expect(inode(s)).toBe(inode(target)); + } + }); + + it("overwrite removes a pre-existing file at the source before linking", () => { + const target = makeTarget("NEW-CONTENT\n"); + const source = path.join(mkTmp(), "slot"); + fs.writeFileSync(source, "STALE-CONTENT\n"); + const staleInode = inode(source); + + const out = api.link.batch(target, [source], { noSymlinks: true, overwrite: true }); + + expect(out.created).toEqual([{ source, mechanism: "hardlink" }]); + // The stale regular file was unlinked and replaced by a hardlink to target. + expect(inode(source)).toBe(inode(target)); + expect(inode(source)).not.toBe(staleInode); + expect(fs.readFileSync(source, "utf8")).toBe("NEW-CONTENT\n"); + }); + + it.skipIf(isWin)("falls back to a copy when a symlink cannot be created (EEXIST, no overwrite)", () => { + const target = makeTarget("COPIED-FROM-TARGET\n"); + const source = path.join(mkTmp(), "occupied"); + // A regular file already sits at the source and overwrite is false, so the + // symlink attempt hits EEXIST → non-privilege, non-win32 → copy fallback. + fs.writeFileSync(source, "was here first\n"); + + const out = api.link.batch(target, [source]); + + expect(out.created).toEqual([{ source, mechanism: "copy" }]); + expect(out.fallbackToCopy).toEqual([source]); + expect(fs.lstatSync(source).isSymbolicLink()).toBe(false); + // The target's bytes were copied over the occupant. + expect(fs.readFileSync(source, "utf8")).toBe("COPIED-FROM-TARGET\n"); + }); + + it("throws when neither a hardlink nor a copy can be created (missing target)", () => { + const missingTarget = path.join(mkTmp(), "does-not-exist"); + const source = path.join(mkTmp(), "slot"); + + expect(() => api.link.batch(missingTarget, [source], { noSymlinks: true })).toThrow(); + // Nothing was left behind at the source. + expect(fs.existsSync(source)).toBe(false); + }); + + it.skipIf(isWin)("throws when a symlink fails and the copy fallback also fails (source is a directory)", () => { + const target = makeTarget(); + const sourceDir = path.join(mkTmp(), "iam-a-dir"); + fs.mkdirSync(sourceDir); + + // symlink → EEXIST (dir present), copy → EISDIR: both fail, batch rethrows. + expect(() => api.link.batch(target, [sourceDir])).toThrow(); + // The directory is left intact — the failed copy never clobbered it. + expect(fs.statSync(sourceDir).isDirectory()).toBe(true); + }); + + it("returns empty results for an empty source list", () => { + const target = makeTarget(); + const out = api.link.batch(target, []); + expect(out).toEqual({ created: [], fallbackToCopy: [] }); + }); +}); + +describe("api.link.copyExecutable", () => { + function makeSource(content = "#!/bin/sh\necho hi\n") { + const s = path.join(mkTmp(), "src"); + fs.writeFileSync(s, content); + if (!isWin) fs.chmodSync(s, 0o644); // start non-executable so +x is provably added + return s; + } + + it("copies into a new dest and sets the executable bit on POSIX", () => { + const source = makeSource(); + const dest = path.join(mkTmp(), "nested", "dest"); // dir absent → mkdirSync + lstat catch + + api.link.copyExecutable(source, dest); + + expect(fs.readFileSync(dest, "utf8")).toBe("#!/bin/sh\necho hi\n"); + if (!isWin) expect(fs.statSync(dest).mode & 0o111).toBe(0o111); + }); + + it("overwrite (default) removes and replaces an existing dest", () => { + const source = makeSource("NEW\n"); + const dest = path.join(mkTmp(), "dest"); + fs.writeFileSync(dest, "OLD\n"); + + api.link.copyExecutable(source, dest); + expect(fs.readFileSync(dest, "utf8")).toBe("NEW\n"); + }); + + it("overwrite:false skips the pre-removal but still copies the source over the dest", () => { + const source = makeSource("NEWER\n"); + const dest = path.join(mkTmp(), "dest"); + fs.writeFileSync(dest, "OLDER\n"); + + api.link.copyExecutable(source, dest, { overwrite: false }); + expect(fs.readFileSync(dest, "utf8")).toBe("NEWER\n"); + }); +}); diff --git a/tests/link-coverage.test.mjs b/tests/link-coverage.test.mjs new file mode 100644 index 0000000..5ce7336 --- /dev/null +++ b/tests/link-coverage.test.mjs @@ -0,0 +1,660 @@ +/** + * @Project: @cldmv/git-embedded + * @Filename: /tests/link-coverage.test.mjs + * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. + * + * Coverage-completing behavior tests for the link + install-hooks layer, + * complementing tests/install-link.test.mjs and tests/install-hooks.test.mjs. + * Everything here is driven through the composed slothlet api against REAL + * files in temp dirs; the branches that only fire on Windows (privilege-denied + * symlink → UAC batch) or on a copy/chmod failure are exercised by: + * + * - Pinning `process.platform` to "win32" for the duration of a single + * synchronous `api.link.batch` call, then restoring it. + * - Injecting controlled failures into the fs primitives the leaf calls + * (`symlinkSync`, `linkSync`, `chmodSync`) — the leaf reads them off the + * shared node:fs object at call time, so a temporary property swap makes the + * documented fallback/branch fire without needing a real cross-volume mount + * or a real UAC prompt. + * - Overriding `api.link.elevateWindows` (the Windows-only helper is excluded + * from coverage and cannot run on POSIX) with a stub that returns each of the + * result shapes the batch caller must handle: cancelled, failed, succeeded. + * + * All stubs are restored in a finally before any assertion runs, so a failed + * expectation can never leave process.platform or fs mutated for later tests. + */ + +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { getApi } from "./_setup.mjs"; + +const isWin = process.platform === "win32"; +const tmpRoots = []; + +// Keep every throwaway dir inside the repo's gitignored tmp/ rather than the +// system /tmp, per the project scratch convention. +const repoTmp = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "tmp"); +fs.mkdirSync(repoTmp, { recursive: true }); + +function mkTmp() { + const dir = fs.mkdtempSync(path.join(repoTmp, "git-embedded-linkcov-")); + tmpRoots.push(dir); + return dir; +} + +function makeTarget(content = "#!/bin/sh\necho TARGET\n") { + const t = path.join(mkTmp(), "target"); + fs.writeFileSync(t, content); + return t; +} + +function inode(p) { + const st = fs.statSync(p); + return `${st.dev}:${st.ino}`; +} + +// --- stub helpers (each returns a restore fn) --------------------------------- + +function stubPlatform(value) { + const orig = Object.getOwnPropertyDescriptor(process, "platform"); + Object.defineProperty(process, "platform", { value, configurable: true }); + return () => Object.defineProperty(process, "platform", orig); +} + +function patchFs(overrides) { + const saved = {}; + for (const k of Object.keys(overrides)) { + saved[k] = fs[k]; + fs[k] = overrides[k]; + } + return () => { + for (const k of Object.keys(saved)) fs[k] = saved[k]; + }; +} + +function stubElevate(api, fn) { + const orig = api.link.elevateWindows; + api.link.elevateWindows = fn; + return () => { + api.link.elevateWindows = orig; + }; +} + +/** + * Run a synchronous batch() call with process.platform pinned to "win32" and + * optional fs / elevateWindows stubs, restoring everything before returning so + * an assertion on the captured result/error cannot leak stub state. + */ +function withWin32(api, { fs: fsOverrides, elevate } = {}, call) { + const restorePlatform = stubPlatform("win32"); + const restoreFs = fsOverrides ? patchFs(fsOverrides) : () => {}; + const restoreElevate = elevate ? stubElevate(api, elevate) : () => {}; + let result; + let error; + try { + try { + result = call(); + } catch (e) { + error = e; + } + } finally { + restoreElevate(); + restoreFs(); + restorePlatform(); + } + return { result, error }; +} + +let originalEnv; +beforeEach(() => { + originalEnv = { ...process.env }; + // Redirect the append-only transaction log into a throwaway state dir so the + // install/uninstall ops here never touch the real ~/.local/state. + const stateDir = mkTmp(); + process.env.XDG_STATE_HOME = stateDir; + if (isWin) process.env.LOCALAPPDATA = stateDir; +}); + +afterEach(() => { + process.env = originalEnv; + while (tmpRoots.length) { + const d = tmpRoots.pop(); + try { + fs.chmodSync(d, 0o755); + } catch { + // ignore + } + try { + fs.rmSync(d, { recursive: true, force: true }); + } catch { + // ignore + } + } +}); + +let api; +beforeAll(async () => { + api = await getApi(); +}); + +// ============================================================================= +// api.link.batch — POSIX base mechanisms (self-contained: no reliance on the +// sibling install-link suite for these lines). +// ============================================================================= +describe("api.link.batch base mechanisms", () => { + it.skipIf(isWin)("creates a symlink per source that resolves to the target", () => { + const target = makeTarget(); + const base = mkTmp(); + const sources = ["a", "b"].map((n) => path.join(base, "nested", n)); + + const out = api.link.batch(target, sources); + + expect(new Set(out.created.map((c) => c.mechanism))).toEqual(new Set(["symlink"])); + expect(out.fallbackToCopy).toEqual([]); + for (const s of sources) { + expect(fs.lstatSync(s).isSymbolicLink()).toBe(true); + expect(fs.realpathSync(s)).toBe(fs.realpathSync(target)); + } + }); + + it("creates hardlinks under noSymlinks that share the target's inode", () => { + const target = makeTarget(); + const source = path.join(mkTmp(), "nested", "hl"); + + const out = api.link.batch(target, [source], { noSymlinks: true }); + + expect(out.created).toEqual([{ source, mechanism: "hardlink" }]); + expect(inode(source)).toBe(inode(target)); + }); + + it("overwrite removes a pre-existing file before hardlinking (removeIfExists true branch)", () => { + const target = makeTarget("NEW\n"); + const source = path.join(mkTmp(), "slot"); + fs.writeFileSync(source, "STALE\n"); + const staleInode = inode(source); + + const out = api.link.batch(target, [source], { noSymlinks: true, overwrite: true }); + + expect(out.created).toEqual([{ source, mechanism: "hardlink" }]); + expect(inode(source)).toBe(inode(target)); + expect(inode(source)).not.toBe(staleInode); + }); + + it("overwrite on a not-yet-existing source is a no-op removal (removeIfExists false branch)", () => { + const target = makeTarget(); + const source = path.join(mkTmp(), "fresh"); // nothing to remove + const out = api.link.batch(target, [source], { noSymlinks: true, overwrite: true }); + expect(out.created).toEqual([{ source, mechanism: "hardlink" }]); + expect(inode(source)).toBe(inode(target)); + }); + + it.skipIf(isWin)("overwrite removes a pre-existing file before symlinking (symlink-path removeIfExists)", () => { + const target = makeTarget("FRESH\n"); + const source = path.join(mkTmp(), "slot"); + fs.writeFileSync(source, "STALE\n"); // exercises the overwrite branch in the symlink loop + const out = api.link.batch(target, [source], { overwrite: true }); + expect(out.created).toEqual([{ source, mechanism: "symlink" }]); + expect(fs.lstatSync(source).isSymbolicLink()).toBe(true); + expect(fs.readFileSync(source, "utf8")).toBe("FRESH\n"); + }); +}); + +// ============================================================================= +// api.link.batch — copy fallbacks (POSIX), driven by injecting fs failures. +// ============================================================================= +describe("api.link.batch copy fallbacks", () => { + it("noSymlinks: falls back to copy (with +x) when the hardlink fails", () => { + const target = makeTarget("COPIED\n"); + const source = path.join(mkTmp(), "sub", "cp"); + // linkSync fails as if cross-device; copyFileSync stays real and succeeds. + const restore = patchFs({ + linkSync: () => { + const e = new Error("cross-device link not permitted"); + e.code = "EXDEV"; + throw e; + } + }); + let out; + try { + out = api.link.batch(target, [source], { noSymlinks: true }); + } finally { + restore(); + } + expect(out.created).toEqual([{ source, mechanism: "copy" }]); + expect(out.fallbackToCopy).toEqual([source]); + expect(fs.readFileSync(source, "utf8")).toBe("COPIED\n"); + if (!isWin) expect(fs.statSync(source).mode & 0o111).not.toBe(0); + }); + + it("noSymlinks: copy still succeeds when the best-effort chmod throws", () => { + const target = makeTarget("CHMODLESS\n"); + const source = path.join(mkTmp(), "sub", "cp2"); + const restore = patchFs({ + linkSync: () => { + const e = new Error("no hardlink"); + e.code = "EXDEV"; + throw e; + }, + chmodSync: () => { + const e = new Error("chmod denied"); + e.code = "EPERM"; + throw e; + } + }); + let out; + try { + out = api.link.batch(target, [source], { noSymlinks: true }); + } finally { + restore(); + } + // chmod failure is swallowed (best-effort); the copy still counts. + expect(out.created).toEqual([{ source, mechanism: "copy" }]); + expect(fs.readFileSync(source, "utf8")).toBe("CHMODLESS\n"); + }); + + it("noSymlinks: throws the copy error when neither hardlink nor copy can be made", () => { + const source = path.join(mkTmp(), "slot"); + const restore = patchFs({ + linkSync: () => { + const e = new Error("no hardlink"); + e.code = "EXDEV"; + throw e; + }, + copyFileSync: () => { + const e = new Error("no copy either"); + e.code = "ENOSPC"; + throw e; + } + }); + let err; + try { + try { + api.link.batch(makeTarget(), [source], { noSymlinks: true }); + } catch (e) { + err = e; + } + } finally { + restore(); + } + // slothlet re-wraps a leaf throw as a SlothletError, embedding the original + // message — assert on that rather than the (now-wrapped) .code. + expect(err).toBeInstanceOf(Error); + expect(err.message).toMatch(/no copy either/); + expect(fs.existsSync(source)).toBe(false); + }); + + it("symlink: non-privilege failure (non-win32) falls back to copy", () => { + const target = makeTarget("SYM-COPY\n"); + const source = path.join(mkTmp(), "sub", "occupied"); + const restore = patchFs({ + symlinkSync: () => { + const e = new Error("already exists"); + e.code = "EEXIST"; + throw e; + } + }); + let out; + try { + out = api.link.batch(target, [source]); // default = symlink + } finally { + restore(); + } + expect(out.created).toEqual([{ source, mechanism: "copy" }]); + expect(out.fallbackToCopy).toEqual([source]); + expect(fs.readFileSync(source, "utf8")).toBe("SYM-COPY\n"); + }); + + it("symlink: rethrows the symlink error when the copy fallback also fails", () => { + const source = path.join(mkTmp(), "sub", "occupied"); + const restore = patchFs({ + symlinkSync: () => { + const e = new Error("symlink boom"); + e.code = "EEXIST"; + throw e; + }, + copyFileSync: () => { + const e = new Error("copy boom"); + e.code = "EISDIR"; + throw e; + } + }); + let err; + try { + try { + api.link.batch(makeTarget(), [source]); + } catch (e) { + err = e; + } + } finally { + restore(); + } + // The SYMLINK error is what surfaces (ln.error), not the copy error. + expect(err).toBeInstanceOf(Error); + expect(err.message).toMatch(/symlink boom/); + expect(err.message).not.toMatch(/copy boom/); + }); +}); + +// ============================================================================= +// api.link.batch — Windows privilege-denied → deferred → elevateWindows. +// Exercised on POSIX by pinning process.platform and stubbing the helper. +// ============================================================================= +describe("api.link.batch windows elevation path", () => { + // One error per source name so a single call drives every isPrivilegeError + // branch: EPERM (WIN_PRIV_NOT_HELD), EACCES, and errno === -4048. + const privSymlink = (_target, source) => { + const e = new Error("privilege not held"); + if (source.endsWith("-eperm")) e.code = "EPERM"; + else if (source.endsWith("-eacces")) e.code = "EACCES"; + else { + e.code = "UNKNOWN"; + e.errno = -4048; + } + throw e; + }; + + it("defers every privilege-denied symlink and marks them symlink-elevated on success", () => { + const target = makeTarget(); + const base = mkTmp(); + const sources = ["s-eperm", "s-eacces", "s-errno"].map((n) => path.join(base, n)); + let receivedPlan = null; + + const { result, error } = withWin32( + api, + { + fs: { symlinkSync: privSymlink }, + elevate: (plan) => { + receivedPlan = plan; + return { ok: true, cancelled: false, exitCode: 0 }; + } + }, + () => api.link.batch(target, sources) + ); + + expect(error).toBeUndefined(); + expect(result.created).toEqual(sources.map((source) => ({ source, mechanism: "symlink-elevated" }))); + expect(result.fallbackToCopy).toEqual([]); + // The helper received the full {source,target} plan for the deferred set. + expect(receivedPlan).toEqual(sources.map((source) => ({ source, target }))); + }); + + it("throws CancelledByUser when the UAC prompt is cancelled (with helper message)", () => { + const target = makeTarget(); + const source = path.join(mkTmp(), "s-eperm"); + + const { error } = withWin32( + api, + { + fs: { symlinkSync: privSymlink }, + elevate: () => ({ ok: false, cancelled: true, message: "UAC elevation cancelled by user" }) + }, + () => api.link.batch(target, [source]) + ); + + // The leaf throws CancelledByUser (its constructor runs); slothlet re-wraps + // it, so the distinguishing signal available here is the embedded message. + expect(error).toBeDefined(); + expect(error).toBeInstanceOf(Error); + expect(error.message).toMatch(/UAC elevation cancelled by user/); + }); + + it("throws CancelledByUser with a default message when the helper omits one", () => { + const target = makeTarget(); + const source = path.join(mkTmp(), "s-eacces"); + + const { error } = withWin32( + api, + { + fs: { symlinkSync: privSymlink }, + elevate: () => ({ ok: false, cancelled: true }) + }, + () => api.link.batch(target, [source]) + ); + + expect(error).toBeDefined(); + // The default (helper supplied no message): "UAC elevation cancelled", + // distinct from the "…cancelled by user" message the helper can pass. + expect(error.message).toContain("UAC elevation cancelled"); + expect(error.message).not.toContain("by user"); + }); + + it("throws the helper's message when elevation fails (not cancelled)", () => { + const target = makeTarget(); + const source = path.join(mkTmp(), "s-eperm"); + + const { error } = withWin32( + api, + { + fs: { symlinkSync: privSymlink }, + elevate: () => ({ ok: false, cancelled: false, exitCode: 2, message: "powershell blew up" }) + }, + () => api.link.batch(target, [source]) + ); + + expect(error).toBeInstanceOf(Error); + expect(error.message).toMatch(/powershell blew up/); + }); + + it("throws a synthesized exit-code message when elevation fails without a message", () => { + const target = makeTarget(); + const source = path.join(mkTmp(), "s-eperm"); + + const { error } = withWin32( + api, + { + fs: { symlinkSync: privSymlink }, + elevate: () => ({ ok: false, cancelled: false, exitCode: 7 }) + }, + () => api.link.batch(target, [source]) + ); + + expect(error).toBeInstanceOf(Error); + expect(error.message).toMatch(/elevated symlink batch failed \(exit 7\)/); + }); + + it("on win32, a NON-privilege symlink failure copies instead of deferring", () => { + const target = makeTarget("WIN-COPY\n"); + const source = path.join(mkTmp(), "sub", "plain"); + let elevateCalled = false; + + const { result, error } = withWin32( + api, + { + fs: { + symlinkSync: () => { + const e = new Error("not a privilege problem"); + e.code = "EEXIST"; + e.errno = -17; + throw e; + } + }, + elevate: () => { + elevateCalled = true; + return { ok: true, cancelled: false, exitCode: 0 }; + } + }, + () => api.link.batch(target, [source]) + ); + + expect(error).toBeUndefined(); + expect(result.created).toEqual([{ source, mechanism: "copy" }]); + expect(result.fallbackToCopy).toEqual([source]); + expect(elevateCalled).toBe(false); // deferred set was empty → helper never invoked + // copyFileSync ran; on win32 the +x chmod is skipped (branch under test). + expect(fs.readFileSync(source, "utf8")).toBe("WIN-COPY\n"); + }); + + it("on win32, a symlink that throws a falsy error is treated as non-privilege (copies)", () => { + const target = makeTarget("FALSY\n"); + const source = path.join(mkTmp(), "sub", "falsy"); + + const { result, error } = withWin32( + api, + { + fs: { + symlinkSync: () => { + throw undefined; + } + }, + elevate: () => ({ ok: true, cancelled: false, exitCode: 0 }) + }, + () => api.link.batch(target, [source]) + ); + + expect(error).toBeUndefined(); + expect(result.created).toEqual([{ source, mechanism: "copy" }]); + expect(fs.readFileSync(source, "utf8")).toBe("FALSY\n"); + }); + + it("returns empty results for an empty source list", () => { + const out = api.link.batch(makeTarget(), []); + expect(out).toEqual({ created: [], fallbackToCopy: [] }); + }); +}); + +// ============================================================================= +// api.link.copyExecutable — the win32 branch (chmod skipped) plus overwrite arms. +// ============================================================================= +describe("api.link.copyExecutable", () => { + function makeSource(content = "#!/bin/sh\necho hi\n") { + const s = path.join(mkTmp(), "src"); + fs.writeFileSync(s, content); + if (!isWin) fs.chmodSync(s, 0o644); + return s; + } + + it("copies into a new dest and sets +x on POSIX (default overwrite, nothing to remove)", () => { + const source = makeSource(); + const dest = path.join(mkTmp(), "nested", "dest"); + api.link.copyExecutable(source, dest); + expect(fs.readFileSync(dest, "utf8")).toBe("#!/bin/sh\necho hi\n"); + if (!isWin) expect(fs.statSync(dest).mode & 0o111).toBe(0o111); + }); + + it("overwrite (default) removes and replaces an existing dest", () => { + const source = makeSource("NEW\n"); + const dest = path.join(mkTmp(), "dest"); + fs.writeFileSync(dest, "OLD\n"); + api.link.copyExecutable(source, dest); + expect(fs.readFileSync(dest, "utf8")).toBe("NEW\n"); + }); + + it("overwrite:false skips pre-removal but still copies over the dest", () => { + const source = makeSource("NEWER\n"); + const dest = path.join(mkTmp(), "dest"); + fs.writeFileSync(dest, "OLDER\n"); + api.link.copyExecutable(source, dest, { overwrite: false }); + expect(fs.readFileSync(dest, "utf8")).toBe("NEWER\n"); + }); + + it("on win32 the +x chmod is skipped (copy only)", () => { + const source = makeSource("WINEXE\n"); + const dest = path.join(mkTmp(), "dest"); + const restorePlatform = stubPlatform("win32"); + try { + api.link.copyExecutable(source, dest, { overwrite: true }); + } finally { + restorePlatform(); + } + expect(fs.readFileSync(dest, "utf8")).toBe("WINEXE\n"); + }); +}); + +// ============================================================================= +// api.install.hooks — the three uncovered arms: unknown op, unreadable dest on +// install (existing = "") and on uninstall (body = ""), and the uninstall +// "kept foreign package hook" branch. +// ============================================================================= +describe("api.install.hooks coverage completion", () => { + const FOREIGN_PACKAGE_HOOK = "post-checkout"; // a name in PACKAGE_HOOK_MAP + + it("throws on an unknown op", async () => { + await expect(async () => api.install.hooks("frobnicate", mkTmp())).rejects.toThrow(/unknown op "frobnicate"/); + }); + + it("install: skips a dest whose bytes cannot be read (readFileSync throws → treated as foreign)", async () => { + const gitDir = mkTmp(); + const hooksDir = path.join(gitDir, "hooks"); + fs.mkdirSync(hooksDir, { recursive: true }); + // A directory sitting where the hook file would be: existsSync() is true, + // but readFileSync() throws EISDIR → the catch sets existing = "". + fs.mkdirSync(path.join(hooksDir, FOREIGN_PACKAGE_HOOK)); + + const out = await api.install.hooks("install", gitDir); + + const skipped = Array.from(out.skipped).map((s) => s.name); + expect(skipped).toContain(FOREIGN_PACKAGE_HOOK); + expect(Array.from(out.installed)).not.toContain(FOREIGN_PACKAGE_HOOK); + // The unreadable dest was left untouched. + expect(fs.statSync(path.join(hooksDir, FOREIGN_PACKAGE_HOOK)).isDirectory()).toBe(true); + }); + + it("install: --force overwrites a foreign (readable) hook", async () => { + const gitDir = mkTmp(); + const hooksDir = path.join(gitDir, "hooks"); + fs.mkdirSync(hooksDir, { recursive: true }); + fs.writeFileSync(path.join(hooksDir, FOREIGN_PACKAGE_HOOK), "#!/bin/sh\n# not ours\n"); + + const out = await api.install.hooks("install", gitDir, { force: true }); + + expect(Array.from(out.installed)).toContain(FOREIGN_PACKAGE_HOOK); + expect(fs.readFileSync(path.join(hooksDir, FOREIGN_PACKAGE_HOOK), "utf8")).toContain("git-embedded"); + }); + + it("install: re-installs its own hooks on a second run (owned dest, not skipped)", async () => { + const gitDir = mkTmp(); + await api.install.hooks("install", gitDir); + const out = await api.install.hooks("install", gitDir); + expect(Array.from(out.skipped)).toEqual([]); + expect(Array.from(out.installed)).toContain(FOREIGN_PACKAGE_HOOK); + }); + + it("uninstall: removes only the git-embedded-owned hooks", async () => { + const gitDir = mkTmp(); + await api.install.hooks("install", gitDir); + const out = await api.install.hooks("uninstall", gitDir); + const removed = Array.from(out.removed); + for (const name of ["post-checkout", "post-merge", "post-rewrite", "reference-transaction", "pre-push"]) { + expect(removed).toContain(name); + expect(fs.existsSync(path.join(gitDir, "hooks", name))).toBe(false); + } + }); + + it("uninstall: is a no-op for hooks that do not exist", async () => { + const gitDir = mkTmp(); // no hooks dir at all → every dest is absent + const out = await api.install.hooks("uninstall", gitDir); + expect(Array.from(out.removed)).toEqual([]); + expect(Array.from(out.kept)).toEqual([]); + }); + + it("uninstall: keeps a foreign package-named hook (body does not include git-embedded)", async () => { + const gitDir = mkTmp(); + const hooksDir = path.join(gitDir, "hooks"); + fs.mkdirSync(hooksDir, { recursive: true }); + fs.writeFileSync(path.join(hooksDir, FOREIGN_PACKAGE_HOOK), "#!/bin/sh\necho someone-else\n"); + + const out = await api.install.hooks("uninstall", gitDir); + + const kept = Array.from(out.kept).map((k) => k.name); + expect(kept).toContain(FOREIGN_PACKAGE_HOOK); + expect(Array.from(out.removed)).not.toContain(FOREIGN_PACKAGE_HOOK); + // Left in place, bytes intact. + expect(fs.readFileSync(path.join(hooksDir, FOREIGN_PACKAGE_HOOK), "utf8")).toBe("#!/bin/sh\necho someone-else\n"); + }); + + it("uninstall: keeps a dest whose bytes cannot be read (readFileSync throws → body = '')", async () => { + const gitDir = mkTmp(); + const hooksDir = path.join(gitDir, "hooks"); + fs.mkdirSync(hooksDir, { recursive: true }); + // Directory where a hook would be → existsSync true, readFileSync throws. + fs.mkdirSync(path.join(hooksDir, FOREIGN_PACKAGE_HOOK)); + + const out = await api.install.hooks("uninstall", gitDir); + + const kept = Array.from(out.kept).map((k) => k.name); + expect(kept).toContain(FOREIGN_PACKAGE_HOOK); + expect(fs.statSync(path.join(hooksDir, FOREIGN_PACKAGE_HOOK)).isDirectory()).toBe(true); + }); +}); diff --git a/tests/root-coverage.test.mjs b/tests/root-coverage.test.mjs new file mode 100644 index 0000000..7d1e7b7 --- /dev/null +++ b/tests/root-coverage.test.mjs @@ -0,0 +1,318 @@ +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 chalk from "chalk"; +import { Command, Help } from "commander"; +import { makeCustomHelp } from "../src/api/commander/custom-help.mjs"; +import { getApi } from "./_setup.mjs"; + +// node:readline's ES module namespace object cannot be vi.spyOn'd directly — +// its properties are non-configurable (confirmed: `vi.spyOn(readlineNs, +// "createInterface")` throws "Module namespace is not configurable in ESM"). +// A full module mock is the supported way to control what +// context.readline.createInterface() returns for api.prompt.confirm's +// interactive branch. vi.mock is hoisted above every import in this file +// (including the transitive "node:readline" import inside _setup.mjs), so +// the composed api's context.readline resolves to this mock. +const { createInterfaceMock } = vi.hoisted(() => ({ createInterfaceMock: vi.fn() })); +vi.mock("node:readline", () => ({ + createInterface: createInterfaceMock, + default: { createInterface: createInterfaceMock } +})); + +/** + * Coverage top-up for src/api/prompt.mjs, paths.mjs, git.mjs, report.mjs, and + * src/api/commander/custom-help.mjs — closing the line/branch/function gaps + * left after tests/helpers.test.mjs and tests/commander-help.test.mjs. + * + * Same house style as those two files: exercise the composed slothlet api + * against real temp git repos / XDG dirs (git.mjs, paths.mjs, report.mjs), + * and a real commander Command tree through the directly-imported + * custom-help.mjs factory (matching commander-help.test.mjs's pattern). + * prompt.mjs is the one exception that needs the readline module mock above + * to drive its interactive path deterministically. + */ + +const tmpRoots = []; + +function mkTmp() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "git-embedded-rootcov-")); + tmpRoots.push(dir); + return dir; +} + +const stripAnsi = (s) => String(s).replace(new RegExp(String.fromCharCode(27) + "\\[[0-9;]*m", "g"), ""); + +let originalEnv; +let originalCwd; +let originalIsTTYDescriptor; +let originalPlatformDescriptor; + +beforeEach(() => { + originalEnv = { ...process.env }; + originalCwd = process.cwd(); + originalIsTTYDescriptor = Object.getOwnPropertyDescriptor(process.stdin, "isTTY"); + originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, "platform"); + // Hermetic git: ignore host/global/system 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"; + // Isolate the transaction-log/state location. + const sd = mkTmp(); + process.env.XDG_STATE_HOME = sd; + if (process.platform === "win32") process.env.LOCALAPPDATA = sd; + createInterfaceMock.mockReset(); +}); + +afterEach(() => { + try { + process.chdir(originalCwd); + } catch { + // ignore + } + process.env = originalEnv; + if (originalIsTTYDescriptor) Object.defineProperty(process.stdin, "isTTY", originalIsTTYDescriptor); + else delete process.stdin.isTTY; + if (originalPlatformDescriptor) Object.defineProperty(process, "platform", originalPlatformDescriptor); + 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(); +}); + +// --------------------------------------------------------------------------- +// api.prompt.confirm +// --------------------------------------------------------------------------- + +describe("api.prompt.confirm", () => { + it("yes:true bypasses TTY/readline entirely and resolves true", async () => { + createInterfaceMock.mockImplementation(() => { + throw new Error("createInterface must not be called when opts.yes is true"); + }); + await expect(api.prompt.confirm("Proceed?", { yes: true })).resolves.toBe(true); + expect(createInterfaceMock).not.toHaveBeenCalled(); + }); + + it("non-interactive (no TTY) returns defaultYes without prompting; also covers the omitted-opts default", async () => { + Object.defineProperty(process.stdin, "isTTY", { value: false, configurable: true }); + + // opts entirely omitted → opts defaults to {}, and defaultYes/yes both take + // their destructuring defaults (false) in the same call. + await expect(api.prompt.confirm("Proceed?")).resolves.toBe(false); + // Explicit defaultYes:true on the same non-interactive path. + await expect(api.prompt.confirm("Proceed?", { defaultYes: true })).resolves.toBe(true); + // Explicit yes:false is indistinguishable from omitted but exercises the + // destructuring default path via a real (not implicit) opts object too. + await expect(api.prompt.confirm("Proceed?", { yes: false, defaultYes: false })).resolves.toBe(false); + + expect(createInterfaceMock).not.toHaveBeenCalled(); + }); + + it("interactive: answer 'y' resolves true and renders the [y/N] suffix for defaultYes:false", async () => { + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + const questionMock = vi.fn((_q, cb) => cb("y")); + const closeMock = vi.fn(); + createInterfaceMock.mockReturnValue({ question: questionMock, close: closeMock }); + + await expect(api.prompt.confirm("Continue?", { defaultYes: false })).resolves.toBe(true); + + expect(createInterfaceMock).toHaveBeenCalledWith({ input: process.stdin, output: process.stdout }); + expect(questionMock).toHaveBeenCalledTimes(1); + expect(questionMock.mock.calls[0][0]).toBe("Continue? [y/N] "); + expect(closeMock).toHaveBeenCalledTimes(1); + }); + + it("interactive: whitespace/mixed-case 'YES' resolves true and renders the [Y/n] suffix for defaultYes:true", async () => { + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + const questionMock = vi.fn((_q, cb) => cb(" YES ")); + createInterfaceMock.mockReturnValue({ question: questionMock, close: vi.fn() }); + + await expect(api.prompt.confirm("Continue?", { defaultYes: true })).resolves.toBe(true); + + expect(questionMock.mock.calls[0][0]).toBe("Continue? [Y/n] "); + }); + + it("interactive: an empty answer resolves defaultYes, both when true and false", async () => { + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + + createInterfaceMock.mockReturnValue({ question: (_q, cb) => cb(""), close: vi.fn() }); + await expect(api.prompt.confirm("Continue?", { defaultYes: true })).resolves.toBe(true); + + createInterfaceMock.mockReturnValue({ question: (_q, cb) => cb(""), close: vi.fn() }); + await expect(api.prompt.confirm("Continue?", { defaultYes: false })).resolves.toBe(false); + }); + + it("interactive: any other answer (e.g. 'no') resolves false regardless of defaultYes", async () => { + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + createInterfaceMock.mockReturnValue({ question: (_q, cb) => cb("no"), close: vi.fn() }); + + await expect(api.prompt.confirm("Continue?", { defaultYes: true })).resolves.toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// api.paths — win32 branch of stateDir() +// --------------------------------------------------------------------------- + +describe("api.paths.stateDir on win32", () => { + function setWin32() { + Object.defineProperty(process, "platform", { value: "win32", configurable: true }); + } + + it("uses LOCALAPPDATA when set", () => { + setWin32(); + const fakeLocal = mkTmp(); + process.env.LOCALAPPDATA = fakeLocal; + expect(api.paths.stateDir()).toBe(path.join(fakeLocal, "git-embedded")); + }); + + it("falls back to homedir/AppData/Local when LOCALAPPDATA is unset", () => { + setWin32(); + delete process.env.LOCALAPPDATA; + expect(api.paths.stateDir()).toBe(path.join(os.homedir(), "AppData", "Local", "git-embedded")); + }); +}); + +// --------------------------------------------------------------------------- +// api.git — defensive fallback reachable only when the git binary is missing +// --------------------------------------------------------------------------- + +describe("api.git — run() when the git binary itself cannot be spawned", () => { + it.skipIf(process.platform === "win32")("getConfig returns null (not throw) when PATH has no git binary", () => { + const prevPath = process.env.PATH; + process.env.PATH = ""; + try { + // spawnSync("git", ...) fails with ENOENT: res.status is null, so + // `res.status ?? 1` falls back to 1 (not 0) → getConfig sees code!==0. + expect(api.git.getConfig("core.hooksPath")).toBeNull(); + } finally { + process.env.PATH = prevPath; + } + }); +}); + +// --------------------------------------------------------------------------- +// api.report — edge-case input that reaches fmtKv's internal null/empty guard +// --------------------------------------------------------------------------- + +describe("api.report.detectionHeader — edge cases", () => { + it("omits the Missing entries line when the missing array joins to an empty string", () => { + const out = []; + vi.spyOn(console, "log").mockImplementation((...a) => out.push(a.map(String).join(" "))); + + // dispatcher.missing.length (1) passes detectionHeader's own guard, but + // [""].join(", ") is "" — reaching fmtKv's internal `value === ""` check, + // which every OTHER call site in detectionHeader already guards against + // before calling fmtKv at all. + api.report.detectionHeader({ + kind: "dispatcher-missing-symlinks", + dispatcher: { dispatcherPath: "/d/_dispatch", missing: [""] } + }); + + const text = stripAnsi(out.join("\n")); + expect(text).toContain("Dispatcher"); + expect(text).toContain("/d/_dispatch"); + expect(text).not.toContain("Missing entries"); + }); +}); + +// --------------------------------------------------------------------------- +// src/api/commander/custom-help.mjs — remaining branches +// (direct-import style, matching tests/commander-help.test.mjs) +// --------------------------------------------------------------------------- + +function buildCustomHelp() { + return makeCustomHelp(Help, { chalk }); +} + +function plainCustomHelp() { + return new (buildCustomHelp().CustomHelp)({ colorMode: "never" }); +} + +describe("custom-help.mjs — remaining branches", () => { + it("omits the Options section entirely when a command has no visible options at all", () => { + const help = plainCustomHelp(); + const cmd = new Command("bare"); + cmd.helpOption(false); // removes the default -h/--help too + const out = help.formatHelp(cmd, help); + expect(out).not.toContain("Options:"); + }); + + it("falls back to c.description() when the passed-in helper has no commandDescription method", () => { + const help = plainCustomHelp(); + const program = new Command("root"); + program.command("worker").description("Do background work."); + + // A helper that behaves like a real Help instance for everything else, + // but shadows commandDescription with an own falsy property. + const fakeHelper = Object.create(help); + fakeHelper.commandDescription = undefined; + + const out = help.formatHelp(program, fakeHelper); + expect(out).toContain("worker"); + expect(out).toContain("Do background work."); + }); + + it("stops walking the parent chain at an ancestor without a .name function", () => { + const help = plainCustomHelp(); + const program = new Command("root"); + const child = program.command("child"); + child.parent = { notACommand: true }; // malformed ancestor: no .name at all + const out = help.formatHelp(child, help); + expect(out.split("\n")[0]).toContain("Usage: child"); + expect(out.split("\n")[0]).not.toContain("root"); + }); + + it("skips an empty command name when building the usage chain", () => { + const help = plainCustomHelp(); + const bare = new Command(); // no name → name() returns "" + bare.helpOption(false); + expect(help.formatHelp(bare, help).split("\n")[0]).toBe("Usage: "); + }); + + it("defaults a subcommand's args to none in a synthesized example when _args is missing", () => { + const help = plainCustomHelp(); + const program = new Command("root"); + const restore = program.command("restore"); + const inner = restore.command("inner"); + delete inner._args; // simulate a foreign/malformed command node + + const out = help.formatHelp(restore, help); // non-top-level → collectExamples's sub._args path + expect(out).toContain("$ root restore inner"); + }); + + it("stops the top-level examples walk at a node with no .commands array", () => { + const help = plainCustomHelp(); + const program = new Command("root"); // top-level → collectExamples's walk() path + const child = program.command("child"); + child._exampleList = ["$ root child --now"]; + delete child.commands; // malformed node: walk() must stop, not crash + + const out = help.formatHelp(program, help); + expect(out).toContain("$ root child --now"); + }); + + it("produces no Aliases line when the alias list joins to an empty string", () => { + const help = plainCustomHelp(); + const program = new Command("root"); + const sub = program.command("sub"); + sub._aliases = [""]; // survives the `!== name()` filter but joins to "" + + const out = help.formatHelp(program, help); + expect(out).not.toContain("Aliases:"); + }); +}); diff --git a/tests/run-vitest.mjs b/tests/run-vitest.mjs new file mode 100644 index 0000000..f46fd4e --- /dev/null +++ b/tests/run-vitest.mjs @@ -0,0 +1,53 @@ +/** + * @fileoverview OOM-safe Vitest runner for git-embedded — delegates to + * @cldmv/vitest-runner, which spawns each test file in its own child process and + * (under coverage) uses a blob-per-file + `--mergeReports` strategy so a single + * process never holds coverage data for the whole suite. Mirrors how @cldmv/slothlet + * runs its suite. + * + * Usage: + * node tests/run-vitest.mjs # run all tests + * node tests/run-vitest.mjs --coverage # with coverage (verbose) + * node tests/run-vitest.mjs --coverage-quiet # with coverage (progress bar + summary) + * node tests/run-vitest.mjs # filter by path/name + * + * Args before a `--` delimiter are forwarded to Vitest; args after it are test + * patterns. A value-taking flag needs the delimiter, e.g.: + * node tests/run-vitest.mjs --reporter verbose -- + */ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { run } from "@cldmv/vitest-runner"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const argv = process.argv.slice(2); + +// A `--` delimiter separates forwarded Vitest args (before it) from test patterns +// (after it), so a value-taking flag such as `--reporter verbose` isn't misread as a +// pattern. Without a `--`, the legacy heuristic applies: non-flag tokens are test +// patterns and flag tokens are forwarded to vitest. +const delimiter = argv.indexOf("--"); +const forwarded = delimiter === -1 ? argv.filter((a) => a.startsWith("-")) : argv.slice(0, delimiter); +const testPatterns = delimiter === -1 ? argv.filter((a) => !a.startsWith("-")) : argv.slice(delimiter + 1); + +const coverageQuiet = forwarded.includes("--coverage-quiet"); +const coverage = coverageQuiet || forwarded.includes("--coverage"); +const passthrough = forwarded.filter((a) => a !== "--coverage" && a !== "--coverage-quiet"); + +// VITEST_WORKERS overrides the worker count; ignore an unset / invalid / non-positive value. +const parsedWorkers = parseInt(process.env.VITEST_WORKERS ?? "", 10); +const workers = Number.isInteger(parsedWorkers) && parsedWorkers > 0 ? parsedWorkers : 4; + +const code = await run({ + cwd: root, + testDir: "tests", + vitestConfig: ".configs/vitest.config.mjs", + // git-embedded uses the plain `*.test.mjs` convention rather than `*.test.vitest.mjs`. + testFilePattern: /\.test\.mjs$/, + testPatterns, + workers, + coverageQuiet, + vitestArgs: [...(coverage ? ["--coverage"] : []), ...passthrough], + nodeEnv: process.env.NODE_ENV || "development" +}); +process.exit(code);