simt-dev 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- python/simt_dev/__init__.py +32 -0
- python/simt_dev/cli.py +225 -0
- simt_dev/__init__.py +32 -0
- simt_dev/cli.py +225 -0
- simt_dev/vendored/CHANGELOG.md +166 -0
- simt_dev/vendored/bin/simt-dev.mjs +28 -0
- simt_dev/vendored/docs/decisions/ADR-001-push-requires-approval.md +42 -0
- simt_dev/vendored/docs/decisions/ADR-002-refactor-blast-radius.md +45 -0
- simt_dev/vendored/docs/decisions/ADR-003-test-breadth.md +46 -0
- simt_dev/vendored/docs/decisions/ADR-004-hook-owns-format.md +45 -0
- simt_dev/vendored/docs/decisions/ADR-005-file-length.md +42 -0
- simt_dev/vendored/docs/decisions/ADR-006-coverage-bar.md +59 -0
- simt_dev/vendored/docs/decisions/ADR-007-section-skeleton.md +54 -0
- simt_dev/vendored/docs/decisions/ADR-008-verdict-vocabulary.md +59 -0
- simt_dev/vendored/docs/decisions/ADR-009-skill-layout.md +48 -0
- simt_dev/vendored/docs/decisions/ADR-010-runner-invocation.md +56 -0
- simt_dev/vendored/docs/decisions/ADR-011-windows-support.md +52 -0
- simt_dev/vendored/docs/decisions/ADR-012-drop-windows-support.md +59 -0
- simt_dev/vendored/docs/decisions/ADR-013-genericity-by-facts-and-local-profiles.md +114 -0
- simt_dev/vendored/docs/decisions/ADR-014-workspace-is-configuration.md +211 -0
- simt_dev/vendored/docs/decisions/ADR-015-a-source-is-content-not-a-location.md +117 -0
- simt_dev/vendored/docs/decisions/ADR-016-a-divergent-rule-is-retired-not-merged.md +180 -0
- simt_dev/vendored/docs/decisions/ADR-017-a-declared-gate-must-be-runnable.md +89 -0
- simt_dev/vendored/docs/decisions/ADR-018-an-unset-hooks-path-is-not-an-absent-hook.md +183 -0
- simt_dev/vendored/docs/decisions/ADR-019-a-declared-command-is-the-runner.md +110 -0
- simt_dev/vendored/docs/decisions/ADR-020-a-rule-is-written-per-surface-and-pointed-not-copied.md +100 -0
- simt_dev/vendored/docs/decisions/ADR-021-one-surface-per-repo.md +94 -0
- simt_dev/vendored/docs/decisions/ADR-022-a-key-simt-dev-reads-is-prefixed.md +98 -0
- simt_dev/vendored/docs/decisions/ADR-023-the-gate-ships-as-a-file-the-repo-owns.md +106 -0
- simt_dev/vendored/docs/decisions/ADR-024-simt-dev-overwrites-its-own-gate-file.md +111 -0
- simt_dev/vendored/docs/decisions/ADR-025-a-baseline-is-a-region-in-a-file-the-repo-owns.md +153 -0
- simt_dev/vendored/docs/decisions/ADR-026-a-config-without-a-region-is-a-file-the-repo-owns.md +117 -0
- simt_dev/vendored/docs/decisions/ADR-027-the-one-gitattributes-line-that-is-neither-dangerous-nor-empty.md +130 -0
- simt_dev/vendored/docs/decisions/ADR-028-a-base-file-is-a-floor-so-the-disagreement-it-cannot-repair-becomes-a-check.md +131 -0
- simt_dev/vendored/docs/decisions/ADR-029-the-pins-a-repository-declares-are-its-own.md +120 -0
- simt_dev/vendored/docs/decisions/ADR-030-what-a-plan-does-not-ship-is-a-decision-with-a-record.md +114 -0
- simt_dev/vendored/docs/decisions/ADR-031-the-suite-stages-the-environment-it-needs.md +129 -0
- simt_dev/vendored/docs/decisions/ADR-032-provenance-is-a-content-digest-not-a-commit.md +141 -0
- simt_dev/vendored/docs/decisions/ADR-033-three-plan-rows-closed-by-decision-not-by-authoring.md +82 -0
- simt_dev/vendored/docs/decisions/ADR-034-a-staged-tool-refuses-a-partially-staged-file.md +91 -0
- simt_dev/vendored/docs/decisions/ADR-035-one-write-decision-and-one-json-document.md +119 -0
- simt_dev/vendored/docs/decisions/ADR-036-a-writer-that-writes-says-so-and-the-instructions-naming-it-move-with-it.md +103 -0
- simt_dev/vendored/docs/decisions/ADR-037-unchanged-means-writing-would-not-change-a-byte.md +100 -0
- simt_dev/vendored/docs/decisions/ADR-038-the-gate-filter-belongs-to-every-manifest-writer.md +95 -0
- simt_dev/vendored/docs/decisions/ADR-039-a-ci-step-runs-a-form-that-can-fail.md +101 -0
- simt_dev/vendored/docs/decisions/ADR-040-the-last-writer-that-wrote-by-default-was-not-the-one-named.md +158 -0
- simt_dev/vendored/docs/decisions/ADR-041-the-suite-installs-the-package-in-order-to-test-it.md +92 -0
- simt_dev/vendored/docs/decisions/ADR-042-a-declared-glob-must-match-a-path-built-from-it.md +94 -0
- simt_dev/vendored/docs/decisions/ADR-043-a-keyword-is-a-phrase-and-two-letters-is-a-word.md +90 -0
- simt_dev/vendored/docs/decisions/ADR-044-compatibility-is-not-evidence.md +116 -0
- simt_dev/vendored/docs/decisions/ADR-045-a-dashboard-that-counts-a-defect.md +119 -0
- simt_dev/vendored/docs/decisions/ADR-046-one-rule-one-copy.md +140 -0
- simt_dev/vendored/docs/decisions/ADR-047-a-profile-selects-on-what-a-repository-runs-on.md +141 -0
- simt_dev/vendored/docs/decisions/ADR-048-a-baseline-carries-only-keys-a-reader-honours.md +150 -0
- simt_dev/vendored/docs/decisions/ADR-049-a-ruling-that-a-later-record-moved-says-so-where-it-is-printed.md +153 -0
- simt_dev/vendored/docs/decisions/ADR-050-a-byte-count-is-not-a-region-and-updated-is-not-a-change-of-rules.md +102 -0
- simt_dev/vendored/docs/decisions/ADR-051-a-pointer-is-the-readers-only-route-so-it-is-not-a-convenience.md +134 -0
- simt_dev/vendored/docs/decisions/ADR-052-a-dependency-is-counted-against-the-budget-and-there-is-one-budget.md +101 -0
- simt_dev/vendored/docs/decisions/ADR-053-the-node-floor-is-22-20-0.md +49 -0
- simt_dev/vendored/docs/decisions/ADR-054-the-digest-covers-every-byte-that-leaves-the-package.md +100 -0
- simt_dev/vendored/docs/decisions/ADR-055-a-status-line-travels-so-it-may-not-name-a-location.md +98 -0
- simt_dev/vendored/docs/decisions/ADR-056-three-names-one-of-which-is-cheap-to-move.md +160 -0
- simt_dev/vendored/docs/decisions/ADR-057-an-identity-is-per-developer-and-a-repository-does-not-own-it.md +115 -0
- simt_dev/vendored/docs/decisions/ADR-058-one-tool-three-channels-and-the-order-is-forced.md +148 -0
- simt_dev/vendored/docs/decisions/README.md +530 -0
- simt_dev/vendored/graph/skills.graph.json +1622 -0
- simt_dev/vendored/package.json +43 -0
- simt_dev/vendored/rules/core.md +76 -0
- simt_dev/vendored/rules/mdc/intake-questions.mdc +50 -0
- simt_dev/vendored/rules/mdc/preview-postflight.mdc +61 -0
- simt_dev/vendored/rules/mdc/slice-commit.mdc +52 -0
- simt_dev/vendored/rules/mdc/test-gate.mdc +54 -0
- simt_dev/vendored/rules/stacks/android.md +63 -0
- simt_dev/vendored/rules/stacks/cloudflare-workers.md +65 -0
- simt_dev/vendored/rules/stacks/connectiq.md +62 -0
- simt_dev/vendored/rules/stacks/container.md +31 -0
- simt_dev/vendored/rules/stacks/jvm.md +54 -0
- simt_dev/vendored/rules/stacks/node.md +45 -0
- simt_dev/vendored/rules/stacks/python.md +48 -0
- simt_dev/vendored/rules/stacks/react-native.md +58 -0
- simt_dev/vendored/rules/stacks/react-web.md +61 -0
- simt_dev/vendored/rules/stacks/typescript.md +21 -0
- simt_dev/vendored/rules/stacks/uv.md +32 -0
- simt_dev/vendored/src/argv.mjs +116 -0
- simt_dev/vendored/src/ci/dependabot.mjs +268 -0
- simt_dev/vendored/src/ci/workflow.mjs +360 -0
- simt_dev/vendored/src/commands/ci.mjs +285 -0
- simt_dev/vendored/src/commands/compare.mjs +195 -0
- simt_dev/vendored/src/commands/graph.mjs +73 -0
- simt_dev/vendored/src/commands/help.mjs +108 -0
- simt_dev/vendored/src/commands/hooks.mjs +471 -0
- simt_dev/vendored/src/commands/index.mjs +241 -0
- simt_dev/vendored/src/commands/init.mjs +632 -0
- simt_dev/vendored/src/commands/mcp.mjs +63 -0
- simt_dev/vendored/src/commands/rules.mjs +374 -0
- simt_dev/vendored/src/commands/skills.mjs +647 -0
- simt_dev/vendored/src/commands/skillsinstall.mjs +515 -0
- simt_dev/vendored/src/commands/sync.mjs +331 -0
- simt_dev/vendored/src/commands/upgrade.mjs +202 -0
- simt_dev/vendored/src/compare/discover.mjs +212 -0
- simt_dev/vendored/src/compare/hooksstate.mjs +159 -0
- simt_dev/vendored/src/compare/row.mjs +299 -0
- simt_dev/vendored/src/decisions/index.mjs +536 -0
- simt_dev/vendored/src/graph/load.mjs +541 -0
- simt_dev/vendored/src/hooks/capability.mjs +129 -0
- simt_dev/vendored/src/hooks/dispatcher.mjs +268 -0
- simt_dev/vendored/src/hooks/gates.mjs +144 -0
- simt_dev/vendored/src/hooks/resolve.mjs +194 -0
- simt_dev/vendored/src/hooks/shim.mjs +140 -0
- simt_dev/vendored/src/hooks/where.mjs +118 -0
- simt_dev/vendored/src/identity.mjs +218 -0
- simt_dev/vendored/src/locate.mjs +75 -0
- simt_dev/vendored/src/mcp/server.mjs +167 -0
- simt_dev/vendored/src/mcp/tools.mjs +410 -0
- simt_dev/vendored/src/name.mjs +95 -0
- simt_dev/vendored/src/output/index.mjs +96 -0
- simt_dev/vendored/src/projection/baselines.mjs +209 -0
- simt_dev/vendored/src/projection/conflicts.mjs +205 -0
- simt_dev/vendored/src/projection/manifest.mjs +478 -0
- simt_dev/vendored/src/projection/markers.mjs +349 -0
- simt_dev/vendored/src/projection/mdc.mjs +327 -0
- simt_dev/vendored/src/projection/project.mjs +504 -0
- simt_dev/vendored/src/projection/rules.mjs +640 -0
- simt_dev/vendored/src/projection/surfaces.mjs +398 -0
- simt_dev/vendored/src/projection/targets.mjs +221 -0
- simt_dev/vendored/src/resolve/fingerprint.mjs +553 -0
- simt_dev/vendored/src/resolve/near.mjs +61 -0
- simt_dev/vendored/src/resolve/resolve.mjs +457 -0
- simt_dev/vendored/src/resolve/source.mjs +105 -0
- simt_dev/vendored/src/toolchain.mjs +202 -0
- simt_dev/vendored/src/tsconfig.mjs +314 -0
- simt_dev/vendored/src/upgrade/plan.mjs +203 -0
- simt_dev/vendored/src/workspace.mjs +289 -0
- simt_dev-0.2.0.dist-info/METADATA +150 -0
- simt_dev-0.2.0.dist-info/RECORD +138 -0
- simt_dev-0.2.0.dist-info/WHEEL +4 -0
- simt_dev-0.2.0.dist-info/entry_points.txt +2 -0
- simt_dev-0.2.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// Generate the POSIX sh hook shim.
|
|
2
|
+
//
|
|
3
|
+
// The shim is intentionally the smallest thing that can work: it resolves the
|
|
4
|
+
// dispatcher and execs it. Everything else lives in Node, where it can be
|
|
5
|
+
// tested. The event name is baked in at generation time rather than derived from
|
|
6
|
+
// `$0`, because a repo may symlink or copy `pre-push` into `pre-commit` and
|
|
7
|
+
// `$0` would then name the wrong hook - a silent way to run the wrong gates.
|
|
8
|
+
//
|
|
9
|
+
// POSIX sh only. ADR-012 dropped Windows, so there is exactly one code path and
|
|
10
|
+
// no `.cmd` shim to keep in sync.
|
|
11
|
+
//
|
|
12
|
+
// Git's own arguments are deliberately not forwarded. `pre-push` passes a remote
|
|
13
|
+
// name and URL, which argv would then misread as command arguments.
|
|
14
|
+
//
|
|
15
|
+
// The resolution block itself is NOT here: it is shared with the CI workflow, which
|
|
16
|
+
// has to find the binary by the same rules or the gate passes locally and cannot
|
|
17
|
+
// start in CI. See src/locate.mjs.
|
|
18
|
+
|
|
19
|
+
import { renderLocator } from '../locate.mjs';
|
|
20
|
+
import { BIN_ENV, CLI, MANIFEST_FILE } from '../name.mjs';
|
|
21
|
+
|
|
22
|
+
/** Events the shared dispatcher knows how to run. */
|
|
23
|
+
export const EVENTS = ['pre-commit', 'pre-push'];
|
|
24
|
+
|
|
25
|
+
/** Directory the shims are written to inside a repo. */
|
|
26
|
+
export const HOOKS_DIR = '.simt-hooks';
|
|
27
|
+
|
|
28
|
+
/** Name of the git config key that points at the shim directory. */
|
|
29
|
+
export const HOOKS_PATH_KEY = 'core.hooksPath';
|
|
30
|
+
|
|
31
|
+
export function fileFor(event) {
|
|
32
|
+
return `${HOOKS_DIR}/${event}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {string} event one of EVENTS
|
|
37
|
+
* @returns {string} POSIX sh source, LF-terminated, executable
|
|
38
|
+
*/
|
|
39
|
+
export function renderShim(event) {
|
|
40
|
+
if (!EVENTS.includes(event)) {
|
|
41
|
+
throw new Error(`unknown hook event ${event}; known: ${EVENTS.join(', ')}`);
|
|
42
|
+
}
|
|
43
|
+
const D = '$';
|
|
44
|
+
return `#!/bin/sh
|
|
45
|
+
# Generated by simt-dev. Do not edit.
|
|
46
|
+
#
|
|
47
|
+
# Every decision this hook makes is declared in ${MANIFEST_FILE}, so a reviewer reads
|
|
48
|
+
# the gate list in a diff instead of reverse-engineering shell. Reinstall with:
|
|
49
|
+
# ${CLI} hooks install --yes
|
|
50
|
+
#
|
|
51
|
+
# This file only locates the dispatcher and hands over. It resolves in order:
|
|
52
|
+
# 1. ${D}SIMT_DEV_BIN explicit override, for CI images and odd checkouts
|
|
53
|
+
# 2. node_modules/ the pinned project dependency
|
|
54
|
+
# 3. simt-dev on PATH a global install
|
|
55
|
+
# If none resolves it REFUSES rather than exiting 0. A hook that cannot verify
|
|
56
|
+
# anything must never report success - that is what teaches authors to reach for
|
|
57
|
+
# --no-verify.
|
|
58
|
+
|
|
59
|
+
set -eu
|
|
60
|
+
|
|
61
|
+
ROOT="${D}(git rev-parse --show-toplevel 2>/dev/null || pwd)"
|
|
62
|
+
|
|
63
|
+
${renderLocator(`hooks run ${event} --root "${D}ROOT"`, {
|
|
64
|
+
subject: 'the dispatcher',
|
|
65
|
+
remedy: `REMEDY set ${BIN_ENV} to the ${CLI} binary, or unset ${HOOKS_PATH_KEY} to opt out`,
|
|
66
|
+
})}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* A line only a chained shim carries.
|
|
71
|
+
*
|
|
72
|
+
* The same reason `renderShim` bakes in its event rather than reading `$0`: a
|
|
73
|
+
* shim's name cannot identify it, because the whole point of a chained shim is that
|
|
74
|
+
* it is called `post-commit` because the repository called its hook that. Identity
|
|
75
|
+
* has to come from the bytes, so this marker is what `hooks status` and `compare`
|
|
76
|
+
* read to tell our shim from somebody else's.
|
|
77
|
+
*/
|
|
78
|
+
export const CHAIN_MARKER = '# simt-dev chain-shim: the repository hook below still runs';
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* A shim for an event this tool does not gate, so the repository's own hook keeps
|
|
82
|
+
* running after routing.
|
|
83
|
+
*
|
|
84
|
+
* `core.hooksPath` does not select a directory for the events this tool cares about
|
|
85
|
+
* and leave the rest alone: git reads EVERY hook from the named directory and from
|
|
86
|
+
* nowhere else. So the moment routing lands, a repository whose `post-commit` does
|
|
87
|
+
* real work loses it - not loudly, because nothing failed, only silently, because
|
|
88
|
+
* there is now no `post-commit` where git looks for one.
|
|
89
|
+
*
|
|
90
|
+
* There is nothing to enforce on such an event, so this shim does not run the
|
|
91
|
+
* dispatcher at all. It runs the hook it found and forwards git's arguments, which
|
|
92
|
+
* the dispatcher shim deliberately does not do: the program on the other end here is
|
|
93
|
+
* the repository's own hook and expects them.
|
|
94
|
+
*
|
|
95
|
+
* `target` is relative to the repository's top level wherever it can be. A path
|
|
96
|
+
* baked in absolute would break the first time anyone moves or clones the
|
|
97
|
+
* repository somewhere else, which for a hook is the difference between a gate that
|
|
98
|
+
* runs and one that stops existing.
|
|
99
|
+
*
|
|
100
|
+
* @param {string} event the hook event, e.g. `post-commit`. Not required to be in
|
|
101
|
+
* {@link EVENTS}: that list is what this tool gates, and chaining exists precisely
|
|
102
|
+
* for the events it does not.
|
|
103
|
+
* @param {string} target the repository's hook, relative to its top level or absolute
|
|
104
|
+
* @returns {string} POSIX sh source, LF-terminated, executable
|
|
105
|
+
*/
|
|
106
|
+
export function renderChainedShim(event, target) {
|
|
107
|
+
const D = '$';
|
|
108
|
+
return `#!/bin/sh
|
|
109
|
+
# Generated by simt-dev. Do not edit.
|
|
110
|
+
${CHAIN_MARKER}
|
|
111
|
+
#
|
|
112
|
+
# simt-dev does not gate ${event}, so nothing in this file enforces anything. It is
|
|
113
|
+
# here because git reads every hook from ${HOOKS_DIR} once core.hooksPath points
|
|
114
|
+
# there, and without this file the repository's own ${event} would stop running.
|
|
115
|
+
#
|
|
116
|
+
# It runs, in order:
|
|
117
|
+
# 1. ${target}
|
|
118
|
+
# forwarding git's arguments, which that hook expects. Reinstall with:
|
|
119
|
+
# ${CLI} hooks install --yes
|
|
120
|
+
|
|
121
|
+
set -eu
|
|
122
|
+
|
|
123
|
+
ROOT="${D}(git rev-parse --show-toplevel 2>/dev/null || pwd)"
|
|
124
|
+
|
|
125
|
+
TARGET='${target}'
|
|
126
|
+
case "${D}TARGET" in
|
|
127
|
+
/*) ;;
|
|
128
|
+
*) TARGET="${D}ROOT/${D}TARGET" ;;
|
|
129
|
+
esac
|
|
130
|
+
|
|
131
|
+
if [ ! -x "${D}TARGET" ]; then
|
|
132
|
+
echo "simt: ${event} is chained to a hook that is not there." >&2
|
|
133
|
+
echo " expected: ${D}TARGET" >&2
|
|
134
|
+
echo "REMEDY restore that file, or delete ${fileFor(event)} to stop chaining it." >&2
|
|
135
|
+
exit 1
|
|
136
|
+
fi
|
|
137
|
+
|
|
138
|
+
exec "${D}TARGET" "${D}@"
|
|
139
|
+
`;
|
|
140
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Work out which directory git will actually run hooks from.
|
|
2
|
+
//
|
|
3
|
+
// This exists because "core.hooksPath is unset" does NOT mean "git runs no
|
|
4
|
+
// hooks". Unset means git falls back to the default location, which in a normal
|
|
5
|
+
// repository is `.git/hooks` and in a linked worktree is the *main* repository's
|
|
6
|
+
// `.git/hooks`. A tool that reads unset as "none" reports a live hand-written
|
|
7
|
+
// hook as absent, and then installs over it without asking.
|
|
8
|
+
//
|
|
9
|
+
// `git rev-parse --git-path hooks` looks like the answer and is not one:
|
|
10
|
+
// normal repo, unset -> ".git/hooks" (relative, correct here)
|
|
11
|
+
// worktree, configured -> "hooks" (relative to the worktree top)
|
|
12
|
+
// worktree, unset -> "./" <- nonsense; the worktree's .git
|
|
13
|
+
// is a FILE, so this names the
|
|
14
|
+
// whole repository
|
|
15
|
+
// An implementation that trusted it would enumerate a worktree's entire root as
|
|
16
|
+
// its hooks directory and report every file in the repo as a hook. So the rule is
|
|
17
|
+
// resolved here, from the two facts git does report reliably.
|
|
18
|
+
//
|
|
19
|
+
// ADR-012 dropped Windows, but nothing here is POSIX-specific; the worktree
|
|
20
|
+
// handling is the only subtlety and it is the same on every platform.
|
|
21
|
+
|
|
22
|
+
import { execFile } from 'node:child_process';
|
|
23
|
+
import { readdir, stat } from 'node:fs/promises';
|
|
24
|
+
import { isAbsolute, join, resolve } from 'node:path';
|
|
25
|
+
import { promisify } from 'node:util';
|
|
26
|
+
|
|
27
|
+
const exec = promisify(execFile);
|
|
28
|
+
|
|
29
|
+
async function git(root, args) {
|
|
30
|
+
const { stdout } = await exec('git', args, { cwd: root });
|
|
31
|
+
return stdout.trim();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The directory git runs hooks from, and which of the two rules produced it.
|
|
36
|
+
*
|
|
37
|
+
* @param {string} root
|
|
38
|
+
* @returns {Promise<{dir:string, how:'configured'|'default'|'not-a-repo', top:string|null,
|
|
39
|
+
* commonDir:string|null, reason?:string}>}
|
|
40
|
+
*/
|
|
41
|
+
export async function resolveHooksDir(root) {
|
|
42
|
+
let top;
|
|
43
|
+
try {
|
|
44
|
+
top = await git(root, ['rev-parse', '--show-toplevel']);
|
|
45
|
+
} catch {
|
|
46
|
+
return { dir: join(root, '.git', 'hooks'), how: 'not-a-repo', top: null, commonDir: null, reason: 'not a git repository' };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let configured = null;
|
|
50
|
+
try {
|
|
51
|
+
const value = await git(root, ['config', '--get', 'core.hooksPath']);
|
|
52
|
+
configured = value === '' ? null : value;
|
|
53
|
+
} catch {
|
|
54
|
+
configured = null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Rule 1: core.hooksPath wins. A relative value resolves against the top level
|
|
58
|
+
// of the working tree, which is what makes a `hooks` directory work.
|
|
59
|
+
if (configured !== null) {
|
|
60
|
+
return {
|
|
61
|
+
dir: isAbsolute(configured) ? configured : resolve(top, configured),
|
|
62
|
+
how: 'configured',
|
|
63
|
+
top,
|
|
64
|
+
commonDir: null,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Rule 2: the default, which is inside the COMMON git dir. A linked worktree
|
|
69
|
+
// has its own git dir but shares hooks with its parent, so this is the one
|
|
70
|
+
// step where `root/.git` is the wrong answer.
|
|
71
|
+
let common = '.git';
|
|
72
|
+
try {
|
|
73
|
+
common = await git(root, ['rev-parse', '--git-common-dir']);
|
|
74
|
+
} catch {
|
|
75
|
+
// A repository whose common dir cannot be read still has a working tree;
|
|
76
|
+
// the top-level .git guess is the only thing left and may be right.
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
dir: isAbsolute(common) ? join(common, 'hooks') : resolve(top, common, 'hooks'),
|
|
80
|
+
how: 'default',
|
|
81
|
+
top,
|
|
82
|
+
commonDir: common,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Executable hooks git will actually run, excluding git's own `*.sample`
|
|
88
|
+
* templates and anything simt-dev generated.
|
|
89
|
+
*
|
|
90
|
+
* @param {string} root
|
|
91
|
+
* @returns {Promise<{event:string, path:string, bytes:number}[]>}
|
|
92
|
+
*/
|
|
93
|
+
export async function foreignHooks(root) {
|
|
94
|
+
const { dir, how } = await resolveHooksDir(root);
|
|
95
|
+
if (how === 'not-a-repo') return [];
|
|
96
|
+
|
|
97
|
+
let entries;
|
|
98
|
+
try {
|
|
99
|
+
entries = await readdir(dir);
|
|
100
|
+
} catch {
|
|
101
|
+
return [];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const found = [];
|
|
105
|
+
for (const name of entries.sort()) {
|
|
106
|
+
if (name.endsWith('.sample')) continue;
|
|
107
|
+
const path = join(dir, name);
|
|
108
|
+
let info;
|
|
109
|
+
try {
|
|
110
|
+
info = await stat(path);
|
|
111
|
+
} catch {
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (!info.isFile() || (info.mode & 0o111) === 0) continue;
|
|
115
|
+
found.push({ event: name, path, bytes: info.size });
|
|
116
|
+
}
|
|
117
|
+
return found;
|
|
118
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// The per-developer git identity, as a file the repository does not track.
|
|
2
|
+
//
|
|
3
|
+
// A repository has more than one person in it, and each of them may already have an
|
|
4
|
+
// identity this tool has no business choosing. So the rules here are conservative in
|
|
5
|
+
// that order:
|
|
6
|
+
//
|
|
7
|
+
// 1. If `git.config` already exists, it is left exactly as it is. Not updated, not
|
|
8
|
+
// merged, not reformatted. It may have been written by a person, by an older
|
|
9
|
+
// version of this tool, or by hand years ago, and none of those are this tool's
|
|
10
|
+
// to second-guess.
|
|
11
|
+
// 2. Otherwise the identity comes from the repository's own last commit author.
|
|
12
|
+
// That is the person who has been working here, which is the best available
|
|
13
|
+
// answer to "who commits here" and requires no guessing: it is a fact, not a
|
|
14
|
+
// default.
|
|
15
|
+
// 3. Otherwise from the git identity configured for this machine or this user,
|
|
16
|
+
// which is what would have been used anyway had this file never existed.
|
|
17
|
+
// 4. Otherwise nothing is written. An identity nobody configured is not one to
|
|
18
|
+
// invent, and a file with a placeholder in it is worse than no file: git would
|
|
19
|
+
// use the placeholder.
|
|
20
|
+
//
|
|
21
|
+
// The file is deliberately NOT written into the repository's tracked files. It holds
|
|
22
|
+
// whoever is committing, and committing it publishes one developer's name and email
|
|
23
|
+
// to everybody else in the repository. `init` adds it to .gitignore when it creates
|
|
24
|
+
// one; a repository that already tracks it is left that way, because removing a
|
|
25
|
+
// tracked file is not something an adopting command should do silently.
|
|
26
|
+
|
|
27
|
+
import { execFile } from 'node:child_process';
|
|
28
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
29
|
+
import { join } from 'node:path';
|
|
30
|
+
import { promisify } from 'node:util';
|
|
31
|
+
|
|
32
|
+
const exec = promisify(execFile);
|
|
33
|
+
|
|
34
|
+
/** The file, at the repository root. Named for what it is: a git config fragment. */
|
|
35
|
+
export const IDENTITY_FILE = 'git.config';
|
|
36
|
+
|
|
37
|
+
/** The git config key that pulls the file in. */
|
|
38
|
+
export const INCLUDE_KEY = 'include.path';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Run a git command and return trimmed stdout, or null if it fails.
|
|
42
|
+
*
|
|
43
|
+
* Null rather than an exception for every case: a repository that is not a git
|
|
44
|
+
* repository, a version of git that does not know a flag, a machine with no
|
|
45
|
+
* configured identity. None of those is an error worth refusing an adoption over.
|
|
46
|
+
*
|
|
47
|
+
* @param {string} cwd
|
|
48
|
+
* @param {string[]} args
|
|
49
|
+
* @returns {Promise<string|null>}
|
|
50
|
+
*/
|
|
51
|
+
async function git(cwd, args) {
|
|
52
|
+
try {
|
|
53
|
+
const { stdout } = await exec('git', args, { cwd });
|
|
54
|
+
return stdout.trim();
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Who last committed here.
|
|
62
|
+
*
|
|
63
|
+
* `%an` and `%ae` rather than the configured identity on purpose: this is what
|
|
64
|
+
* actually happened, which is the answer to the question being asked. Reading
|
|
65
|
+
* `git config user.email` here would return the value this file is about to be
|
|
66
|
+
* derived from, and the step would be circular.
|
|
67
|
+
*
|
|
68
|
+
* @param {string} root
|
|
69
|
+
* @returns {Promise<{name: string|null, email: string|null}>}
|
|
70
|
+
*/
|
|
71
|
+
export async function lastCommitAuthor(root) {
|
|
72
|
+
const line = await git(root, ['log', '-1', '--format=%an <%ae>']);
|
|
73
|
+
if (!line) return { name: null, email: null };
|
|
74
|
+
// The name may itself contain angle brackets, so the address is split off the
|
|
75
|
+
// LAST one rather than the first.
|
|
76
|
+
const at = line.lastIndexOf('<');
|
|
77
|
+
const close = line.lastIndexOf('>');
|
|
78
|
+
if (at === -1 || close < at) return { name: line || null, email: null };
|
|
79
|
+
return {
|
|
80
|
+
name: line.slice(0, at).trim() || null,
|
|
81
|
+
email: line.slice(at + 1, close).trim() || null,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The identity this machine would use anyway.
|
|
87
|
+
*
|
|
88
|
+
* `--get` rather than `--global`: it resolves through system, global and local in
|
|
89
|
+
* git's own order, so what it returns is what a commit would have used had this file
|
|
90
|
+
* never been created. That is the fallback's whole point - it must not be a second
|
|
91
|
+
* opinion about what identity means.
|
|
92
|
+
*
|
|
93
|
+
* @param {string} root
|
|
94
|
+
* @returns {Promise<{name: string|null, email: string|null}>}
|
|
95
|
+
*/
|
|
96
|
+
export async function configuredIdentity(root) {
|
|
97
|
+
const read = async (key) => {
|
|
98
|
+
const value = await git(root, ['config', '--get', key]);
|
|
99
|
+
return value && value !== '' ? value : null;
|
|
100
|
+
};
|
|
101
|
+
return { name: await read('user.name'), email: await read('user.email') };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The identity to write, and where it came from.
|
|
106
|
+
*
|
|
107
|
+
* @param {string} root
|
|
108
|
+
* @returns {Promise<{name: string|null, email: string|null, from: 'last-commit'|'configured'|null,
|
|
109
|
+
* exists: boolean, bytes: number}>}
|
|
110
|
+
*/
|
|
111
|
+
export async function resolveIdentity(root) {
|
|
112
|
+
const path = join(root, IDENTITY_FILE);
|
|
113
|
+
try {
|
|
114
|
+
const info = await stat(path);
|
|
115
|
+
if (info.isFile()) {
|
|
116
|
+
return { name: null, email: null, from: null, exists: true, bytes: info.size };
|
|
117
|
+
}
|
|
118
|
+
} catch {
|
|
119
|
+
// Absent is the case this whole module exists for.
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const committed = await lastCommitAuthor(root);
|
|
123
|
+
if (committed.name && committed.email) {
|
|
124
|
+
return { ...committed, from: 'last-commit', exists: false, bytes: 0 };
|
|
125
|
+
}
|
|
126
|
+
const configured = await configuredIdentity(root);
|
|
127
|
+
if (configured.name || configured.email) {
|
|
128
|
+
return { ...configured, from: 'configured', exists: false, bytes: 0 };
|
|
129
|
+
}
|
|
130
|
+
return { name: null, email: null, from: null, exists: false, bytes: 0 };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The file's bytes.
|
|
135
|
+
*
|
|
136
|
+
* Only the keys that were found are written. A partial identity is written as a
|
|
137
|
+
* partial identity rather than padded with a placeholder, because a placeholder is
|
|
138
|
+
* an identity git would use.
|
|
139
|
+
*
|
|
140
|
+
* @param {{name: string|null, email: string|null}} identity
|
|
141
|
+
* @returns {string} LF-terminated
|
|
142
|
+
*/
|
|
143
|
+
export function renderIdentityFile({ name, email }) {
|
|
144
|
+
const lines = ['[user]'];
|
|
145
|
+
if (name) lines.push(`\tname = ${name}`);
|
|
146
|
+
if (email) lines.push(`\temail = ${email}`);
|
|
147
|
+
return `${lines.join('\n')}\n`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Whether `include.path` already names this repository's own file.
|
|
152
|
+
*
|
|
153
|
+
* @param {string} root
|
|
154
|
+
* @returns {Promise<boolean>}
|
|
155
|
+
*/
|
|
156
|
+
export async function includesOwnIdentity(root) {
|
|
157
|
+
const values = (await git(root, ['config', '--local', '--get-all', INCLUDE_KEY])) ?? '';
|
|
158
|
+
return values.split('\n').map((v) => v.trim()).filter(Boolean).includes(`../${IDENTITY_FILE}`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Point `core.hooksPath`-style local config at this repository's own identity file.
|
|
163
|
+
*
|
|
164
|
+
* `--replace-all` rather than `--add`, and that is the whole point: a value naming
|
|
165
|
+
* ANOTHER repository's identity file is not an addition to keep, it is a link to
|
|
166
|
+
* break. Two of the repositories here had exactly that, and both worked only because
|
|
167
|
+
* the sibling checkout happened to exist at the expected relative path.
|
|
168
|
+
*
|
|
169
|
+
* @param {string} root
|
|
170
|
+
* @returns {Promise<void>}
|
|
171
|
+
*/
|
|
172
|
+
export async function includeOwnIdentity(root) {
|
|
173
|
+
await git(root, ['config', '--local', '--replace-all', INCLUDE_KEY, `../${IDENTITY_FILE}`]);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Whether the repository tracks the identity file, which it should not.
|
|
178
|
+
*
|
|
179
|
+
* @param {string} root
|
|
180
|
+
* @returns {Promise<boolean>}
|
|
181
|
+
*/
|
|
182
|
+
export async function tracksIdentity(root) {
|
|
183
|
+
const listed = await git(root, ['ls-files', '--error-unmatch', IDENTITY_FILE]);
|
|
184
|
+
return listed === IDENTITY_FILE;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Add the identity file to .gitignore, if it is not already there.
|
|
189
|
+
*
|
|
190
|
+
* Matching on the exact line rather than a substring, because a `.gitignore`
|
|
191
|
+
* entry of `/git.config` or `git.config` both mean it while a comment mentioning
|
|
192
|
+
* it means nothing, and appending a duplicate of an entry that is already present
|
|
193
|
+
* is how a file ends up listed twice.
|
|
194
|
+
*
|
|
195
|
+
* @param {string} root
|
|
196
|
+
* @returns {Promise<boolean>} whether the file was changed
|
|
197
|
+
*/
|
|
198
|
+
export async function ignoreIdentity(root) {
|
|
199
|
+
const path = join(root, '.gitignore');
|
|
200
|
+
let text;
|
|
201
|
+
try {
|
|
202
|
+
text = await readFile(path, 'utf8');
|
|
203
|
+
} catch {
|
|
204
|
+
text = '';
|
|
205
|
+
}
|
|
206
|
+
const already = text.split('\n').map((l) => l.trim()).includes(IDENTITY_FILE);
|
|
207
|
+
if (already) return false;
|
|
208
|
+
const prefix = text === '' || text.endsWith('\n') ? '' : '\n';
|
|
209
|
+
const block =
|
|
210
|
+
`${prefix}\n` +
|
|
211
|
+
'# Per-developer git identity, written by `simt init`. Not a file to share:\n' +
|
|
212
|
+
'# it carries whoever is committing into the tree, and a repository has more than\n' +
|
|
213
|
+
'# one of those.\n' +
|
|
214
|
+
`${IDENTITY_FILE}\n`;
|
|
215
|
+
const { writeFile } = await import('node:fs/promises');
|
|
216
|
+
await writeFile(path, text + block);
|
|
217
|
+
return true;
|
|
218
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Find the simt-dev binary, in POSIX sh, as one block of text.
|
|
2
|
+
//
|
|
3
|
+
// Two generated files run simt-dev on someone else's machine and must agree about
|
|
4
|
+
// how it is found: the hook shim (src/hooks/shim.mjs) and the CI workflow
|
|
5
|
+
// (src/ci/workflow.mjs). They were written separately and the second copy would
|
|
6
|
+
// have been a real divergence risk - a change to the resolution order that reaches
|
|
7
|
+
// the hook but not the workflow means the gate passes locally and is unfindable in
|
|
8
|
+
// CI, which is the failure this whole tool is about.
|
|
9
|
+
//
|
|
10
|
+
// The caller's shell is expected to have `set -eu` and a `ROOT` already defined:
|
|
11
|
+
// the shim derives ROOT from git, the workflow takes it from the runner. That is why
|
|
12
|
+
// this is a block and not a script.
|
|
13
|
+
|
|
14
|
+
import { BIN_ENV, BIN_REL, CLI, PACKAGE_NAME } from './name.mjs';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {string} command arguments to hand the tool, with `$ROOT` spelled as a shell
|
|
18
|
+
* expansion - `hooks run pre-commit --root "$ROOT"`, `check --root "$ROOT"`
|
|
19
|
+
* @param {object} [opts]
|
|
20
|
+
* @param {string} [opts.subject] what it could not find, as the caller names it:
|
|
21
|
+
* the shim says "the dispatcher", the workflow says the command
|
|
22
|
+
* @param {string} [opts.remedy] the last line, which is the only part that differs
|
|
23
|
+
* between a hook (which can be opted out of) and a CI job (which cannot)
|
|
24
|
+
* @returns {string} POSIX sh source
|
|
25
|
+
*/
|
|
26
|
+
export function renderLocator(command, { subject = CLI, remedy = `REMEDY run: npm i -D ${PACKAGE_NAME}` } = {}) {
|
|
27
|
+
const D = '$';
|
|
28
|
+
return `# ${D}${BIN_ENV} must name ONE program: an executable on PATH, or a .mjs entry
|
|
29
|
+
# point to hand to node. It cannot be a command line, because there is no safe way
|
|
30
|
+
# to split one in sh without eval, and a value with a space is refused rather than
|
|
31
|
+
# half-executed - running from a source checkout is normal for anyone working on
|
|
32
|
+
# this project itself, so this path has to work with no install step.
|
|
33
|
+
run_simt_dev() {
|
|
34
|
+
case "${D}1" in
|
|
35
|
+
*' '*)
|
|
36
|
+
echo "${CLI}: ${D}${BIN_ENV} names a command line, not a program." >&2
|
|
37
|
+
echo " set it to a single executable or a .mjs path, e.g." >&2
|
|
38
|
+
echo " ${BIN_ENV}=/path/to/checkout/${BIN_REL}" >&2
|
|
39
|
+
exit 1
|
|
40
|
+
;;
|
|
41
|
+
*.mjs)
|
|
42
|
+
# shift first: "${D}@" still holds ${D}1, which would pass the program path to
|
|
43
|
+
# itself as the first argument.
|
|
44
|
+
program="${D}1"
|
|
45
|
+
shift
|
|
46
|
+
exec node "${D}program" "${D}@"
|
|
47
|
+
;;
|
|
48
|
+
*)
|
|
49
|
+
exec "${D}1" "${D}@"
|
|
50
|
+
;;
|
|
51
|
+
esac
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if [ -n "${D}{${BIN_ENV}:-}" ]; then
|
|
55
|
+
run_simt_dev "${D}${BIN_ENV}" ${command}
|
|
56
|
+
fi
|
|
57
|
+
|
|
58
|
+
if [ -f "${D}ROOT/node_modules/${PACKAGE_NAME}/${BIN_REL}" ]; then
|
|
59
|
+
exec node "${D}ROOT/node_modules/${PACKAGE_NAME}/${BIN_REL}" ${command}
|
|
60
|
+
fi
|
|
61
|
+
|
|
62
|
+
if command -v ${CLI} >/dev/null 2>&1; then
|
|
63
|
+
exec ${CLI} ${command}
|
|
64
|
+
fi
|
|
65
|
+
|
|
66
|
+
echo "${CLI}: cannot locate ${subject}." >&2
|
|
67
|
+
# Every expansion in these diagnostics must carry a default. Under \`set -eu\` an
|
|
68
|
+
# unbound variable aborts the shell, so an unguarded ${D}${BIN_ENV} here would
|
|
69
|
+
# kill the message that exists to explain the failure - and this block only runs
|
|
70
|
+
# when nothing resolved, which is the one moment the reader needs it.
|
|
71
|
+
echo " looked for: ${D}{${BIN_ENV}:-<unset>}, ${D}ROOT/node_modules/${PACKAGE_NAME}, ${CLI} on PATH" >&2
|
|
72
|
+
echo "${remedy}" >&2
|
|
73
|
+
exit 1
|
|
74
|
+
`;
|
|
75
|
+
}
|