commit-cop 1.0.0 → 1.1.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 (74) hide show
  1. package/README.md +98 -20
  2. package/dist/checks/binaryFileCheck.d.ts +3 -0
  3. package/dist/checks/binaryFileCheck.d.ts.map +1 -0
  4. package/dist/checks/binaryFileCheck.js +53 -0
  5. package/dist/checks/binaryFileCheck.js.map +1 -0
  6. package/dist/checks/debuggerCheck.d.ts +3 -0
  7. package/dist/checks/debuggerCheck.d.ts.map +1 -0
  8. package/dist/checks/debuggerCheck.js +28 -0
  9. package/dist/checks/debuggerCheck.js.map +1 -0
  10. package/dist/checks/generatedFolderCheck.d.ts.map +1 -1
  11. package/dist/checks/generatedFolderCheck.js +7 -2
  12. package/dist/checks/generatedFolderCheck.js.map +1 -1
  13. package/dist/checks/junkFileCheck.d.ts +3 -0
  14. package/dist/checks/junkFileCheck.d.ts.map +1 -0
  15. package/dist/checks/junkFileCheck.js +30 -0
  16. package/dist/checks/junkFileCheck.js.map +1 -0
  17. package/dist/checks/lockfileDriftCheck.d.ts +3 -0
  18. package/dist/checks/lockfileDriftCheck.d.ts.map +1 -0
  19. package/dist/checks/lockfileDriftCheck.js +32 -0
  20. package/dist/checks/lockfileDriftCheck.js.map +1 -0
  21. package/dist/checks/mergeConflictCheck.d.ts +3 -0
  22. package/dist/checks/mergeConflictCheck.d.ts.map +1 -0
  23. package/dist/checks/mergeConflictCheck.js +33 -0
  24. package/dist/checks/mergeConflictCheck.js.map +1 -0
  25. package/dist/checks/secretCheck.d.ts.map +1 -1
  26. package/dist/checks/secretCheck.js +15 -4
  27. package/dist/checks/secretCheck.js.map +1 -1
  28. package/dist/checks/sensitiveFilenameCheck.d.ts +3 -0
  29. package/dist/checks/sensitiveFilenameCheck.d.ts.map +1 -0
  30. package/dist/checks/sensitiveFilenameCheck.js +41 -0
  31. package/dist/checks/sensitiveFilenameCheck.js.map +1 -0
  32. package/dist/checks/utils.d.ts +9 -0
  33. package/dist/checks/utils.d.ts.map +1 -0
  34. package/dist/checks/utils.js +48 -0
  35. package/dist/checks/utils.js.map +1 -0
  36. package/dist/index.js +1 -1
  37. package/dist/index.js.map +1 -1
  38. package/dist/scanner.d.ts.map +1 -1
  39. package/dist/scanner.js +14 -2
  40. package/dist/scanner.js.map +1 -1
  41. package/package.json +6 -5
  42. package/src/brand.ts +3 -0
  43. package/src/checks/binaryFileCheck.ts +64 -0
  44. package/src/checks/consoleLogCheck.ts +3 -2
  45. package/src/checks/debuggerCheck.ts +33 -0
  46. package/src/checks/envFileCheck.ts +3 -2
  47. package/src/checks/focusedTestCheck.ts +2 -2
  48. package/src/checks/generatedFolderCheck.ts +13 -5
  49. package/src/checks/junkFileCheck.ts +40 -0
  50. package/src/checks/largeFileCheck.ts +2 -2
  51. package/src/checks/localHostCheck.ts +2 -2
  52. package/src/checks/lockfileDriftCheck.ts +40 -0
  53. package/src/checks/mergeConflictCheck.ts +41 -0
  54. package/src/checks/secretCheck.ts +33 -17
  55. package/src/checks/sensitiveFilenameCheck.ts +51 -0
  56. package/src/checks/utils.ts +62 -0
  57. package/src/fix/debugCode.ts +74 -0
  58. package/src/fix/focusedTests.ts +26 -0
  59. package/src/fix/gitignore.ts +108 -0
  60. package/src/fix/junkFiles.ts +16 -0
  61. package/src/fix/lockfile.ts +23 -0
  62. package/src/fix/matchers.ts +141 -0
  63. package/src/fix/runFix.ts +96 -0
  64. package/src/fix/unstage.ts +25 -0
  65. package/src/fix/utils.ts +50 -0
  66. package/src/git.ts +2 -1
  67. package/src/hook.ts +98 -0
  68. package/src/index.ts +45 -27
  69. package/src/reporter.ts +70 -30
  70. package/src/runScan.ts +35 -0
  71. package/src/scanner.ts +19 -4
  72. package/src/types.ts +5 -0
  73. package/test.ts +5 -1
  74. package/testing.ts +3 -0
@@ -0,0 +1,74 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { isCodeFile } from "../checks/utils.js";
4
+ import { getStagedFiles } from "../git.js";
5
+ import type { WipFixOptions } from "../types.js";
6
+
7
+ const CONSOLE_LOG_LINE = /^\s*console\.log\([^)]*\);?\s*$/;
8
+ const DEBUGGER_LINE = /^\s*debugger;?\s*$/;
9
+
10
+ function stripDebugLines(
11
+ content: string,
12
+ options: { removeConsoleLog: boolean; removeDebugger: boolean }
13
+ ): { updated: string; removed: number } {
14
+ const lines = content.split("\n");
15
+ const kept: string[] = [];
16
+ let removed = 0;
17
+
18
+ for (const line of lines) {
19
+ const isConsoleLog = CONSOLE_LOG_LINE.test(line);
20
+ const isDebugger = DEBUGGER_LINE.test(line);
21
+
22
+ if (
23
+ (options.removeConsoleLog && isConsoleLog) ||
24
+ (options.removeDebugger && isDebugger)
25
+ ) {
26
+ removed += 1;
27
+ continue;
28
+ }
29
+ kept.push(line);
30
+ }
31
+
32
+ return { updated: kept.join("\n"), removed };
33
+ }
34
+
35
+ export function fixDebugCode(
36
+ cwd = process.cwd(),
37
+ options: WipFixOptions = {}
38
+ ): string[] {
39
+ const removeConsoleLog = Boolean(options.fixConsoleLog);
40
+ const removeDebugger = true;
41
+
42
+ if (!removeConsoleLog && !removeDebugger) {
43
+ return [];
44
+ }
45
+
46
+ let stagedFiles: string[];
47
+
48
+ try {
49
+ stagedFiles = getStagedFiles();
50
+ } catch {
51
+ return [];
52
+ }
53
+
54
+ const fixed: string[] = [];
55
+
56
+ for (const file of stagedFiles) {
57
+ if (!isCodeFile(file)) continue;
58
+
59
+ const absolutePath = path.resolve(cwd, file);
60
+ if (!fs.existsSync(absolutePath)) continue;
61
+
62
+ const content = fs.readFileSync(absolutePath, "utf-8");
63
+ const { updated, removed } = stripDebugLines(content, {
64
+ removeConsoleLog,
65
+ removeDebugger,
66
+ });
67
+ if (removed === 0) continue;
68
+
69
+ fs.writeFileSync(absolutePath, updated, "utf-8");
70
+ fixed.push(file);
71
+ }
72
+
73
+ return fixed;
74
+ }
@@ -0,0 +1,26 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { walkCodeFiles } from "./utils.js";
4
+
5
+ const FOCUSED_TEST_PATTERN = /\b(test|it|describe)\.only\b/g;
6
+
7
+ function isTestFile(filePath: string): boolean {
8
+ return filePath.includes("test") || filePath.includes("spec");
9
+ }
10
+
11
+ export function fixFocusedTests(cwd = process.cwd()): string[] {
12
+ const files = walkCodeFiles(cwd, isTestFile);
13
+ const fixed: string[] = [];
14
+
15
+ for (const absolutePath of files) {
16
+ const content = fs.readFileSync(absolutePath, "utf-8");
17
+ if (!FOCUSED_TEST_PATTERN.test(content)) continue;
18
+
19
+ FOCUSED_TEST_PATTERN.lastIndex = 0;
20
+ const updated = content.replace(FOCUSED_TEST_PATTERN, "$1");
21
+ fs.writeFileSync(absolutePath, updated, "utf-8");
22
+ fixed.push(path.relative(cwd, absolutePath));
23
+ }
24
+
25
+ return fixed;
26
+ }
@@ -0,0 +1,108 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ const GITIGNORE_PATH = ".gitignore";
5
+
6
+ type GitignoreEntry = {
7
+ line: string;
8
+ isPresent: (lines: string[]) => boolean;
9
+ };
10
+
11
+ const entries: GitignoreEntry[] = [
12
+ {
13
+ line: ".env",
14
+ isPresent: (lines) =>
15
+ lines.some((line) => line === ".env" || line.startsWith(".env")),
16
+ },
17
+ {
18
+ line: "node_modules/",
19
+ isPresent: (lines) =>
20
+ lines.some(
21
+ (line) => line === "node_modules" || line === "node_modules/"
22
+ ),
23
+ },
24
+ {
25
+ line: "dist/",
26
+ isPresent: (lines) =>
27
+ lines.some((line) => line === "dist" || line === "dist/"),
28
+ },
29
+ {
30
+ line: "build/",
31
+ isPresent: (lines) =>
32
+ lines.some((line) => line === "build" || line === "build/"),
33
+ },
34
+ {
35
+ line: ".next/",
36
+ isPresent: (lines) =>
37
+ lines.some((line) => line === ".next" || line === ".next/"),
38
+ },
39
+ {
40
+ line: "coverage/",
41
+ isPresent: (lines) =>
42
+ lines.some((line) => line === "coverage" || line === "coverage/"),
43
+ },
44
+ {
45
+ line: ".DS_Store",
46
+ isPresent: (lines) =>
47
+ lines.some((line) => line.toLowerCase() === ".ds_store"),
48
+ },
49
+ {
50
+ line: "Thumbs.db",
51
+ isPresent: (lines) =>
52
+ lines.some((line) => line.toLowerCase() === "thumbs.db"),
53
+ },
54
+ {
55
+ line: "desktop.ini",
56
+ isPresent: (lines) =>
57
+ lines.some((line) => line.toLowerCase() === "desktop.ini"),
58
+ },
59
+ {
60
+ line: "*.swp",
61
+ isPresent: (lines) => lines.includes("*.swp"),
62
+ },
63
+ {
64
+ line: "*.bak",
65
+ isPresent: (lines) => lines.includes("*.bak"),
66
+ },
67
+ {
68
+ line: "*.tmp",
69
+ isPresent: (lines) => lines.includes("*.tmp"),
70
+ },
71
+ ];
72
+
73
+ function parseGitignoreLines(content: string): string[] {
74
+ return content
75
+ .split("\n")
76
+ .map((line) => line.trim())
77
+ .filter((line) => line.length > 0 && !line.startsWith("#"));
78
+ }
79
+
80
+ export function fixGitignore(cwd = process.cwd()): string[] {
81
+ const filePath = path.join(cwd, GITIGNORE_PATH);
82
+ const applied: string[] = [];
83
+
84
+ const existingContent = fs.existsSync(filePath)
85
+ ? fs.readFileSync(filePath, "utf-8")
86
+ : "";
87
+ const existingLines = parseGitignoreLines(existingContent);
88
+
89
+ const missing = entries.filter((entry) => !entry.isPresent(existingLines));
90
+ if (missing.length === 0) {
91
+ return applied;
92
+ }
93
+
94
+ const additions = missing.map((entry) => entry.line);
95
+ const needsLeadingNewline =
96
+ existingContent.length > 0 && !existingContent.endsWith("\n");
97
+ const block =
98
+ (needsLeadingNewline ? "\n" : "") +
99
+ (existingContent.length > 0 ? "\n" : "") +
100
+ "# Added by commit-cop wip-fix\n" +
101
+ additions.join("\n") +
102
+ "\n";
103
+
104
+ fs.writeFileSync(filePath, existingContent + block, "utf-8");
105
+ applied.push(...additions);
106
+
107
+ return applied;
108
+ }
@@ -0,0 +1,16 @@
1
+ import fs from "node:fs";
2
+ import { isJunkFile } from "./matchers.js";
3
+ import { walkRepo } from "./utils.js";
4
+
5
+ export function fixJunkFiles(cwd = process.cwd()): string[] {
6
+ const removed: string[] = [];
7
+
8
+ walkRepo(cwd, (absolutePath, relativePath) => {
9
+ if (!isJunkFile(relativePath)) return;
10
+
11
+ fs.unlinkSync(absolutePath);
12
+ removed.push(relativePath);
13
+ });
14
+
15
+ return removed;
16
+ }
@@ -0,0 +1,23 @@
1
+ import { execSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+
5
+ export function fixLockfile(cwd = process.cwd()): boolean {
6
+ const packageJson = path.join(cwd, "package.json");
7
+ const packageLock = path.join(cwd, "package-lock.json");
8
+
9
+ if (!fs.existsSync(packageJson)) {
10
+ return false;
11
+ }
12
+
13
+ const needsInstall =
14
+ !fs.existsSync(packageLock) ||
15
+ fs.statSync(packageJson).mtimeMs > fs.statSync(packageLock).mtimeMs;
16
+
17
+ if (!needsInstall) {
18
+ return false;
19
+ }
20
+
21
+ execSync("npm install", { cwd, stdio: "inherit" });
22
+ return true;
23
+ }
@@ -0,0 +1,141 @@
1
+ import fs from "node:fs";
2
+ import {
3
+ getBaseName,
4
+ matchesAnyPattern,
5
+ normalizePath,
6
+ } from "../checks/utils.js";
7
+
8
+ export const GENERATED_FOLDERS = [
9
+ "node_modules/",
10
+ "dist/",
11
+ "build/",
12
+ ".next/",
13
+ "coverage/",
14
+ ];
15
+
16
+ const junkExactNames = new Set([
17
+ ".ds_store",
18
+ "thumbs.db",
19
+ "desktop.ini",
20
+ ]);
21
+
22
+ const junkNamePatterns = [/\.swp$/i, /\.bak$/i, /\.tmp$/i, /~$/];
23
+
24
+ const sensitiveExactNames = new Set([
25
+ "id_rsa",
26
+ "id_ed25519",
27
+ "credentials.json",
28
+ "serviceaccountkey.json",
29
+ ".npmrc",
30
+ ".pypirc",
31
+ ]);
32
+
33
+ const sensitiveNamePatterns = [
34
+ /\.pem$/i,
35
+ /\.p12$/i,
36
+ /\.key$/i,
37
+ /^firebase-adminsdk.*\.json$/i,
38
+ ];
39
+
40
+ const binaryExtensions = new Set([
41
+ ".zip",
42
+ ".exe",
43
+ ".dll",
44
+ ".mp4",
45
+ ".mov",
46
+ ".sqlite",
47
+ ".db",
48
+ ]);
49
+
50
+ const MAX_SIZE_MB = 5;
51
+
52
+ function matchesGeneratedFolder(normalizedPath: string): string | null {
53
+ for (const folder of GENERATED_FOLDERS) {
54
+ if (
55
+ normalizedPath.startsWith(folder) ||
56
+ normalizedPath.includes(`/${folder}`)
57
+ ) {
58
+ return folder;
59
+ }
60
+ }
61
+ return null;
62
+ }
63
+
64
+ export function isEnvFile(file: string): boolean {
65
+ const baseName = getBaseName(file);
66
+ return baseName === ".env" || baseName.startsWith(".env.");
67
+ }
68
+
69
+ export function isSensitiveFile(file: string): boolean {
70
+ const baseName = getBaseName(file);
71
+ const normalizedBaseName = baseName.toLowerCase();
72
+ const normalizedPath = normalizePath(file).toLowerCase();
73
+
74
+ return (
75
+ sensitiveExactNames.has(normalizedBaseName) ||
76
+ matchesAnyPattern(baseName, sensitiveNamePatterns) ||
77
+ normalizedPath.endsWith("/credentials.json") ||
78
+ normalizedPath.includes("serviceaccountkey.json")
79
+ );
80
+ }
81
+
82
+ export function isGeneratedPath(file: string): boolean {
83
+ return matchesGeneratedFolder(normalizePath(file)) !== null;
84
+ }
85
+
86
+ export function isJunkFile(file: string): boolean {
87
+ const baseName = getBaseName(file);
88
+ const normalizedBaseName = baseName.toLowerCase();
89
+
90
+ return (
91
+ junkExactNames.has(normalizedBaseName) ||
92
+ matchesAnyPattern(baseName, junkNamePatterns)
93
+ );
94
+ }
95
+
96
+ function hasNullBytes(file: string): boolean {
97
+ const buffer = fs.readFileSync(file);
98
+ const sampleSize = Math.min(buffer.length, 8192);
99
+
100
+ for (let index = 0; index < sampleSize; index += 1) {
101
+ if (buffer[index] === 0) {
102
+ return true;
103
+ }
104
+ }
105
+
106
+ return false;
107
+ }
108
+
109
+ export function isBinaryFile(file: string): boolean {
110
+ if (!fs.existsSync(file)) return false;
111
+
112
+ const baseName = getBaseName(file);
113
+ const extension = baseName.includes(".")
114
+ ? baseName.slice(baseName.lastIndexOf(".")).toLowerCase()
115
+ : "";
116
+
117
+ if (binaryExtensions.has(extension)) return true;
118
+
119
+ try {
120
+ return hasNullBytes(file);
121
+ } catch {
122
+ return false;
123
+ }
124
+ }
125
+
126
+ export function isLargeFile(file: string): boolean {
127
+ if (!fs.existsSync(file)) return false;
128
+
129
+ const sizeMb = fs.statSync(file).size / 1024 / 1024;
130
+ return sizeMb > MAX_SIZE_MB;
131
+ }
132
+
133
+ export function shouldUnstage(file: string): string | null {
134
+ if (isEnvFile(file)) return "environment file";
135
+ if (isSensitiveFile(file)) return "sensitive file";
136
+ if (isGeneratedPath(file)) return "generated path";
137
+ if (isJunkFile(file)) return "junk file";
138
+ if (isBinaryFile(file)) return "binary file";
139
+ if (isLargeFile(file)) return "large file";
140
+ return null;
141
+ }
@@ -0,0 +1,96 @@
1
+ import chalk from "chalk";
2
+ import type { WipFixOptions } from "../types.js";
3
+ import { fixDebugCode } from "./debugCode.js";
4
+ import { fixFocusedTests } from "./focusedTests.js";
5
+ import { fixGitignore } from "./gitignore.js";
6
+ import { fixJunkFiles } from "./junkFiles.js";
7
+ import { fixLockfile } from "./lockfile.js";
8
+ import { fixUnstageRiskyFiles } from "./unstage.js";
9
+
10
+ function printSection(title: string, items: string[]): void {
11
+ if (items.length === 0) return;
12
+ console.log(chalk.green(`\n${title}`));
13
+ for (const item of items) {
14
+ console.log(` ${item}`);
15
+ }
16
+ }
17
+
18
+ function printSkipped(message: string): void {
19
+ console.log(chalk.dim(`\n${message}`));
20
+ }
21
+
22
+ export function runWipFix(options: WipFixOptions = {}): void {
23
+ const fixConsoleLog = Boolean(options.fixConsoleLog);
24
+
25
+ console.log(chalk.bold("commit-cop wip-fix"));
26
+ console.log(chalk.dim("(work in progress — more fixes may be added later)\n"));
27
+
28
+ const gitignoreAdded = fixGitignore();
29
+ const testsFixed = fixFocusedTests();
30
+ const debugFixed = fixDebugCode(process.cwd(), { fixConsoleLog });
31
+ const junkRemoved = fixJunkFiles();
32
+ const unstaged = fixUnstageRiskyFiles();
33
+ const lockfileSynced = fixLockfile();
34
+
35
+ if (gitignoreAdded.length > 0) {
36
+ console.log(chalk.green("Updated .gitignore:"));
37
+ for (const entry of gitignoreAdded) {
38
+ console.log(` + ${entry}`);
39
+ }
40
+ } else {
41
+ console.log(
42
+ chalk.dim(".gitignore already includes expected entries from checks")
43
+ );
44
+ }
45
+
46
+ printSection("Replaced focused test (.only) calls in:", testsFixed);
47
+ if (testsFixed.length === 0) {
48
+ printSkipped("No test.only / it.only / describe.only in test files");
49
+ }
50
+
51
+ if (fixConsoleLog) {
52
+ printSection("Removed debug lines from staged files:", debugFixed);
53
+ if (debugFixed.length === 0) {
54
+ printSkipped("No staged standalone console.log or debugger lines");
55
+ }
56
+ } else {
57
+ printSkipped("Skipped console.log removal (use --fix-console-log to enable)");
58
+ if (debugFixed.length > 0) {
59
+ printSection("Removed debugger lines from staged files:", debugFixed);
60
+ } else {
61
+ printSkipped("No staged debugger lines");
62
+ }
63
+ }
64
+
65
+ printSection("Deleted junk files:", junkRemoved);
66
+ if (junkRemoved.length === 0) {
67
+ printSkipped("No junk files found on disk");
68
+ }
69
+
70
+ printSection("Unstaged risky files:", unstaged);
71
+ if (unstaged.length === 0) {
72
+ printSkipped("No staged env, sensitive, generated, junk, binary, or large files");
73
+ }
74
+
75
+ if (lockfileSynced) {
76
+ console.log(chalk.green("\nRan npm install to sync package-lock.json"));
77
+ } else {
78
+ printSkipped("package-lock.json is up to date (or no package.json)");
79
+ }
80
+
81
+ printSkipped(
82
+ "Cannot auto-fix: merge conflicts, secrets, or localhost URLs — resolve those manually"
83
+ );
84
+
85
+ const changed =
86
+ gitignoreAdded.length > 0 ||
87
+ testsFixed.length > 0 ||
88
+ debugFixed.length > 0 ||
89
+ junkRemoved.length > 0 ||
90
+ unstaged.length > 0 ||
91
+ lockfileSynced;
92
+
93
+ if (!changed) {
94
+ console.log(chalk.dim("\nNothing to fix."));
95
+ }
96
+ }
@@ -0,0 +1,25 @@
1
+ import { execSync } from "node:child_process";
2
+ import { getStagedFiles } from "../git.js";
3
+ import { shouldUnstage } from "./matchers.js";
4
+
5
+ export function fixUnstageRiskyFiles(): string[] {
6
+ let stagedFiles: string[];
7
+
8
+ try {
9
+ stagedFiles = getStagedFiles();
10
+ } catch {
11
+ return [];
12
+ }
13
+
14
+ const unstaged: string[] = [];
15
+
16
+ for (const file of stagedFiles) {
17
+ const reason = shouldUnstage(file);
18
+ if (!reason) continue;
19
+
20
+ execSync(`git restore --staged -- "${file}"`, { stdio: "pipe" });
21
+ unstaged.push(`${file} (${reason})`);
22
+ }
23
+
24
+ return unstaged;
25
+ }
@@ -0,0 +1,50 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ export const SKIP_DIRS = new Set([
5
+ "node_modules",
6
+ "dist",
7
+ "build",
8
+ ".next",
9
+ "coverage",
10
+ ".git",
11
+ ]);
12
+
13
+ export function walkRepo(
14
+ cwd: string,
15
+ onFile: (absolutePath: string, relativePath: string) => void
16
+ ): void {
17
+ function walk(dir: string): void {
18
+ if (!fs.existsSync(dir)) return;
19
+
20
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
21
+ if (SKIP_DIRS.has(entry.name)) continue;
22
+
23
+ const absolutePath = path.join(dir, entry.name);
24
+ const relativePath = path.relative(cwd, absolutePath);
25
+
26
+ if (entry.isDirectory()) {
27
+ walk(absolutePath);
28
+ continue;
29
+ }
30
+
31
+ onFile(absolutePath, relativePath);
32
+ }
33
+ }
34
+
35
+ walk(cwd);
36
+ }
37
+
38
+ export function walkCodeFiles(
39
+ cwd: string,
40
+ predicate: (relativePath: string) => boolean,
41
+ files: string[] = []
42
+ ): string[] {
43
+ walkRepo(cwd, (absolutePath, relativePath) => {
44
+ if (!/\.(ts|tsx|js|jsx|mjs|cjs)$/.test(relativePath)) return;
45
+ if (!predicate(relativePath)) return;
46
+ files.push(absolutePath);
47
+ });
48
+
49
+ return files;
50
+ }
package/src/git.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { execSync } from "node:child_process";
2
+ import { PRODUCT_NAME } from "./brand.js";
2
3
 
3
4
  export function getStagedFiles(): string[] {
4
5
  try {
@@ -11,6 +12,6 @@ export function getStagedFiles(): string[] {
11
12
  .map((file) => file.trim())
12
13
  .filter(Boolean);
13
14
  } catch {
14
- throw new Error("CommitClean must be run inside a Git repository.");
15
+ throw new Error(`${PRODUCT_NAME} must be run inside a Git repository.`);
15
16
  }
16
17
  }
package/src/hook.ts ADDED
@@ -0,0 +1,98 @@
1
+ import { execSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { CLI_NAME, PRODUCT_NAME } from "./brand.js";
5
+
6
+ const HOOK_MARKER = "# Generated by Commit Cop";
7
+ const BACKUP_SUFFIX = ".commit-cop.backup";
8
+
9
+ function getGitHooksDir(): string {
10
+ try {
11
+ const gitPath = execSync("git rev-parse --git-path hooks", {
12
+ encoding: "utf-8",
13
+ }).trim();
14
+
15
+ return path.resolve(gitPath);
16
+ } catch {
17
+ throw new Error(`${PRODUCT_NAME} install must be run inside a Git repository.`);
18
+ }
19
+ }
20
+
21
+ function buildHookScript(strict: boolean): string {
22
+ const strictFlag = strict ? " --strict" : "";
23
+
24
+ return `#!/bin/sh
25
+ ${HOOK_MARKER}
26
+ # Re-run: npx ${CLI_NAME} install
27
+
28
+ cd "$(git rev-parse --show-toplevel)" || exit 1
29
+
30
+ if [ -x "./node_modules/.bin/${CLI_NAME}" ]; then
31
+ exec ./node_modules/.bin/${CLI_NAME}${strictFlag}
32
+ fi
33
+
34
+ if command -v ${CLI_NAME} >/dev/null 2>&1; then
35
+ exec ${CLI_NAME}${strictFlag}
36
+ fi
37
+
38
+ exec npx ${CLI_NAME}${strictFlag}
39
+ `;
40
+ }
41
+
42
+ export function installHook(strict = false): void {
43
+ const hooksDir = getGitHooksDir();
44
+ const hookPath = path.join(hooksDir, "pre-commit");
45
+ const backupPath = `${hookPath}${BACKUP_SUFFIX}`;
46
+
47
+ fs.mkdirSync(hooksDir, { recursive: true });
48
+
49
+ if (fs.existsSync(hookPath)) {
50
+ const existing = fs.readFileSync(hookPath, "utf-8");
51
+
52
+ if (existing.includes(HOOK_MARKER)) {
53
+ console.log(`${PRODUCT_NAME}: Updating existing pre-commit hook.`);
54
+ } else {
55
+ fs.copyFileSync(hookPath, backupPath);
56
+ console.log(
57
+ `${PRODUCT_NAME}: Backed up existing pre-commit hook to ${path.basename(backupPath)}.`
58
+ );
59
+ }
60
+ }
61
+
62
+ fs.writeFileSync(hookPath, buildHookScript(strict), { mode: 0o755 });
63
+
64
+ console.log(`${PRODUCT_NAME}: Pre-commit hook installed.`);
65
+ console.log(` ${hookPath}`);
66
+ console.log("");
67
+ console.log("Commit Cop will now run automatically on git commit.");
68
+ }
69
+
70
+ export function uninstallHook(): void {
71
+ const hooksDir = getGitHooksDir();
72
+ const hookPath = path.join(hooksDir, "pre-commit");
73
+ const backupPath = `${hookPath}${BACKUP_SUFFIX}`;
74
+
75
+ if (!fs.existsSync(hookPath)) {
76
+ console.log(`${PRODUCT_NAME}: No pre-commit hook found.`);
77
+ return;
78
+ }
79
+
80
+ const existing = fs.readFileSync(hookPath, "utf-8");
81
+
82
+ if (!existing.includes(HOOK_MARKER)) {
83
+ throw new Error(
84
+ `${PRODUCT_NAME} did not install this pre-commit hook. Remove it manually if needed.`
85
+ );
86
+ }
87
+
88
+ fs.unlinkSync(hookPath);
89
+
90
+ if (fs.existsSync(backupPath)) {
91
+ fs.copyFileSync(backupPath, hookPath);
92
+ fs.unlinkSync(backupPath);
93
+ console.log(`${PRODUCT_NAME}: Pre-commit hook removed and previous hook restored.`);
94
+ return;
95
+ }
96
+
97
+ console.log(`${PRODUCT_NAME}: Pre-commit hook removed.`);
98
+ }