diff --git a/.changeset/push-interactive-file-select.md b/.changeset/push-interactive-file-select.md new file mode 100644 index 0000000..fa3cf00 --- /dev/null +++ b/.changeset/push-interactive-file-select.md @@ -0,0 +1,9 @@ +--- +"@tktco/create-devenv": minor +--- + +push コマンドに unified diff を見ながらファイルを選択できる機能を追加 + +- デフォルトで差分を表示しながらチェックボックスでファイル選択が可能に +- `--no-interactive` オプションで従来の確認プロンプトに切り替え可能 +- `--force` オプションは引き続き確認なしで全ファイルを push diff --git a/packages/create-devenv/package.json b/packages/create-devenv/package.json index fa04663..99fb2ce 100644 --- a/packages/create-devenv/package.json +++ b/packages/create-devenv/package.json @@ -27,6 +27,7 @@ "citty": "^0.1.6", "consola": "^3.4.2", "defu": "^6.1.4", + "diff": "^8.0.2", "giget": "^2.0.0", "ignore": "^7.0.5", "pathe": "^2.0.3", diff --git a/packages/create-devenv/src/commands/push.ts b/packages/create-devenv/src/commands/push.ts index 42f6ba2..4766d83 100644 --- a/packages/create-devenv/src/commands/push.ts +++ b/packages/create-devenv/src/commands/push.ts @@ -11,6 +11,7 @@ import { promptPrBody, promptPrTitle, promptPushConfirm, + promptSelectFilesWithDiff, } from "../prompts/push"; import { detectDiff, formatDiff, getPushableFiles } from "../utils/diff"; import { createPullRequest, getGitHubToken } from "../utils/github"; @@ -45,6 +46,12 @@ export const pushCommand = defineCommand({ description: "確認プロンプトをスキップ", default: false, }, + interactive: { + type: "boolean", + alias: "i", + description: "差分を確認しながらファイルを選択(デフォルト有効)", + default: true, + }, }, async run({ args }) { const targetDir = resolve(args.dir); @@ -100,7 +107,7 @@ export const pushCommand = defineCommand({ }); // push 対象ファイルを取得 - const pushableFiles = getPushableFiles(diff); + let pushableFiles = getPushableFiles(diff); if (pushableFiles.length === 0) { consola.info("push するファイルがありません。"); @@ -119,8 +126,15 @@ export const pushCommand = defineCommand({ return; } - // 確認プロンプト - if (!args.force) { + // ファイル選択(デフォルト動作) + if (args.interactive && !args.force) { + pushableFiles = await promptSelectFilesWithDiff(pushableFiles); + if (pushableFiles.length === 0) { + consola.info("ファイルが選択されませんでした。キャンセルします。"); + return; + } + } else if (!args.force) { + // --no-interactive 時は従来の確認プロンプト const confirmed = await promptPushConfirm(diff); if (!confirmed) { consola.info("キャンセルしました。"); diff --git a/packages/create-devenv/src/prompts/push.ts b/packages/create-devenv/src/prompts/push.ts index 80ce74e..e7c9652 100644 --- a/packages/create-devenv/src/prompts/push.ts +++ b/packages/create-devenv/src/prompts/push.ts @@ -1,6 +1,10 @@ -import { confirm, input, password } from "@inquirer/prompts"; -import type { DiffResult } from "../modules/schemas"; -import { formatDiff } from "../utils/diff"; +import { checkbox, confirm, input, password } from "@inquirer/prompts"; +import type { DiffResult, FileDiff } from "../modules/schemas"; +import { + colorizeUnifiedDiff, + formatDiff, + generateUnifiedDiff, +} from "../utils/diff"; /** * push 実行前の確認プロンプト @@ -78,3 +82,35 @@ export async function promptGitHubToken(): Promise { }, }); } + +/** + * diff を表示しながらファイルを選択するプロンプト + */ +export async function promptSelectFilesWithDiff( + pushableFiles: FileDiff[], +): Promise { + if (pushableFiles.length === 0) { + return []; + } + + // 各ファイルの unified diff を表示 + console.log("\n=== 変更内容(unified diff)===\n"); + for (const file of pushableFiles) { + const icon = file.type === "added" ? "[+]" : "[~]"; + console.log(`--- ${icon} ${file.path} ---`); + console.log(colorizeUnifiedDiff(generateUnifiedDiff(file))); + console.log(); + } + + // チェックボックスでファイル選択 + const choices = pushableFiles.map((file) => ({ + name: `[${file.type === "added" ? "+" : "~"}] ${file.path}`, + value: file, + checked: true, + })); + + return checkbox({ + message: "PR に含めるファイルを選択してください", + choices, + }); +} diff --git a/packages/create-devenv/src/utils/__tests__/diff.test.ts b/packages/create-devenv/src/utils/__tests__/diff.test.ts new file mode 100644 index 0000000..63fb38a --- /dev/null +++ b/packages/create-devenv/src/utils/__tests__/diff.test.ts @@ -0,0 +1,188 @@ +import { describe, expect, it } from "vitest"; +import type { FileDiff } from "../../modules/schemas"; +import { colorizeUnifiedDiff, generateUnifiedDiff } from "../diff"; + +describe("diff", () => { + describe("generateUnifiedDiff", () => { + it("added タイプのファイルで unified diff を生成する", () => { + const fileDiff: FileDiff = { + path: "new-file.txt", + type: "added", + localContent: "line1\nline2\nline3\n", + templateContent: undefined, + }; + + const result = generateUnifiedDiff(fileDiff); + + expect(result).toContain("--- new-file.txt"); + expect(result).toContain("+++ new-file.txt"); + expect(result).toContain("+line1"); + expect(result).toContain("+line2"); + expect(result).toContain("+line3"); + }); + + it("modified タイプのファイルで unified diff を生成する", () => { + const fileDiff: FileDiff = { + path: "existing-file.txt", + type: "modified", + localContent: "line1\nmodified line\nline3\n", + templateContent: "line1\noriginal line\nline3\n", + }; + + const result = generateUnifiedDiff(fileDiff); + + expect(result).toContain("--- existing-file.txt"); + expect(result).toContain("+++ existing-file.txt"); + expect(result).toContain("-original line"); + expect(result).toContain("+modified line"); + }); + + it("deleted タイプのファイルでは空文字列を返す", () => { + const fileDiff: FileDiff = { + path: "deleted-file.txt", + type: "deleted", + localContent: undefined, + templateContent: "content\n", + }; + + const result = generateUnifiedDiff(fileDiff); + + expect(result).toBe(""); + }); + + it("unchanged タイプのファイルでは空文字列を返す", () => { + const fileDiff: FileDiff = { + path: "unchanged-file.txt", + type: "unchanged", + localContent: "same content\n", + templateContent: "same content\n", + }; + + const result = generateUnifiedDiff(fileDiff); + + expect(result).toBe(""); + }); + + it("空のファイルを追加する場合", () => { + const fileDiff: FileDiff = { + path: "empty-file.txt", + type: "added", + localContent: "", + templateContent: undefined, + }; + + const result = generateUnifiedDiff(fileDiff); + + expect(result).toContain("--- empty-file.txt"); + expect(result).toContain("+++ empty-file.txt"); + }); + + it("内容が undefined の場合でも正しく処理する", () => { + const fileDiff: FileDiff = { + path: "file.txt", + type: "added", + localContent: undefined, + templateContent: undefined, + }; + + const result = generateUnifiedDiff(fileDiff); + + // エラーなく空の diff が生成される + expect(result).toContain("--- file.txt"); + }); + + it("複数行の変更を含む diff を生成する", () => { + const fileDiff: FileDiff = { + path: "config.json", + type: "modified", + localContent: `{ + "name": "new-name", + "version": "2.0.0", + "description": "updated" +}`, + templateContent: `{ + "name": "old-name", + "version": "1.0.0", + "description": "original" +}`, + }; + + const result = generateUnifiedDiff(fileDiff); + + expect(result).toContain('- "name": "old-name"'); + expect(result).toContain('+ "name": "new-name"'); + expect(result).toContain('- "version": "1.0.0"'); + expect(result).toContain('+ "version": "2.0.0"'); + }); + }); + + describe("colorizeUnifiedDiff", () => { + it("追加行を緑色にする", () => { + const diff = "+added line"; + + const result = colorizeUnifiedDiff(diff); + + expect(result).toBe("\x1b[32m+added line\x1b[0m"); + }); + + it("削除行を赤色にする", () => { + const diff = "-removed line"; + + const result = colorizeUnifiedDiff(diff); + + expect(result).toBe("\x1b[31m-removed line\x1b[0m"); + }); + + it("ハンク行をシアン色にする", () => { + const diff = "@@ -1,3 +1,4 @@"; + + const result = colorizeUnifiedDiff(diff); + + expect(result).toBe("\x1b[36m@@ -1,3 +1,4 @@\x1b[0m"); + }); + + it("ヘッダー行をボールドにする", () => { + const diff = "--- file.txt\n+++ file.txt"; + + const result = colorizeUnifiedDiff(diff); + + expect(result).toContain("\x1b[1m--- file.txt\x1b[0m"); + expect(result).toContain("\x1b[1m+++ file.txt\x1b[0m"); + }); + + it("コンテキスト行はそのまま", () => { + const diff = " unchanged line"; + + const result = colorizeUnifiedDiff(diff); + + expect(result).toBe(" unchanged line"); + }); + + it("複数行の diff を正しくカラー化する", () => { + const diff = `--- file.txt ++++ file.txt +@@ -1,3 +1,3 @@ + line1 +-old line ++new line + line3`; + + const result = colorizeUnifiedDiff(diff); + + const lines = result.split("\n"); + expect(lines[0]).toBe("\x1b[1m--- file.txt\x1b[0m"); + expect(lines[1]).toBe("\x1b[1m+++ file.txt\x1b[0m"); + expect(lines[2]).toBe("\x1b[36m@@ -1,3 +1,3 @@\x1b[0m"); + expect(lines[3]).toBe(" line1"); + expect(lines[4]).toBe("\x1b[31m-old line\x1b[0m"); + expect(lines[5]).toBe("\x1b[32m+new line\x1b[0m"); + expect(lines[6]).toBe(" line3"); + }); + + it("空の diff を処理する", () => { + const result = colorizeUnifiedDiff(""); + + expect(result).toBe(""); + }); + }); +}); diff --git a/packages/create-devenv/src/utils/diff.ts b/packages/create-devenv/src/utils/diff.ts index f5596f2..63bd993 100644 --- a/packages/create-devenv/src/utils/diff.ts +++ b/packages/create-devenv/src/utils/diff.ts @@ -1,6 +1,7 @@ import { existsSync } from "node:fs"; import { readFile } from "node:fs/promises"; import consola from "consola"; +import { createPatch } from "diff"; import { join } from "pathe"; import { getModuleById } from "../modules"; import type { @@ -174,3 +175,43 @@ export function hasDiff(diff: DiffResult): boolean { diff.summary.deleted > 0 ); } + +/** + * FileDiff から unified diff 形式の文字列を生成 + */ +export function generateUnifiedDiff(fileDiff: FileDiff): string { + const { path, type, localContent, templateContent } = fileDiff; + + switch (type) { + case "added": + return createPatch(path, "", localContent || "", "template", "local"); + case "modified": + return createPatch( + path, + templateContent || "", + localContent || "", + "template", + "local", + ); + default: + return ""; + } +} + +/** + * unified diff にカラーを適用 + */ +export function colorizeUnifiedDiff(diff: string): string { + return diff + .split("\n") + .map((line) => { + if (line.startsWith("+++") || line.startsWith("---")) { + return `\x1b[1m${line}\x1b[0m`; // ボールド + } + if (line.startsWith("+")) return `\x1b[32m${line}\x1b[0m`; // 緑 + if (line.startsWith("-")) return `\x1b[31m${line}\x1b[0m`; // 赤 + if (line.startsWith("@@")) return `\x1b[36m${line}\x1b[0m`; // シアン + return line; + }) + .join("\n"); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 04c70fb..9d4bc0a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: defu: specifier: ^6.1.4 version: 6.1.4 + diff: + specifier: ^8.0.2 + version: 8.0.2 giget: specifier: ^2.0.0 version: 2.0.0 @@ -1046,6 +1049,10 @@ packages: resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} engines: {node: '>=8'} + diff@8.0.2: + resolution: {integrity: sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==} + engines: {node: '>=0.3.1'} + dir-glob@3.0.1: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} @@ -2899,6 +2906,8 @@ snapshots: detect-indent@6.1.0: {} + diff@8.0.2: {} + dir-glob@3.0.1: dependencies: path-type: 4.0.0