Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions cmd/branch/abort.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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 "マージ"
Expand All @@ -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
}
Expand Down
56 changes: 56 additions & 0 deletions cmd/branch/abort_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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)
Expand Down Expand Up @@ -110,36 +136,66 @@ 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` は作成するインジケータファイルのパス(通常は <repo>/.git/<NAME>)。
t.Helper()

// ファイルを書き込む際のパーミッションはテスト内のみの利用なので 0o600 を指定しています。
// - 所有者に読み/書き、他に権限なし(セキュリティ的に最小限の権限)
if err := os.WriteFile(path, []byte("dummy"), 0o600); err != nil {
t.Fatalf("failed to write indicator file: %v", err)
}
}

// 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)
}
Expand Down
Loading