safe-push 0.2.1 → 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/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 execGit(["rev-parse", "--abbrev-ref", "HEAD"]);
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
- const branch = await getCurrentBranch();
39
- try {
40
- await execGit(["rev-parse", "--verify", `${remote}/${branch}`]);
41
- return false;
42
- } catch {
43
- return true;
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 execGit(["log", "-1", "--format=%ae"]);
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
- const envEmail = process.env.SAFE_PUSH_EMAIL;
60
- if (envEmail) {
61
- return envEmail;
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
- return execGit(["config", "user.email"]);
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
- const branch = await getCurrentBranch();
73
- const isNew = await isNewBranch(remote);
81
+ return withSpan("safe-push.git.getDiffFiles", async () => {
82
+ const branch = await getCurrentBranch();
83
+ const isNew = await isNewBranch(remote);
74
84
 
75
- let baseBranch: string;
76
- if (isNew) {
77
- // 新規ブランチの場合、mainまたはmasterを基準にする
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}/master`]);
84
- baseBranch = `${remote}/master`;
89
+ await execGit(["rev-parse", "--verify", `${remote}/main`]);
90
+ baseBranch = `${remote}/main`;
85
91
  } catch {
86
- // mainもmasterもない場合は空の配列を返す
87
- return [];
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
- const output = await execGit(["diff", "--name-only", baseBranch, "HEAD"]);
95
- if (!output) {
96
- return [];
97
- }
104
+ const output = await execGit(["diff", "--name-only", baseBranch, "HEAD"]);
105
+ if (!output) {
106
+ return [];
107
+ }
98
108
 
99
- return output.split("\n").filter(Boolean);
109
+ return output.split("\n").filter(Boolean);
110
+ });
100
111
  }
101
112
 
102
113
  /**
@@ -106,72 +117,120 @@ export async function execPush(
106
117
  args: string[] = [],
107
118
  remote = "origin"
108
119
  ): Promise<{ success: boolean; output: string }> {
109
- let pushArgs: string[];
120
+ return withSpan("safe-push.git.execPush", async (span) => {
121
+ let pushArgs: string[];
110
122
 
111
- // ユーザーがremote/refspecを明示的に指定したか判定
112
- const hasUserRefspec = args.some((arg) => !arg.startsWith("-"));
123
+ // ユーザーがremote/refspecを明示的に指定したか判定
124
+ const hasUserRefspec = args.some((arg) => !arg.startsWith("-"));
113
125
 
114
- if (hasUserRefspec) {
115
- // ユーザー指定のremote/refspecをそのまま使用
116
- pushArgs = ["push", ...args];
117
- } else {
118
- // 自動でremote/branchを決定
119
- const branch = await getCurrentBranch();
120
- const isNew = await isNewBranch(remote);
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
- pushArgs = ["push"];
134
+ pushArgs = ["push"];
123
135
 
124
- // 新規ブランチ、またはユーザーが-uを指定した場合に追加
125
- const hasSetUpstream = args.some(
126
- (a) => a === "-u" || a === "--set-upstream"
127
- );
128
- if (isNew || hasSetUpstream) {
129
- pushArgs.push("-u");
136
+ // 新規ブランチ、またはユーザーが-uを指定した場合に追加
137
+ const hasSetUpstream = args.some(
138
+ (a) => a === "-u" || a === "--set-upstream"
139
+ );
140
+ if (isNew || hasSetUpstream) {
141
+ pushArgs.push("-u");
142
+ }
143
+
144
+ pushArgs.push(remote, branch);
145
+
146
+ // -u/--set-upstream以外のフラグを追加
147
+ const remainingFlags = args.filter(
148
+ (a) => a !== "-u" && a !== "--set-upstream"
149
+ );
150
+ pushArgs.push(...remainingFlags);
130
151
  }
131
152
 
132
- pushArgs.push(remote, branch);
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
+ }
174
+ }
133
175
 
134
- // -u/--set-upstream以外のフラグを追加
135
- const remainingFlags = args.filter(
136
- (a) => a !== "-u" && a !== "--set-upstream"
137
- );
138
- pushArgs.push(...remainingFlags);
139
- }
176
+ span.addEvent("push.result", {
177
+ success: result.success,
178
+ hasUserRefspec,
179
+ });
140
180
 
141
- try {
142
- const output = await execGit(pushArgs);
143
- return { success: true, output };
144
- } catch (error) {
145
- if (error instanceof GitError) {
146
- return { success: false, output: error.message };
181
+ return result;
182
+ });
183
+ }
184
+
185
+ /**
186
+ * リポジトリの visibility を取得(gh CLI を使用)
187
+ */
188
+ export async function getRepoVisibility(): Promise<string> {
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);
147
206
  }
148
- return {
149
- success: false,
150
- output: error instanceof Error ? error.message : String(error),
151
- };
152
- }
207
+ });
153
208
  }
154
209
 
155
210
  /**
156
211
  * Gitリポジトリ内かどうかを確認
157
212
  */
158
213
  export async function isGitRepository(): Promise<boolean> {
159
- try {
160
- await execGit(["rev-parse", "--git-dir"]);
161
- return true;
162
- } catch {
163
- return false;
164
- }
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
+ });
165
222
  }
166
223
 
167
224
  /**
168
225
  * コミットが存在するか確認
169
226
  */
170
227
  export async function hasCommits(): Promise<boolean> {
171
- try {
172
- await execGit(["rev-parse", "HEAD"]);
173
- return true;
174
- } catch {
175
- return false;
176
- }
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
+ });
177
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.1.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
- program.parse();
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);
@@ -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
@@ -6,12 +6,26 @@ import { z } from "zod";
6
6
  export const OnForbiddenSchema = z.enum(["error", "prompt"]);
7
7
  export type OnForbidden = z.infer<typeof OnForbiddenSchema>;
8
8
 
9
+ /**
10
+ * リポジトリの visibility
11
+ */
12
+ export const RepoVisibilitySchema = z.enum(["public", "private", "internal"]);
13
+ export type RepoVisibility = z.infer<typeof RepoVisibilitySchema>;
14
+
15
+ /**
16
+ * トレースエクスポーター
17
+ */
18
+ export const TraceExporterSchema = z.enum(["otlp", "console"]);
19
+ export type TraceExporter = z.infer<typeof TraceExporterSchema>;
20
+
9
21
  /**
10
22
  * 設定ファイルのスキーマ
11
23
  */
12
24
  export const ConfigSchema = z.object({
13
25
  forbiddenPaths: z.array(z.string()).default([".github/"]),
14
26
  onForbidden: OnForbiddenSchema.default("error"),
27
+ allowedVisibility: z.array(RepoVisibilitySchema).optional(),
28
+ trace: TraceExporterSchema.optional(),
15
29
  });
16
30
  export type Config = z.infer<typeof ConfigSchema>;
17
31
 
@@ -29,6 +43,8 @@ export interface CheckResult {
29
43
  currentBranch: string;
30
44
  authorEmail: string;
31
45
  localEmail: string;
46
+ repoVisibility?: string;
47
+ visibilityAllowed?: boolean;
32
48
  };
33
49
  }
34
50
 
@@ -58,3 +74,14 @@ export class ConfigError extends Error {
58
74
  this.name = "ConfigError";
59
75
  }
60
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
+ }