diff --git a/cmd/branch/abort.go b/cmd/branch/abort.go index 93b35e9..afd2ae3 100644 --- a/cmd/branch/abort.go +++ b/cmd/branch/abort.go @@ -73,54 +73,82 @@ func runAbortCommand(_ *cobra.Command, args []string) error { // normalizeAbortOperation はユーザー入力をサポートする操作名に変換します func normalizeAbortOperation(op string) (string, error) { + // 文字列の正規化処理 + // - `strings.TrimSpace` で前後の空白を除去 + // - `strings.ToLower` で大文字小文字を統一 + // - `strings.ReplaceAll` でアンダースコアをハイフンに置換 + // これにより、ユーザー入力のバリエーション(例: " ReBase", "cherrypick")を + // 受け付けやすくしています。 normalized := strings.ToLower(strings.TrimSpace(op)) + // 入力の区切り文字を統一するため、アンダースコアをハイフンに置換します。 + // 例: "cherry_pick" -> "cherry-pick" として扱うことで、 + // ユーザーがアンダースコア/ハイフンどちらを使っても同一操作として扱えるようにします。 normalized = strings.ReplaceAll(normalized, "_", "-") + // switch 文で受け付ける操作名を決定します。 + // - 複数の case を列挙することで同義の入力を一つの正規形にまとめています。 + // - 成功時は正規化された操作名(例: "rebase")を返し、エラー時は説明付きで返します。 switch normalized { case "merge": return "merge", nil case "rebase": return "rebase", nil case "cherry", "cherry-pick", "cherrypick": + // "cherry" を許容して "cherry-pick" に統一 return "cherry-pick", nil case "revert": return "revert", nil default: + // サポート外の操作の場合はエラーを返す return "", fmt.Errorf("サポートされていない操作です: %s", op) } } // detectAbortOperation は現在のGitディレクトリから進行中の操作を判定します func detectAbortOperation() (string, error) { + // getGitDir で .git ディレクトリの絶対パスを取得します。 + // エラーがあれば検出不能としてそのまま返します。 gitDir, err := getGitDir() if err != nil { return "", err } + // rebase は 2 種類の作業ディレクトリを持つため両方を確認します。 + // - rebase-apply: 非対話的/メールベースの rebase で使われる場合がある + // - rebase-merge: 対話的 rebase 等で使われる場合がある rebaseDirs := []string{"rebase-apply", "rebase-merge"} for _, dir := range rebaseDirs { + // filepath.Join は複数のパス要素を OS に依存しない形で結合します。 if pathExists(filepath.Join(gitDir, dir)) { + // 見つかった時点で rebase が進行中と判定 return "rebase", nil } } + // CHERRY_PICK_HEAD が存在すればチェリーピック中 if pathExists(filepath.Join(gitDir, "CHERRY_PICK_HEAD")) { return "cherry-pick", nil } + // REVERT_HEAD が存在すればリバート中 if pathExists(filepath.Join(gitDir, "REVERT_HEAD")) { return "revert", nil } + // MERGE_HEAD が存在すればマージ中 if pathExists(filepath.Join(gitDir, "MERGE_HEAD")) { return "merge", nil } + // どの操作も検出できない場合はエラーを返して引数による指定を促す return "", fmt.Errorf("中止できる操作が検出されませんでした。引数で操作を指定してください") } // abortOperation は指定された操作を実際に中止します func abortOperation(operation string) error { + // 実際の Git コマンドを実行する箇所。 + // gitcmd.RunWithIO は呼び出し元の標準入出力に接続してコマンドを実行するため、 + // ユーザー対話やエラー出力がそのまま端末に表示されます。 switch operation { case "merge": return gitcmd.RunWithIO("merge", "--abort") @@ -131,12 +159,15 @@ func abortOperation(operation string) error { case "revert": return gitcmd.RunWithIO("revert", "--abort") default: + // 想定外の操作名が来た場合は明示的にエラーを返す return fmt.Errorf("未対応の操作です: %s", operation) } } // abortOperationLabel は日本語の表示名を返します func abortOperationLabel(operation string) string { + // 表示用に日本語ラベルを返すヘルパー関数 + // switch 文で対応する日本語を返し、未対応の文字列はそのまま返します。 switch operation { case "merge": return "マージ" @@ -153,29 +184,45 @@ func abortOperationLabel(operation string) string { // getGitDir は現在のリポジトリの .git ディレクトリへの絶対パスを返します func getGitDir() (string, error) { + // git rev-parse --git-dir はリポジトリの .git ディレクトリのパスを返します。 + // - 絶対パスが返る場合と相対パスが返る場合がある(サブモジュール等)ため、 + // 相対パスだった場合はカレントディレクトリと結合して絶対パスに直します。 output, err := gitcmd.Run("rev-parse", "--git-dir") if err != nil { return "", fmt.Errorf("Gitディレクトリの取得に失敗しました: %w", err) } + // gitcmd.Run の返す値は出力(末尾に改行が含まれることがある)なので + // strings.TrimSpace で余分な空白や改行を削除します。 dir := strings.TrimSpace(string(output)) + + // filepath.IsAbs で絶対パスかどうか判定します。 + // - 絶対パスならそのまま返す if filepath.IsAbs(dir) { return dir, nil } + // 相対パスの場合は現在の作業ディレクトリを取得して結合します。 + // - os.Getwd は現在のカレントワーキングディレクトリの絶対パスを返します。 cwd, err := os.Getwd() if err != nil { return "", fmt.Errorf("カレントディレクトリの取得に失敗しました: %w", err) } + // filepath.Join で OS に依存しない形でパス結合 return filepath.Join(cwd, dir), nil } // pathExists はファイルまたはディレクトリの存在を確認します func pathExists(path string) bool { + // 空文字列は存在しないとみなす if path == "" { return false } + + // os.Stat はファイル情報を返し、存在しない場合はエラーを返す。 + // - 存在する場合: err == nil + // - 存在しない場合: err != nil(詳細を判定するには os.IsNotExist(err) を利用可能) _, err := os.Stat(path) return err == nil } diff --git a/cmd/branch/abort_test.go b/cmd/branch/abort_test.go index c390780..7044213 100644 --- a/cmd/branch/abort_test.go +++ b/cmd/branch/abort_test.go @@ -9,6 +9,20 @@ import ( ) // TestNormalizeAbortOperation はユーザー入力の正規化をテストします +// +// 詳細 (文法/意図): +// - このテストは表形式 (table-driven) で複数の入力ケースを定義し、 +// 各ケースごとに期待される正規化結果を検証します。 +// - `t.Parallel()` を呼んでいるため、サブテストは並列実行される可能性があります。 +// そのためサブテスト内で使用するループ変数を再バインドしてクロージャキャプチャ問題を避けています。 +// - テストの構造: +// 1. `tests` スライスに入力と期待値を列挙 +// 2. for-range で各ケースを取り出し、`t.Run` でサブテストを作成 +// 3. サブテスト内で `normalizeAbortOperation` を呼び出し、結果と期待値を比較 +// +// 文法レベルのポイント: +// - table-driven テストは新しいケースを追加しやすく、期待値を明示しやすい。 +// - 並列化 (`t.Parallel()`) はテスト速度の向上に寄与するが、クロージャのキャプチャに注意が必要。 func TestNormalizeAbortOperation(t *testing.T) { t.Parallel() @@ -25,9 +39,21 @@ func TestNormalizeAbortOperation(t *testing.T) { } for _, tt := range tests { + // ループ変数を新しいローカル変数に再バインドします。 + // 理由: + // - Go の for-range ではループ変数が再利用されるため、クロージャがその変数を参照すると + // 並列実行時(t.Parallel())にすべてのサブテストが同じ最終値を参照してしまう可能性があります。 + // - 各イテレーションごとに `tt := tt` で新しい変数に再バインドすることで、クロージャは + // そのイテレーション固有の値を捕捉し、並列サブテストでも安全に動作します。 tt := tt + + // サブテスト: 各入力ケースを個別のサブテストとして実行します。 + // - 第一引数はテスト名(ここでは入力文字列) + // - 無名関数内で再度 `t.Parallel()` を呼ぶことで各サブテスト自身も並列化されます。 t.Run(tt.input, func(t *testing.T) { t.Parallel() + + // 実際の呼び出しと検証 actual, err := normalizeAbortOperation(tt.input) if err != nil { t.Fatalf("normalizeAbortOperation(%q) returned error: %v", tt.input, err) @@ -110,28 +136,53 @@ func TestDetectAbortOperation_NoOp(t *testing.T) { // withRepo は一時Gitリポジトリでコールバックを実行します func withRepo(t *testing.T, fn func(gitDir string)) { + // withRepo は一時的なテスト用 Git リポジトリを作成し、指定のコールバックを実行します。 + // + // 文法/意図: + // - テストで必要な前処理(リポジトリ作成、初期コミット)を集約することで各テストの冗長性を減らす。 + // - 作成したリポジトリのディレクトリにカレントディレクトリを移動してからコールバックを呼ぶ。 + // これにより、`git` コマンドや `getGitDir` の動作が期待通りにリポジトリを参照できる。 + // - 終了時に `defer` を使って元の作業ディレクトリへ復帰させることで、テスト間の副作用を防止する。 + // + // 実装の流れ: + // 1. `testutil.NewGitRepo` で一時リポジトリを作成 + // 2. 簡単なファイルを作ってコミット(`git init` 後の最小セットアップ) + // 3. 現在の作業ディレクトリを保存し、テスト用リポジトリへ `chdir` + // 4. コールバックに `.git` ディレクトリのパスを渡す t.Helper() repo := testutil.NewGitRepo(t) repo.CreateFile("README.md", "# Test") repo.Commit("initial") + // 元の作業ディレクトリを取得して保存 oldDir, err := os.Getwd() if err != nil { t.Fatalf("failed to get working directory: %v", err) } + // テスト終了後に元のディレクトリへ戻す(副作用のクリーンアップ) defer func() { _ = os.Chdir(oldDir) }() + // テスト用リポジトリのルートへ移動する if err := os.Chdir(repo.Dir); err != nil { t.Fatalf("failed to chdir: %v", err) } + // コールバックには `.git` ディレクトリの絶対パスを渡す fn(filepath.Join(repo.Dir, ".git")) } // writeIndicator は指定されたパスに空ファイルを作成します func writeIndicator(t *testing.T, path string) { + // 文法/意図: + // - Git の進行中操作判定では特定のファイル(例: MERGE_HEAD, CHERRY_PICK_HEAD)や + // ディレクトリ(rebase-merge 等)の存在を確認します。このヘルパーはその指標ファイルを + // テスト用に作成するためのものです。 + // - 第2引数 `path` は作成するインジケータファイルのパス(通常は /.git/)。 t.Helper() + + // ファイルを書き込む際のパーミッションはテスト内のみの利用なので 0o600 を指定しています。 + // - 所有者に読み/書き、他に権限なし(セキュリティ的に最小限の権限) if err := os.WriteFile(path, []byte("dummy"), 0o600); err != nil { t.Fatalf("failed to write indicator file: %v", err) } @@ -139,7 +190,12 @@ func writeIndicator(t *testing.T, path string) { // createDir は指定されたディレクトリを作成します func createDir(t *testing.T, path string) { + // 文法/意図: + // - テスト内で rebase 等の進行中状態を模倣するためにディレクトリを作成するユーティリティ。 + // - `os.MkdirAll` を使うことで中間ディレクトリが存在しなくても確実に作成できます。 + // - パーミッション 0o755 は所有者に書き込みを許可し、グループ/その他に読み/実行を許可します。 t.Helper() + if err := os.MkdirAll(path, 0o755); err != nil { t.Fatalf("failed to create directory: %v", err) }