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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "safe-push",
3
- "version": "0.2.1",
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
@@ -1,11 +1,52 @@
1
- import type { Config, CheckResult } from "./types";
1
+ import type { Config, CheckResult, RepoVisibility } from "./types";
2
2
  import {
3
3
  getCurrentBranch,
4
4
  isNewBranch,
5
5
  getLastCommitAuthorEmail,
6
6
  getLocalEmail,
7
7
  getDiffFiles,
8
+ getRepoVisibility,
8
9
  } from "./git";
10
+ import { withSpan } from "./telemetry";
11
+
12
+ /**
13
+ * Visibility チェック結果
14
+ */
15
+ export interface VisibilityCheckResult {
16
+ allowed: boolean;
17
+ reason: string;
18
+ visibility: string;
19
+ }
20
+
21
+ /**
22
+ * リポジトリの visibility が許可リストに含まれるかチェック
23
+ * allowedVisibility が未設定または空配列の場合は null を返す(チェック不要)
24
+ */
25
+ export async function checkVisibility(
26
+ allowedVisibility?: RepoVisibility[]
27
+ ): Promise<VisibilityCheckResult | null> {
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);
35
+
36
+ span.addEvent("visibility.result", {
37
+ value: visibility,
38
+ allowed,
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
+ });
49
+ }
9
50
 
10
51
  /**
11
52
  * ファイルパスが禁止パターンにマッチするか判定
@@ -55,58 +96,69 @@ function findForbiddenFiles(
55
96
  * (禁止エリア変更なし) AND (新規ブランチ OR 最終コミットが自分)
56
97
  */
57
98
  export async function checkPush(config: Config): Promise<CheckResult> {
58
- const currentBranch = await getCurrentBranch();
59
- const newBranch = await isNewBranch();
60
- const authorEmail = await getLastCommitAuthorEmail();
61
- const localEmail = await getLocalEmail();
62
- const diffFiles = await getDiffFiles();
63
-
64
- const forbiddenFiles = findForbiddenFiles(diffFiles, config.forbiddenPaths);
65
- const hasForbiddenChanges = forbiddenFiles.length > 0;
66
- const isOwnLastCommit =
67
- authorEmail.toLowerCase() === localEmail.toLowerCase();
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();
68
105
 
69
- const details = {
70
- isNewBranch: newBranch,
71
- isOwnLastCommit,
72
- hasForbiddenChanges,
73
- forbiddenFiles,
74
- currentBranch,
75
- authorEmail,
76
- localEmail,
77
- };
106
+ const forbiddenFiles = findForbiddenFiles(diffFiles, config.forbiddenPaths);
107
+ const hasForbiddenChanges = forbiddenFiles.length > 0;
108
+ const isOwnLastCommit =
109
+ authorEmail.toLowerCase() === localEmail.toLowerCase();
78
110
 
79
- // 禁止エリアに変更がある場合は常にブロック
80
- if (hasForbiddenChanges) {
81
- return {
82
- allowed: false,
83
- reason: `Forbidden files detected: ${forbiddenFiles.join(", ")}`,
84
- details,
111
+ const details = {
112
+ isNewBranch: newBranch,
113
+ isOwnLastCommit,
114
+ hasForbiddenChanges,
115
+ forbiddenFiles,
116
+ currentBranch,
117
+ authorEmail,
118
+ localEmail,
85
119
  };
86
- }
87
120
 
88
- // 新規ブランチの場合は許可
89
- if (newBranch) {
90
- return {
91
- allowed: true,
92
- reason: "New branch - no restrictions",
93
- details,
94
- };
95
- }
121
+ let result: CheckResult;
96
122
 
97
- // 最終コミットが自分の場合は許可
98
- if (isOwnLastCommit) {
99
- return {
100
- allowed: true,
101
- reason: "Last commit is yours",
102
- details,
103
- };
104
- }
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
+ });
105
161
 
106
- // それ以外はブロック
107
- return {
108
- allowed: false,
109
- reason: `Last commit is by someone else (${authorEmail})`,
110
- details,
111
- };
162
+ return result;
163
+ });
112
164
  }
@@ -1,8 +1,10 @@
1
1
  import { Command } from "commander";
2
2
  import { loadConfig } from "../config";
3
- import { checkPush } from "../checker";
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,34 +14,59 @@ 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
- try {
17
+ await withSpan("safe-push.check", async (rootSpan) => {
16
18
  // Gitリポジトリ内か確認
17
19
  if (!(await isGitRepository())) {
18
20
  printError("Not a git repository");
19
- process.exit(1);
21
+ throw new ExitError(1);
20
22
  }
21
23
 
22
24
  // コミットが存在するか確認
23
25
  if (!(await hasCommits())) {
24
26
  printError("No commits found");
25
- process.exit(1);
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
 
40
+ // visibility チェック
41
+ if (config.allowedVisibility && config.allowedVisibility.length > 0) {
42
+ try {
43
+ const visibilityResult = await checkVisibility(config.allowedVisibility);
44
+ if (visibilityResult) {
45
+ result.details.repoVisibility = visibilityResult.visibility;
46
+ result.details.visibilityAllowed = visibilityResult.allowed;
47
+ if (!visibilityResult.allowed) {
48
+ result.allowed = false;
49
+ result.reason = visibilityResult.reason;
50
+ }
51
+ }
52
+ } catch (error) {
53
+ if (error instanceof ExitError) throw error;
54
+ result.details.repoVisibility = "unknown";
55
+ result.details.visibilityAllowed = false;
56
+ result.allowed = false;
57
+ result.reason = `Failed to check repository visibility. Ensure 'gh' CLI is installed and authenticated.`;
58
+ }
59
+ }
60
+
31
61
  if (options.json) {
32
62
  printCheckResultJson(result);
33
63
  } else {
34
64
  printCheckResultHuman(result);
35
65
  }
36
66
 
37
- process.exit(result.allowed ? 0 : 1);
38
- } catch (error) {
39
- printError(
40
- `Check failed: ${error instanceof Error ? error.message : String(error)}`
41
- );
42
- process.exit(1);
43
- }
67
+ if (!result.allowed) {
68
+ throw new ExitError(1);
69
+ }
70
+ });
44
71
  });
45
72
  }
@@ -68,6 +68,9 @@ export function createConfigCommand(): Command {
68
68
  ` forbiddenPaths: ${JSON.stringify(configData.forbiddenPaths)}`
69
69
  );
70
70
  console.log(` onForbidden: ${configData.onForbidden}`);
71
+ console.log(
72
+ ` allowedVisibility: ${configData.allowedVisibility ? JSON.stringify(configData.allowedVisibility) : "(not set - all visibilities allowed)"}`
73
+ );
71
74
  console.log("");
72
75
  }
73
76
  } catch (error) {
@@ -1,6 +1,6 @@
1
1
  import { Command } from "commander";
2
2
  import { loadConfig } from "../config";
3
- import { checkPush } from "../checker";
3
+ import { checkPush, checkVisibility } from "../checker";
4
4
  import { isGitRepository, hasCommits, execPush } from "../git";
5
5
  import {
6
6
  printError,
@@ -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,37 +23,63 @@ 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
- try {
26
+ await withSpan("safe-push.push", async (rootSpan) => {
25
27
  // Gitリポジトリ内か確認
26
28
  if (!(await isGitRepository())) {
27
29
  printError("Not a git repository");
28
- process.exit(1);
30
+ throw new ExitError(1);
29
31
  }
30
32
 
31
33
  // コミットが存在するか確認
32
34
  if (!(await hasCommits())) {
33
35
  printError("No commits found");
34
- process.exit(1);
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
+
47
+ // visibility チェック(--force でもバイパスできない)
48
+ if (config.allowedVisibility && config.allowedVisibility.length > 0) {
49
+ try {
50
+ const visibilityResult = await checkVisibility(config.allowedVisibility);
51
+ if (visibilityResult && !visibilityResult.allowed) {
52
+ printError(visibilityResult.reason);
53
+ throw new ExitError(1);
54
+ }
55
+ } catch (error) {
56
+ if (error instanceof ExitError) throw error;
57
+ printError(
58
+ `Failed to check repository visibility. Ensure 'gh' CLI is installed and authenticated.\n ${error instanceof Error ? error.message : String(error)}`
59
+ );
60
+ throw new ExitError(1);
61
+ }
62
+ }
63
+
39
64
  // --forceオプションが指定されている場合はチェックをスキップ
40
65
  if (options.force) {
41
66
  printWarning("Safety checks bypassed with --force");
42
67
 
43
68
  if (options.dryRun) {
44
69
  printSuccess("Dry run: would push (checks bypassed)");
45
- process.exit(0);
70
+ throw new ExitError(0);
46
71
  }
47
72
 
48
73
  const result = await execPush(gitArgs);
49
74
  if (result.success) {
75
+ if (result.output) {
76
+ console.log(result.output);
77
+ }
50
78
  printSuccess("Push successful");
51
- process.exit(0);
79
+ return;
52
80
  } else {
53
81
  printError(`Push failed: ${result.output}`);
54
- process.exit(1);
82
+ throw new ExitError(1);
55
83
  }
56
84
  }
57
85
 
@@ -72,45 +100,46 @@ export function createPushCommand(): Command {
72
100
  if (confirmed) {
73
101
  if (options.dryRun) {
74
102
  printSuccess("Dry run: would push (user confirmed)");
75
- process.exit(0);
103
+ throw new ExitError(0);
76
104
  }
77
105
 
78
106
  const result = await execPush(gitArgs);
79
107
  if (result.success) {
108
+ if (result.output) {
109
+ console.log(result.output);
110
+ }
80
111
  printSuccess("Push successful");
81
- process.exit(0);
112
+ return;
82
113
  } else {
83
114
  printError(`Push failed: ${result.output}`);
84
- process.exit(1);
115
+ throw new ExitError(1);
85
116
  }
86
117
  } else {
87
118
  printError("Push cancelled by user");
88
- process.exit(1);
119
+ throw new ExitError(1);
89
120
  }
90
121
  }
91
122
 
92
- process.exit(1);
123
+ throw new ExitError(1);
93
124
  }
94
125
 
95
126
  // チェック通過、pushを実行
96
127
  if (options.dryRun) {
97
128
  printSuccess("Dry run: would push");
98
- process.exit(0);
129
+ throw new ExitError(0);
99
130
  }
100
131
 
101
132
  const result = await execPush(gitArgs);
102
133
  if (result.success) {
134
+ if (result.output) {
135
+ console.log(result.output);
136
+ }
103
137
  printSuccess("Push successful");
104
- process.exit(0);
138
+ return;
105
139
  } else {
106
140
  printError(`Push failed: ${result.output}`);
107
- process.exit(1);
141
+ throw new ExitError(1);
108
142
  }
109
- } catch (error) {
110
- printError(
111
- `Push failed: ${error instanceof Error ? error.message : String(error)}`
112
- );
113
- process.exit(1);
114
- }
143
+ });
115
144
  });
116
145
  }
@@ -60,6 +60,13 @@ export function printCheckResultHuman(result: CheckResult): void {
60
60
  ` Forbidden changes: ${details.hasForbiddenChanges ? "Yes" : "No"}`
61
61
  );
62
62
 
63
+ if (details.repoVisibility !== undefined) {
64
+ console.log(` Repo visibility: ${details.repoVisibility}`);
65
+ console.log(
66
+ ` Visibility allowed: ${details.visibilityAllowed ? "Yes" : "No"}`
67
+ );
68
+ }
69
+
63
70
  if (details.forbiddenFiles.length > 0) {
64
71
  console.log("");
65
72
  console.log("Forbidden files changed:");
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
  */
@@ -88,11 +91,20 @@ export function saveConfig(config: Config, configPath?: string): void {
88
91
  fs.mkdirSync(dir, { recursive: true });
89
92
  }
90
93
 
94
+ const allowedVisibilitySection = config.allowedVisibility
95
+ ? `,\n // 許可するリポジトリ visibility: "public" | "private" | "internal"\n "allowedVisibility": ${JSON.stringify(config.allowedVisibility)}`
96
+ : "";
97
+
98
+ const traceSection = config.trace
99
+ ? `,\n // トレーシング: "otlp" | "console" (省略で無効)\n "trace": "${config.trace}"`
100
+ : "";
101
+
91
102
  const content = `{
103
+ "$schema": "${CONFIG_SCHEMA_URL}",
92
104
  // 禁止エリア(Globパターン)
93
105
  "forbiddenPaths": ${JSON.stringify(config.forbiddenPaths, null, 4).replace(/\n/g, "\n ")},
94
106
  // 禁止時の動作: "error" | "prompt"
95
- "onForbidden": "${config.onForbidden}"
107
+ "onForbidden": "${config.onForbidden}"${allowedVisibilitySection}${traceSection}
96
108
  }
97
109
  `;
98
110