safe-push 0.3.0 → 0.4.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.
- package/.claude/settings.local.json +8 -0
- package/bun.lock +54 -0
- package/config.schema.json +47 -0
- package/dist/index.js +7374 -166
- package/package.json +10 -3
- package/scripts/generate-schema.ts +13 -0
- package/src/checker.ts +79 -60
- package/src/commands/check.ts +17 -10
- package/src/commands/push.ts +35 -22
- package/src/config.ts +9 -1
- package/src/git.ts +136 -100
- package/src/index.ts +37 -2
- package/src/telemetry.ts +95 -0
- package/src/types.ts +18 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "safe-push",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Git push safety checker - blocks pushes to forbidden areas",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -9,9 +9,15 @@
|
|
|
9
9
|
"scripts": {
|
|
10
10
|
"build": "bun build ./src/index.ts --outdir ./dist --target bun",
|
|
11
11
|
"typecheck": "tsc --noEmit",
|
|
12
|
-
"dev": "bun run ./src/index.ts"
|
|
12
|
+
"dev": "bun run ./src/index.ts",
|
|
13
|
+
"generate-schema": "bun run ./scripts/generate-schema.ts"
|
|
13
14
|
},
|
|
14
15
|
"dependencies": {
|
|
16
|
+
"@opentelemetry/api": "^1.9.0",
|
|
17
|
+
"@opentelemetry/exporter-trace-otlp-http": "^0.212.0",
|
|
18
|
+
"@opentelemetry/resources": "^2.5.1",
|
|
19
|
+
"@opentelemetry/sdk-trace-base": "^2.5.1",
|
|
20
|
+
"@opentelemetry/semantic-conventions": "^1.39.0",
|
|
15
21
|
"commander": "^12.1.0",
|
|
16
22
|
"jsonc-parser": "^3.3.1",
|
|
17
23
|
"zod": "^3.23.8"
|
|
@@ -19,6 +25,7 @@
|
|
|
19
25
|
"devDependencies": {
|
|
20
26
|
"@types/bun": "latest",
|
|
21
27
|
"@types/node": "^22.10.0",
|
|
22
|
-
"typescript": "^5.7.2"
|
|
28
|
+
"typescript": "^5.7.2",
|
|
29
|
+
"zod-to-json-schema": "^3.25.1"
|
|
23
30
|
}
|
|
24
31
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { zodToJsonSchema } from "zod-to-json-schema";
|
|
4
|
+
import { ConfigSchema } from "../src/types";
|
|
5
|
+
|
|
6
|
+
const jsonSchema = zodToJsonSchema(ConfigSchema, {
|
|
7
|
+
name: "SafePushConfig",
|
|
8
|
+
$refStrategy: "none",
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
const outPath = path.join(import.meta.dirname, "..", "config.schema.json");
|
|
12
|
+
fs.writeFileSync(outPath, JSON.stringify(jsonSchema, null, 2) + "\n", "utf-8");
|
|
13
|
+
console.log(`Generated ${outPath}`);
|
package/src/checker.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
getDiffFiles,
|
|
8
8
|
getRepoVisibility,
|
|
9
9
|
} from "./git";
|
|
10
|
+
import { withSpan } from "./telemetry";
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* Visibility チェック結果
|
|
@@ -24,20 +25,27 @@ export interface VisibilityCheckResult {
|
|
|
24
25
|
export async function checkVisibility(
|
|
25
26
|
allowedVisibility?: RepoVisibility[]
|
|
26
27
|
): Promise<VisibilityCheckResult | null> {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
return withSpan("safe-push.check.visibility", async (span) => {
|
|
29
|
+
if (!allowedVisibility || allowedVisibility.length === 0) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const visibility = await getRepoVisibility();
|
|
34
|
+
const allowed = allowedVisibility.includes(visibility as RepoVisibility);
|
|
30
35
|
|
|
31
|
-
|
|
32
|
-
|
|
36
|
+
span.addEvent("visibility.result", {
|
|
37
|
+
value: visibility,
|
|
38
|
+
allowed,
|
|
39
|
+
});
|
|
33
40
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
+
return {
|
|
42
|
+
allowed,
|
|
43
|
+
reason: allowed
|
|
44
|
+
? `Repository visibility "${visibility}" is allowed`
|
|
45
|
+
: `Repository visibility "${visibility}" is not in allowed list: [${allowedVisibility.join(", ")}]`,
|
|
46
|
+
visibility,
|
|
47
|
+
};
|
|
48
|
+
});
|
|
41
49
|
}
|
|
42
50
|
|
|
43
51
|
/**
|
|
@@ -88,58 +96,69 @@ function findForbiddenFiles(
|
|
|
88
96
|
* (禁止エリア変更なし) AND (新規ブランチ OR 最終コミットが自分)
|
|
89
97
|
*/
|
|
90
98
|
export async function checkPush(config: Config): Promise<CheckResult> {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
99
|
+
return withSpan("safe-push.check.push", async (span) => {
|
|
100
|
+
const currentBranch = await getCurrentBranch();
|
|
101
|
+
const newBranch = await isNewBranch();
|
|
102
|
+
const authorEmail = await getLastCommitAuthorEmail();
|
|
103
|
+
const localEmail = await getLocalEmail();
|
|
104
|
+
const diffFiles = await getDiffFiles();
|
|
96
105
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
106
|
+
const forbiddenFiles = findForbiddenFiles(diffFiles, config.forbiddenPaths);
|
|
107
|
+
const hasForbiddenChanges = forbiddenFiles.length > 0;
|
|
108
|
+
const isOwnLastCommit =
|
|
109
|
+
authorEmail.toLowerCase() === localEmail.toLowerCase();
|
|
101
110
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
};
|
|
111
|
-
|
|
112
|
-
// 禁止エリアに変更がある場合は常にブロック
|
|
113
|
-
if (hasForbiddenChanges) {
|
|
114
|
-
return {
|
|
115
|
-
allowed: false,
|
|
116
|
-
reason: `Forbidden files detected: ${forbiddenFiles.join(", ")}`,
|
|
117
|
-
details,
|
|
111
|
+
const details = {
|
|
112
|
+
isNewBranch: newBranch,
|
|
113
|
+
isOwnLastCommit,
|
|
114
|
+
hasForbiddenChanges,
|
|
115
|
+
forbiddenFiles,
|
|
116
|
+
currentBranch,
|
|
117
|
+
authorEmail,
|
|
118
|
+
localEmail,
|
|
118
119
|
};
|
|
119
|
-
}
|
|
120
120
|
|
|
121
|
-
|
|
122
|
-
if (newBranch) {
|
|
123
|
-
return {
|
|
124
|
-
allowed: true,
|
|
125
|
-
reason: "New branch - no restrictions",
|
|
126
|
-
details,
|
|
127
|
-
};
|
|
128
|
-
}
|
|
121
|
+
let result: CheckResult;
|
|
129
122
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
123
|
+
// 禁止エリアに変更がある場合は常にブロック
|
|
124
|
+
if (hasForbiddenChanges) {
|
|
125
|
+
result = {
|
|
126
|
+
allowed: false,
|
|
127
|
+
reason: `Forbidden files detected: ${forbiddenFiles.join(", ")}`,
|
|
128
|
+
details,
|
|
129
|
+
};
|
|
130
|
+
} else if (newBranch) {
|
|
131
|
+
// 新規ブランチの場合は許可
|
|
132
|
+
result = {
|
|
133
|
+
allowed: true,
|
|
134
|
+
reason: "New branch - no restrictions",
|
|
135
|
+
details,
|
|
136
|
+
};
|
|
137
|
+
} else if (isOwnLastCommit) {
|
|
138
|
+
// 最終コミットが自分の場合は許可
|
|
139
|
+
result = {
|
|
140
|
+
allowed: true,
|
|
141
|
+
reason: "Last commit is yours",
|
|
142
|
+
details,
|
|
143
|
+
};
|
|
144
|
+
} else {
|
|
145
|
+
// それ以外はブロック
|
|
146
|
+
result = {
|
|
147
|
+
allowed: false,
|
|
148
|
+
reason: `Last commit is by someone else (${authorEmail})`,
|
|
149
|
+
details,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
span.addEvent("check.result", {
|
|
154
|
+
allowed: result.allowed,
|
|
155
|
+
reason: result.reason,
|
|
156
|
+
isNewBranch: newBranch,
|
|
157
|
+
isOwnLastCommit,
|
|
158
|
+
hasForbiddenChanges,
|
|
159
|
+
forbiddenFileCount: forbiddenFiles.length,
|
|
160
|
+
});
|
|
138
161
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
allowed: false,
|
|
142
|
-
reason: `Last commit is by someone else (${authorEmail})`,
|
|
143
|
-
details,
|
|
144
|
-
};
|
|
162
|
+
return result;
|
|
163
|
+
});
|
|
145
164
|
}
|
package/src/commands/check.ts
CHANGED
|
@@ -3,6 +3,8 @@ import { loadConfig } from "../config";
|
|
|
3
3
|
import { checkPush, checkVisibility } from "../checker";
|
|
4
4
|
import { isGitRepository, hasCommits } from "../git";
|
|
5
5
|
import { printError, printCheckResultJson, printCheckResultHuman } from "./utils";
|
|
6
|
+
import { ExitError } from "../types";
|
|
7
|
+
import { withSpan } from "../telemetry";
|
|
6
8
|
|
|
7
9
|
/**
|
|
8
10
|
* checkコマンドを作成
|
|
@@ -12,20 +14,27 @@ export function createCheckCommand(): Command {
|
|
|
12
14
|
.description("Check if push is allowed")
|
|
13
15
|
.option("--json", "Output result as JSON")
|
|
14
16
|
.action(async (options: { json?: boolean }) => {
|
|
15
|
-
|
|
17
|
+
await withSpan("safe-push.check", async (rootSpan) => {
|
|
16
18
|
// Gitリポジトリ内か確認
|
|
17
19
|
if (!(await isGitRepository())) {
|
|
18
20
|
printError("Not a git repository");
|
|
19
|
-
|
|
21
|
+
throw new ExitError(1);
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
// コミットが存在するか確認
|
|
23
25
|
if (!(await hasCommits())) {
|
|
24
26
|
printError("No commits found");
|
|
25
|
-
|
|
27
|
+
throw new ExitError(1);
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
const config = loadConfig();
|
|
31
|
+
|
|
32
|
+
rootSpan.addEvent("config.loaded", {
|
|
33
|
+
forbiddenPaths: JSON.stringify(config.forbiddenPaths),
|
|
34
|
+
onForbidden: config.onForbidden,
|
|
35
|
+
hasVisibilityRule: !!(config.allowedVisibility && config.allowedVisibility.length > 0),
|
|
36
|
+
});
|
|
37
|
+
|
|
29
38
|
const result = await checkPush(config);
|
|
30
39
|
|
|
31
40
|
// visibility チェック
|
|
@@ -41,6 +50,7 @@ export function createCheckCommand(): Command {
|
|
|
41
50
|
}
|
|
42
51
|
}
|
|
43
52
|
} catch (error) {
|
|
53
|
+
if (error instanceof ExitError) throw error;
|
|
44
54
|
result.details.repoVisibility = "unknown";
|
|
45
55
|
result.details.visibilityAllowed = false;
|
|
46
56
|
result.allowed = false;
|
|
@@ -54,12 +64,9 @@ export function createCheckCommand(): Command {
|
|
|
54
64
|
printCheckResultHuman(result);
|
|
55
65
|
}
|
|
56
66
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
);
|
|
62
|
-
process.exit(1);
|
|
63
|
-
}
|
|
67
|
+
if (!result.allowed) {
|
|
68
|
+
throw new ExitError(1);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
64
71
|
});
|
|
65
72
|
}
|
package/src/commands/push.ts
CHANGED
|
@@ -9,6 +9,8 @@ import {
|
|
|
9
9
|
printCheckResultHuman,
|
|
10
10
|
promptConfirm,
|
|
11
11
|
} from "./utils";
|
|
12
|
+
import { ExitError } from "../types";
|
|
13
|
+
import { withSpan } from "../telemetry";
|
|
12
14
|
|
|
13
15
|
/**
|
|
14
16
|
* pushコマンドを作成
|
|
@@ -21,34 +23,41 @@ export function createPushCommand(): Command {
|
|
|
21
23
|
.allowUnknownOption()
|
|
22
24
|
.action(async (options: { force?: boolean; dryRun?: boolean }, command: Command) => {
|
|
23
25
|
const gitArgs = command.args;
|
|
24
|
-
|
|
26
|
+
await withSpan("safe-push.push", async (rootSpan) => {
|
|
25
27
|
// Gitリポジトリ内か確認
|
|
26
28
|
if (!(await isGitRepository())) {
|
|
27
29
|
printError("Not a git repository");
|
|
28
|
-
|
|
30
|
+
throw new ExitError(1);
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
// コミットが存在するか確認
|
|
32
34
|
if (!(await hasCommits())) {
|
|
33
35
|
printError("No commits found");
|
|
34
|
-
|
|
36
|
+
throw new ExitError(1);
|
|
35
37
|
}
|
|
36
38
|
|
|
37
39
|
const config = loadConfig();
|
|
38
40
|
|
|
41
|
+
rootSpan.addEvent("config.loaded", {
|
|
42
|
+
forbiddenPaths: JSON.stringify(config.forbiddenPaths),
|
|
43
|
+
onForbidden: config.onForbidden,
|
|
44
|
+
hasVisibilityRule: !!(config.allowedVisibility && config.allowedVisibility.length > 0),
|
|
45
|
+
});
|
|
46
|
+
|
|
39
47
|
// visibility チェック(--force でもバイパスできない)
|
|
40
48
|
if (config.allowedVisibility && config.allowedVisibility.length > 0) {
|
|
41
49
|
try {
|
|
42
50
|
const visibilityResult = await checkVisibility(config.allowedVisibility);
|
|
43
51
|
if (visibilityResult && !visibilityResult.allowed) {
|
|
44
52
|
printError(visibilityResult.reason);
|
|
45
|
-
|
|
53
|
+
throw new ExitError(1);
|
|
46
54
|
}
|
|
47
55
|
} catch (error) {
|
|
56
|
+
if (error instanceof ExitError) throw error;
|
|
48
57
|
printError(
|
|
49
58
|
`Failed to check repository visibility. Ensure 'gh' CLI is installed and authenticated.\n ${error instanceof Error ? error.message : String(error)}`
|
|
50
59
|
);
|
|
51
|
-
|
|
60
|
+
throw new ExitError(1);
|
|
52
61
|
}
|
|
53
62
|
}
|
|
54
63
|
|
|
@@ -58,16 +67,19 @@ export function createPushCommand(): Command {
|
|
|
58
67
|
|
|
59
68
|
if (options.dryRun) {
|
|
60
69
|
printSuccess("Dry run: would push (checks bypassed)");
|
|
61
|
-
|
|
70
|
+
throw new ExitError(0);
|
|
62
71
|
}
|
|
63
72
|
|
|
64
73
|
const result = await execPush(gitArgs);
|
|
65
74
|
if (result.success) {
|
|
75
|
+
if (result.output) {
|
|
76
|
+
console.log(result.output);
|
|
77
|
+
}
|
|
66
78
|
printSuccess("Push successful");
|
|
67
|
-
|
|
79
|
+
return;
|
|
68
80
|
} else {
|
|
69
81
|
printError(`Push failed: ${result.output}`);
|
|
70
|
-
|
|
82
|
+
throw new ExitError(1);
|
|
71
83
|
}
|
|
72
84
|
}
|
|
73
85
|
|
|
@@ -88,45 +100,46 @@ export function createPushCommand(): Command {
|
|
|
88
100
|
if (confirmed) {
|
|
89
101
|
if (options.dryRun) {
|
|
90
102
|
printSuccess("Dry run: would push (user confirmed)");
|
|
91
|
-
|
|
103
|
+
throw new ExitError(0);
|
|
92
104
|
}
|
|
93
105
|
|
|
94
106
|
const result = await execPush(gitArgs);
|
|
95
107
|
if (result.success) {
|
|
108
|
+
if (result.output) {
|
|
109
|
+
console.log(result.output);
|
|
110
|
+
}
|
|
96
111
|
printSuccess("Push successful");
|
|
97
|
-
|
|
112
|
+
return;
|
|
98
113
|
} else {
|
|
99
114
|
printError(`Push failed: ${result.output}`);
|
|
100
|
-
|
|
115
|
+
throw new ExitError(1);
|
|
101
116
|
}
|
|
102
117
|
} else {
|
|
103
118
|
printError("Push cancelled by user");
|
|
104
|
-
|
|
119
|
+
throw new ExitError(1);
|
|
105
120
|
}
|
|
106
121
|
}
|
|
107
122
|
|
|
108
|
-
|
|
123
|
+
throw new ExitError(1);
|
|
109
124
|
}
|
|
110
125
|
|
|
111
126
|
// チェック通過、pushを実行
|
|
112
127
|
if (options.dryRun) {
|
|
113
128
|
printSuccess("Dry run: would push");
|
|
114
|
-
|
|
129
|
+
throw new ExitError(0);
|
|
115
130
|
}
|
|
116
131
|
|
|
117
132
|
const result = await execPush(gitArgs);
|
|
118
133
|
if (result.success) {
|
|
134
|
+
if (result.output) {
|
|
135
|
+
console.log(result.output);
|
|
136
|
+
}
|
|
119
137
|
printSuccess("Push successful");
|
|
120
|
-
|
|
138
|
+
return;
|
|
121
139
|
} else {
|
|
122
140
|
printError(`Push failed: ${result.output}`);
|
|
123
|
-
|
|
141
|
+
throw new ExitError(1);
|
|
124
142
|
}
|
|
125
|
-
}
|
|
126
|
-
printError(
|
|
127
|
-
`Push failed: ${error instanceof Error ? error.message : String(error)}`
|
|
128
|
-
);
|
|
129
|
-
process.exit(1);
|
|
130
|
-
}
|
|
143
|
+
});
|
|
131
144
|
});
|
|
132
145
|
}
|
package/src/config.ts
CHANGED
|
@@ -4,6 +4,9 @@ import * as os from "node:os";
|
|
|
4
4
|
import * as jsonc from "jsonc-parser";
|
|
5
5
|
import { ConfigSchema, ConfigError, type Config } from "./types";
|
|
6
6
|
|
|
7
|
+
const CONFIG_SCHEMA_URL =
|
|
8
|
+
"https://raw.githubusercontent.com/shoppingjaws/safe-push/main/config.schema.json";
|
|
9
|
+
|
|
7
10
|
/**
|
|
8
11
|
* 設定ファイルのデフォルトパス
|
|
9
12
|
*/
|
|
@@ -92,11 +95,16 @@ export function saveConfig(config: Config, configPath?: string): void {
|
|
|
92
95
|
? `,\n // 許可するリポジトリ visibility: "public" | "private" | "internal"\n "allowedVisibility": ${JSON.stringify(config.allowedVisibility)}`
|
|
93
96
|
: "";
|
|
94
97
|
|
|
98
|
+
const traceSection = config.trace
|
|
99
|
+
? `,\n // トレーシング: "otlp" | "console" (省略で無効)\n "trace": "${config.trace}"`
|
|
100
|
+
: "";
|
|
101
|
+
|
|
95
102
|
const content = `{
|
|
103
|
+
"$schema": "${CONFIG_SCHEMA_URL}",
|
|
96
104
|
// 禁止エリア(Globパターン)
|
|
97
105
|
"forbiddenPaths": ${JSON.stringify(config.forbiddenPaths, null, 4).replace(/\n/g, "\n ")},
|
|
98
106
|
// 禁止時の動作: "error" | "prompt"
|
|
99
|
-
"onForbidden": "${config.onForbidden}"${allowedVisibilitySection}
|
|
107
|
+
"onForbidden": "${config.onForbidden}"${allowedVisibilitySection}${traceSection}
|
|
100
108
|
}
|
|
101
109
|
`;
|
|
102
110
|
|