@cldmv/git-embedded 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +145 -0
  3. package/bin/git-embedded.mjs +185 -0
  4. package/docs/design.md +122 -0
  5. package/docs/use-case-private-tests.md +97 -0
  6. package/hooks/_dispatch.template +67 -0
  7. package/hooks/reference-transaction +54 -0
  8. package/hooks/update-embedded-repos +56 -0
  9. package/messages/setup-bare-githooks.md +25 -0
  10. package/messages/setup-dispatcher-canonical-complete.md +30 -0
  11. package/messages/setup-dispatcher-missing-symlinks.md +56 -0
  12. package/messages/setup-dispatcher-non-conforming.md +35 -0
  13. package/messages/setup-husky.md +35 -0
  14. package/messages/setup-init-templatedir.md +21 -0
  15. package/messages/setup-lefthook.md +48 -0
  16. package/messages/setup-none.md +27 -0
  17. package/messages/setup-pre-commit.md +47 -0
  18. package/messages/setup-simple-git-hooks.md +40 -0
  19. package/messages/setup-system-hookspath.md +25 -0
  20. package/package.json +96 -0
  21. package/src/api/cli/doctor.mjs +15 -0
  22. package/src/api/cli/init.mjs +26 -0
  23. package/src/api/cli/install-hooks.mjs +124 -0
  24. package/src/api/cli/install-template.mjs +43 -0
  25. package/src/api/cli/link.mjs +42 -0
  26. package/src/api/cli/print-hook-script.mjs +35 -0
  27. package/src/api/cli/uninstall-hooks.mjs +21 -0
  28. package/src/api/cli/version.mjs +17 -0
  29. package/src/api/commander/custom-help.mjs +249 -0
  30. package/src/api/detect/dispatcher.mjs +218 -0
  31. package/src/api/detect/husky.mjs +23 -0
  32. package/src/api/detect/lefthook.mjs +37 -0
  33. package/src/api/detect/pre-commit.mjs +35 -0
  34. package/src/api/detect/run.mjs +71 -0
  35. package/src/api/detect/simple-git-hooks.mjs +26 -0
  36. package/src/api/git.mjs +59 -0
  37. package/src/api/install/dispatcher.mjs +51 -0
  38. package/src/api/install/hooks.mjs +80 -0
  39. package/src/api/install/template.mjs +15 -0
  40. package/src/api/link/batch.mjs +130 -0
  41. package/src/api/link/copy-executable.mjs +27 -0
  42. package/src/api/link/elevate-windows.mjs +52 -0
  43. package/src/api/log.mjs +38 -0
  44. package/src/api/messages/load.mjs +27 -0
  45. package/src/api/paths.mjs +43 -0
  46. package/src/api/prompt.mjs +26 -0
  47. package/src/api/report.mjs +83 -0
  48. package/src/lib/elevate-windows-child.mjs +43 -0
@@ -0,0 +1,249 @@
1
+ /**
2
+ * Build a `Help` subclass that renders colorized, multi-line help with the
3
+ * look the rest of the CLDMV CLIs use. The bin entry passes commander's
4
+ * statically imported `Help` class in once.
5
+ *
6
+ * `deps.chalk` is also passed by the bin: commander invokes the help methods
7
+ * outside any slothlet-tracked call, so the slothlet `context` proxy isn't
8
+ * live in those frames. Closing over the chalk dep keeps the help renderer
9
+ * standalone — it doesn't need the runtime to be active.
10
+ *
11
+ * @param {typeof import("commander").Help} HelpClass
12
+ * @param {{ chalk: import("chalk").ChalkInstance }} deps
13
+ * @returns {{ CustomHelp: typeof import("commander").Help, applyCustomHelpRecursive: Function }}
14
+ */
15
+ export function makeCustomHelp(HelpClass, deps) {
16
+ const chalk = deps && deps.chalk;
17
+ if (!chalk) throw new Error("makeCustomHelp: deps.chalk is required");
18
+ class CustomHelp extends HelpClass {
19
+ static ensureHelpOption(cmd) {
20
+ if (typeof cmd.helpOption === "function" && !cmd.options.some((opt) => opt.long === "--help")) {
21
+ cmd.helpOption("-h, --help", "Show help for this command");
22
+ }
23
+ if (cmd.commands && cmd.commands.length) {
24
+ for (const sub of cmd.commands) CustomHelp.ensureHelpOption(sub);
25
+ }
26
+ }
27
+
28
+ constructor(opts = {}) {
29
+ super();
30
+ this.colorMode = typeof opts.colorMode === "string" ? opts.colorMode : "auto";
31
+ this.initColorMode();
32
+ }
33
+
34
+ formatHelp(cmd, helper) {
35
+ const color = (fn, str) => (this.colorMode === "never" ? str : fn(str));
36
+
37
+ const output = [];
38
+ const fullChain = getFullCommandChain(cmd);
39
+ const usageArgList = helper.visibleArguments(cmd);
40
+ const usageArgStr = usageArgList
41
+ .map((a) => {
42
+ const argStr = a.required ? `<${a.name()}>` : `[${a.name()}]`;
43
+ const colorFn = a.required ? chalk.magenta : chalk.yellow;
44
+ return color(colorFn, argStr);
45
+ })
46
+ .join(" ");
47
+ const usageOptionList = helper.visibleOptions(cmd).filter((o) => o.long !== "--help");
48
+ let usageLine = fullChain;
49
+ if (usageArgStr) usageLine += ` ${usageArgStr}`;
50
+ if (usageOptionList.length) usageLine += " [options]";
51
+ output.push(color(chalk.bold, "Usage:") + ` ${usageLine}`);
52
+ output.push("");
53
+
54
+ if (cmd.description()) {
55
+ output.push(color(chalk.bold, "Description:"));
56
+ output.push(cmd.description());
57
+ output.push("");
58
+ }
59
+
60
+ if (cmd._aliases && Array.isArray(cmd._aliases) && cmd._aliases.length > 0 && cmd.name() !== "help") {
61
+ output.push(color(chalk.bold, "Aliases:"));
62
+ for (const alias of cmd._aliases) output.push(` ${color(chalk.yellow, alias)}`);
63
+ output.push("");
64
+ }
65
+
66
+ const commandList = helper.visibleCommands(cmd);
67
+ if (commandList.length) {
68
+ const isTopLevel = !cmd.parent;
69
+ output.push(color(chalk.bold, isTopLevel ? "Commands:" : "Sub Commands:"));
70
+ printCommands(commandList, 1, output, color, helper);
71
+ }
72
+
73
+ const optionList = helper.visibleOptions(cmd);
74
+ if (optionList.length) {
75
+ output.push(color(chalk.bold, "Options:"));
76
+ const width = optionList.reduce((max, o) => Math.max(max, helper.optionTerm(o).length), 0);
77
+ for (const o of optionList) {
78
+ const term = helper.optionTerm(o).padEnd(width);
79
+ output.push(` ${color(chalk.green, term)} ${o.description}`);
80
+ }
81
+ output.push("");
82
+ }
83
+
84
+ const argList = helper.visibleArguments(cmd);
85
+ if (argList.length) {
86
+ output.push(color(chalk.bold, "Arguments:"));
87
+ for (const a of argList) {
88
+ const argStr = a.required ? `<${a.name()}>` : `[${a.name()}]`;
89
+ const argColor = a.required ? chalk.magenta : chalk.yellow;
90
+ const desc = a.description ? ` ${a.description}` : "";
91
+ output.push(` ${color(argColor, argStr)}${desc}`);
92
+ }
93
+ output.push("");
94
+ }
95
+
96
+ const examples = collectExamples(cmd);
97
+ if (examples.length) {
98
+ output.push(color(chalk.bold, "Examples:"));
99
+ const argPattern = /(<([\w|-]+)>)|(\[([\w|-]+)\])/g;
100
+ for (const ex of examples) {
101
+ const colored = ex.replace(argPattern, (match, p1, p2, p3, p4) => {
102
+ if (p2) return color(chalk.magenta, match);
103
+ if (p4) return color(chalk.yellow, match);
104
+ return match;
105
+ });
106
+ output.push(` ${colored}`);
107
+ }
108
+ output.push("");
109
+ }
110
+
111
+ while (output.length > 0 && output[output.length - 1].trim() === "") output.pop();
112
+ output.push(color(chalk.gray, "\nFor more information, use a command with help, --help, or -h."));
113
+ output.push("");
114
+ return output.join("\n");
115
+ }
116
+
117
+ setColorMode(mode) {
118
+ this.colorMode = mode;
119
+ if (mode === "always") chalk.level = 3;
120
+ else if (mode === "never") chalk.level = 0;
121
+ }
122
+
123
+ initColorMode() {
124
+ if (process.env.NO_COLOR) this.setColorMode("never");
125
+ else if (process.env.FORCE_COLOR) this.setColorMode("always");
126
+ }
127
+ }
128
+
129
+ function applyCustomHelpRecursive(cmd) {
130
+ cmd.createHelp = () => new CustomHelp();
131
+ if (typeof cmd.showHelpAfterError === "function") cmd.showHelpAfterError(true);
132
+ CustomHelp.ensureHelpOption(cmd);
133
+ if (cmd.commands && cmd.commands.length) {
134
+ for (const sub of cmd.commands) applyCustomHelpRecursive(sub);
135
+ }
136
+ }
137
+
138
+ function printCommands(commands, indent, output, color, helper) {
139
+ for (const c of commands) {
140
+ let term = c.name();
141
+ if (c.options && c.options.some((opt) => opt.long !== "--help")) term += " [options]";
142
+ if (c._args && c._args.length) {
143
+ term +=
144
+ " " +
145
+ c._args
146
+ .map((a) => {
147
+ let name = a.name();
148
+ if (a.variadic) name += "...";
149
+ const argStr = a.required ? `<${name}>` : `[${name}]`;
150
+ const colorFn = a.required ? chalk.magenta : chalk.yellow;
151
+ return color(colorFn, argStr);
152
+ })
153
+ .join(" ");
154
+ }
155
+ let aliases = [];
156
+ if (Array.isArray(c._aliases)) aliases = c._aliases.filter((a) => a !== c.name());
157
+ else if (typeof c._aliases === "string" && c._aliases !== c.name()) aliases = [c._aliases];
158
+ const desc = (helper.commandDescription ? helper.commandDescription(c) : c.description()) || "";
159
+
160
+ const pad = " ".repeat(indent);
161
+ const cmdColor = indent === 1 ? chalk.cyan : chalk.blueBright;
162
+ output.push(`${pad}${color(cmdColor, term)}`);
163
+ if (aliases.length) {
164
+ wrapTextWithHangingIndent(aliases.join(", "), indent + 1, "Aliases", (s) => color(chalk.yellow.italic, s)).forEach(
165
+ (l) => output.push(l)
166
+ );
167
+ }
168
+ if (desc) {
169
+ wrapTextWithHangingIndent(desc, indent + 1, "Description", (s) => color(chalk.gray.italic, s)).forEach((l) =>
170
+ output.push(l)
171
+ );
172
+ }
173
+ output.push("");
174
+
175
+ if (c.commands && c.commands.length) {
176
+ printCommands(helper.visibleCommands(c), indent + 1, output, color, helper);
177
+ }
178
+ }
179
+ }
180
+
181
+ return { CustomHelp, applyCustomHelpRecursive };
182
+ }
183
+
184
+ function getFullCommandChain(cmd) {
185
+ const names = [];
186
+ let current = cmd;
187
+ while (current) {
188
+ if (current.name && typeof current.name === "function") {
189
+ const n = current.name();
190
+ if (n) names.unshift(n);
191
+ }
192
+ current = current.parent;
193
+ }
194
+ return names.join(" ");
195
+ }
196
+
197
+ function wrapTextWithHangingIndent(text, indent, label, labelColor, width) {
198
+ const pad = " ".repeat(indent);
199
+ const prefix = "- ";
200
+ const labelStr = label ? label + ": " : "";
201
+ const hangingPad = pad + " ".repeat(prefix.length + labelStr.length);
202
+ const maxWidth = (width || process.stdout.columns || 80) - (pad.length + prefix.length + labelStr.length);
203
+ const words = text.split(/\s+/);
204
+ const lines = [];
205
+ let line = "";
206
+ for (const word of words) {
207
+ if ((line + word).length > maxWidth) {
208
+ lines.push(line.trim());
209
+ line = word + " ";
210
+ } else {
211
+ line += word + " ";
212
+ }
213
+ }
214
+ if (line.trim()) lines.push(line.trim());
215
+ return lines.map((l, i) => (i === 0 ? pad + prefix + labelColor(labelStr) + l : hangingPad + l));
216
+ }
217
+
218
+ function collectExamples(cmd) {
219
+ if (!cmd.parent) {
220
+ const all = [];
221
+ walk(cmd, all);
222
+ const dedup = Array.from(new Set(all));
223
+ for (let i = dedup.length - 1; i > 0; i--) {
224
+ const j = Math.floor(Math.random() * (i + 1));
225
+ [dedup[i], dedup[j]] = [dedup[j], dedup[i]];
226
+ }
227
+ return dedup.slice(0, 5);
228
+ }
229
+ const own = cmd._exampleList;
230
+ const out = Array.isArray(own) ? own.slice() : [];
231
+ if (cmd.commands && cmd.commands.length) {
232
+ for (const sub of cmd.commands) {
233
+ const subName = sub.name();
234
+ const parentChain = getFullCommandChain(cmd);
235
+ let ex = `$ ${parentChain} ${subName}`;
236
+ const subArgs = (sub._args || []).map((a) => (a.required ? `<${a.name()}>` : `[${a.name()}]`)).join(" ");
237
+ if (subArgs) ex += ` ${subArgs}`;
238
+ if (!out.some((e) => e.includes(subName))) out.push(ex);
239
+ }
240
+ }
241
+ return out;
242
+ }
243
+
244
+ function walk(cmd, into) {
245
+ if (Array.isArray(cmd._exampleList)) for (const e of cmd._exampleList) into.push(e);
246
+ if (cmd.commands) for (const sub of cmd.commands) walk(sub, into);
247
+ }
248
+
249
+ export default { makeCustomHelp };
@@ -0,0 +1,218 @@
1
+ import { context } from "@cldmv/slothlet/runtime";
2
+
3
+ export const REQUIRED_HOOKS = ["post-checkout", "post-merge", "post-rewrite", "reference-transaction"];
4
+
5
+ export const STANDARD_HOOK_NAMES = [
6
+ "applypatch-msg",
7
+ "commit-msg",
8
+ "post-applypatch",
9
+ "post-checkout",
10
+ "post-commit",
11
+ "post-merge",
12
+ "post-rewrite",
13
+ "pre-applypatch",
14
+ "pre-auto-gc",
15
+ "pre-commit",
16
+ "pre-merge-commit",
17
+ "pre-push",
18
+ "pre-rebase",
19
+ "prepare-commit-msg",
20
+ "reference-transaction"
21
+ ];
22
+
23
+ const DISPATCHER_MARKER = "# git-embedded-compatible dispatcher";
24
+
25
+ const CHAIN_PATTERNS = [
26
+ /exec\s+"?\$repo_hook"?/,
27
+ /exec\s+"?\$\{?repo_hook\}?"?/,
28
+ /exec\s+"?\$git_dir\/hooks\/\$hook"?/
29
+ ];
30
+
31
+ function readFull(p) {
32
+ try {
33
+ return context.fs.readFileSync(p, "utf8");
34
+ } catch {
35
+ return null;
36
+ }
37
+ }
38
+
39
+ function chainCheck(text) {
40
+ if (!text) return false;
41
+ return CHAIN_PATTERNS.some((re) => re.test(text));
42
+ }
43
+
44
+ /**
45
+ * Inspect a hooks directory and classify it. The return shape is one of:
46
+ * { kind: "dispatcher-canonical-complete", dispatcherPath, missing: [], present: [...] }
47
+ * { kind: "dispatcher-missing-symlinks", dispatcherPath, missing: [...], present: [...] }
48
+ * { kind: "dispatcher-non-conforming", dispatcherPath, reason }
49
+ * { kind: "bare-githooks", hookFiles: [...] }
50
+ * { kind: "empty" }
51
+ *
52
+ * @param {string} dir
53
+ */
54
+ export default function dispatcher(dir) {
55
+ const { fs, path } = context;
56
+ if (!dir) return { kind: "empty" };
57
+
58
+ let entries;
59
+ try {
60
+ entries = fs.readdirSync(dir, { withFileTypes: true });
61
+ } catch {
62
+ return { kind: "empty", reason: "directory not readable" };
63
+ }
64
+
65
+ const fileEntries = entries.filter((e) => !e.name.startsWith("."));
66
+ if (fileEntries.length === 0) return { kind: "empty" };
67
+
68
+ let dispatcherPath = null;
69
+ const dispatchCandidate = path.join(dir, "_dispatch");
70
+ if (fs.existsSync(dispatchCandidate)) dispatcherPath = dispatchCandidate;
71
+
72
+ const byInode = new Map();
73
+ const symlinkTargets = new Map();
74
+ const realFiles = new Map();
75
+
76
+ for (const entry of fileEntries) {
77
+ const full = path.join(dir, entry.name);
78
+ let lst;
79
+ try {
80
+ lst = fs.lstatSync(full);
81
+ } catch {
82
+ continue;
83
+ }
84
+ if (lst.isSymbolicLink()) {
85
+ try {
86
+ const target = fs.readlinkSync(full);
87
+ const resolved = path.isAbsolute(target) ? target : path.resolve(dir, target);
88
+ symlinkTargets.set(entry.name, resolved);
89
+ } catch {
90
+ symlinkTargets.set(entry.name, null);
91
+ }
92
+ } else if (lst.isFile()) {
93
+ realFiles.set(entry.name, full);
94
+ const key = `${lst.dev}:${lst.ino}`;
95
+ if (!byInode.has(key)) byInode.set(key, []);
96
+ byInode.get(key).push(entry.name);
97
+ }
98
+ }
99
+
100
+ if (!dispatcherPath) {
101
+ let best = [];
102
+ for (const names of byInode.values()) {
103
+ const standard = names.filter((n) => STANDARD_HOOK_NAMES.includes(n));
104
+ if (standard.length >= 3 && standard.length > best.length) best = standard;
105
+ }
106
+ if (best.length > 0) dispatcherPath = path.join(dir, best[0]);
107
+ }
108
+
109
+ let copyClusterTarget = null;
110
+ if (!dispatcherPath && realFiles.size >= 3) {
111
+ const contentBuckets = new Map();
112
+ for (const [name, p] of realFiles) {
113
+ if (!STANDARD_HOOK_NAMES.includes(name)) continue;
114
+ try {
115
+ const key = fs.readFileSync(p).toString("base64");
116
+ if (!contentBuckets.has(key)) contentBuckets.set(key, []);
117
+ contentBuckets.get(key).push(name);
118
+ } catch {
119
+ continue;
120
+ }
121
+ }
122
+ let best = [];
123
+ for (const names of contentBuckets.values()) {
124
+ if (names.length >= 3 && names.length > best.length) best = names;
125
+ }
126
+ if (best.length > 0) {
127
+ copyClusterTarget = best;
128
+ dispatcherPath = path.join(dir, best[0]);
129
+ }
130
+ }
131
+
132
+ if (!dispatcherPath) {
133
+ const hookFiles = fileEntries.map((e) => e.name).filter((n) => STANDARD_HOOK_NAMES.includes(n) || !n.includes("."));
134
+ if (hookFiles.length === 0) return { kind: "empty" };
135
+ return { kind: "bare-githooks", dir, hookFiles };
136
+ }
137
+
138
+ const dispatcherText = readFull(dispatcherPath);
139
+ const hasChain = chainCheck(dispatcherText);
140
+ const hasMarker = dispatcherText && dispatcherText.includes(DISPATCHER_MARKER);
141
+
142
+ if (!hasChain) {
143
+ return {
144
+ kind: "dispatcher-non-conforming",
145
+ dir,
146
+ dispatcherPath,
147
+ reason: "dispatcher does not chain to per-repo hooks"
148
+ };
149
+ }
150
+
151
+ const dispatcherStat = (() => {
152
+ try {
153
+ return fs.statSync(dispatcherPath);
154
+ } catch {
155
+ return null;
156
+ }
157
+ })();
158
+ const dispatcherInodeKey = dispatcherStat ? `${dispatcherStat.dev}:${dispatcherStat.ino}` : null;
159
+ const dispatcherReal = (() => {
160
+ try {
161
+ return fs.realpathSync(dispatcherPath);
162
+ } catch {
163
+ return dispatcherPath;
164
+ }
165
+ })();
166
+
167
+ const present = [];
168
+ const missing = [];
169
+ for (const hook of REQUIRED_HOOKS) {
170
+ const full = path.join(dir, hook);
171
+ if (!fs.existsSync(full) && !symlinkTargets.has(hook)) {
172
+ missing.push(hook);
173
+ continue;
174
+ }
175
+
176
+ let resolved = null;
177
+ if (symlinkTargets.has(hook)) {
178
+ resolved = symlinkTargets.get(hook);
179
+ } else {
180
+ try {
181
+ resolved = fs.realpathSync(full);
182
+ } catch {
183
+ resolved = full;
184
+ }
185
+ }
186
+
187
+ if (resolved && path.resolve(resolved) === path.resolve(dispatcherReal)) {
188
+ present.push(hook);
189
+ continue;
190
+ }
191
+
192
+ try {
193
+ const lst = fs.lstatSync(full);
194
+ if (!lst.isSymbolicLink()) {
195
+ const st = fs.statSync(full);
196
+ const key = `${st.dev}:${st.ino}`;
197
+ if (key === dispatcherInodeKey) {
198
+ present.push(hook);
199
+ continue;
200
+ }
201
+ }
202
+ } catch {
203
+ // fall through
204
+ }
205
+
206
+ if (copyClusterTarget && copyClusterTarget.includes(hook)) {
207
+ present.push(hook);
208
+ continue;
209
+ }
210
+
211
+ missing.push(hook);
212
+ }
213
+
214
+ if (missing.length === 0) {
215
+ return { kind: "dispatcher-canonical-complete", dir, dispatcherPath, hasMarker, present };
216
+ }
217
+ return { kind: "dispatcher-missing-symlinks", dir, dispatcherPath, hasMarker, present, missing };
218
+ }
@@ -0,0 +1,23 @@
1
+ import { context } from "@cldmv/slothlet/runtime";
2
+
3
+ /**
4
+ * Detect Husky in a repo.
5
+ *
6
+ * @param {string} repoRoot absolute path to the repo root
7
+ * @returns {{kind:"husky",dir:string,prepare:string|null,version:string|null}|null}
8
+ */
9
+ export default function husky(repoRoot) {
10
+ if (!repoRoot) return null;
11
+ const { fs, path, wispSync } = context;
12
+ const dir = path.join(repoRoot, ".husky");
13
+ if (!fs.existsSync(dir)) return null;
14
+ let pkg = null;
15
+ try {
16
+ pkg = wispSync(path.join(repoRoot, "package.json"));
17
+ } catch {
18
+ pkg = null;
19
+ }
20
+ const prepare = pkg && pkg.scripts && pkg.scripts.prepare;
21
+ const version = pkg && ((pkg.devDependencies && pkg.devDependencies.husky) || (pkg.dependencies && pkg.dependencies.husky));
22
+ return { kind: "husky", dir, prepare: prepare || null, version: version || null };
23
+ }
@@ -0,0 +1,37 @@
1
+ import { context } from "@cldmv/slothlet/runtime";
2
+
3
+ const CONFIG_NAMES = ["lefthook.yml", "lefthook.yaml", ".lefthook.yml", ".lefthook.yaml"];
4
+
5
+ function readHead(p, n = 4) {
6
+ try {
7
+ return context.fs.readFileSync(p, "utf8").split(/\r?\n/).slice(0, n).join("\n");
8
+ } catch {
9
+ return null;
10
+ }
11
+ }
12
+
13
+ /**
14
+ * Detect Lefthook in a repo.
15
+ *
16
+ * @param {string} repoRoot
17
+ * @param {string|null} gitDir
18
+ * @returns {{kind:"lefthook",configFile:string|null,headerIn?:string}|null}
19
+ */
20
+ export default function lefthook(repoRoot, gitDir) {
21
+ if (!repoRoot) return null;
22
+ const { fs, path } = context;
23
+ const config = CONFIG_NAMES.map((c) => path.join(repoRoot, c)).find((p) => fs.existsSync(p));
24
+ if (config) return { kind: "lefthook", configFile: config };
25
+ if (gitDir) {
26
+ const hooksDir = path.join(gitDir, "hooks");
27
+ if (fs.existsSync(hooksDir)) {
28
+ for (const f of fs.readdirSync(hooksDir)) {
29
+ const head = readHead(path.join(hooksDir, f));
30
+ if (head && /lefthook/i.test(head)) {
31
+ return { kind: "lefthook", configFile: null, headerIn: path.join(hooksDir, f) };
32
+ }
33
+ }
34
+ }
35
+ }
36
+ return null;
37
+ }
@@ -0,0 +1,35 @@
1
+ import { context } from "@cldmv/slothlet/runtime";
2
+
3
+ function readHead(p, n = 4) {
4
+ try {
5
+ return context.fs.readFileSync(p, "utf8").split(/\r?\n/).slice(0, n).join("\n");
6
+ } catch {
7
+ return null;
8
+ }
9
+ }
10
+
11
+ /**
12
+ * Detect Python pre-commit in a repo.
13
+ *
14
+ * @param {string} repoRoot
15
+ * @param {string|null} gitDir
16
+ * @returns {{kind:"pre-commit",configFile:string|null,headerIn?:string}|null}
17
+ */
18
+ export default function preCommit(repoRoot, gitDir) {
19
+ if (!repoRoot) return null;
20
+ const { fs, path } = context;
21
+ const config = path.join(repoRoot, ".pre-commit-config.yaml");
22
+ if (fs.existsSync(config)) return { kind: "pre-commit", configFile: config };
23
+ if (gitDir) {
24
+ const hooksDir = path.join(gitDir, "hooks");
25
+ if (fs.existsSync(hooksDir)) {
26
+ for (const f of fs.readdirSync(hooksDir)) {
27
+ const head = readHead(path.join(hooksDir, f));
28
+ if (head && /File generated by pre-commit/i.test(head)) {
29
+ return { kind: "pre-commit", configFile: null, headerIn: path.join(hooksDir, f) };
30
+ }
31
+ }
32
+ }
33
+ }
34
+ return null;
35
+ }
@@ -0,0 +1,71 @@
1
+ import { self, context } from "@cldmv/slothlet/runtime";
2
+
3
+ /**
4
+ * Inspect `cwd` and return a structured classification. Foreign-manager
5
+ * checks (Husky / Lefthook / simple-git-hooks / pre-commit) take precedence
6
+ * over hooks-dir classification; system-scope `core.hooksPath` is reported
7
+ * with its sub-classification; otherwise the effective hooks dir is
8
+ * classified; otherwise `init.templateDir` fallback or "none".
9
+ *
10
+ * @param {string} [cwd]
11
+ */
12
+ export default function run(cwd = process.cwd()) {
13
+ const { fs, path } = context;
14
+ const repoRoot = self.git.getRepoRoot(cwd);
15
+ const gitDir = self.git.getGitDir(cwd);
16
+ const scopes = self.git.getAllHooksPathScopes(cwd);
17
+ const effectiveHooksPath = self.git.getEffectiveHooksPath(cwd);
18
+ const initTemplateDir = self.git.getInitTemplateDir();
19
+
20
+ const paths = { repoRoot, gitDir, effectiveHooksPath, initTemplateDir };
21
+ const signals = { hooksPathScopes: scopes, initTemplateDir };
22
+
23
+ if (repoRoot) {
24
+ const husky = self.detect.husky(repoRoot);
25
+ if (husky) return { kind: "husky", paths, signals, foreign: husky, action: "refuse" };
26
+
27
+ const lefthook = self.detect.lefthook(repoRoot, gitDir);
28
+ if (lefthook) return { kind: "lefthook", paths, signals, foreign: lefthook, action: "refuse" };
29
+
30
+ const sgh = self.detect.simpleGitHooks(repoRoot);
31
+ if (sgh) return { kind: "simple-git-hooks", paths, signals, foreign: sgh, action: "refuse" };
32
+
33
+ const preCommit = self.detect.preCommit(repoRoot, gitDir);
34
+ if (preCommit) return { kind: "pre-commit", paths, signals, foreign: preCommit, action: "refuse" };
35
+ }
36
+
37
+ const systemPath = scopes.system;
38
+ if (systemPath && !scopes.global && !scopes.local) {
39
+ const sub = self.detect.dispatcher(effectiveHooksPath || systemPath);
40
+ return {
41
+ kind: "system-hookspath",
42
+ paths,
43
+ signals,
44
+ dispatcher: sub.kind.startsWith("dispatcher") ? sub : null,
45
+ subClassification: sub,
46
+ action: sub.kind === "dispatcher-canonical-complete" ? "install" : "refuse"
47
+ };
48
+ }
49
+
50
+ if (effectiveHooksPath) {
51
+ const sub = self.detect.dispatcher(effectiveHooksPath);
52
+ if (sub.kind === "dispatcher-canonical-complete") {
53
+ return { kind: "dispatcher-canonical-complete", paths, signals, dispatcher: sub, action: "install" };
54
+ }
55
+ if (sub.kind === "dispatcher-missing-symlinks") {
56
+ return { kind: "dispatcher-missing-symlinks", paths, signals, dispatcher: sub, action: "heal-then-install" };
57
+ }
58
+ if (sub.kind === "dispatcher-non-conforming") {
59
+ return { kind: "dispatcher-non-conforming", paths, signals, dispatcher: sub, action: "refuse" };
60
+ }
61
+ if (sub.kind === "bare-githooks") {
62
+ return { kind: "bare-githooks", paths, signals, bare: sub, action: "refuse" };
63
+ }
64
+ }
65
+
66
+ if (initTemplateDir && fs.existsSync(path.join(initTemplateDir, "hooks"))) {
67
+ return { kind: "init-templatedir", paths, signals, templateDir: initTemplateDir, action: "suggest-dispatcher" };
68
+ }
69
+
70
+ return { kind: "none", paths, signals, action: "suggest-dispatcher" };
71
+ }
@@ -0,0 +1,26 @@
1
+ import { context } from "@cldmv/slothlet/runtime";
2
+
3
+ /**
4
+ * Detect simple-git-hooks in a repo.
5
+ *
6
+ * @param {string} repoRoot
7
+ * @returns {{kind:"simple-git-hooks",configIn:string,config?:object}|null}
8
+ */
9
+ export default function simpleGitHooks(repoRoot) {
10
+ if (!repoRoot) return null;
11
+ const { fs, path, wispSync } = context;
12
+ let pkg = null;
13
+ try {
14
+ pkg = wispSync(path.join(repoRoot, "package.json"));
15
+ } catch {
16
+ pkg = null;
17
+ }
18
+ if (pkg && pkg["simple-git-hooks"]) {
19
+ return { kind: "simple-git-hooks", configIn: "package.json", config: pkg["simple-git-hooks"] };
20
+ }
21
+ const standalone = path.join(repoRoot, ".simple-git-hooks.json");
22
+ if (fs.existsSync(standalone)) {
23
+ return { kind: "simple-git-hooks", configIn: standalone };
24
+ }
25
+ return null;
26
+ }