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/src/git.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { $ } from "bun";
|
|
2
2
|
import { GitError } from "./types";
|
|
3
|
+
import { withSpan } from "./telemetry";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Gitコマンドを実行し、結果を返す
|
|
@@ -28,40 +29,48 @@ async function execGit(args: string[]): Promise<string> {
|
|
|
28
29
|
* 現在のブランチ名を取得
|
|
29
30
|
*/
|
|
30
31
|
export async function getCurrentBranch(): Promise<string> {
|
|
31
|
-
return
|
|
32
|
+
return withSpan("safe-push.git.getCurrentBranch", async () => {
|
|
33
|
+
return execGit(["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
34
|
+
});
|
|
32
35
|
}
|
|
33
36
|
|
|
34
37
|
/**
|
|
35
38
|
* リモートに存在しない新規ブランチかどうかを判定
|
|
36
39
|
*/
|
|
37
40
|
export async function isNewBranch(remote = "origin"): Promise<boolean> {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
41
|
+
return withSpan("safe-push.git.isNewBranch", async () => {
|
|
42
|
+
const branch = await getCurrentBranch();
|
|
43
|
+
try {
|
|
44
|
+
await execGit(["rev-parse", "--verify", `${remote}/${branch}`]);
|
|
45
|
+
return false;
|
|
46
|
+
} catch {
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
});
|
|
45
50
|
}
|
|
46
51
|
|
|
47
52
|
/**
|
|
48
53
|
* 最後のコミットの作者メールアドレスを取得
|
|
49
54
|
*/
|
|
50
55
|
export async function getLastCommitAuthorEmail(): Promise<string> {
|
|
51
|
-
return
|
|
56
|
+
return withSpan("safe-push.git.getLastCommitAuthorEmail", async () => {
|
|
57
|
+
return execGit(["log", "-1", "--format=%ae"]);
|
|
58
|
+
});
|
|
52
59
|
}
|
|
53
60
|
|
|
54
61
|
/**
|
|
55
62
|
* ローカルのGit設定からメールアドレスを取得
|
|
56
63
|
*/
|
|
57
64
|
export async function getLocalEmail(): Promise<string> {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
65
|
+
return withSpan("safe-push.git.getLocalEmail", async () => {
|
|
66
|
+
// 環境変数が設定されている場合はそちらを優先
|
|
67
|
+
const envEmail = process.env.SAFE_PUSH_EMAIL;
|
|
68
|
+
if (envEmail) {
|
|
69
|
+
return envEmail;
|
|
70
|
+
}
|
|
63
71
|
|
|
64
|
-
|
|
72
|
+
return execGit(["config", "user.email"]);
|
|
73
|
+
});
|
|
65
74
|
}
|
|
66
75
|
|
|
67
76
|
/**
|
|
@@ -69,34 +78,36 @@ export async function getLocalEmail(): Promise<string> {
|
|
|
69
78
|
* 新規ブランチの場合はmainまたはmasterとの差分を取得
|
|
70
79
|
*/
|
|
71
80
|
export async function getDiffFiles(remote = "origin"): Promise<string[]> {
|
|
72
|
-
|
|
73
|
-
|
|
81
|
+
return withSpan("safe-push.git.getDiffFiles", async () => {
|
|
82
|
+
const branch = await getCurrentBranch();
|
|
83
|
+
const isNew = await isNewBranch(remote);
|
|
74
84
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
try {
|
|
79
|
-
await execGit(["rev-parse", "--verify", `${remote}/main`]);
|
|
80
|
-
baseBranch = `${remote}/main`;
|
|
81
|
-
} catch {
|
|
85
|
+
let baseBranch: string;
|
|
86
|
+
if (isNew) {
|
|
87
|
+
// 新規ブランチの場合、mainまたはmasterを基準にする
|
|
82
88
|
try {
|
|
83
|
-
await execGit(["rev-parse", "--verify", `${remote}/
|
|
84
|
-
baseBranch = `${remote}/
|
|
89
|
+
await execGit(["rev-parse", "--verify", `${remote}/main`]);
|
|
90
|
+
baseBranch = `${remote}/main`;
|
|
85
91
|
} catch {
|
|
86
|
-
|
|
87
|
-
|
|
92
|
+
try {
|
|
93
|
+
await execGit(["rev-parse", "--verify", `${remote}/master`]);
|
|
94
|
+
baseBranch = `${remote}/master`;
|
|
95
|
+
} catch {
|
|
96
|
+
// mainもmasterもない場合は空の配列を返す
|
|
97
|
+
return [];
|
|
98
|
+
}
|
|
88
99
|
}
|
|
100
|
+
} else {
|
|
101
|
+
baseBranch = `${remote}/${branch}`;
|
|
89
102
|
}
|
|
90
|
-
} else {
|
|
91
|
-
baseBranch = `${remote}/${branch}`;
|
|
92
|
-
}
|
|
93
103
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
104
|
+
const output = await execGit(["diff", "--name-only", baseBranch, "HEAD"]);
|
|
105
|
+
if (!output) {
|
|
106
|
+
return [];
|
|
107
|
+
}
|
|
98
108
|
|
|
99
|
-
|
|
109
|
+
return output.split("\n").filter(Boolean);
|
|
110
|
+
});
|
|
100
111
|
}
|
|
101
112
|
|
|
102
113
|
/**
|
|
@@ -106,95 +117,120 @@ export async function execPush(
|
|
|
106
117
|
args: string[] = [],
|
|
107
118
|
remote = "origin"
|
|
108
119
|
): Promise<{ success: boolean; output: string }> {
|
|
109
|
-
|
|
120
|
+
return withSpan("safe-push.git.execPush", async (span) => {
|
|
121
|
+
let pushArgs: string[];
|
|
110
122
|
|
|
111
|
-
|
|
112
|
-
|
|
123
|
+
// ユーザーがremote/refspecを明示的に指定したか判定
|
|
124
|
+
const hasUserRefspec = args.some((arg) => !arg.startsWith("-"));
|
|
113
125
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
126
|
+
if (hasUserRefspec) {
|
|
127
|
+
// ユーザー指定のremote/refspecをそのまま使用
|
|
128
|
+
pushArgs = ["push", ...args];
|
|
129
|
+
} else {
|
|
130
|
+
// 自動でremote/branchを決定
|
|
131
|
+
const branch = await getCurrentBranch();
|
|
132
|
+
const isNew = await isNewBranch(remote);
|
|
121
133
|
|
|
122
|
-
|
|
134
|
+
pushArgs = ["push"];
|
|
123
135
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
136
|
+
// 新規ブランチ、またはユーザーが-uを指定した場合に追加
|
|
137
|
+
const hasSetUpstream = args.some(
|
|
138
|
+
(a) => a === "-u" || a === "--set-upstream"
|
|
139
|
+
);
|
|
140
|
+
if (isNew || hasSetUpstream) {
|
|
141
|
+
pushArgs.push("-u");
|
|
142
|
+
}
|
|
131
143
|
|
|
132
|
-
|
|
144
|
+
pushArgs.push(remote, branch);
|
|
133
145
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
146
|
+
// -u/--set-upstream以外のフラグを追加
|
|
147
|
+
const remainingFlags = args.filter(
|
|
148
|
+
(a) => a !== "-u" && a !== "--set-upstream"
|
|
149
|
+
);
|
|
150
|
+
pushArgs.push(...remainingFlags);
|
|
151
|
+
}
|
|
140
152
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
153
|
+
let result: { success: boolean; output: string };
|
|
154
|
+
try {
|
|
155
|
+
const proc = await $`git ${pushArgs}`.quiet();
|
|
156
|
+
const stdout = proc.stdout.toString().trim();
|
|
157
|
+
const stderr = proc.stderr.toString().trim();
|
|
158
|
+
const output = [stdout, stderr].filter(Boolean).join("\n");
|
|
159
|
+
result = { success: true, output };
|
|
160
|
+
} catch (error) {
|
|
161
|
+
if (error && typeof error === "object" && "exitCode" in error) {
|
|
162
|
+
const stderr =
|
|
163
|
+
"stderr" in error ? String((error as { stderr: unknown }).stderr).trim() : "";
|
|
164
|
+
const stdout =
|
|
165
|
+
"stdout" in error ? String((error as { stdout: unknown }).stdout).trim() : "";
|
|
166
|
+
const output = [stdout, stderr].filter(Boolean).join("\n");
|
|
167
|
+
result = { success: false, output: output || `Push failed with exit code ${(error as { exitCode: number }).exitCode}` };
|
|
168
|
+
} else {
|
|
169
|
+
result = {
|
|
170
|
+
success: false,
|
|
171
|
+
output: error instanceof Error ? error.message : String(error),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
147
174
|
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
175
|
+
|
|
176
|
+
span.addEvent("push.result", {
|
|
177
|
+
success: result.success,
|
|
178
|
+
hasUserRefspec,
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
return result;
|
|
182
|
+
});
|
|
153
183
|
}
|
|
154
184
|
|
|
155
185
|
/**
|
|
156
186
|
* リポジトリの visibility を取得(gh CLI を使用)
|
|
157
187
|
*/
|
|
158
188
|
export async function getRepoVisibility(): Promise<string> {
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
189
|
+
return withSpan("safe-push.git.getRepoVisibility", async () => {
|
|
190
|
+
const command = "gh repo view --json visibility --jq '.visibility'";
|
|
191
|
+
try {
|
|
192
|
+
const result = await $`gh repo view --json visibility --jq .visibility`.quiet();
|
|
193
|
+
return result.stdout.toString().trim().toLowerCase();
|
|
194
|
+
} catch (error) {
|
|
195
|
+
if (error && typeof error === "object" && "exitCode" in error) {
|
|
196
|
+
const exitCode = (error as { exitCode: number }).exitCode;
|
|
197
|
+
const stderr =
|
|
198
|
+
"stderr" in error ? String((error as { stderr: unknown }).stderr) : "";
|
|
199
|
+
throw new GitError(
|
|
200
|
+
`Failed to get repository visibility: ${stderr || command}`,
|
|
201
|
+
command,
|
|
202
|
+
exitCode
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
throw new GitError(`Failed to get repository visibility: ${command}`, command, null);
|
|
173
206
|
}
|
|
174
|
-
|
|
175
|
-
}
|
|
207
|
+
});
|
|
176
208
|
}
|
|
177
209
|
|
|
178
210
|
/**
|
|
179
211
|
* Gitリポジトリ内かどうかを確認
|
|
180
212
|
*/
|
|
181
213
|
export async function isGitRepository(): Promise<boolean> {
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
214
|
+
return withSpan("safe-push.git.isGitRepository", async () => {
|
|
215
|
+
try {
|
|
216
|
+
await execGit(["rev-parse", "--git-dir"]);
|
|
217
|
+
return true;
|
|
218
|
+
} catch {
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
});
|
|
188
222
|
}
|
|
189
223
|
|
|
190
224
|
/**
|
|
191
225
|
* コミットが存在するか確認
|
|
192
226
|
*/
|
|
193
227
|
export async function hasCommits(): Promise<boolean> {
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
228
|
+
return withSpan("safe-push.git.hasCommits", async () => {
|
|
229
|
+
try {
|
|
230
|
+
await execGit(["rev-parse", "HEAD"]);
|
|
231
|
+
return true;
|
|
232
|
+
} catch {
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
});
|
|
200
236
|
}
|
package/src/index.ts
CHANGED
|
@@ -3,16 +3,51 @@ import { Command } from "commander";
|
|
|
3
3
|
import { createCheckCommand } from "./commands/check";
|
|
4
4
|
import { createPushCommand } from "./commands/push";
|
|
5
5
|
import { createConfigCommand } from "./commands/config";
|
|
6
|
+
import { initTelemetry, shutdownTelemetry } from "./telemetry";
|
|
7
|
+
import { loadConfig } from "./config";
|
|
8
|
+
import { ExitError } from "./types";
|
|
6
9
|
|
|
7
10
|
const program = new Command();
|
|
8
11
|
|
|
9
12
|
program
|
|
10
13
|
.name("safe-push")
|
|
11
14
|
.description("Git push safety checker - blocks pushes to forbidden areas")
|
|
12
|
-
.version("0.
|
|
15
|
+
.version("0.3.0")
|
|
16
|
+
.option("--trace [exporter]", "Enable OpenTelemetry tracing (otlp|console)");
|
|
17
|
+
|
|
18
|
+
program.hook("preAction", async () => {
|
|
19
|
+
const traceOpt = program.opts().trace;
|
|
20
|
+
|
|
21
|
+
// CLI フラグ優先、なければ config を参照
|
|
22
|
+
let exporter: "otlp" | "console" | undefined;
|
|
23
|
+
if (traceOpt) {
|
|
24
|
+
exporter = traceOpt === "otlp" ? "otlp" : "console";
|
|
25
|
+
} else {
|
|
26
|
+
const config = loadConfig();
|
|
27
|
+
if (config.trace) {
|
|
28
|
+
exporter = config.trace;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (exporter) {
|
|
33
|
+
await initTelemetry(exporter);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
13
36
|
|
|
14
37
|
program.addCommand(createCheckCommand());
|
|
15
38
|
program.addCommand(createPushCommand());
|
|
16
39
|
program.addCommand(createConfigCommand());
|
|
17
40
|
|
|
18
|
-
|
|
41
|
+
let exitCode = 0;
|
|
42
|
+
try {
|
|
43
|
+
await program.parseAsync();
|
|
44
|
+
} catch (error) {
|
|
45
|
+
if (error instanceof ExitError) {
|
|
46
|
+
exitCode = error.exitCode;
|
|
47
|
+
} else {
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
} finally {
|
|
51
|
+
await shutdownTelemetry();
|
|
52
|
+
}
|
|
53
|
+
process.exit(exitCode);
|
package/src/telemetry.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { trace, type Span, SpanStatusCode, type Attributes } from "@opentelemetry/api";
|
|
2
|
+
|
|
3
|
+
const TRACER_NAME = "safe-push";
|
|
4
|
+
|
|
5
|
+
let shutdownFn: (() => Promise<void>) | null = null;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* OpenTelemetry トレーシングを初期化する。
|
|
9
|
+
* SDK は動的 import で読み込み、--trace 未使用時はゼロオーバーヘッドとする。
|
|
10
|
+
*/
|
|
11
|
+
export async function initTelemetry(exporter: "otlp" | "console"): Promise<void> {
|
|
12
|
+
const { BasicTracerProvider, SimpleSpanProcessor } = await import(
|
|
13
|
+
"@opentelemetry/sdk-trace-base"
|
|
14
|
+
);
|
|
15
|
+
const { resourceFromAttributes } = await import("@opentelemetry/resources");
|
|
16
|
+
const { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } = await import(
|
|
17
|
+
"@opentelemetry/semantic-conventions"
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
let spanExporter;
|
|
21
|
+
if (exporter === "otlp") {
|
|
22
|
+
const { OTLPTraceExporter } = await import(
|
|
23
|
+
"@opentelemetry/exporter-trace-otlp-http"
|
|
24
|
+
);
|
|
25
|
+
spanExporter = new OTLPTraceExporter();
|
|
26
|
+
} else {
|
|
27
|
+
const { ConsoleSpanExporter } = await import(
|
|
28
|
+
"@opentelemetry/sdk-trace-base"
|
|
29
|
+
);
|
|
30
|
+
spanExporter = new ConsoleSpanExporter();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const resource = resourceFromAttributes({
|
|
34
|
+
[ATTR_SERVICE_NAME]: "safe-push",
|
|
35
|
+
[ATTR_SERVICE_VERSION]: "0.3.0",
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const provider = new BasicTracerProvider({
|
|
39
|
+
resource,
|
|
40
|
+
spanProcessors: [new SimpleSpanProcessor(spanExporter)],
|
|
41
|
+
});
|
|
42
|
+
trace.setGlobalTracerProvider(provider);
|
|
43
|
+
|
|
44
|
+
shutdownFn = async () => {
|
|
45
|
+
await provider.forceFlush();
|
|
46
|
+
await provider.shutdown();
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* トレーシングをシャットダウンし、バッファ内のスパンをフラッシュする。
|
|
52
|
+
*/
|
|
53
|
+
export async function shutdownTelemetry(): Promise<void> {
|
|
54
|
+
if (shutdownFn) {
|
|
55
|
+
await shutdownFn();
|
|
56
|
+
shutdownFn = null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* トレーサーを取得する。
|
|
62
|
+
* initTelemetry() 未呼び出し時は no-op tracer が返る。
|
|
63
|
+
*/
|
|
64
|
+
export function getTracer() {
|
|
65
|
+
return trace.getTracer(TRACER_NAME);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* スパンを作成し、関数を実行する。エラー時はスパンにエラーを記録して再 throw する。
|
|
70
|
+
*/
|
|
71
|
+
export async function withSpan<T>(
|
|
72
|
+
name: string,
|
|
73
|
+
fn: (span: Span) => Promise<T>,
|
|
74
|
+
attributes?: Attributes,
|
|
75
|
+
): Promise<T> {
|
|
76
|
+
const tracer = getTracer();
|
|
77
|
+
return tracer.startActiveSpan(name, { attributes }, async (span) => {
|
|
78
|
+
try {
|
|
79
|
+
const result = await fn(span);
|
|
80
|
+
span.setStatus({ code: SpanStatusCode.OK });
|
|
81
|
+
return result;
|
|
82
|
+
} catch (error) {
|
|
83
|
+
span.setStatus({
|
|
84
|
+
code: SpanStatusCode.ERROR,
|
|
85
|
+
message: error instanceof Error ? error.message : String(error),
|
|
86
|
+
});
|
|
87
|
+
span.recordException(
|
|
88
|
+
error instanceof Error ? error : new Error(String(error)),
|
|
89
|
+
);
|
|
90
|
+
throw error;
|
|
91
|
+
} finally {
|
|
92
|
+
span.end();
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -12,6 +12,12 @@ export type OnForbidden = z.infer<typeof OnForbiddenSchema>;
|
|
|
12
12
|
export const RepoVisibilitySchema = z.enum(["public", "private", "internal"]);
|
|
13
13
|
export type RepoVisibility = z.infer<typeof RepoVisibilitySchema>;
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* トレースエクスポーター
|
|
17
|
+
*/
|
|
18
|
+
export const TraceExporterSchema = z.enum(["otlp", "console"]);
|
|
19
|
+
export type TraceExporter = z.infer<typeof TraceExporterSchema>;
|
|
20
|
+
|
|
15
21
|
/**
|
|
16
22
|
* 設定ファイルのスキーマ
|
|
17
23
|
*/
|
|
@@ -19,6 +25,7 @@ export const ConfigSchema = z.object({
|
|
|
19
25
|
forbiddenPaths: z.array(z.string()).default([".github/"]),
|
|
20
26
|
onForbidden: OnForbiddenSchema.default("error"),
|
|
21
27
|
allowedVisibility: z.array(RepoVisibilitySchema).optional(),
|
|
28
|
+
trace: TraceExporterSchema.optional(),
|
|
22
29
|
});
|
|
23
30
|
export type Config = z.infer<typeof ConfigSchema>;
|
|
24
31
|
|
|
@@ -67,3 +74,14 @@ export class ConfigError extends Error {
|
|
|
67
74
|
this.name = "ConfigError";
|
|
68
75
|
}
|
|
69
76
|
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* コマンドハンドラから exit code を伝搬するためのエラー。
|
|
80
|
+
* process.exit() を直接呼ばず、index.ts で shutdown 後に exit する。
|
|
81
|
+
*/
|
|
82
|
+
export class ExitError extends Error {
|
|
83
|
+
constructor(public readonly exitCode: number) {
|
|
84
|
+
super(`Process exiting with code ${exitCode}`);
|
|
85
|
+
this.name = "ExitError";
|
|
86
|
+
}
|
|
87
|
+
}
|