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/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "safe-push",
3
+ "version": "0.1.0",
4
+ "description": "Git push safety checker - blocks pushes to forbidden areas",
5
+ "type": "module",
6
+ "bin": {
7
+ "safe-push": "./dist/index.js"
8
+ },
9
+ "scripts": {
10
+ "build": "bun build ./src/index.ts --outdir ./dist --target bun",
11
+ "typecheck": "tsc --noEmit",
12
+ "dev": "bun run ./src/index.ts"
13
+ },
14
+ "dependencies": {
15
+ "commander": "^12.1.0",
16
+ "jsonc-parser": "^3.3.1",
17
+ "zod": "^3.23.8"
18
+ },
19
+ "devDependencies": {
20
+ "@types/bun": "latest",
21
+ "@types/node": "^22.10.0",
22
+ "typescript": "^5.7.2"
23
+ }
24
+ }
package/src/checker.ts ADDED
@@ -0,0 +1,112 @@
1
+ import type { Config, CheckResult } from "./types";
2
+ import {
3
+ getCurrentBranch,
4
+ isNewBranch,
5
+ getLastCommitAuthorEmail,
6
+ getLocalEmail,
7
+ getDiffFiles,
8
+ } from "./git";
9
+
10
+ /**
11
+ * ファイルパスが禁止パターンにマッチするか判定
12
+ */
13
+ function matchesForbiddenPath(
14
+ filePath: string,
15
+ forbiddenPaths: string[]
16
+ ): boolean {
17
+ for (const pattern of forbiddenPaths) {
18
+ // 末尾にスラッシュがあるパターンはディレクトリ判定
19
+ if (pattern.endsWith("/")) {
20
+ const dirPattern = pattern.slice(0, -1);
21
+ if (filePath.startsWith(dirPattern + "/") || filePath === dirPattern) {
22
+ return true;
23
+ }
24
+ } else {
25
+ // Globパターンを簡易的に正規表現に変換
26
+ const regexPattern = pattern
27
+ .replace(/[.+^${}()|[\]\\]/g, "\\$&")
28
+ .replace(/\*/g, ".*")
29
+ .replace(/\?/g, ".");
30
+ const regex = new RegExp(`^${regexPattern}$`);
31
+ if (regex.test(filePath)) {
32
+ return true;
33
+ }
34
+ }
35
+ }
36
+ return false;
37
+ }
38
+
39
+ /**
40
+ * 禁止エリアに変更があるファイルを抽出
41
+ */
42
+ function findForbiddenFiles(
43
+ changedFiles: string[],
44
+ forbiddenPaths: string[]
45
+ ): string[] {
46
+ return changedFiles.filter((file) =>
47
+ matchesForbiddenPath(file, forbiddenPaths)
48
+ );
49
+ }
50
+
51
+ /**
52
+ * Push可否をチェック
53
+ *
54
+ * Push許可条件:
55
+ * (禁止エリア変更なし) AND (新規ブランチ OR 最終コミットが自分)
56
+ */
57
+ 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();
68
+
69
+ const details = {
70
+ isNewBranch: newBranch,
71
+ isOwnLastCommit,
72
+ hasForbiddenChanges,
73
+ forbiddenFiles,
74
+ currentBranch,
75
+ authorEmail,
76
+ localEmail,
77
+ };
78
+
79
+ // 禁止エリアに変更がある場合は常にブロック
80
+ if (hasForbiddenChanges) {
81
+ return {
82
+ allowed: false,
83
+ reason: `Forbidden files detected: ${forbiddenFiles.join(", ")}`,
84
+ details,
85
+ };
86
+ }
87
+
88
+ // 新規ブランチの場合は許可
89
+ if (newBranch) {
90
+ return {
91
+ allowed: true,
92
+ reason: "New branch - no restrictions",
93
+ details,
94
+ };
95
+ }
96
+
97
+ // 最終コミットが自分の場合は許可
98
+ if (isOwnLastCommit) {
99
+ return {
100
+ allowed: true,
101
+ reason: "Last commit is yours",
102
+ details,
103
+ };
104
+ }
105
+
106
+ // それ以外はブロック
107
+ return {
108
+ allowed: false,
109
+ reason: `Last commit is by someone else (${authorEmail})`,
110
+ details,
111
+ };
112
+ }
@@ -0,0 +1,45 @@
1
+ import { Command } from "commander";
2
+ import { loadConfig } from "../config";
3
+ import { checkPush } from "../checker";
4
+ import { isGitRepository, hasCommits } from "../git";
5
+ import { printError, printCheckResultJson, printCheckResultHuman } from "./utils";
6
+
7
+ /**
8
+ * checkコマンドを作成
9
+ */
10
+ export function createCheckCommand(): Command {
11
+ return new Command("check")
12
+ .description("Check if push is allowed")
13
+ .option("--json", "Output result as JSON")
14
+ .action(async (options: { json?: boolean }) => {
15
+ try {
16
+ // Gitリポジトリ内か確認
17
+ if (!(await isGitRepository())) {
18
+ printError("Not a git repository");
19
+ process.exit(1);
20
+ }
21
+
22
+ // コミットが存在するか確認
23
+ if (!(await hasCommits())) {
24
+ printError("No commits found");
25
+ process.exit(1);
26
+ }
27
+
28
+ const config = loadConfig();
29
+ const result = await checkPush(config);
30
+
31
+ if (options.json) {
32
+ printCheckResultJson(result);
33
+ } else {
34
+ printCheckResultHuman(result);
35
+ }
36
+
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
+ }
44
+ });
45
+ }
@@ -0,0 +1,89 @@
1
+ import { Command } from "commander";
2
+ import {
3
+ getConfigPath,
4
+ loadConfig,
5
+ initConfig,
6
+ configExists,
7
+ } from "../config";
8
+ import { printSuccess, printError, printInfo } from "./utils";
9
+
10
+ /**
11
+ * configサブコマンドを作成
12
+ */
13
+ export function createConfigCommand(): Command {
14
+ const config = new Command("config").description("Manage configuration");
15
+
16
+ config
17
+ .command("init")
18
+ .description("Initialize configuration file")
19
+ .option("-f, --force", "Overwrite existing configuration")
20
+ .action((options: { force?: boolean }) => {
21
+ try {
22
+ const result = initConfig(undefined, options.force);
23
+
24
+ if (result.created) {
25
+ printSuccess(`Configuration file created at: ${result.path}`);
26
+ } else {
27
+ printInfo(`Configuration file already exists at: ${result.path}`);
28
+ printInfo("Use --force to overwrite");
29
+ }
30
+ } catch (error) {
31
+ printError(
32
+ `Failed to initialize config: ${error instanceof Error ? error.message : String(error)}`
33
+ );
34
+ process.exit(1);
35
+ }
36
+ });
37
+
38
+ config
39
+ .command("show")
40
+ .description("Show current configuration")
41
+ .option("--json", "Output as JSON")
42
+ .action((options: { json?: boolean }) => {
43
+ try {
44
+ const configPath = getConfigPath();
45
+ const exists = configExists();
46
+ const configData = loadConfig();
47
+
48
+ if (options.json) {
49
+ console.log(
50
+ JSON.stringify(
51
+ {
52
+ path: configPath,
53
+ exists,
54
+ config: configData,
55
+ },
56
+ null,
57
+ 2
58
+ )
59
+ );
60
+ } else {
61
+ console.log("");
62
+ console.log("Configuration:");
63
+ console.log(` Path: ${configPath}`);
64
+ console.log(` Exists: ${exists ? "Yes" : "No (using defaults)"}`);
65
+ console.log("");
66
+ console.log("Settings:");
67
+ console.log(
68
+ ` forbiddenPaths: ${JSON.stringify(configData.forbiddenPaths)}`
69
+ );
70
+ console.log(` onForbidden: ${configData.onForbidden}`);
71
+ console.log("");
72
+ }
73
+ } catch (error) {
74
+ printError(
75
+ `Failed to load config: ${error instanceof Error ? error.message : String(error)}`
76
+ );
77
+ process.exit(1);
78
+ }
79
+ });
80
+
81
+ config
82
+ .command("path")
83
+ .description("Show configuration file path")
84
+ .action(() => {
85
+ console.log(getConfigPath());
86
+ });
87
+
88
+ return config;
89
+ }
@@ -0,0 +1,114 @@
1
+ import { Command } from "commander";
2
+ import { loadConfig } from "../config";
3
+ import { checkPush } from "../checker";
4
+ import { isGitRepository, hasCommits, execPush } from "../git";
5
+ import {
6
+ printError,
7
+ printSuccess,
8
+ printWarning,
9
+ printCheckResultHuman,
10
+ promptConfirm,
11
+ } from "./utils";
12
+
13
+ /**
14
+ * pushコマンドを作成
15
+ */
16
+ export function createPushCommand(): Command {
17
+ return new Command("push")
18
+ .description("Check and push if allowed")
19
+ .option("-f, --force", "Bypass safety checks")
20
+ .option("--dry-run", "Show what would be pushed without actually pushing")
21
+ .action(async (options: { force?: boolean; dryRun?: boolean }) => {
22
+ try {
23
+ // Gitリポジトリ内か確認
24
+ if (!(await isGitRepository())) {
25
+ printError("Not a git repository");
26
+ process.exit(1);
27
+ }
28
+
29
+ // コミットが存在するか確認
30
+ if (!(await hasCommits())) {
31
+ printError("No commits found");
32
+ process.exit(1);
33
+ }
34
+
35
+ const config = loadConfig();
36
+
37
+ // --forceオプションが指定されている場合はチェックをスキップ
38
+ if (options.force) {
39
+ printWarning("Safety checks bypassed with --force");
40
+
41
+ if (options.dryRun) {
42
+ printSuccess("Dry run: would push (checks bypassed)");
43
+ process.exit(0);
44
+ }
45
+
46
+ const result = await execPush();
47
+ if (result.success) {
48
+ printSuccess("Push successful");
49
+ process.exit(0);
50
+ } else {
51
+ printError(`Push failed: ${result.output}`);
52
+ process.exit(1);
53
+ }
54
+ }
55
+
56
+ // 通常のチェックを実行
57
+ const checkResult = await checkPush(config);
58
+ printCheckResultHuman(checkResult);
59
+
60
+ if (!checkResult.allowed) {
61
+ // onForbiddenの設定に応じて動作を変更
62
+ if (
63
+ config.onForbidden === "prompt" &&
64
+ checkResult.details.hasForbiddenChanges
65
+ ) {
66
+ const confirmed = await promptConfirm(
67
+ "Push is blocked due to forbidden changes. Push anyway?"
68
+ );
69
+
70
+ if (confirmed) {
71
+ if (options.dryRun) {
72
+ printSuccess("Dry run: would push (user confirmed)");
73
+ process.exit(0);
74
+ }
75
+
76
+ const result = await execPush();
77
+ if (result.success) {
78
+ printSuccess("Push successful");
79
+ process.exit(0);
80
+ } else {
81
+ printError(`Push failed: ${result.output}`);
82
+ process.exit(1);
83
+ }
84
+ } else {
85
+ printError("Push cancelled by user");
86
+ process.exit(1);
87
+ }
88
+ }
89
+
90
+ process.exit(1);
91
+ }
92
+
93
+ // チェック通過、pushを実行
94
+ if (options.dryRun) {
95
+ printSuccess("Dry run: would push");
96
+ process.exit(0);
97
+ }
98
+
99
+ const result = await execPush();
100
+ if (result.success) {
101
+ printSuccess("Push successful");
102
+ process.exit(0);
103
+ } else {
104
+ printError(`Push failed: ${result.output}`);
105
+ process.exit(1);
106
+ }
107
+ } catch (error) {
108
+ printError(
109
+ `Push failed: ${error instanceof Error ? error.message : String(error)}`
110
+ );
111
+ process.exit(1);
112
+ }
113
+ });
114
+ }
@@ -0,0 +1,86 @@
1
+ import type { CheckResult } from "../types";
2
+
3
+ /**
4
+ * 成功メッセージを表示
5
+ */
6
+ export function printSuccess(message: string): void {
7
+ console.log(`✅ ${message}`);
8
+ }
9
+
10
+ /**
11
+ * エラーメッセージを表示
12
+ */
13
+ export function printError(message: string): void {
14
+ console.error(`❌ ${message}`);
15
+ }
16
+
17
+ /**
18
+ * 警告メッセージを表示
19
+ */
20
+ export function printWarning(message: string): void {
21
+ console.warn(`⚠️ ${message}`);
22
+ }
23
+
24
+ /**
25
+ * 情報メッセージを表示
26
+ */
27
+ export function printInfo(message: string): void {
28
+ console.log(`ℹ️ ${message}`);
29
+ }
30
+
31
+ /**
32
+ * チェック結果をJSON形式で出力
33
+ */
34
+ export function printCheckResultJson(result: CheckResult): void {
35
+ console.log(JSON.stringify(result, null, 2));
36
+ }
37
+
38
+ /**
39
+ * チェック結果を人間が読みやすい形式で出力
40
+ */
41
+ export function printCheckResultHuman(result: CheckResult): void {
42
+ const { allowed, reason, details } = result;
43
+
44
+ console.log("");
45
+ console.log("═══════════════════════════════════════");
46
+ console.log(allowed ? "✅ Push ALLOWED" : "❌ Push BLOCKED");
47
+ console.log("═══════════════════════════════════════");
48
+ console.log("");
49
+ console.log(`Reason: ${reason}`);
50
+ console.log("");
51
+ console.log("Details:");
52
+ console.log(` Branch: ${details.currentBranch}`);
53
+ console.log(` New branch: ${details.isNewBranch ? "Yes" : "No"}`);
54
+ console.log(` Last commit author: ${details.authorEmail}`);
55
+ console.log(` Local user email: ${details.localEmail}`);
56
+ console.log(
57
+ ` Own last commit: ${details.isOwnLastCommit ? "Yes" : "No"}`
58
+ );
59
+ console.log(
60
+ ` Forbidden changes: ${details.hasForbiddenChanges ? "Yes" : "No"}`
61
+ );
62
+
63
+ if (details.forbiddenFiles.length > 0) {
64
+ console.log("");
65
+ console.log("Forbidden files changed:");
66
+ for (const file of details.forbiddenFiles) {
67
+ console.log(` - ${file}`);
68
+ }
69
+ }
70
+ console.log("");
71
+ }
72
+
73
+ /**
74
+ * ユーザーに確認を求める(y/n)
75
+ */
76
+ export async function promptConfirm(message: string): Promise<boolean> {
77
+ const prompt = `${message} [y/N]: `;
78
+ process.stdout.write(prompt);
79
+
80
+ for await (const line of console) {
81
+ const answer = line.trim().toLowerCase();
82
+ return answer === "y" || answer === "yes";
83
+ }
84
+
85
+ return false;
86
+ }
package/src/config.ts ADDED
@@ -0,0 +1,117 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import * as os from "node:os";
4
+ import * as jsonc from "jsonc-parser";
5
+ import { ConfigSchema, ConfigError, type Config } from "./types";
6
+
7
+ /**
8
+ * 設定ファイルのデフォルトパス
9
+ */
10
+ export function getConfigPath(): string {
11
+ return path.join(os.homedir(), ".config", "safe-push", "config.jsonc");
12
+ }
13
+
14
+ /**
15
+ * デフォルト設定
16
+ */
17
+ export function getDefaultConfig(): Config {
18
+ return {
19
+ forbiddenPaths: [".github/"],
20
+ onForbidden: "error",
21
+ };
22
+ }
23
+
24
+ /**
25
+ * 設定ファイルが存在するか確認
26
+ */
27
+ export function configExists(configPath?: string): boolean {
28
+ const filePath = configPath ?? getConfigPath();
29
+ return fs.existsSync(filePath);
30
+ }
31
+
32
+ /**
33
+ * 設定ファイルを読み込む
34
+ */
35
+ export function loadConfig(configPath?: string): Config {
36
+ const filePath = configPath ?? getConfigPath();
37
+
38
+ if (!fs.existsSync(filePath)) {
39
+ // 設定ファイルがない場合はデフォルト設定を返す
40
+ return getDefaultConfig();
41
+ }
42
+
43
+ try {
44
+ const content = fs.readFileSync(filePath, "utf-8");
45
+ const errors: jsonc.ParseError[] = [];
46
+ const parsed = jsonc.parse(content, errors);
47
+
48
+ if (errors.length > 0) {
49
+ const errorMessages = errors
50
+ .map(
51
+ (e) => `${jsonc.printParseErrorCode(e.error)} at offset ${e.offset}`
52
+ )
53
+ .join(", ");
54
+ throw new ConfigError(
55
+ `Failed to parse config file: ${errorMessages}`,
56
+ filePath
57
+ );
58
+ }
59
+
60
+ const result = ConfigSchema.safeParse(parsed);
61
+ if (!result.success) {
62
+ const issues = result.error.issues
63
+ .map((i) => `${i.path.join(".")}: ${i.message}`)
64
+ .join(", ");
65
+ throw new ConfigError(`Invalid config: ${issues}`, filePath);
66
+ }
67
+
68
+ return result.data;
69
+ } catch (error) {
70
+ if (error instanceof ConfigError) {
71
+ throw error;
72
+ }
73
+ throw new ConfigError(
74
+ `Failed to read config file: ${error instanceof Error ? error.message : String(error)}`,
75
+ filePath
76
+ );
77
+ }
78
+ }
79
+
80
+ /**
81
+ * 設定ファイルを保存する
82
+ */
83
+ export function saveConfig(config: Config, configPath?: string): void {
84
+ const filePath = configPath ?? getConfigPath();
85
+ const dir = path.dirname(filePath);
86
+
87
+ if (!fs.existsSync(dir)) {
88
+ fs.mkdirSync(dir, { recursive: true });
89
+ }
90
+
91
+ const content = `{
92
+ // 禁止エリア(Globパターン)
93
+ "forbiddenPaths": ${JSON.stringify(config.forbiddenPaths, null, 4).replace(/\n/g, "\n ")},
94
+ // 禁止時の動作: "error" | "prompt"
95
+ "onForbidden": "${config.onForbidden}"
96
+ }
97
+ `;
98
+
99
+ fs.writeFileSync(filePath, content, "utf-8");
100
+ }
101
+
102
+ /**
103
+ * 設定ファイルを初期化(既存の場合は上書きしない)
104
+ */
105
+ export function initConfig(
106
+ configPath?: string,
107
+ force = false
108
+ ): { created: boolean; path: string } {
109
+ const filePath = configPath ?? getConfigPath();
110
+
111
+ if (fs.existsSync(filePath) && !force) {
112
+ return { created: false, path: filePath };
113
+ }
114
+
115
+ saveConfig(getDefaultConfig(), filePath);
116
+ return { created: true, path: filePath };
117
+ }