safe-push 0.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.
package/src/git.ts ADDED
@@ -0,0 +1,162 @@
1
+ import { $ } from "bun";
2
+ import { GitError } from "./types";
3
+
4
+ /**
5
+ * Gitコマンドを実行し、結果を返す
6
+ */
7
+ async function execGit(args: string[]): Promise<string> {
8
+ const command = `git ${args.join(" ")}`;
9
+ try {
10
+ const result = await $`git ${args}`.quiet();
11
+ return result.stdout.toString().trim();
12
+ } catch (error) {
13
+ if (error && typeof error === "object" && "exitCode" in error) {
14
+ const exitCode = (error as { exitCode: number }).exitCode;
15
+ const stderr =
16
+ "stderr" in error ? String((error as { stderr: unknown }).stderr) : "";
17
+ throw new GitError(
18
+ `Git command failed: ${stderr || command}`,
19
+ command,
20
+ exitCode
21
+ );
22
+ }
23
+ throw new GitError(`Git command failed: ${command}`, command, null);
24
+ }
25
+ }
26
+
27
+ /**
28
+ * 現在のブランチ名を取得
29
+ */
30
+ export async function getCurrentBranch(): Promise<string> {
31
+ return execGit(["rev-parse", "--abbrev-ref", "HEAD"]);
32
+ }
33
+
34
+ /**
35
+ * リモートに存在しない新規ブランチかどうかを判定
36
+ */
37
+ 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
+ }
45
+ }
46
+
47
+ /**
48
+ * 最後のコミットの作者メールアドレスを取得
49
+ */
50
+ export async function getLastCommitAuthorEmail(): Promise<string> {
51
+ return execGit(["log", "-1", "--format=%ae"]);
52
+ }
53
+
54
+ /**
55
+ * ローカルのGit設定からメールアドレスを取得
56
+ */
57
+ export async function getLocalEmail(): Promise<string> {
58
+ // 環境変数が設定されている場合はそちらを優先
59
+ const envEmail = process.env.SAFE_PUSH_EMAIL;
60
+ if (envEmail) {
61
+ return envEmail;
62
+ }
63
+
64
+ return execGit(["config", "user.email"]);
65
+ }
66
+
67
+ /**
68
+ * リモートとの差分ファイル一覧を取得
69
+ * 新規ブランチの場合はmainまたはmasterとの差分を取得
70
+ */
71
+ export async function getDiffFiles(remote = "origin"): Promise<string[]> {
72
+ const branch = await getCurrentBranch();
73
+ const isNew = await isNewBranch(remote);
74
+
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 {
82
+ try {
83
+ await execGit(["rev-parse", "--verify", `${remote}/master`]);
84
+ baseBranch = `${remote}/master`;
85
+ } catch {
86
+ // mainもmasterもない場合は空の配列を返す
87
+ return [];
88
+ }
89
+ }
90
+ } else {
91
+ baseBranch = `${remote}/${branch}`;
92
+ }
93
+
94
+ const output = await execGit(["diff", "--name-only", baseBranch, "HEAD"]);
95
+ if (!output) {
96
+ return [];
97
+ }
98
+
99
+ return output.split("\n").filter(Boolean);
100
+ }
101
+
102
+ /**
103
+ * git pushを実行
104
+ */
105
+ export async function execPush(
106
+ args: string[] = [],
107
+ remote = "origin"
108
+ ): Promise<{ success: boolean; output: string }> {
109
+ const branch = await getCurrentBranch();
110
+ const isNew = await isNewBranch(remote);
111
+
112
+ const pushArgs = ["push"];
113
+
114
+ // 新規ブランチの場合は-uオプションを追加
115
+ if (isNew) {
116
+ pushArgs.push("-u");
117
+ }
118
+
119
+ pushArgs.push(remote, branch);
120
+
121
+ // 追加の引数がある場合
122
+ if (args.length > 0) {
123
+ pushArgs.push(...args);
124
+ }
125
+
126
+ try {
127
+ const output = await execGit(pushArgs);
128
+ return { success: true, output };
129
+ } catch (error) {
130
+ if (error instanceof GitError) {
131
+ return { success: false, output: error.message };
132
+ }
133
+ return {
134
+ success: false,
135
+ output: error instanceof Error ? error.message : String(error),
136
+ };
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Gitリポジトリ内かどうかを確認
142
+ */
143
+ export async function isGitRepository(): Promise<boolean> {
144
+ try {
145
+ await execGit(["rev-parse", "--git-dir"]);
146
+ return true;
147
+ } catch {
148
+ return false;
149
+ }
150
+ }
151
+
152
+ /**
153
+ * コミットが存在するか確認
154
+ */
155
+ export async function hasCommits(): Promise<boolean> {
156
+ try {
157
+ await execGit(["rev-parse", "HEAD"]);
158
+ return true;
159
+ } catch {
160
+ return false;
161
+ }
162
+ }
package/src/index.ts ADDED
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env bun
2
+ import { Command } from "commander";
3
+ import { createCheckCommand } from "./commands/check";
4
+ import { createPushCommand } from "./commands/push";
5
+ import { createConfigCommand } from "./commands/config";
6
+
7
+ const program = new Command();
8
+
9
+ program
10
+ .name("safe-push")
11
+ .description("Git push safety checker - blocks pushes to forbidden areas")
12
+ .version("0.1.0");
13
+
14
+ program.addCommand(createCheckCommand());
15
+ program.addCommand(createPushCommand());
16
+ program.addCommand(createConfigCommand());
17
+
18
+ program.parse();
package/src/types.ts ADDED
@@ -0,0 +1,60 @@
1
+ import { z } from "zod";
2
+
3
+ /**
4
+ * 禁止時の動作
5
+ */
6
+ export const OnForbiddenSchema = z.enum(["error", "prompt"]);
7
+ export type OnForbidden = z.infer<typeof OnForbiddenSchema>;
8
+
9
+ /**
10
+ * 設定ファイルのスキーマ
11
+ */
12
+ export const ConfigSchema = z.object({
13
+ forbiddenPaths: z.array(z.string()).default([".github/"]),
14
+ onForbidden: OnForbiddenSchema.default("error"),
15
+ });
16
+ export type Config = z.infer<typeof ConfigSchema>;
17
+
18
+ /**
19
+ * Pushチェック結果
20
+ */
21
+ export interface CheckResult {
22
+ allowed: boolean;
23
+ reason: string;
24
+ details: {
25
+ isNewBranch: boolean;
26
+ isOwnLastCommit: boolean;
27
+ hasForbiddenChanges: boolean;
28
+ forbiddenFiles: string[];
29
+ currentBranch: string;
30
+ authorEmail: string;
31
+ localEmail: string;
32
+ };
33
+ }
34
+
35
+ /**
36
+ * Git操作関連のエラー
37
+ */
38
+ export class GitError extends Error {
39
+ constructor(
40
+ message: string,
41
+ public readonly command: string,
42
+ public readonly exitCode: number | null
43
+ ) {
44
+ super(message);
45
+ this.name = "GitError";
46
+ }
47
+ }
48
+
49
+ /**
50
+ * 設定ファイル関連のエラー
51
+ */
52
+ export class ConfigError extends Error {
53
+ constructor(
54
+ message: string,
55
+ public readonly path?: string
56
+ ) {
57
+ super(message);
58
+ this.name = "ConfigError";
59
+ }
60
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "declaration": false,
5
+ "declarationMap": false
6
+ },
7
+ "exclude": ["node_modules", "dist", "**/*.test.ts"]
8
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "strict": true,
7
+ "esModuleInterop": true,
8
+ "skipLibCheck": true,
9
+ "forceConsistentCasingInFileNames": true,
10
+ "resolveJsonModule": true,
11
+ "declaration": true,
12
+ "declarationMap": true,
13
+ "outDir": "./dist",
14
+ "rootDir": "./src",
15
+ "types": ["bun-types", "node"]
16
+ },
17
+ "include": ["src/**/*"],
18
+ "exclude": ["node_modules", "dist"]
19
+ }