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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "safe-push",
3
- "version": "0.3.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
- if (!allowedVisibility || allowedVisibility.length === 0) {
28
- return null;
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
- const visibility = await getRepoVisibility();
32
- const allowed = allowedVisibility.includes(visibility as RepoVisibility);
36
+ span.addEvent("visibility.result", {
37
+ value: visibility,
38
+ allowed,
39
+ });
33
40
 
34
- return {
35
- allowed,
36
- reason: allowed
37
- ? `Repository visibility "${visibility}" is allowed`
38
- : `Repository visibility "${visibility}" is not in allowed list: [${allowedVisibility.join(", ")}]`,
39
- visibility,
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
- const currentBranch = await getCurrentBranch();
92
- const newBranch = await isNewBranch();
93
- const authorEmail = await getLastCommitAuthorEmail();
94
- const localEmail = await getLocalEmail();
95
- const diffFiles = await getDiffFiles();
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
- const forbiddenFiles = findForbiddenFiles(diffFiles, config.forbiddenPaths);
98
- const hasForbiddenChanges = forbiddenFiles.length > 0;
99
- const isOwnLastCommit =
100
- authorEmail.toLowerCase() === localEmail.toLowerCase();
106
+ const forbiddenFiles = findForbiddenFiles(diffFiles, config.forbiddenPaths);
107
+ const hasForbiddenChanges = forbiddenFiles.length > 0;
108
+ const isOwnLastCommit =
109
+ authorEmail.toLowerCase() === localEmail.toLowerCase();
101
110
 
102
- const details = {
103
- isNewBranch: newBranch,
104
- isOwnLastCommit,
105
- hasForbiddenChanges,
106
- forbiddenFiles,
107
- currentBranch,
108
- authorEmail,
109
- localEmail,
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
- if (isOwnLastCommit) {
132
- return {
133
- allowed: true,
134
- reason: "Last commit is yours",
135
- details,
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
- return {
141
- allowed: false,
142
- reason: `Last commit is by someone else (${authorEmail})`,
143
- details,
144
- };
162
+ return result;
163
+ });
145
164
  }
@@ -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
- 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
 
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
- process.exit(result.allowed ? 0 : 1);
58
- } catch (error) {
59
- printError(
60
- `Check failed: ${error instanceof Error ? error.message : String(error)}`
61
- );
62
- process.exit(1);
63
- }
67
+ if (!result.allowed) {
68
+ throw new ExitError(1);
69
+ }
70
+ });
64
71
  });
65
72
  }
@@ -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
- 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
+
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
- process.exit(1);
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
- process.exit(1);
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
- process.exit(0);
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
- process.exit(0);
79
+ return;
68
80
  } else {
69
81
  printError(`Push failed: ${result.output}`);
70
- process.exit(1);
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
- process.exit(0);
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
- process.exit(0);
112
+ return;
98
113
  } else {
99
114
  printError(`Push failed: ${result.output}`);
100
- process.exit(1);
115
+ throw new ExitError(1);
101
116
  }
102
117
  } else {
103
118
  printError("Push cancelled by user");
104
- process.exit(1);
119
+ throw new ExitError(1);
105
120
  }
106
121
  }
107
122
 
108
- process.exit(1);
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
- process.exit(0);
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
- process.exit(0);
138
+ return;
121
139
  } else {
122
140
  printError(`Push failed: ${result.output}`);
123
- process.exit(1);
141
+ throw new ExitError(1);
124
142
  }
125
- } catch (error) {
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