Skip to content

Commit 80ace77

Browse files
committed
feat: git rename-branch コマンドを追加し、ブランチ名の変更とリモート更新機能を実装
feat: `git abort` コマンドを追加し、進行中のGit操作を安全に中止する機能を実装 docs: `branch.md`に`git rename-branch`と`git abort`の使用方法を追加 test: `rename_branch`および`abort`コマンドのテストケースを追加 fix: テストユーティリティのパーミッション設定を明確化 setup: セットアップスクリプトに新しいコマンドを追加
1 parent 99cff20 commit 80ace77

File tree

11 files changed

+770
-2
lines changed

11 files changed

+770
-2
lines changed

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,6 @@ docs
2121
copilot-instructions.md
2222
.claude
2323
.vscode
24-
.serena
24+
.serena
25+
26+
checksheet.csv

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,12 @@ Git の日常操作を少しだけ楽にするための拡張コマンド集で
2121
ブランチの作成、切り替え、削除、同期など。
2222

2323
- `git newbranch` - ブランチを削除して作り直し、トラッキングブランチとして設定
24+
- `git rename-branch` - 現在のブランチ名を安全に変更し、--push でリモートも更新
2425
- `git delete-local-branches` - マージ済みローカルブランチをまとめて削除
2526
- `git recent` - 最近使用したブランチを時系列で表示して切り替え
2627
- `git back` - 前のブランチやタグに戻る(`git checkout -` のショートカット)
2728
- `git sync` - リモートのデフォルトブランチと同期(rebase使用)
29+
- `git abort` - 進行中の rebase / merge / cherry-pick / revert を自動判定して中止
2830

2931
[詳細はこちら](doc/commands/branch.md)
3032

@@ -174,6 +176,7 @@ go build -o ~/bin/git-plus .
174176
# 各コマンド用のシンボリックリンクを作成
175177
cd ~/bin
176178
ln -s git-plus git-newbranch
179+
ln -s git-plus git-rename-branch
177180
ln -s git-plus git-reset-tag
178181
ln -s git-plus git-amend
179182
ln -s git-plus git-squash
@@ -230,6 +233,7 @@ go build -o "$env:USERPROFILE\bin\git-plus.exe" .
230233
# 各コマンド用のコピーを作成
231234
$binPath = "$env:USERPROFILE\bin"
232235
Copy-Item "$binPath\git-plus.exe" "$binPath\git-newbranch.exe"
236+
Copy-Item "$binPath\git-plus.exe" "$binPath\git-rename-branch.exe"
233237
Copy-Item "$binPath\git-plus.exe" "$binPath\git-reset-tag.exe"
234238
Copy-Item "$binPath\git-plus.exe" "$binPath\git-amend.exe"
235239
Copy-Item "$binPath\git-plus.exe" "$binPath\git-squash.exe"

cmd/branch/abort.go

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
// ================================================================================
2+
// abort.go
3+
// ================================================================================
4+
// このファイルは git の拡張コマンド abort を実装しています。
5+
//
6+
// 【概要】
7+
// 進行中の Git 操作(rebase / merge / cherry-pick / revert)を安全に中止します。
8+
// 引数を指定しない場合は現在の状態を判定し、該当する操作を自動で選択します。
9+
//
10+
// 【使用例】
11+
//
12+
// git abort # 状態から自動検出して中止
13+
// git abort merge # マージを強制的に中止
14+
// git abort rebase # リベースを強制的に中止
15+
//
16+
// ================================================================================
17+
package branch
18+
19+
import (
20+
"fmt"
21+
"os"
22+
"path/filepath"
23+
"strings"
24+
25+
"github.com/spf13/cobra"
26+
"github.com/tonbiattack/git-plus/cmd"
27+
"github.com/tonbiattack/git-plus/internal/gitcmd"
28+
)
29+
30+
// abortCmd は進行中のGit操作を中止するコマンドです
31+
var abortCmd = &cobra.Command{
32+
Use: "abort [merge|rebase|cherry-pick|revert]",
33+
Short: "進行中のGit操作を安全に中止",
34+
Long: `進行中の rebase / merge / cherry-pick / revert を安全に中止します。
35+
36+
引数を指定しない場合は現在の状態を判定して自動的に操作を選択します。`,
37+
Example: ` git abort # 自動検出して中止
38+
git abort merge # マージを中止
39+
git abort rebase # リベースを中止`,
40+
Args: cobra.MaximumNArgs(1),
41+
RunE: runAbortCommand,
42+
}
43+
44+
// runAbortCommand は abort コマンドのメイン処理です
45+
func runAbortCommand(_ *cobra.Command, args []string) error {
46+
var (
47+
operation string
48+
err error
49+
)
50+
51+
if len(args) > 0 {
52+
operation, err = normalizeAbortOperation(args[0])
53+
if err != nil {
54+
return err
55+
}
56+
} else {
57+
operation, err = detectAbortOperation()
58+
if err != nil {
59+
return err
60+
}
61+
}
62+
63+
label := abortOperationLabel(operation)
64+
fmt.Printf("%sを中止します...\n", label)
65+
66+
if err := abortOperation(operation); err != nil {
67+
return fmt.Errorf("%sの中止に失敗しました: %w", label, err)
68+
}
69+
70+
fmt.Println("中止が完了しました。")
71+
return nil
72+
}
73+
74+
// normalizeAbortOperation はユーザー入力をサポートする操作名に変換します
75+
func normalizeAbortOperation(op string) (string, error) {
76+
normalized := strings.ToLower(strings.TrimSpace(op))
77+
normalized = strings.ReplaceAll(normalized, "_", "-")
78+
79+
switch normalized {
80+
case "merge":
81+
return "merge", nil
82+
case "rebase":
83+
return "rebase", nil
84+
case "cherry", "cherry-pick", "cherrypick":
85+
return "cherry-pick", nil
86+
case "revert":
87+
return "revert", nil
88+
default:
89+
return "", fmt.Errorf("サポートされていない操作です: %s", op)
90+
}
91+
}
92+
93+
// detectAbortOperation は現在のGitディレクトリから進行中の操作を判定します
94+
func detectAbortOperation() (string, error) {
95+
gitDir, err := getGitDir()
96+
if err != nil {
97+
return "", err
98+
}
99+
100+
rebaseDirs := []string{"rebase-apply", "rebase-merge"}
101+
for _, dir := range rebaseDirs {
102+
if pathExists(filepath.Join(gitDir, dir)) {
103+
return "rebase", nil
104+
}
105+
}
106+
107+
if pathExists(filepath.Join(gitDir, "CHERRY_PICK_HEAD")) {
108+
return "cherry-pick", nil
109+
}
110+
111+
if pathExists(filepath.Join(gitDir, "REVERT_HEAD")) {
112+
return "revert", nil
113+
}
114+
115+
if pathExists(filepath.Join(gitDir, "MERGE_HEAD")) {
116+
return "merge", nil
117+
}
118+
119+
return "", fmt.Errorf("中止できる操作が検出されませんでした。引数で操作を指定してください")
120+
}
121+
122+
// abortOperation は指定された操作を実際に中止します
123+
func abortOperation(operation string) error {
124+
switch operation {
125+
case "merge":
126+
return gitcmd.RunWithIO("merge", "--abort")
127+
case "rebase":
128+
return gitcmd.RunWithIO("rebase", "--abort")
129+
case "cherry-pick":
130+
return gitcmd.RunWithIO("cherry-pick", "--abort")
131+
case "revert":
132+
return gitcmd.RunWithIO("revert", "--abort")
133+
default:
134+
return fmt.Errorf("未対応の操作です: %s", operation)
135+
}
136+
}
137+
138+
// abortOperationLabel は日本語の表示名を返します
139+
func abortOperationLabel(operation string) string {
140+
switch operation {
141+
case "merge":
142+
return "マージ"
143+
case "rebase":
144+
return "リベース"
145+
case "cherry-pick":
146+
return "チェリーピック"
147+
case "revert":
148+
return "リバート"
149+
default:
150+
return operation
151+
}
152+
}
153+
154+
// getGitDir は現在のリポジトリの .git ディレクトリへの絶対パスを返します
155+
func getGitDir() (string, error) {
156+
output, err := gitcmd.Run("rev-parse", "--git-dir")
157+
if err != nil {
158+
return "", fmt.Errorf("Gitディレクトリの取得に失敗しました: %w", err)
159+
}
160+
161+
dir := strings.TrimSpace(string(output))
162+
if filepath.IsAbs(dir) {
163+
return dir, nil
164+
}
165+
166+
cwd, err := os.Getwd()
167+
if err != nil {
168+
return "", fmt.Errorf("カレントディレクトリの取得に失敗しました: %w", err)
169+
}
170+
171+
return filepath.Join(cwd, dir), nil
172+
}
173+
174+
// pathExists はファイルまたはディレクトリの存在を確認します
175+
func pathExists(path string) bool {
176+
if path == "" {
177+
return false
178+
}
179+
_, err := os.Stat(path)
180+
return err == nil
181+
}
182+
183+
func init() {
184+
cmd.RootCmd.AddCommand(abortCmd)
185+
}

cmd/branch/abort_test.go

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
package branch
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
8+
"github.com/tonbiattack/git-plus/internal/testutil"
9+
)
10+
11+
// TestNormalizeAbortOperation はユーザー入力の正規化をテストします
12+
func TestNormalizeAbortOperation(t *testing.T) {
13+
t.Parallel()
14+
15+
tests := []struct {
16+
input string
17+
expected string
18+
}{
19+
{"merge", "merge"},
20+
{"Rebase", "rebase"},
21+
{"CHERRY-PICK", "cherry-pick"},
22+
{"cherry_pick", "cherry-pick"},
23+
{"cherry", "cherry-pick"},
24+
{"revert", "revert"},
25+
}
26+
27+
for _, tt := range tests {
28+
tt := tt
29+
t.Run(tt.input, func(t *testing.T) {
30+
t.Parallel()
31+
actual, err := normalizeAbortOperation(tt.input)
32+
if err != nil {
33+
t.Fatalf("normalizeAbortOperation(%q) returned error: %v", tt.input, err)
34+
}
35+
if actual != tt.expected {
36+
t.Fatalf("normalizeAbortOperation(%q) = %q, want %q", tt.input, actual, tt.expected)
37+
}
38+
})
39+
}
40+
41+
if _, err := normalizeAbortOperation("unknown"); err == nil {
42+
t.Fatalf("normalizeAbortOperation should fail for unsupported operation")
43+
}
44+
}
45+
46+
// TestDetectAbortOperation_Merge はMERGE_HEAD検出をテストします
47+
func TestDetectAbortOperation_Merge(t *testing.T) {
48+
withRepo(t, func(gitDir string) {
49+
writeIndicator(t, filepath.Join(gitDir, "MERGE_HEAD"))
50+
op, err := detectAbortOperation()
51+
if err != nil {
52+
t.Fatalf("detectAbortOperation returned error: %v", err)
53+
}
54+
if op != "merge" {
55+
t.Fatalf("detectAbortOperation = %q, want %q", op, "merge")
56+
}
57+
})
58+
}
59+
60+
// TestDetectAbortOperation_Rebase は rebase ディレクトリ検出をテストします
61+
func TestDetectAbortOperation_Rebase(t *testing.T) {
62+
withRepo(t, func(gitDir string) {
63+
createDir(t, filepath.Join(gitDir, "rebase-merge"))
64+
op, err := detectAbortOperation()
65+
if err != nil {
66+
t.Fatalf("detectAbortOperation returned error: %v", err)
67+
}
68+
if op != "rebase" {
69+
t.Fatalf("detectAbortOperation = %q, want %q", op, "rebase")
70+
}
71+
})
72+
}
73+
74+
// TestDetectAbortOperation_Revert は REVERT_HEAD を検出することをテストします
75+
func TestDetectAbortOperation_Revert(t *testing.T) {
76+
withRepo(t, func(gitDir string) {
77+
writeIndicator(t, filepath.Join(gitDir, "REVERT_HEAD"))
78+
op, err := detectAbortOperation()
79+
if err != nil {
80+
t.Fatalf("detectAbortOperation returned error: %v", err)
81+
}
82+
if op != "revert" {
83+
t.Fatalf("detectAbortOperation = %q, want %q", op, "revert")
84+
}
85+
})
86+
}
87+
88+
// TestDetectAbortOperation_CherryPick は CHERRY_PICK_HEAD を検出することをテストします
89+
func TestDetectAbortOperation_CherryPick(t *testing.T) {
90+
withRepo(t, func(gitDir string) {
91+
writeIndicator(t, filepath.Join(gitDir, "CHERRY_PICK_HEAD"))
92+
op, err := detectAbortOperation()
93+
if err != nil {
94+
t.Fatalf("detectAbortOperation returned error: %v", err)
95+
}
96+
if op != "cherry-pick" {
97+
t.Fatalf("detectAbortOperation = %q, want %q", op, "cherry-pick")
98+
}
99+
})
100+
}
101+
102+
// TestDetectAbortOperation_NoOp は操作が検出されない場合にエラーとなることをテストします
103+
func TestDetectAbortOperation_NoOp(t *testing.T) {
104+
withRepo(t, func(_ string) {
105+
if _, err := detectAbortOperation(); err == nil {
106+
t.Fatalf("detectAbortOperation should fail when no operation is detected")
107+
}
108+
})
109+
}
110+
111+
// withRepo は一時Gitリポジトリでコールバックを実行します
112+
func withRepo(t *testing.T, fn func(gitDir string)) {
113+
t.Helper()
114+
115+
repo := testutil.NewGitRepo(t)
116+
repo.CreateFile("README.md", "# Test")
117+
repo.Commit("initial")
118+
119+
oldDir, err := os.Getwd()
120+
if err != nil {
121+
t.Fatalf("failed to get working directory: %v", err)
122+
}
123+
defer func() { _ = os.Chdir(oldDir) }()
124+
125+
if err := os.Chdir(repo.Dir); err != nil {
126+
t.Fatalf("failed to chdir: %v", err)
127+
}
128+
129+
fn(filepath.Join(repo.Dir, ".git"))
130+
}
131+
132+
// writeIndicator は指定されたパスに空ファイルを作成します
133+
func writeIndicator(t *testing.T, path string) {
134+
t.Helper()
135+
if err := os.WriteFile(path, []byte("dummy"), 0o600); err != nil {
136+
t.Fatalf("failed to write indicator file: %v", err)
137+
}
138+
}
139+
140+
// createDir は指定されたディレクトリを作成します
141+
func createDir(t *testing.T, path string) {
142+
t.Helper()
143+
if err := os.MkdirAll(path, 0o755); err != nil {
144+
t.Fatalf("failed to create directory: %v", err)
145+
}
146+
}

cmd/branch/back_test.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,20 @@ func TestBackCmd_SwitchBranch(t *testing.T) {
6666
// デフォルトブランチに戻る
6767
repo.CheckoutBranch(defaultBranch)
6868

69+
// 現在の作業ディレクトリを取得します。
70+
// - `os.Getwd()` は現在のカレントワーキングディレクトリの絶対パスを返します。
71+
// - 戻り値: (string, error)
72+
// - string: カレントディレクトリのパス
73+
// - error: 取得に失敗した場合のエラー(例: 権限がない、パス不在など)
74+
// このテストでは、テスト実行中にカレントディレクトリを一時的にリポジトリのディレクトリに変更するため、
75+
// 終了時に `defer` で元のディレクトリへ戻す目的で保存しています。
76+
// 現在の作業ディレクトリを取得します。
77+
// - `os.Getwd()` は現在のカレントワーキングディレクトリの絶対パスを返します。
78+
// - 戻り値: (string, error)
79+
// - string: カレントディレクトリのパス
80+
// - error: 取得に失敗した場合のエラー(例: 権限がない、パス不在など)
81+
// このテストでは、テスト実行中にカレントディレクトリを一時的にリポジトリのディレクトリに変更するため、
82+
// 終了時に `defer` で元のディレクトリへ戻す目的で保存しています。
6983
oldDir, err := os.Getwd()
7084
if err != nil {
7185
t.Fatalf("Failed to get current directory: %v", err)

0 commit comments

Comments
 (0)