easy-worktree 0.1.0__py3-none-any.whl → 0.1.2__py3-none-any.whl
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.
- easy_worktree/__init__.py +862 -583
- {easy_worktree-0.1.0.dist-info → easy_worktree-0.1.2.dist-info}/METADATA +77 -9
- easy_worktree-0.1.2.dist-info/RECORD +6 -0
- easy_worktree/__init__.py_snippet_helper +0 -18
- easy_worktree-0.1.0.dist-info/RECORD +0 -7
- {easy_worktree-0.1.0.dist-info → easy_worktree-0.1.2.dist-info}/WHEEL +0 -0
- {easy_worktree-0.1.0.dist-info → easy_worktree-0.1.2.dist-info}/entry_points.txt +0 -0
- {easy_worktree-0.1.0.dist-info → easy_worktree-0.1.2.dist-info}/licenses/LICENSE +0 -0
easy_worktree/__init__.py
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
"""
|
|
3
3
|
Git worktree を簡単に管理するための CLI ツール
|
|
4
4
|
"""
|
|
5
|
+
|
|
5
6
|
import os
|
|
6
7
|
import subprocess
|
|
7
8
|
import shutil
|
|
@@ -15,233 +16,209 @@ import toml
|
|
|
15
16
|
# 言語判定
|
|
16
17
|
def is_japanese() -> bool:
|
|
17
18
|
"""LANG環境変数から日本語かどうかを判定"""
|
|
18
|
-
lang = os.environ.get(
|
|
19
|
-
return
|
|
19
|
+
lang = os.environ.get("LANG", "")
|
|
20
|
+
return "ja" in lang.lower()
|
|
20
21
|
|
|
21
22
|
|
|
22
23
|
# メッセージ辞書
|
|
23
24
|
MESSAGES = {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
'usage': {
|
|
29
|
-
'en': 'Usage: wt clone <repository_url>',
|
|
30
|
-
'ja': '使用方法: wt clone <repository_url>'
|
|
31
|
-
},
|
|
32
|
-
'usage_add': {
|
|
33
|
-
'en': 'Usage: wt add (ad) <work_name> [<base_branch>]',
|
|
34
|
-
'ja': '使用方法: wt add (ad) <作業名> [<base_branch>]'
|
|
35
|
-
},
|
|
36
|
-
'usage_rm': {
|
|
37
|
-
'en': 'Usage: wt rm <work_name>',
|
|
38
|
-
'ja': '使用方法: wt rm <作業名>'
|
|
39
|
-
},
|
|
40
|
-
'base_not_found': {
|
|
41
|
-
'en': 'Main repository directory not found',
|
|
42
|
-
'ja': 'メインリポジトリのディレクトリが見つかりません'
|
|
43
|
-
},
|
|
44
|
-
'run_in_wt_dir': {
|
|
45
|
-
'en': 'Please run inside WT_<repository_name>/ directory',
|
|
46
|
-
'ja': 'WT_<repository_name>/ ディレクトリ内で実行してください'
|
|
47
|
-
},
|
|
48
|
-
'already_exists': {
|
|
49
|
-
'en': '{} already exists',
|
|
50
|
-
'ja': '{} はすでに存在します'
|
|
51
|
-
},
|
|
52
|
-
'cloning': {
|
|
53
|
-
'en': 'Cloning: {} -> {}',
|
|
54
|
-
'ja': 'クローン中: {} -> {}'
|
|
55
|
-
},
|
|
56
|
-
'completed_clone': {
|
|
57
|
-
'en': 'Completed: cloned to {}',
|
|
58
|
-
'ja': '完了: {} にクローンしました'
|
|
25
|
+
"error": {"en": "Error: {}", "ja": "エラー: {}"},
|
|
26
|
+
"usage": {
|
|
27
|
+
"en": "Usage: wt clone <repository_url>",
|
|
28
|
+
"ja": "使用方法: wt clone <repository_url>",
|
|
59
29
|
},
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
30
|
+
"usage_add": {
|
|
31
|
+
"en": "Usage: wt add (ad) <work_name> [<base_branch>]",
|
|
32
|
+
"ja": "使用方法: wt add (ad) <作業名> [<base_branch>]",
|
|
63
33
|
},
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
34
|
+
"usage_rm": {"en": "Usage: wt rm <work_name>", "ja": "使用方法: wt rm <作業名>"},
|
|
35
|
+
"base_not_found": {
|
|
36
|
+
"en": "Main repository directory not found",
|
|
37
|
+
"ja": "メインリポジトリのディレクトリが見つかりません",
|
|
67
38
|
},
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
39
|
+
"run_in_wt_dir": {
|
|
40
|
+
"en": "Please run inside WT_<repository_name>/ directory",
|
|
41
|
+
"ja": "WT_<repository_name>/ ディレクトリ内で実行してください",
|
|
71
42
|
},
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
43
|
+
"already_exists": {"en": "{} already exists", "ja": "{} はすでに存在します"},
|
|
44
|
+
"cloning": {"en": "Cloning: {} -> {}", "ja": "クローン中: {} -> {}"},
|
|
45
|
+
"completed_clone": {
|
|
46
|
+
"en": "Completed: cloned to {}",
|
|
47
|
+
"ja": "完了: {} にクローンしました",
|
|
75
48
|
},
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
49
|
+
"not_git_repo": {
|
|
50
|
+
"en": "Current directory is not a git repository",
|
|
51
|
+
"ja": "現在のディレクトリは git リポジトリではありません",
|
|
79
52
|
},
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
53
|
+
"run_at_root": {
|
|
54
|
+
"en": "Please run at repository root directory {}",
|
|
55
|
+
"ja": "リポジトリのルートディレクトリ {} で実行してください",
|
|
83
56
|
},
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
57
|
+
"creating_dir": {"en": "Creating {}...", "ja": "{} を作成中..."},
|
|
58
|
+
"moving": {"en": "Moving {} -> {}...", "ja": "{} -> {} に移動中..."},
|
|
59
|
+
"completed_move": {"en": "Completed: moved to {}", "ja": "完了: {} に移動しました"},
|
|
60
|
+
"use_wt_from": {
|
|
61
|
+
"en": "Use wt command from {} from next time",
|
|
62
|
+
"ja": "次回から {} で wt コマンドを使用してください",
|
|
87
63
|
},
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
64
|
+
"fetching": {
|
|
65
|
+
"en": "Fetching latest information from remote...",
|
|
66
|
+
"ja": "リモートから最新情報を取得中...",
|
|
91
67
|
},
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
68
|
+
"creating_worktree": {"en": "Creating worktree: {}", "ja": "worktree を作成中: {}"},
|
|
69
|
+
"completed_worktree": {
|
|
70
|
+
"en": "Completed: created worktree at {}",
|
|
71
|
+
"ja": "完了: {} に worktree を作成しました",
|
|
95
72
|
},
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
73
|
+
"removing_worktree": {"en": "Removing worktree: {}", "ja": "worktree を削除中: {}"},
|
|
74
|
+
"completed_remove": {
|
|
75
|
+
"en": "Completed: removed {}",
|
|
76
|
+
"ja": "完了: {} を削除しました",
|
|
99
77
|
},
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
78
|
+
"creating_branch": {
|
|
79
|
+
"en": "Creating new branch '{}' from '{}'",
|
|
80
|
+
"ja": "ブランチ '{}' を '{}' から作成しています",
|
|
103
81
|
},
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
82
|
+
"default_branch_not_found": {
|
|
83
|
+
"en": "Could not find default branch (main/master)",
|
|
84
|
+
"ja": "デフォルトブランチ (main/master) が見つかりません",
|
|
107
85
|
},
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
86
|
+
"running_hook": {
|
|
87
|
+
"en": "Running post-add hook: {}",
|
|
88
|
+
"ja": "post-add hook を実行中: {}",
|
|
111
89
|
},
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
90
|
+
"hook_not_executable": {
|
|
91
|
+
"en": "Warning: hook is not executable: {}",
|
|
92
|
+
"ja": "警告: hook が実行可能ではありません: {}",
|
|
115
93
|
},
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
94
|
+
"hook_failed": {
|
|
95
|
+
"en": "Warning: hook exited with code {}",
|
|
96
|
+
"ja": "警告: hook が終了コード {} で終了しました",
|
|
119
97
|
},
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
98
|
+
"usage_clean": {
|
|
99
|
+
"en": "Usage: wt clean (cl) [--days N] [--merged] [--closed] [--all]",
|
|
100
|
+
"ja": "使用方法: wt clean (cl) [--days N] [--merged] [--closed] [--all]",
|
|
123
101
|
},
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
102
|
+
"alias_updated": {
|
|
103
|
+
"en": "Updated alias: {} -> {}",
|
|
104
|
+
"ja": "エイリアスを更新しました: {} -> {}",
|
|
127
105
|
},
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
106
|
+
"no_clean_targets": {
|
|
107
|
+
"en": "No worktrees to clean",
|
|
108
|
+
"ja": "クリーンアップ対象の worktree がありません",
|
|
131
109
|
},
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
110
|
+
"clean_target": {
|
|
111
|
+
"en": "Will remove: {} (created: {}, clean)",
|
|
112
|
+
"ja": "削除対象: {} (作成日時: {}, 変更なし)",
|
|
135
113
|
},
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
114
|
+
"clean_confirm": {
|
|
115
|
+
"en": "Remove {} worktree(s)? [y/N]: ",
|
|
116
|
+
"ja": "{} 個の worktree を削除しますか? [y/N]: ",
|
|
139
117
|
},
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
118
|
+
"alias_removed": {"en": "Removed alias: {}", "ja": "エイリアスを削除しました: {}"},
|
|
119
|
+
"alias_not_found": {
|
|
120
|
+
"en": "Alias not found: {}",
|
|
121
|
+
"ja": "エイリアスが見つかりません: {}",
|
|
143
122
|
},
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
123
|
+
"worktree_name": {"en": "Worktree", "ja": "Worktree"},
|
|
124
|
+
"branch_name": {"en": "Branch", "ja": "ブランチ"},
|
|
125
|
+
"created_at": {"en": "Created", "ja": "作成日時"},
|
|
126
|
+
"last_commit": {"en": "Last Commit", "ja": "最終コミット"},
|
|
127
|
+
"status_label": {"en": "Status", "ja": "状態"},
|
|
128
|
+
"changes_label": {"en": "Changes", "ja": "変更"},
|
|
129
|
+
"syncing": {"en": "Syncing: {} -> {}", "ja": "同期中: {} -> {}"},
|
|
130
|
+
"completed_sync": {
|
|
131
|
+
"en": "Completed sync of {} files",
|
|
132
|
+
"ja": "{} 個のファイルを同期しました",
|
|
147
133
|
},
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
134
|
+
"usage_pr": {
|
|
135
|
+
"en": "Usage: wt pr add <number>",
|
|
136
|
+
"ja": "使用方法: wt pr add <number>",
|
|
151
137
|
},
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
138
|
+
"usage_setup": {
|
|
139
|
+
"en": "Usage: wt setup (su)",
|
|
140
|
+
"ja": "使用方法: wt setup (su)",
|
|
155
141
|
},
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
142
|
+
"usage_stash": {
|
|
143
|
+
"en": "Usage: wt stash (st) <work_name> [<base_branch>]",
|
|
144
|
+
"ja": "使用方法: wt stash (st) <work_name> [<base_branch>]",
|
|
159
145
|
},
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
146
|
+
"stashing_changes": {
|
|
147
|
+
"en": "Stashing local changes...",
|
|
148
|
+
"ja": "ローカルの変更をスタッシュ中...",
|
|
163
149
|
},
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
150
|
+
"popping_stash": {
|
|
151
|
+
"en": "Moving changes to new worktree...",
|
|
152
|
+
"ja": "変更を新しい worktree に移動中...",
|
|
167
153
|
},
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
154
|
+
"nothing_to_stash": {
|
|
155
|
+
"en": "No local changes to stash.",
|
|
156
|
+
"ja": "スタッシュする変更がありません",
|
|
171
157
|
},
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
158
|
+
"select_switched": {
|
|
159
|
+
"en": "Switched worktree to: {}",
|
|
160
|
+
"ja": "作業ディレクトリを切り替えました: {}",
|
|
175
161
|
},
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
162
|
+
"select_not_found": {
|
|
163
|
+
"en": "Worktree not found: {}",
|
|
164
|
+
"ja": "worktree が見つかりません: {}",
|
|
179
165
|
},
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
166
|
+
"select_no_last": {
|
|
167
|
+
"en": "No previous selection found",
|
|
168
|
+
"ja": "以前の選択が見つかりません",
|
|
183
169
|
},
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
170
|
+
"setting_up": {"en": "Setting up: {} -> {}", "ja": "セットアップ中: {} -> {}"},
|
|
171
|
+
"completed_setup": {
|
|
172
|
+
"en": "Completed setup of {} files",
|
|
173
|
+
"ja": "{} 個のファイルをセットアップしました",
|
|
187
174
|
},
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
175
|
+
"suggest_setup": {
|
|
176
|
+
"en": "Some setup files are missing. Run 'wt setup' to initialize this worktree.",
|
|
177
|
+
"ja": "一部のセットアップファイルが不足しています。'wt setup' を実行して初期化してください。",
|
|
191
178
|
},
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
179
|
+
"nesting_error": {
|
|
180
|
+
"en": "Error: Already in a wt subshell ({}). Please 'exit' before switching.",
|
|
181
|
+
"ja": "エラー: すでに wt のサブシェル ({}) 内にいます。切り替える前に 'exit' してください。",
|
|
195
182
|
},
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
183
|
+
"jump_instruction": {
|
|
184
|
+
"en": "Jumping to '{}' ({}). Type 'exit' or Ctrl-D to return.",
|
|
185
|
+
"ja": "'{}' ({}) にジャンプします。戻るには 'exit' または Ctrl-D を入力してください。",
|
|
199
186
|
},
|
|
200
|
-
'popping_stash': {
|
|
201
|
-
'en': 'Moving changes to new worktree...',
|
|
202
|
-
'ja': '変更を新しい worktree に移動中...'
|
|
203
|
-
},
|
|
204
|
-
'nothing_to_stash': {
|
|
205
|
-
'en': 'No local changes to stash.',
|
|
206
|
-
'ja': 'スタッシュする変更がありません'
|
|
207
|
-
}
|
|
208
187
|
}
|
|
209
188
|
|
|
210
189
|
|
|
211
190
|
def msg(key: str, *args) -> str:
|
|
212
191
|
"""言語に応じたメッセージを取得"""
|
|
213
|
-
lang =
|
|
192
|
+
lang = "ja" if is_japanese() else "en"
|
|
214
193
|
message = MESSAGES.get(key, {}).get(lang, key)
|
|
215
194
|
if args:
|
|
216
195
|
return message.format(*args)
|
|
217
196
|
return message
|
|
218
197
|
|
|
219
198
|
|
|
220
|
-
def run_command(
|
|
199
|
+
def run_command(
|
|
200
|
+
cmd: list[str], cwd: Path = None, check: bool = True
|
|
201
|
+
) -> subprocess.CompletedProcess:
|
|
221
202
|
"""コマンドを実行"""
|
|
222
203
|
try:
|
|
223
204
|
# print(f"DEBUG: Running command: {cmd} cwd={cwd}", file=sys.stderr)
|
|
224
205
|
result = subprocess.run(
|
|
225
|
-
cmd,
|
|
226
|
-
cwd=cwd,
|
|
227
|
-
capture_output=True,
|
|
228
|
-
text=True,
|
|
229
|
-
check=check
|
|
206
|
+
cmd, cwd=cwd, capture_output=True, text=True, check=check
|
|
230
207
|
)
|
|
231
208
|
return result
|
|
232
209
|
except subprocess.CalledProcessError as e:
|
|
233
|
-
print(msg(
|
|
210
|
+
print(msg("error", e.stderr), file=sys.stderr)
|
|
234
211
|
sys.exit(1)
|
|
235
212
|
|
|
236
213
|
|
|
237
214
|
def get_repository_name(url: str) -> str:
|
|
238
215
|
"""リポジトリ URL から名前を抽出"""
|
|
239
216
|
# URL から .git を削除して最後の部分を取得
|
|
240
|
-
match = re.search(r
|
|
217
|
+
match = re.search(r"/([^/]+?)(?:\.git)?$", url)
|
|
241
218
|
if match:
|
|
242
219
|
name = match.group(1)
|
|
243
220
|
# サービス名などが含まれる場合のクリーンアップ
|
|
244
|
-
return name.split(
|
|
221
|
+
return name.split(":")[-1]
|
|
245
222
|
# ローカルパスの場合
|
|
246
223
|
return Path(url).stem
|
|
247
224
|
|
|
@@ -251,18 +228,18 @@ def load_config(base_dir: Path) -> dict:
|
|
|
251
228
|
config_file = base_dir / ".wt" / "config.toml"
|
|
252
229
|
default_config = {
|
|
253
230
|
"worktrees_dir": ".worktrees",
|
|
254
|
-
"
|
|
255
|
-
"auto_copy_on_add": True
|
|
231
|
+
"setup_files": [".env"],
|
|
232
|
+
"auto_copy_on_add": True,
|
|
256
233
|
}
|
|
257
|
-
|
|
234
|
+
|
|
258
235
|
if config_file.exists():
|
|
259
236
|
try:
|
|
260
|
-
with open(config_file,
|
|
237
|
+
with open(config_file, "r", encoding="utf-8") as f:
|
|
261
238
|
user_config = toml.load(f)
|
|
262
239
|
default_config.update(user_config)
|
|
263
240
|
except Exception as e:
|
|
264
|
-
print(msg(
|
|
265
|
-
|
|
241
|
+
print(msg("error", f"Failed to load config: {e}"), file=sys.stderr)
|
|
242
|
+
|
|
266
243
|
return default_config
|
|
267
244
|
|
|
268
245
|
|
|
@@ -271,8 +248,8 @@ def save_config(base_dir: Path, config: dict):
|
|
|
271
248
|
wt_dir = base_dir / ".wt"
|
|
272
249
|
wt_dir.mkdir(exist_ok=True)
|
|
273
250
|
config_file = wt_dir / "config.toml"
|
|
274
|
-
|
|
275
|
-
with open(config_file,
|
|
251
|
+
|
|
252
|
+
with open(config_file, "w", encoding="utf-8") as f:
|
|
276
253
|
toml.dump(config, f)
|
|
277
254
|
|
|
278
255
|
|
|
@@ -286,32 +263,35 @@ def create_hook_template(base_dir: Path):
|
|
|
286
263
|
# config.toml
|
|
287
264
|
config_file = wt_dir / "config.toml"
|
|
288
265
|
if not config_file.exists():
|
|
289
|
-
save_config(
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
266
|
+
save_config(
|
|
267
|
+
base_dir,
|
|
268
|
+
{
|
|
269
|
+
"worktrees_dir": ".worktrees",
|
|
270
|
+
"setup_files": [".env"],
|
|
271
|
+
"auto_copy_on_add": True,
|
|
272
|
+
},
|
|
273
|
+
)
|
|
294
274
|
|
|
295
275
|
# .gitignore (repository root) に worktrees_dir を追加
|
|
296
276
|
config = load_config(base_dir)
|
|
297
277
|
worktrees_dir_name = config.get("worktrees_dir", ".worktrees")
|
|
298
278
|
root_gitignore = base_dir / ".gitignore"
|
|
299
|
-
|
|
279
|
+
|
|
300
280
|
entries = [f"{worktrees_dir_name}/"]
|
|
301
|
-
|
|
281
|
+
|
|
302
282
|
if root_gitignore.exists():
|
|
303
|
-
content = root_gitignore.read_text(encoding=
|
|
283
|
+
content = root_gitignore.read_text(encoding="utf-8")
|
|
304
284
|
updated = False
|
|
305
285
|
for entry in entries:
|
|
306
286
|
if entry not in content:
|
|
307
|
-
if content and not content.endswith(
|
|
308
|
-
content +=
|
|
287
|
+
if content and not content.endswith("\n"):
|
|
288
|
+
content += "\n"
|
|
309
289
|
content += f"{entry}\n"
|
|
310
290
|
updated = True
|
|
311
291
|
if updated:
|
|
312
|
-
root_gitignore.write_text(content, encoding=
|
|
292
|
+
root_gitignore.write_text(content, encoding="utf-8")
|
|
313
293
|
else:
|
|
314
|
-
root_gitignore.write_text("\n".join(entries) + "\n", encoding=
|
|
294
|
+
root_gitignore.write_text("\n".join(entries) + "\n", encoding="utf-8")
|
|
315
295
|
|
|
316
296
|
# post-add hook テンプレート
|
|
317
297
|
hook_file = wt_dir / "post-add"
|
|
@@ -379,9 +359,6 @@ wt add <作業名>
|
|
|
379
359
|
# 既存ブランチから worktree を作成
|
|
380
360
|
wt add <作業名> <既存ブランチ名>
|
|
381
361
|
|
|
382
|
-
# エイリアスを作成(current エイリアスで現在の作業を切り替え)
|
|
383
|
-
wt add <作業名> --alias current
|
|
384
|
-
|
|
385
362
|
# worktree 一覧を表示
|
|
386
363
|
wt list
|
|
387
364
|
|
|
@@ -391,31 +368,6 @@ wt rm <作業名>
|
|
|
391
368
|
|
|
392
369
|
詳細は https://github.com/igtm/easy-worktree を参照してください。
|
|
393
370
|
|
|
394
|
-
## エイリアスとは
|
|
395
|
-
|
|
396
|
-
エイリアスは、worktree へのシンボリックリンク(symbolic link)です。同じエイリアス名で異なる worktree を指すことで、固定されたパスで複数のブランチを切り替えられます。
|
|
397
|
-
|
|
398
|
-
### エイリアスの便利な使い方
|
|
399
|
-
|
|
400
|
-
**VSCode ワークスペースでの活用**
|
|
401
|
-
|
|
402
|
-
`current` などの固定エイリアスを VSCode のワークスペースとして開くことで、worktree を切り替えても VSCode を開き直す必要がなくなります。
|
|
403
|
-
|
|
404
|
-
```bash
|
|
405
|
-
# 最初の作業
|
|
406
|
-
wt add feature-a --alias current
|
|
407
|
-
code current # VSCode で current を開く
|
|
408
|
-
|
|
409
|
-
# 別の作業に切り替え(VSCode は開いたまま)
|
|
410
|
-
wt add feature-b --alias current
|
|
411
|
-
# current エイリアスが feature-b を指すようになる
|
|
412
|
-
```
|
|
413
|
-
|
|
414
|
-
このように、エイリアスを使うことで:
|
|
415
|
-
- VSCode のワークスペース設定が維持される
|
|
416
|
-
- 拡張機能の設定やウィンドウレイアウトが保持される
|
|
417
|
-
- ブランチ切り替えのたびにエディタを開き直す手間が不要
|
|
418
|
-
|
|
419
371
|
## post-add フック
|
|
420
372
|
|
|
421
373
|
`post-add` フックは、worktree 作成後に自動実行されるスクリプトです。
|
|
@@ -462,43 +414,15 @@ wt add <work_name>
|
|
|
462
414
|
# Create a worktree from an existing branch
|
|
463
415
|
wt add <work_name> <existing_branch_name>
|
|
464
416
|
|
|
465
|
-
# Create an alias (use "current" alias to switch between tasks)
|
|
466
|
-
wt add <work_name> --alias current
|
|
467
|
-
|
|
468
417
|
# List worktrees
|
|
469
418
|
wt list
|
|
470
419
|
|
|
471
420
|
# Remove a worktree
|
|
472
|
-
wt
|
|
421
|
+
wt remove <work_name>
|
|
473
422
|
```
|
|
474
423
|
|
|
475
424
|
For more details, see https://github.com/igtm/easy-worktree
|
|
476
425
|
|
|
477
|
-
## What are Aliases?
|
|
478
|
-
|
|
479
|
-
Aliases are symbolic links to worktrees. By pointing the same alias name to different worktrees, you can switch between multiple branches using a fixed path.
|
|
480
|
-
|
|
481
|
-
### Smart Use of Aliases
|
|
482
|
-
|
|
483
|
-
**Using with VSCode Workspace**
|
|
484
|
-
|
|
485
|
-
By opening a fixed alias like `current` as a VSCode workspace, you can switch worktrees without needing to reopen VSCode.
|
|
486
|
-
|
|
487
|
-
```bash
|
|
488
|
-
# First task
|
|
489
|
-
wt add feature-a --alias current
|
|
490
|
-
code current # Open current in VSCode
|
|
491
|
-
|
|
492
|
-
# Switch to another task (VSCode stays open)
|
|
493
|
-
wt add feature-b --alias current
|
|
494
|
-
# The current alias now points to feature-b
|
|
495
|
-
```
|
|
496
|
-
|
|
497
|
-
Benefits of using aliases:
|
|
498
|
-
- VSCode workspace settings are preserved
|
|
499
|
-
- Extension settings and window layouts are maintained
|
|
500
|
-
- No need to reopen the editor when switching branches
|
|
501
|
-
|
|
502
426
|
## post-add Hook
|
|
503
427
|
|
|
504
428
|
The `post-add` hook is a script that runs automatically after creating a worktree.
|
|
@@ -529,17 +453,33 @@ The `post-add` hook is a script that runs automatically after creating a worktre
|
|
|
529
453
|
|
|
530
454
|
def find_base_dir() -> Path | None:
|
|
531
455
|
"""現在のディレクトリまたは親ディレクトリから git root を探す"""
|
|
456
|
+
# ワークツリーでもメインリポジトリのルートを見つけられるように
|
|
532
457
|
try:
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
458
|
+
# --git-common-dir はメインリポジトリの .git ディレクトリを返す
|
|
459
|
+
result = run_command(["git", "rev-parse", "--git-common-dir"], check=False)
|
|
460
|
+
if result.returncode == 0:
|
|
461
|
+
git_common_dir = Path(result.stdout.strip())
|
|
462
|
+
if not git_common_dir.is_absolute():
|
|
463
|
+
# 相対パスの場合は CWD からのパス
|
|
464
|
+
git_common_dir = (Path.cwd() / git_common_dir).resolve()
|
|
465
|
+
|
|
466
|
+
# .git ディレクトリの親がベースディレクトリ
|
|
467
|
+
if git_common_dir.name == ".git":
|
|
468
|
+
return git_common_dir.parent
|
|
469
|
+
else:
|
|
470
|
+
# ベアリポジトリなどの場合はそのディレクトリ自体
|
|
471
|
+
return git_common_dir
|
|
472
|
+
except Exception:
|
|
473
|
+
pass
|
|
474
|
+
|
|
475
|
+
try:
|
|
476
|
+
result = run_command(["git", "rev-parse", "--show-toplevel"], check=False)
|
|
537
477
|
if result.returncode == 0:
|
|
538
478
|
return Path(result.stdout.strip())
|
|
539
479
|
except Exception:
|
|
540
480
|
pass
|
|
541
481
|
|
|
542
|
-
#
|
|
482
|
+
# fallback
|
|
543
483
|
current = Path.cwd()
|
|
544
484
|
for parent in [current] + list(current.parents):
|
|
545
485
|
if (parent / ".git").exists():
|
|
@@ -551,21 +491,21 @@ def find_base_dir() -> Path | None:
|
|
|
551
491
|
def cmd_clone(args: list[str]):
|
|
552
492
|
"""wt clone <repository_url> [dest_dir] - Clone a repository"""
|
|
553
493
|
if len(args) < 1:
|
|
554
|
-
print(msg(
|
|
494
|
+
print(msg("usage"), file=sys.stderr)
|
|
555
495
|
sys.exit(1)
|
|
556
496
|
|
|
557
497
|
repo_url = args[0]
|
|
558
498
|
repo_name = get_repository_name(repo_url)
|
|
559
|
-
|
|
499
|
+
|
|
560
500
|
dest_dir = Path(args[1]) if len(args) > 1 else Path(repo_name)
|
|
561
501
|
|
|
562
502
|
if dest_dir.exists():
|
|
563
|
-
print(msg(
|
|
503
|
+
print(msg("error", msg("already_exists", dest_dir)), file=sys.stderr)
|
|
564
504
|
sys.exit(1)
|
|
565
505
|
|
|
566
|
-
print(msg(
|
|
506
|
+
print(msg("cloning", repo_url, dest_dir), file=sys.stderr)
|
|
567
507
|
run_command(["git", "clone", repo_url, str(dest_dir)])
|
|
568
|
-
print(msg(
|
|
508
|
+
print(msg("completed_clone", dest_dir), file=sys.stderr)
|
|
569
509
|
|
|
570
510
|
# post-add hook と設定ファイルを作成
|
|
571
511
|
create_hook_template(dest_dir)
|
|
@@ -577,13 +517,11 @@ def cmd_init(args: list[str]):
|
|
|
577
517
|
|
|
578
518
|
# 現在のディレクトリが git リポジトリか確認
|
|
579
519
|
result = run_command(
|
|
580
|
-
["git", "rev-parse", "--show-toplevel"],
|
|
581
|
-
cwd=current_dir,
|
|
582
|
-
check=False
|
|
520
|
+
["git", "rev-parse", "--show-toplevel"], cwd=current_dir, check=False
|
|
583
521
|
)
|
|
584
522
|
|
|
585
523
|
if result.returncode != 0:
|
|
586
|
-
print(msg(
|
|
524
|
+
print(msg("error", msg("not_git_repo")), file=sys.stderr)
|
|
587
525
|
sys.exit(1)
|
|
588
526
|
|
|
589
527
|
git_root = Path(result.stdout.strip())
|
|
@@ -595,24 +533,35 @@ def cmd_init(args: list[str]):
|
|
|
595
533
|
def get_default_branch(base_dir: Path) -> str:
|
|
596
534
|
"""Detect default branch (main/master)"""
|
|
597
535
|
# 1. Try origin/HEAD
|
|
598
|
-
result = run_command(
|
|
536
|
+
result = run_command(
|
|
537
|
+
["git", "rev-parse", "--abbrev-ref", "origin/HEAD"], cwd=base_dir, check=False
|
|
538
|
+
)
|
|
599
539
|
if result.returncode == 0:
|
|
600
540
|
return result.stdout.strip().replace("origin/", "")
|
|
601
|
-
|
|
541
|
+
|
|
602
542
|
# 2. Try common names
|
|
603
543
|
for b in ["main", "master"]:
|
|
604
|
-
if
|
|
544
|
+
if (
|
|
545
|
+
run_command(
|
|
546
|
+
["git", "rev-parse", "--verify", b], cwd=base_dir, check=False
|
|
547
|
+
).returncode
|
|
548
|
+
== 0
|
|
549
|
+
):
|
|
605
550
|
return b
|
|
606
551
|
|
|
607
552
|
# 3. Fallback to current HEAD
|
|
608
|
-
result = run_command(
|
|
553
|
+
result = run_command(
|
|
554
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=base_dir, check=False
|
|
555
|
+
)
|
|
609
556
|
if result.returncode == 0:
|
|
610
557
|
return result.stdout.strip()
|
|
611
|
-
|
|
558
|
+
|
|
612
559
|
return None
|
|
613
560
|
|
|
614
561
|
|
|
615
|
-
def run_post_add_hook(
|
|
562
|
+
def run_post_add_hook(
|
|
563
|
+
worktree_path: Path, work_name: str, base_dir: Path, branch: str = None
|
|
564
|
+
):
|
|
616
565
|
"""worktree 作成後の hook を実行"""
|
|
617
566
|
# .wt/post-add を探す
|
|
618
567
|
hook_path = base_dir / ".wt" / "post-add"
|
|
@@ -621,20 +570,22 @@ def run_post_add_hook(worktree_path: Path, work_name: str, base_dir: Path, branc
|
|
|
621
570
|
return # hook がなければ何もしない
|
|
622
571
|
|
|
623
572
|
if not os.access(hook_path, os.X_OK):
|
|
624
|
-
print(msg(
|
|
573
|
+
print(msg("hook_not_executable", hook_path), file=sys.stderr)
|
|
625
574
|
return
|
|
626
575
|
|
|
627
576
|
# 環境変数を設定
|
|
628
577
|
env = os.environ.copy()
|
|
629
|
-
env.update(
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
578
|
+
env.update(
|
|
579
|
+
{
|
|
580
|
+
"WT_WORKTREE_PATH": str(worktree_path),
|
|
581
|
+
"WT_WORKTREE_NAME": work_name,
|
|
582
|
+
"WT_BASE_DIR": str(base_dir),
|
|
583
|
+
"WT_BRANCH": branch or work_name,
|
|
584
|
+
"WT_ACTION": "add",
|
|
585
|
+
}
|
|
586
|
+
)
|
|
587
|
+
|
|
588
|
+
print(msg("running_hook", hook_path), file=sys.stderr)
|
|
638
589
|
try:
|
|
639
590
|
result = subprocess.run(
|
|
640
591
|
[str(hook_path)],
|
|
@@ -642,22 +593,27 @@ def run_post_add_hook(worktree_path: Path, work_name: str, base_dir: Path, branc
|
|
|
642
593
|
env=env,
|
|
643
594
|
stdout=sys.stderr, # stdout を stderr にリダイレクト (cd 連携のため)
|
|
644
595
|
stderr=sys.stderr,
|
|
645
|
-
check=False
|
|
596
|
+
check=False,
|
|
646
597
|
)
|
|
647
598
|
|
|
648
599
|
if result.returncode != 0:
|
|
649
|
-
print(msg(
|
|
600
|
+
print(msg("hook_failed", result.returncode), file=sys.stderr)
|
|
650
601
|
except Exception as e:
|
|
651
|
-
print(msg(
|
|
602
|
+
print(msg("error", str(e)), file=sys.stderr)
|
|
652
603
|
|
|
653
604
|
|
|
654
|
-
def add_worktree(
|
|
605
|
+
def add_worktree(
|
|
606
|
+
work_name: str,
|
|
607
|
+
branch_to_use: str = None,
|
|
608
|
+
new_branch_base: str = None,
|
|
609
|
+
base_dir: Path = None,
|
|
610
|
+
) -> Path:
|
|
655
611
|
"""Core logic to add a worktree, reused by cmd_add and cmd_stash"""
|
|
656
612
|
if not base_dir:
|
|
657
613
|
base_dir = find_base_dir()
|
|
658
614
|
if not base_dir:
|
|
659
|
-
print(msg(
|
|
660
|
-
print(msg(
|
|
615
|
+
print(msg("error", msg("base_not_found")), file=sys.stderr)
|
|
616
|
+
print(msg("run_in_wt_dir"), file=sys.stderr)
|
|
661
617
|
sys.exit(1)
|
|
662
618
|
|
|
663
619
|
# 設定を読み込む
|
|
@@ -670,48 +626,58 @@ def add_worktree(work_name: str, branch_to_use: str = None, new_branch_base: str
|
|
|
670
626
|
worktree_path = worktrees_dir / work_name
|
|
671
627
|
|
|
672
628
|
if worktree_path.exists():
|
|
673
|
-
print(msg(
|
|
629
|
+
print(msg("error", msg("already_exists", worktree_path)), file=sys.stderr)
|
|
674
630
|
sys.exit(1)
|
|
675
631
|
|
|
676
632
|
# ブランチを最新に更新
|
|
677
|
-
print(msg(
|
|
633
|
+
print(msg("fetching"), file=sys.stderr)
|
|
678
634
|
run_command(["git", "fetch", "--all"], cwd=base_dir)
|
|
679
635
|
|
|
680
636
|
# 本体 (main) を base branch の最新に更新
|
|
681
637
|
result = run_command(
|
|
682
|
-
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
683
|
-
cwd=base_dir,
|
|
684
|
-
check=False
|
|
638
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=base_dir, check=False
|
|
685
639
|
)
|
|
686
640
|
if result.returncode == 0:
|
|
687
641
|
current_branch = result.stdout.strip()
|
|
688
642
|
result = run_command(
|
|
689
643
|
["git", "rev-parse", "--verify", f"origin/{current_branch}"],
|
|
690
644
|
cwd=base_dir,
|
|
691
|
-
check=False
|
|
645
|
+
check=False,
|
|
692
646
|
)
|
|
693
647
|
if result.returncode == 0:
|
|
694
|
-
run_command(
|
|
648
|
+
run_command(
|
|
649
|
+
["git", "pull", "origin", current_branch], cwd=base_dir, check=False
|
|
650
|
+
)
|
|
695
651
|
|
|
696
652
|
# ブランチ作成/チェックアウト
|
|
697
653
|
final_branch_name = None
|
|
698
654
|
if new_branch_base:
|
|
699
655
|
# 新しいブランチをベースから作成
|
|
700
656
|
final_branch_name = work_name
|
|
701
|
-
print(
|
|
657
|
+
print(
|
|
658
|
+
msg("creating_branch", final_branch_name, new_branch_base), file=sys.stderr
|
|
659
|
+
)
|
|
702
660
|
result = run_command(
|
|
703
|
-
[
|
|
661
|
+
[
|
|
662
|
+
"git",
|
|
663
|
+
"worktree",
|
|
664
|
+
"add",
|
|
665
|
+
"-b",
|
|
666
|
+
final_branch_name,
|
|
667
|
+
str(worktree_path),
|
|
668
|
+
new_branch_base,
|
|
669
|
+
],
|
|
704
670
|
cwd=base_dir,
|
|
705
|
-
check=False
|
|
671
|
+
check=False,
|
|
706
672
|
)
|
|
707
673
|
elif branch_to_use:
|
|
708
674
|
# 指定されたブランチをチェックアウト
|
|
709
675
|
final_branch_name = branch_to_use
|
|
710
|
-
print(msg(
|
|
676
|
+
print(msg("creating_worktree", worktree_path), file=sys.stderr)
|
|
711
677
|
result = run_command(
|
|
712
678
|
["git", "worktree", "add", str(worktree_path), final_branch_name],
|
|
713
679
|
cwd=base_dir,
|
|
714
|
-
check=False
|
|
680
|
+
check=False,
|
|
715
681
|
)
|
|
716
682
|
else:
|
|
717
683
|
# 自動判定
|
|
@@ -721,35 +687,41 @@ def add_worktree(work_name: str, branch_to_use: str = None, new_branch_base: str
|
|
|
721
687
|
check_local = run_command(
|
|
722
688
|
["git", "rev-parse", "--verify", final_branch_name],
|
|
723
689
|
cwd=base_dir,
|
|
724
|
-
check=False
|
|
690
|
+
check=False,
|
|
725
691
|
)
|
|
726
692
|
check_remote = run_command(
|
|
727
693
|
["git", "rev-parse", "--verify", f"origin/{final_branch_name}"],
|
|
728
694
|
cwd=base_dir,
|
|
729
|
-
check=False
|
|
695
|
+
check=False,
|
|
730
696
|
)
|
|
731
697
|
|
|
732
698
|
if check_local.returncode == 0 or check_remote.returncode == 0:
|
|
733
699
|
if check_remote.returncode == 0:
|
|
734
|
-
print(msg(
|
|
700
|
+
print(msg("creating_worktree", worktree_path), file=sys.stderr)
|
|
735
701
|
result = run_command(
|
|
736
|
-
[
|
|
702
|
+
[
|
|
703
|
+
"git",
|
|
704
|
+
"worktree",
|
|
705
|
+
"add",
|
|
706
|
+
str(worktree_path),
|
|
707
|
+
f"origin/{final_branch_name}",
|
|
708
|
+
],
|
|
737
709
|
cwd=base_dir,
|
|
738
|
-
check=False
|
|
710
|
+
check=False,
|
|
739
711
|
)
|
|
740
712
|
else:
|
|
741
|
-
print(msg(
|
|
713
|
+
print(msg("creating_worktree", worktree_path), file=sys.stderr)
|
|
742
714
|
result = run_command(
|
|
743
715
|
["git", "worktree", "add", str(worktree_path), final_branch_name],
|
|
744
716
|
cwd=base_dir,
|
|
745
|
-
check=False
|
|
717
|
+
check=False,
|
|
746
718
|
)
|
|
747
719
|
else:
|
|
748
720
|
# デフォルトブランチを探す
|
|
749
721
|
result_sym = run_command(
|
|
750
722
|
["git", "symbolic-ref", "refs/remotes/origin/HEAD", "--short"],
|
|
751
723
|
cwd=base_dir,
|
|
752
|
-
check=False
|
|
724
|
+
check=False,
|
|
753
725
|
)
|
|
754
726
|
|
|
755
727
|
detected_base = None
|
|
@@ -758,37 +730,62 @@ def add_worktree(work_name: str, branch_to_use: str = None, new_branch_base: str
|
|
|
758
730
|
else:
|
|
759
731
|
# remote/local main/master の順に探す
|
|
760
732
|
for b in ["origin/main", "origin/master", "main", "master"]:
|
|
761
|
-
if
|
|
733
|
+
if (
|
|
734
|
+
run_command(
|
|
735
|
+
["git", "rev-parse", "--verify", b],
|
|
736
|
+
cwd=base_dir,
|
|
737
|
+
check=False,
|
|
738
|
+
).returncode
|
|
739
|
+
== 0
|
|
740
|
+
):
|
|
762
741
|
detected_base = b
|
|
763
742
|
break
|
|
764
|
-
|
|
743
|
+
|
|
765
744
|
if not detected_base:
|
|
766
745
|
# 最終手段として現在のブランチを使用
|
|
767
|
-
res_curr = run_command(
|
|
746
|
+
res_curr = run_command(
|
|
747
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
748
|
+
cwd=base_dir,
|
|
749
|
+
check=False,
|
|
750
|
+
)
|
|
768
751
|
if res_curr.returncode == 0:
|
|
769
752
|
detected_base = res_curr.stdout.strip()
|
|
770
753
|
|
|
771
754
|
if not detected_base:
|
|
772
|
-
print(
|
|
755
|
+
print(
|
|
756
|
+
msg("error", msg("default_branch_not_found")), file=sys.stderr
|
|
757
|
+
)
|
|
773
758
|
sys.exit(1)
|
|
774
759
|
|
|
775
|
-
print(
|
|
760
|
+
print(
|
|
761
|
+
msg("creating_branch", final_branch_name, detected_base),
|
|
762
|
+
file=sys.stderr,
|
|
763
|
+
)
|
|
776
764
|
result = run_command(
|
|
777
|
-
[
|
|
765
|
+
[
|
|
766
|
+
"git",
|
|
767
|
+
"worktree",
|
|
768
|
+
"add",
|
|
769
|
+
"-b",
|
|
770
|
+
final_branch_name,
|
|
771
|
+
str(worktree_path),
|
|
772
|
+
detected_base,
|
|
773
|
+
],
|
|
778
774
|
cwd=base_dir,
|
|
779
|
-
check=False
|
|
775
|
+
check=False,
|
|
780
776
|
)
|
|
781
777
|
|
|
782
778
|
if result.returncode == 0:
|
|
783
779
|
# 自動同期
|
|
784
780
|
if config.get("auto_copy_on_add"):
|
|
785
|
-
|
|
786
|
-
for file_name in
|
|
781
|
+
setup_files = config.get("setup_files", [])
|
|
782
|
+
for file_name in setup_files:
|
|
787
783
|
src = base_dir / file_name
|
|
788
784
|
dst = worktree_path / file_name
|
|
789
785
|
if src.exists():
|
|
790
|
-
print(msg(
|
|
786
|
+
print(msg("setting_up", src, dst), file=sys.stderr)
|
|
791
787
|
import shutil
|
|
788
|
+
|
|
792
789
|
shutil.copy2(src, dst)
|
|
793
790
|
|
|
794
791
|
# post-add hook
|
|
@@ -803,24 +800,24 @@ def add_worktree(work_name: str, branch_to_use: str = None, new_branch_base: str
|
|
|
803
800
|
def cmd_add(args: list[str]):
|
|
804
801
|
"""wt add <work_name> [<base_branch>] - Add a worktree"""
|
|
805
802
|
if len(args) < 1:
|
|
806
|
-
print(msg(
|
|
803
|
+
print(msg("usage_add"), file=sys.stderr)
|
|
807
804
|
sys.exit(1)
|
|
808
805
|
|
|
809
806
|
work_name = args[0]
|
|
810
807
|
branch_to_use = args[1] if len(args) >= 2 else None
|
|
811
|
-
|
|
808
|
+
|
|
812
809
|
add_worktree(work_name, branch_to_use=branch_to_use)
|
|
813
810
|
|
|
814
811
|
|
|
815
812
|
def cmd_stash(args: list[str]):
|
|
816
813
|
"""wt stash <work_name> [<base_branch>] - Stash changes and move to new worktree"""
|
|
817
814
|
if len(args) < 1:
|
|
818
|
-
print(msg(
|
|
815
|
+
print(msg("usage_stash"), file=sys.stderr)
|
|
819
816
|
sys.exit(1)
|
|
820
817
|
|
|
821
818
|
base_dir = find_base_dir()
|
|
822
819
|
if not base_dir:
|
|
823
|
-
print(msg(
|
|
820
|
+
print(msg("error", msg("base_not_found")), file=sys.stderr)
|
|
824
821
|
sys.exit(1)
|
|
825
822
|
|
|
826
823
|
# 変更があるかチェック
|
|
@@ -828,59 +825,67 @@ def cmd_stash(args: list[str]):
|
|
|
828
825
|
has_changes = bool(result.stdout.strip())
|
|
829
826
|
|
|
830
827
|
if has_changes:
|
|
831
|
-
print(msg(
|
|
828
|
+
print(msg("stashing_changes"), file=sys.stderr)
|
|
832
829
|
# stash する
|
|
833
830
|
# -u (include untracked)
|
|
834
|
-
run_command(
|
|
831
|
+
run_command(
|
|
832
|
+
["git", "stash", "push", "-u", "-m", f"easy-worktree stash for {args[0]}"],
|
|
833
|
+
cwd=base_dir,
|
|
834
|
+
)
|
|
835
835
|
else:
|
|
836
|
-
print(msg(
|
|
836
|
+
print(msg("nothing_to_stash"), file=sys.stderr)
|
|
837
837
|
|
|
838
838
|
# 新しい worktree を作成
|
|
839
839
|
work_name = args[0]
|
|
840
|
-
|
|
840
|
+
|
|
841
841
|
# base_branch が指定されていない場合は現在のブランチをベースにする
|
|
842
842
|
# 指定されている場合はそれをベースにする
|
|
843
843
|
new_branch_base = args[1] if len(args) >= 2 else None
|
|
844
844
|
if not new_branch_base:
|
|
845
|
-
res = run_command(
|
|
845
|
+
res = run_command(
|
|
846
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=base_dir, check=False
|
|
847
|
+
)
|
|
846
848
|
if res.returncode == 0:
|
|
847
849
|
new_branch_base = res.stdout.strip()
|
|
848
850
|
|
|
849
851
|
# aliasはサポートしないでおく(とりあえずシンプルに)
|
|
850
852
|
# wt stash は常に新しいブランチを作成する振る舞いにする
|
|
851
|
-
wt_path = add_worktree(
|
|
853
|
+
wt_path = add_worktree(
|
|
854
|
+
work_name, new_branch_base=new_branch_base, base_dir=base_dir
|
|
855
|
+
)
|
|
852
856
|
|
|
853
857
|
if has_changes and wt_path:
|
|
854
|
-
print(msg(
|
|
858
|
+
print(msg("popping_stash"), file=sys.stderr)
|
|
855
859
|
# 新しい worktree で stash pop
|
|
856
860
|
run_command(["git", "stash", "pop"], cwd=wt_path)
|
|
857
861
|
|
|
858
862
|
|
|
859
|
-
|
|
860
|
-
|
|
861
863
|
def cmd_pr(args: list[str]):
|
|
862
864
|
"""wt pr <add|co> <number> - Pull Request management"""
|
|
863
865
|
if len(args) < 2:
|
|
864
|
-
print(msg(
|
|
866
|
+
print(msg("usage_pr"), file=sys.stderr)
|
|
865
867
|
sys.exit(1)
|
|
866
868
|
|
|
867
869
|
subcommand = args[0]
|
|
868
870
|
pr_number = args[1]
|
|
869
|
-
|
|
871
|
+
|
|
870
872
|
# Ensure pr_number is a digit
|
|
871
873
|
if not pr_number.isdigit():
|
|
872
|
-
print(msg(
|
|
874
|
+
print(msg("error", f"PR number must be a digit: {pr_number}"), file=sys.stderr)
|
|
873
875
|
sys.exit(1)
|
|
874
876
|
|
|
875
877
|
base_dir = find_base_dir()
|
|
876
878
|
if not base_dir:
|
|
877
|
-
print(msg(
|
|
879
|
+
print(msg("error", msg("base_not_found")), file=sys.stderr)
|
|
878
880
|
sys.exit(1)
|
|
879
881
|
|
|
880
882
|
if subcommand == "add":
|
|
881
883
|
# Check if gh command exists
|
|
882
884
|
if shutil.which("gh") is None:
|
|
883
|
-
print(
|
|
885
|
+
print(
|
|
886
|
+
msg("error", "GitHub CLI (gh) is required for this command"),
|
|
887
|
+
file=sys.stderr,
|
|
888
|
+
)
|
|
884
889
|
sys.exit(1)
|
|
885
890
|
|
|
886
891
|
print(f"Verifying PR #{pr_number}...", file=sys.stderr)
|
|
@@ -888,108 +893,103 @@ def cmd_pr(args: list[str]):
|
|
|
888
893
|
verify_cmd = ["gh", "pr", "view", pr_number, "--json", "number"]
|
|
889
894
|
result = run_command(verify_cmd, cwd=base_dir, check=False)
|
|
890
895
|
if result.returncode != 0:
|
|
891
|
-
print(
|
|
896
|
+
print(
|
|
897
|
+
msg("error", f"PR #{pr_number} not found (or access denied)"),
|
|
898
|
+
file=sys.stderr,
|
|
899
|
+
)
|
|
892
900
|
sys.exit(1)
|
|
893
901
|
|
|
894
902
|
branch_name = f"pr-{pr_number}"
|
|
895
903
|
worktree_name = f"pr@{pr_number}"
|
|
896
|
-
|
|
904
|
+
|
|
897
905
|
print(f"Fetching PR #{pr_number} contents...", file=sys.stderr)
|
|
898
906
|
# Fetch PR head to a local branch
|
|
899
907
|
# git fetch origin pull/ID/head:local-branch
|
|
900
908
|
fetch_cmd = ["git", "fetch", "origin", f"pull/{pr_number}/head:{branch_name}"]
|
|
901
|
-
# We might want to handle case where origin doesn't exist or pull ref is different,
|
|
909
|
+
# We might want to handle case where origin doesn't exist or pull ref is different,
|
|
902
910
|
# but origin pull/ID/head is standard for GitHub.
|
|
903
911
|
run_command(fetch_cmd, cwd=base_dir)
|
|
904
|
-
|
|
912
|
+
|
|
905
913
|
print(f"Creating worktree {worktree_name}...", file=sys.stderr)
|
|
906
914
|
add_worktree(worktree_name, branch_to_use=branch_name, base_dir=base_dir)
|
|
907
|
-
|
|
915
|
+
|
|
908
916
|
elif subcommand == "co":
|
|
909
917
|
# Just a shortcut for checkout pr@<number>
|
|
910
918
|
cmd_checkout([f"pr@{pr_number}"])
|
|
911
919
|
else:
|
|
912
|
-
print(msg(
|
|
920
|
+
print(msg("usage_pr"), file=sys.stderr)
|
|
913
921
|
sys.exit(1)
|
|
914
922
|
|
|
915
923
|
|
|
916
924
|
def get_worktree_info(base_dir: Path) -> list[dict]:
|
|
917
925
|
"""worktree の詳細情報を取得"""
|
|
918
|
-
result = run_command(
|
|
919
|
-
["git", "worktree", "list", "--porcelain"],
|
|
920
|
-
cwd=base_dir
|
|
921
|
-
)
|
|
926
|
+
result = run_command(["git", "worktree", "list", "--porcelain"], cwd=base_dir)
|
|
922
927
|
|
|
923
928
|
worktrees = []
|
|
924
929
|
current = {}
|
|
925
930
|
|
|
926
|
-
for line in result.stdout.strip().split(
|
|
931
|
+
for line in result.stdout.strip().split("\n"):
|
|
927
932
|
if not line:
|
|
928
933
|
if current:
|
|
929
934
|
worktrees.append(current)
|
|
930
935
|
current = {}
|
|
931
936
|
continue
|
|
932
937
|
|
|
933
|
-
if line.startswith(
|
|
934
|
-
current[
|
|
935
|
-
elif line.startswith(
|
|
936
|
-
current[
|
|
937
|
-
elif line.startswith(
|
|
938
|
-
current[
|
|
939
|
-
elif line.startswith(
|
|
940
|
-
current[
|
|
938
|
+
if line.startswith("worktree "):
|
|
939
|
+
current["path"] = line.split(" ", 1)[1]
|
|
940
|
+
elif line.startswith("HEAD "):
|
|
941
|
+
current["head"] = line.split(" ", 1)[1]
|
|
942
|
+
elif line.startswith("branch "):
|
|
943
|
+
current["branch"] = line.split(" ", 1)[1].replace("refs/heads/", "")
|
|
944
|
+
elif line.startswith("detached"):
|
|
945
|
+
current["branch"] = "DETACHED"
|
|
941
946
|
|
|
942
947
|
if current:
|
|
943
948
|
worktrees.append(current)
|
|
944
949
|
|
|
945
950
|
# 各 worktree の詳細情報を取得
|
|
946
951
|
for wt in worktrees:
|
|
947
|
-
path = Path(wt[
|
|
952
|
+
path = Path(wt["path"])
|
|
948
953
|
|
|
949
954
|
# 作成日時(ディレクトリの作成時刻)
|
|
950
955
|
if path.exists():
|
|
951
956
|
stat_info = path.stat()
|
|
952
|
-
wt[
|
|
957
|
+
wt["created"] = datetime.fromtimestamp(stat_info.st_ctime)
|
|
953
958
|
|
|
954
959
|
# 最終コミット日時
|
|
955
960
|
result = run_command(
|
|
956
|
-
["git", "log", "-1", "--format=%ct", wt.get(
|
|
961
|
+
["git", "log", "-1", "--format=%ct", wt.get("head", "HEAD")],
|
|
957
962
|
cwd=base_dir,
|
|
958
|
-
check=False
|
|
963
|
+
check=False,
|
|
959
964
|
)
|
|
960
965
|
if result.returncode == 0 and result.stdout.strip():
|
|
961
|
-
wt[
|
|
966
|
+
wt["last_commit"] = datetime.fromtimestamp(int(result.stdout.strip()))
|
|
962
967
|
|
|
963
968
|
# git status(変更があるか)
|
|
964
|
-
result = run_command(
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
check=False
|
|
968
|
-
)
|
|
969
|
-
wt['is_clean'] = result.returncode == 0 and not result.stdout.strip()
|
|
970
|
-
wt['has_untracked'] = '??' in result.stdout
|
|
969
|
+
result = run_command(["git", "status", "--porcelain"], cwd=path, check=False)
|
|
970
|
+
wt["is_clean"] = result.returncode == 0 and not result.stdout.strip()
|
|
971
|
+
wt["has_untracked"] = "??" in result.stdout
|
|
971
972
|
|
|
972
973
|
# Diff stats取得
|
|
973
974
|
result_diff = run_command(
|
|
974
|
-
["git", "diff", "HEAD", "--shortstat"],
|
|
975
|
-
cwd=path,
|
|
976
|
-
check=False
|
|
975
|
+
["git", "diff", "HEAD", "--shortstat"], cwd=path, check=False
|
|
977
976
|
)
|
|
978
|
-
|
|
977
|
+
|
|
979
978
|
insertions = 0
|
|
980
979
|
deletions = 0
|
|
981
980
|
if result_diff.returncode == 0 and result_diff.stdout.strip():
|
|
982
981
|
out = result_diff.stdout.strip()
|
|
983
|
-
m_plus = re.search(r
|
|
984
|
-
m_minus = re.search(r
|
|
985
|
-
if m_plus:
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
wt['deletions'] = deletions
|
|
982
|
+
m_plus = re.search(r"(\d+) insertion", out)
|
|
983
|
+
m_minus = re.search(r"(\d+) deletion", out)
|
|
984
|
+
if m_plus:
|
|
985
|
+
insertions = int(m_plus.group(1))
|
|
986
|
+
if m_minus:
|
|
987
|
+
deletions = int(m_minus.group(1))
|
|
990
988
|
|
|
991
|
-
|
|
989
|
+
wt["insertions"] = insertions
|
|
990
|
+
wt["deletions"] = deletions
|
|
992
991
|
|
|
992
|
+
return worktrees
|
|
993
993
|
|
|
994
994
|
if result.returncode == 0:
|
|
995
995
|
return result.stdout.strip()
|
|
@@ -998,65 +998,76 @@ def get_worktree_info(base_dir: Path) -> list[dict]:
|
|
|
998
998
|
|
|
999
999
|
def get_pr_info(branch: str, cwd: Path = None) -> str:
|
|
1000
1000
|
"""Get rich GitHub PR information for the branch"""
|
|
1001
|
-
if not branch or branch ==
|
|
1001
|
+
if not branch or branch == "HEAD" or branch == "DETACHED":
|
|
1002
1002
|
return ""
|
|
1003
|
-
|
|
1003
|
+
|
|
1004
1004
|
# Check if gh command exists
|
|
1005
1005
|
if shutil.which("gh") is None:
|
|
1006
1006
|
return ""
|
|
1007
|
-
|
|
1007
|
+
|
|
1008
1008
|
import json
|
|
1009
|
-
|
|
1009
|
+
|
|
1010
|
+
cmd = [
|
|
1011
|
+
"gh",
|
|
1012
|
+
"pr",
|
|
1013
|
+
"list",
|
|
1014
|
+
"--head",
|
|
1015
|
+
branch,
|
|
1016
|
+
"--state",
|
|
1017
|
+
"all",
|
|
1018
|
+
"--json",
|
|
1019
|
+
"state,isDraft,url,createdAt,number",
|
|
1020
|
+
]
|
|
1010
1021
|
result = run_command(cmd, cwd=cwd, check=False)
|
|
1011
|
-
|
|
1022
|
+
|
|
1012
1023
|
if result.returncode != 0 or not result.stdout.strip():
|
|
1013
1024
|
return ""
|
|
1014
|
-
|
|
1025
|
+
|
|
1015
1026
|
try:
|
|
1016
1027
|
prs = json.loads(result.stdout)
|
|
1017
1028
|
if not prs:
|
|
1018
1029
|
return ""
|
|
1019
|
-
|
|
1030
|
+
|
|
1020
1031
|
pr = prs[0]
|
|
1021
|
-
state = pr[
|
|
1022
|
-
is_draft = pr[
|
|
1023
|
-
url = pr[
|
|
1024
|
-
created_at_str = pr[
|
|
1025
|
-
number = pr[
|
|
1026
|
-
|
|
1032
|
+
state = pr["state"]
|
|
1033
|
+
is_draft = pr["isDraft"]
|
|
1034
|
+
url = pr["url"]
|
|
1035
|
+
created_at_str = pr["createdAt"]
|
|
1036
|
+
number = pr["number"]
|
|
1037
|
+
|
|
1027
1038
|
# Parse created_at
|
|
1028
1039
|
# ISO format: 2024-03-20T12:00:00Z
|
|
1029
1040
|
try:
|
|
1030
1041
|
# Localize to local timezone
|
|
1031
|
-
dt_aware = datetime.fromisoformat(created_at_str.replace(
|
|
1042
|
+
dt_aware = datetime.fromisoformat(created_at_str.replace("Z", "+00:00"))
|
|
1032
1043
|
dt_local = dt_aware.astimezone().replace(tzinfo=None)
|
|
1033
1044
|
rel_time = get_relative_time(dt_local)
|
|
1034
1045
|
except Exception:
|
|
1035
1046
|
rel_time = "N/A"
|
|
1036
|
-
|
|
1047
|
+
|
|
1037
1048
|
# Symbols and Colors
|
|
1038
1049
|
GREEN = "\033[32m"
|
|
1039
1050
|
GRAY = "\033[90m"
|
|
1040
1051
|
MAGENTA = "\033[35m"
|
|
1041
1052
|
RED = "\033[31m"
|
|
1042
1053
|
RESET = "\033[0m"
|
|
1043
|
-
|
|
1054
|
+
|
|
1044
1055
|
if is_draft:
|
|
1045
1056
|
symbol = f"{GRAY}◌{RESET}"
|
|
1046
1057
|
elif state == "OPEN":
|
|
1047
1058
|
symbol = f"{GREEN}●{RESET}"
|
|
1048
1059
|
elif state == "MERGED":
|
|
1049
1060
|
symbol = f"{MAGENTA}✔{RESET}"
|
|
1050
|
-
else:
|
|
1061
|
+
else: # CLOSED
|
|
1051
1062
|
symbol = f"{RED}✘{RESET}"
|
|
1052
|
-
|
|
1063
|
+
|
|
1053
1064
|
# Hyperlink for #NUMBER
|
|
1054
1065
|
# ANSI sequence for hyperlink: ESC ] 8 ; ; URL ESC \ TEXT ESC ] 8 ; ; ESC \
|
|
1055
1066
|
link_start = f"\x1b]8;;{url}\x1b\\"
|
|
1056
1067
|
link_end = "\x1b]8;;\x1b\\"
|
|
1057
|
-
|
|
1068
|
+
|
|
1058
1069
|
return f"{symbol} {link_start}#{number}{link_end} ({rel_time})"
|
|
1059
|
-
|
|
1070
|
+
|
|
1060
1071
|
except Exception:
|
|
1061
1072
|
return ""
|
|
1062
1073
|
|
|
@@ -1065,16 +1076,16 @@ def get_relative_time(dt: datetime) -> str:
|
|
|
1065
1076
|
"""Get relative time string"""
|
|
1066
1077
|
if not dt:
|
|
1067
1078
|
return "N/A"
|
|
1068
|
-
|
|
1079
|
+
|
|
1069
1080
|
now = datetime.now()
|
|
1070
1081
|
diff = now - dt
|
|
1071
|
-
|
|
1082
|
+
|
|
1072
1083
|
seconds = diff.total_seconds()
|
|
1073
1084
|
days = diff.days
|
|
1074
|
-
|
|
1085
|
+
|
|
1075
1086
|
if days < 0:
|
|
1076
1087
|
return "just now"
|
|
1077
|
-
|
|
1088
|
+
|
|
1078
1089
|
if days == 0:
|
|
1079
1090
|
if seconds < 60:
|
|
1080
1091
|
return "just now"
|
|
@@ -1083,17 +1094,17 @@ def get_relative_time(dt: datetime) -> str:
|
|
|
1083
1094
|
return f"{minutes}m ago"
|
|
1084
1095
|
hours = int(seconds / 3600)
|
|
1085
1096
|
return f"{hours}h ago"
|
|
1086
|
-
|
|
1097
|
+
|
|
1087
1098
|
if days == 1:
|
|
1088
1099
|
return "yesterday"
|
|
1089
|
-
|
|
1100
|
+
|
|
1090
1101
|
if days < 30:
|
|
1091
1102
|
return f"{days}d ago"
|
|
1092
|
-
|
|
1103
|
+
|
|
1093
1104
|
if days < 365:
|
|
1094
1105
|
months = int(days / 30)
|
|
1095
1106
|
return f"{months}mo ago"
|
|
1096
|
-
|
|
1107
|
+
|
|
1097
1108
|
years = int(days / 365)
|
|
1098
1109
|
return f"{years}y ago"
|
|
1099
1110
|
|
|
@@ -1102,32 +1113,36 @@ def cmd_list(args: list[str]):
|
|
|
1102
1113
|
"""wt list - List worktrees"""
|
|
1103
1114
|
base_dir = find_base_dir()
|
|
1104
1115
|
if not base_dir:
|
|
1105
|
-
print(msg(
|
|
1116
|
+
print(msg("error", msg("base_not_found")), file=sys.stderr)
|
|
1106
1117
|
sys.exit(1)
|
|
1107
1118
|
|
|
1108
1119
|
# --quiet / -q オプション(xargs 用)
|
|
1109
|
-
quiet =
|
|
1110
|
-
show_pr =
|
|
1120
|
+
quiet = "--quiet" in args or "-q" in args
|
|
1121
|
+
show_pr = "--pr" in args
|
|
1111
1122
|
|
|
1112
1123
|
worktrees = get_worktree_info(base_dir)
|
|
1113
1124
|
|
|
1114
1125
|
# ソート: 作成日時の降順(最新が上)
|
|
1115
|
-
worktrees.sort(key=lambda x: x.get(
|
|
1126
|
+
worktrees.sort(key=lambda x: x.get("created", datetime.min), reverse=True)
|
|
1116
1127
|
|
|
1117
1128
|
# PR infoの取得
|
|
1118
1129
|
if show_pr:
|
|
1119
1130
|
for wt in worktrees:
|
|
1120
|
-
branch = wt.get(
|
|
1131
|
+
branch = wt.get("branch", "")
|
|
1121
1132
|
if branch:
|
|
1122
|
-
wt[
|
|
1133
|
+
wt["pr_info"] = get_pr_info(branch, cwd=base_dir)
|
|
1123
1134
|
|
|
1124
1135
|
# 相対時間の計算
|
|
1125
1136
|
for wt in worktrees:
|
|
1126
|
-
wt[
|
|
1137
|
+
wt["relative_time"] = get_relative_time(wt.get("created"))
|
|
1127
1138
|
|
|
1128
1139
|
if quiet:
|
|
1129
1140
|
for wt in worktrees:
|
|
1130
|
-
print(
|
|
1141
|
+
print(
|
|
1142
|
+
Path(wt["path"]).name
|
|
1143
|
+
if Path(wt["path"]).name != base_dir.name
|
|
1144
|
+
else "main"
|
|
1145
|
+
)
|
|
1131
1146
|
return
|
|
1132
1147
|
|
|
1133
1148
|
# "Changes" カラムの表示文字列作成
|
|
@@ -1135,12 +1150,12 @@ def cmd_list(args: list[str]):
|
|
|
1135
1150
|
RED = "\033[31m"
|
|
1136
1151
|
GRAY = "\033[90m"
|
|
1137
1152
|
RESET = "\033[0m"
|
|
1138
|
-
|
|
1153
|
+
|
|
1139
1154
|
for wt in worktrees:
|
|
1140
|
-
plus = wt.get(
|
|
1141
|
-
minus = wt.get(
|
|
1142
|
-
untracked = wt.get(
|
|
1143
|
-
|
|
1155
|
+
plus = wt.get("insertions", 0)
|
|
1156
|
+
minus = wt.get("deletions", 0)
|
|
1157
|
+
untracked = wt.get("has_untracked", False)
|
|
1158
|
+
|
|
1144
1159
|
parts = []
|
|
1145
1160
|
clean_parts = []
|
|
1146
1161
|
if plus > 0:
|
|
@@ -1152,25 +1167,51 @@ def cmd_list(args: list[str]):
|
|
|
1152
1167
|
if untracked:
|
|
1153
1168
|
parts.append(f"{GRAY}??{RESET}")
|
|
1154
1169
|
clean_parts.append("??")
|
|
1155
|
-
|
|
1170
|
+
|
|
1156
1171
|
if not parts:
|
|
1157
|
-
wt[
|
|
1158
|
-
wt[
|
|
1172
|
+
wt["changes_display"] = "-"
|
|
1173
|
+
wt["changes_clean_len"] = 1
|
|
1159
1174
|
else:
|
|
1160
|
-
wt[
|
|
1161
|
-
wt[
|
|
1175
|
+
wt["changes_display"] = " ".join(parts)
|
|
1176
|
+
wt["changes_clean_len"] = len(" ".join(clean_parts))
|
|
1162
1177
|
|
|
1163
1178
|
# カラム幅の計算
|
|
1164
|
-
name_w =
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1179
|
+
name_w = (
|
|
1180
|
+
max(
|
|
1181
|
+
len(msg("worktree_name")),
|
|
1182
|
+
max((len(Path(wt["path"]).name) for wt in worktrees), default=0),
|
|
1183
|
+
)
|
|
1184
|
+
+ 2
|
|
1185
|
+
)
|
|
1186
|
+
branch_w = (
|
|
1187
|
+
max(
|
|
1188
|
+
len(msg("branch_name")),
|
|
1189
|
+
max((len(wt.get("branch", "N/A")) for wt in worktrees), default=0),
|
|
1190
|
+
)
|
|
1191
|
+
+ 2
|
|
1192
|
+
)
|
|
1193
|
+
time_w = (
|
|
1194
|
+
max(
|
|
1195
|
+
len("Created"),
|
|
1196
|
+
max((len(wt.get("relative_time", "")) for wt in worktrees), default=0),
|
|
1197
|
+
)
|
|
1198
|
+
+ 2
|
|
1199
|
+
)
|
|
1200
|
+
status_w = (
|
|
1201
|
+
max(
|
|
1202
|
+
len(msg("changes_label")),
|
|
1203
|
+
max((wt["changes_clean_len"] for wt in worktrees), default=0),
|
|
1204
|
+
)
|
|
1205
|
+
+ 2
|
|
1206
|
+
)
|
|
1168
1207
|
pr_w = 0
|
|
1169
1208
|
if show_pr:
|
|
1170
1209
|
# PR info contains ANSI codes, so calculate real length
|
|
1171
1210
|
import re
|
|
1211
|
+
|
|
1172
1212
|
# More robust ANSI escape regex including hyperlinks
|
|
1173
|
-
ansi_escape = re.compile(
|
|
1213
|
+
ansi_escape = re.compile(
|
|
1214
|
+
r"""
|
|
1174
1215
|
\x1B(?:
|
|
1175
1216
|
[@-Z\\-_]
|
|
1176
1217
|
|
|
|
@@ -1178,17 +1219,26 @@ def cmd_list(args: list[str]):
|
|
|
1178
1219
|
|
|
|
1179
1220
|
\][0-9]*;.*?(\x1B\\|\x07)
|
|
1180
1221
|
)
|
|
1181
|
-
|
|
1222
|
+
""",
|
|
1223
|
+
re.VERBOSE,
|
|
1224
|
+
)
|
|
1225
|
+
|
|
1182
1226
|
def clean_len(s):
|
|
1183
|
-
return len(ansi_escape.sub(
|
|
1184
|
-
|
|
1185
|
-
pr_w =
|
|
1227
|
+
return len(ansi_escape.sub("", s))
|
|
1228
|
+
|
|
1229
|
+
pr_w = (
|
|
1230
|
+
max(
|
|
1231
|
+
3,
|
|
1232
|
+
max((clean_len(wt.get("pr_info", "")) for wt in worktrees), default=0),
|
|
1233
|
+
)
|
|
1234
|
+
+ 2
|
|
1235
|
+
)
|
|
1186
1236
|
|
|
1187
1237
|
# ヘッダー (色付き)
|
|
1188
1238
|
CYAN = "\033[36m"
|
|
1189
1239
|
RESET = "\033[0m"
|
|
1190
1240
|
BOLD = "\033[1m"
|
|
1191
|
-
|
|
1241
|
+
|
|
1192
1242
|
base_header = f"{msg('worktree_name'):<{name_w}} {msg('branch_name'):<{branch_w}} {'Created':<{time_w}} {msg('changes_label'):<{status_w}}"
|
|
1193
1243
|
if show_pr:
|
|
1194
1244
|
header = f"{BOLD}{base_header} PR{RESET}"
|
|
@@ -1196,49 +1246,64 @@ def cmd_list(args: list[str]):
|
|
|
1196
1246
|
else:
|
|
1197
1247
|
header = f"{BOLD}{base_header.rstrip()}{RESET}"
|
|
1198
1248
|
separator_len = len(base_header.rstrip())
|
|
1199
|
-
|
|
1249
|
+
|
|
1200
1250
|
print(header)
|
|
1201
1251
|
print("-" * separator_len)
|
|
1202
1252
|
|
|
1203
1253
|
for wt in worktrees:
|
|
1204
|
-
path = Path(wt[
|
|
1254
|
+
path = Path(wt["path"])
|
|
1205
1255
|
name_display = path.name if path != base_dir else f"{CYAN}(main){RESET}"
|
|
1206
1256
|
name_clean_len = len(path.name) if path != base_dir else 6
|
|
1207
1257
|
name_padding = " " * (name_w - name_clean_len)
|
|
1208
|
-
|
|
1209
|
-
branch = wt.get(
|
|
1210
|
-
rel_time = wt.get(
|
|
1211
|
-
changes_display = wt.get(
|
|
1212
|
-
changes_clean_len = wt.get(
|
|
1213
|
-
|
|
1258
|
+
|
|
1259
|
+
branch = wt.get("branch", "N/A")
|
|
1260
|
+
rel_time = wt.get("relative_time", "N/A")
|
|
1261
|
+
changes_display = wt.get("changes_display", "no changes")
|
|
1262
|
+
changes_clean_len = wt.get("changes_clean_len", 1)
|
|
1263
|
+
|
|
1214
1264
|
# ANSI コード分を補正して表示
|
|
1215
1265
|
changes_padding = " " * (status_w - changes_clean_len)
|
|
1216
|
-
|
|
1217
|
-
print(
|
|
1266
|
+
|
|
1267
|
+
print(
|
|
1268
|
+
f"{name_display}{name_padding} {branch:<{branch_w}} {rel_time:<{time_w}} {changes_display}{changes_padding}",
|
|
1269
|
+
end="",
|
|
1270
|
+
)
|
|
1218
1271
|
if show_pr:
|
|
1219
|
-
|
|
1272
|
+
print(f" {wt.get('pr_info', '')}", end="")
|
|
1220
1273
|
print()
|
|
1221
1274
|
|
|
1222
1275
|
|
|
1223
1276
|
def cmd_remove(args: list[str]):
|
|
1224
1277
|
"""wt rm/remove <work_name> - Remove a worktree"""
|
|
1225
1278
|
if len(args) < 1:
|
|
1226
|
-
print(msg(
|
|
1279
|
+
print(msg("usage_rm"), file=sys.stderr)
|
|
1227
1280
|
sys.exit(1)
|
|
1228
1281
|
|
|
1229
1282
|
base_dir = find_base_dir()
|
|
1230
1283
|
if not base_dir:
|
|
1231
|
-
print(msg(
|
|
1284
|
+
print(msg("error", msg("base_not_found")), file=sys.stderr)
|
|
1232
1285
|
sys.exit(1)
|
|
1233
1286
|
|
|
1234
|
-
|
|
1287
|
+
# Parse flags and find worktree name
|
|
1288
|
+
flags = []
|
|
1289
|
+
work_name = None
|
|
1290
|
+
for arg in args:
|
|
1291
|
+
if arg in ["-f", "--force"]:
|
|
1292
|
+
flags.append(arg)
|
|
1293
|
+
elif not work_name:
|
|
1294
|
+
work_name = arg
|
|
1295
|
+
else:
|
|
1296
|
+
# Additional non-flag arguments are currently ignored or could be treated as error
|
|
1297
|
+
pass
|
|
1298
|
+
|
|
1299
|
+
if not work_name:
|
|
1300
|
+
print(msg("usage_rm"), file=sys.stderr)
|
|
1301
|
+
sys.exit(1)
|
|
1235
1302
|
|
|
1236
1303
|
# worktree を削除
|
|
1237
|
-
print(msg(
|
|
1304
|
+
print(msg("removing_worktree", work_name), file=sys.stderr)
|
|
1238
1305
|
result = run_command(
|
|
1239
|
-
["git", "worktree", "remove"
|
|
1240
|
-
cwd=base_dir,
|
|
1241
|
-
check=False
|
|
1306
|
+
["git", "worktree", "remove"] + flags + [work_name], cwd=base_dir, check=False
|
|
1242
1307
|
)
|
|
1243
1308
|
|
|
1244
1309
|
if result.returncode == 0:
|
|
@@ -1249,26 +1314,262 @@ def cmd_remove(args: list[str]):
|
|
|
1249
1314
|
sys.exit(1)
|
|
1250
1315
|
|
|
1251
1316
|
|
|
1317
|
+
def cmd_checkout(args: list[str]):
|
|
1318
|
+
"""wt co/checkout <work_name> - Get path to a worktree (for cd)"""
|
|
1319
|
+
if len(args) < 1:
|
|
1320
|
+
return
|
|
1321
|
+
|
|
1322
|
+
work_name = args[0]
|
|
1323
|
+
base_dir = find_base_dir()
|
|
1324
|
+
if not base_dir:
|
|
1325
|
+
print(msg("error", msg("base_not_found")), file=sys.stderr)
|
|
1326
|
+
sys.exit(1)
|
|
1327
|
+
|
|
1328
|
+
worktrees = get_worktree_info(base_dir)
|
|
1329
|
+
for wt in worktrees:
|
|
1330
|
+
p = Path(wt["path"])
|
|
1331
|
+
if p.name == work_name or (p == base_dir and work_name == "main"):
|
|
1332
|
+
print(str(p))
|
|
1333
|
+
return
|
|
1334
|
+
|
|
1335
|
+
print(msg("error", msg("select_not_found", work_name)), file=sys.stderr)
|
|
1336
|
+
sys.exit(1)
|
|
1337
|
+
|
|
1338
|
+
|
|
1339
|
+
def cmd_select(args: list[str]):
|
|
1340
|
+
"""wt sl/select [<name>|-] - Manage/Switch worktree selection"""
|
|
1341
|
+
base_dir = find_base_dir()
|
|
1342
|
+
if not base_dir:
|
|
1343
|
+
print(msg("error", msg("base_not_found")), file=sys.stderr)
|
|
1344
|
+
sys.exit(1)
|
|
1345
|
+
|
|
1346
|
+
wt_dir = base_dir / ".wt"
|
|
1347
|
+
wt_dir.mkdir(exist_ok=True)
|
|
1348
|
+
last_sel_file = wt_dir / "last_selection"
|
|
1349
|
+
|
|
1350
|
+
# Get current selection name based on CWD or environment
|
|
1351
|
+
current_sel = os.environ.get("WT_SESSION_NAME")
|
|
1352
|
+
if not current_sel:
|
|
1353
|
+
cwd = Path.cwd().resolve()
|
|
1354
|
+
worktrees = get_worktree_info(base_dir)
|
|
1355
|
+
resolved_base = base_dir.resolve()
|
|
1356
|
+
for wt in worktrees:
|
|
1357
|
+
wt_path = Path(wt["path"]).resolve()
|
|
1358
|
+
if cwd == wt_path or cwd.is_relative_to(wt_path):
|
|
1359
|
+
current_sel = "main" if wt_path == resolved_base else wt_path.name
|
|
1360
|
+
break
|
|
1361
|
+
|
|
1362
|
+
worktrees = get_worktree_info(base_dir)
|
|
1363
|
+
names = []
|
|
1364
|
+
for wt in worktrees:
|
|
1365
|
+
p = Path(wt["path"])
|
|
1366
|
+
name = "main" if p == base_dir else p.name
|
|
1367
|
+
names.append(name)
|
|
1368
|
+
|
|
1369
|
+
if not args:
|
|
1370
|
+
# Interactive mode or list with highlight
|
|
1371
|
+
if shutil.which("fzf") and sys.stdin.isatty():
|
|
1372
|
+
# Run fzf
|
|
1373
|
+
try:
|
|
1374
|
+
# Prepare input for fzf with current highlighted
|
|
1375
|
+
fzf_input = ""
|
|
1376
|
+
for name in names:
|
|
1377
|
+
if name == current_sel:
|
|
1378
|
+
fzf_input += f"{name} (*)\n"
|
|
1379
|
+
else:
|
|
1380
|
+
fzf_input += f"{name}\n"
|
|
1381
|
+
|
|
1382
|
+
process = subprocess.Popen(
|
|
1383
|
+
["fzf", "--height", "40%", "--reverse", "--header", "Select Worktree"],
|
|
1384
|
+
stdin=subprocess.PIPE,
|
|
1385
|
+
stdout=subprocess.PIPE,
|
|
1386
|
+
text=True,
|
|
1387
|
+
)
|
|
1388
|
+
stdout, _ = process.communicate(input=fzf_input)
|
|
1389
|
+
|
|
1390
|
+
if process.returncode == 0 and stdout.strip():
|
|
1391
|
+
selected = stdout.strip().split(" ")[0]
|
|
1392
|
+
switch_selection(selected, base_dir, current_sel, last_sel_file)
|
|
1393
|
+
return
|
|
1394
|
+
except Exception as e:
|
|
1395
|
+
print(f"fzf error: {e}", file=sys.stderr)
|
|
1396
|
+
# Fallback to listing
|
|
1397
|
+
|
|
1398
|
+
# List with highlight
|
|
1399
|
+
YELLOW = "\033[33m"
|
|
1400
|
+
RESET = "\033[0m"
|
|
1401
|
+
BOLD = "\033[1m"
|
|
1402
|
+
|
|
1403
|
+
for name in names:
|
|
1404
|
+
if name == current_sel:
|
|
1405
|
+
print(f"{YELLOW}{BOLD}{name}{RESET}")
|
|
1406
|
+
else:
|
|
1407
|
+
print(name)
|
|
1408
|
+
return
|
|
1409
|
+
|
|
1410
|
+
target = args[0]
|
|
1411
|
+
|
|
1412
|
+
if target == "-":
|
|
1413
|
+
if not last_sel_file.exists():
|
|
1414
|
+
print(msg("error", msg("select_no_last")), file=sys.stderr)
|
|
1415
|
+
sys.exit(1)
|
|
1416
|
+
target = last_sel_file.read_text().strip()
|
|
1417
|
+
if not target:
|
|
1418
|
+
print(msg("error", msg("select_no_last")), file=sys.stderr)
|
|
1419
|
+
sys.exit(1)
|
|
1420
|
+
|
|
1421
|
+
if target not in names:
|
|
1422
|
+
print(msg("error", msg("select_not_found", target)), file=sys.stderr)
|
|
1423
|
+
sys.exit(1)
|
|
1424
|
+
|
|
1425
|
+
switch_selection(target, base_dir, current_sel, last_sel_file)
|
|
1426
|
+
|
|
1427
|
+
|
|
1428
|
+
def cmd_current(args: list[str]):
|
|
1429
|
+
"""wt current (cur) - Show name of the current worktree"""
|
|
1430
|
+
name = os.environ.get("WT_SESSION_NAME")
|
|
1431
|
+
if not name:
|
|
1432
|
+
base_dir = find_base_dir()
|
|
1433
|
+
if not base_dir:
|
|
1434
|
+
return
|
|
1435
|
+
cwd = Path.cwd().resolve()
|
|
1436
|
+
worktrees = get_worktree_info(base_dir)
|
|
1437
|
+
resolved_base = base_dir.resolve()
|
|
1438
|
+
for wt in worktrees:
|
|
1439
|
+
wt_path = Path(wt["path"]).resolve()
|
|
1440
|
+
if cwd == wt_path:
|
|
1441
|
+
name = "main" if wt_path == resolved_base else wt_path.name
|
|
1442
|
+
break
|
|
1443
|
+
if name:
|
|
1444
|
+
print(name)
|
|
1445
|
+
|
|
1446
|
+
|
|
1447
|
+
def switch_selection(target, base_dir, current_sel, last_sel_file):
|
|
1448
|
+
"""Switch selection and update last_selection"""
|
|
1449
|
+
# Calculate target path
|
|
1450
|
+
target_path = base_dir
|
|
1451
|
+
if target != "main":
|
|
1452
|
+
config = load_config(base_dir)
|
|
1453
|
+
worktrees_dir_name = config.get("worktrees_dir", ".worktrees")
|
|
1454
|
+
target_path = base_dir / worktrees_dir_name / target
|
|
1455
|
+
|
|
1456
|
+
if not target_path.exists():
|
|
1457
|
+
print(msg("error", msg("select_not_found", target)), file=sys.stderr)
|
|
1458
|
+
sys.exit(1)
|
|
1459
|
+
|
|
1460
|
+
if target != current_sel:
|
|
1461
|
+
# Save last selection
|
|
1462
|
+
if current_sel:
|
|
1463
|
+
last_sel_file.write_text(current_sel)
|
|
1464
|
+
|
|
1465
|
+
print(msg("select_switched", target), file=sys.stderr)
|
|
1466
|
+
|
|
1467
|
+
# Check for setup files
|
|
1468
|
+
config = load_config(base_dir)
|
|
1469
|
+
setup_files = config.get("setup_files", [])
|
|
1470
|
+
missing = False
|
|
1471
|
+
for f in setup_files:
|
|
1472
|
+
if not (target_path / f).exists():
|
|
1473
|
+
missing = True
|
|
1474
|
+
break
|
|
1475
|
+
if missing:
|
|
1476
|
+
print(f"\033[33m{msg('suggest_setup')}\033[0m", file=sys.stderr)
|
|
1477
|
+
|
|
1478
|
+
if sys.stdout.isatty():
|
|
1479
|
+
# Check for nesting
|
|
1480
|
+
current_session = os.environ.get("WT_SESSION_NAME")
|
|
1481
|
+
if current_session:
|
|
1482
|
+
print(
|
|
1483
|
+
f"\033[31m{msg('nesting_error', current_session)}\033[0m", file=sys.stderr
|
|
1484
|
+
)
|
|
1485
|
+
sys.exit(1)
|
|
1486
|
+
|
|
1487
|
+
# Subshell jump
|
|
1488
|
+
shell = os.environ.get("SHELL", "/bin/sh")
|
|
1489
|
+
print(msg("jump_instruction", target, target_path), file=sys.stderr)
|
|
1490
|
+
|
|
1491
|
+
os.chdir(target_path)
|
|
1492
|
+
os.environ["WT_SESSION_NAME"] = target
|
|
1493
|
+
# Prepend to PS1 for visibility (if supported by shell)
|
|
1494
|
+
ps1 = os.environ.get("PS1", "$ ")
|
|
1495
|
+
if not ps1.startswith("(wt:"):
|
|
1496
|
+
os.environ["PS1"] = f"(wt:{target}) {ps1}"
|
|
1497
|
+
|
|
1498
|
+
# Set terminal title
|
|
1499
|
+
sys.stderr.write(f"\033]0;wt:{target}\007")
|
|
1500
|
+
sys.stderr.flush()
|
|
1501
|
+
|
|
1502
|
+
# Update tmux window name if inside tmux
|
|
1503
|
+
if os.environ.get("TMUX"):
|
|
1504
|
+
subprocess.run(["tmux", "rename-window", f"wt:{target}"], check=False)
|
|
1505
|
+
|
|
1506
|
+
os.execl(shell, shell)
|
|
1507
|
+
else:
|
|
1508
|
+
# Output path for script/backtick use
|
|
1509
|
+
print(str(target_path.absolute()))
|
|
1510
|
+
|
|
1511
|
+
|
|
1512
|
+
def cmd_setup(args: list[str]):
|
|
1513
|
+
"""wt setup - Initialize current worktree (copy setup_files and run hooks)"""
|
|
1514
|
+
base_dir = find_base_dir()
|
|
1515
|
+
if not base_dir:
|
|
1516
|
+
print(msg("error", msg("base_not_found")), file=sys.stderr)
|
|
1517
|
+
sys.exit(1)
|
|
1518
|
+
|
|
1519
|
+
current_dir = Path.cwd()
|
|
1520
|
+
target_path = current_dir
|
|
1521
|
+
|
|
1522
|
+
config = load_config(base_dir)
|
|
1523
|
+
setup_files = config.get("setup_files", [])
|
|
1524
|
+
|
|
1525
|
+
import shutil
|
|
1526
|
+
count = 0
|
|
1527
|
+
for f in setup_files:
|
|
1528
|
+
src = base_dir / f
|
|
1529
|
+
dst = target_path / f
|
|
1530
|
+
if src.exists() and src != dst:
|
|
1531
|
+
print(msg("setting_up", src, dst), file=sys.stderr)
|
|
1532
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
1533
|
+
shutil.copy2(src, dst)
|
|
1534
|
+
count += 1
|
|
1535
|
+
|
|
1536
|
+
if count > 0:
|
|
1537
|
+
print(msg("completed_setup", count), file=sys.stderr)
|
|
1538
|
+
|
|
1539
|
+
# Run post-add hook
|
|
1540
|
+
work_name = target_path.name
|
|
1541
|
+
if target_path == base_dir:
|
|
1542
|
+
work_name = "main"
|
|
1543
|
+
|
|
1544
|
+
# Get branch name for the current worktree
|
|
1545
|
+
branch = None
|
|
1546
|
+
result = run_command(["git", "branch", "--show-current"], cwd=target_path, check=False)
|
|
1547
|
+
if result.returncode == 0:
|
|
1548
|
+
branch = result.stdout.strip()
|
|
1549
|
+
|
|
1550
|
+
run_post_add_hook(target_path, work_name, base_dir, branch)
|
|
1551
|
+
|
|
1552
|
+
|
|
1252
1553
|
def cmd_clean(args: list[str]):
|
|
1253
1554
|
"""wt clean - Remove old/unused/merged worktrees"""
|
|
1254
1555
|
base_dir = find_base_dir()
|
|
1255
1556
|
if not base_dir:
|
|
1256
|
-
print(msg(
|
|
1557
|
+
print(msg("error", msg("base_not_found")), file=sys.stderr)
|
|
1257
1558
|
sys.exit(1)
|
|
1258
1559
|
|
|
1259
1560
|
# オプションを解析
|
|
1260
1561
|
# オプションを解析
|
|
1261
|
-
clean_all =
|
|
1262
|
-
clean_merged =
|
|
1263
|
-
clean_closed =
|
|
1562
|
+
clean_all = "--all" in args
|
|
1563
|
+
clean_merged = "--merged" in args
|
|
1564
|
+
clean_closed = "--closed" in args
|
|
1264
1565
|
days = None
|
|
1265
1566
|
|
|
1266
1567
|
for i, arg in enumerate(args):
|
|
1267
|
-
if arg ==
|
|
1568
|
+
if arg == "--days" and i + 1 < len(args):
|
|
1268
1569
|
try:
|
|
1269
1570
|
days = int(args[i + 1])
|
|
1270
1571
|
except ValueError:
|
|
1271
|
-
print(msg(
|
|
1572
|
+
print(msg("error", "Invalid days value"), file=sys.stderr)
|
|
1272
1573
|
sys.exit(1)
|
|
1273
1574
|
|
|
1274
1575
|
# worktree 情報を取得
|
|
@@ -1288,38 +1589,53 @@ def cmd_clean(args: list[str]):
|
|
|
1288
1589
|
# マージ済みブランチを取得
|
|
1289
1590
|
merged_branches = set()
|
|
1290
1591
|
merged_pr_branches = set()
|
|
1291
|
-
|
|
1592
|
+
|
|
1292
1593
|
# デフォルトブランチを取得して、それに対してマージされているかを確認
|
|
1293
1594
|
default_branch = get_default_branch(base_dir)
|
|
1294
1595
|
default_branch_sha = None
|
|
1295
1596
|
if default_branch:
|
|
1296
|
-
res_sha = run_command(
|
|
1597
|
+
res_sha = run_command(
|
|
1598
|
+
["git", "rev-parse", default_branch], cwd=base_dir, check=False
|
|
1599
|
+
)
|
|
1297
1600
|
if res_sha.returncode == 0:
|
|
1298
1601
|
default_branch_sha = res_sha.stdout.strip()
|
|
1299
|
-
|
|
1602
|
+
|
|
1300
1603
|
if clean_merged and default_branch:
|
|
1301
1604
|
# Local merged branches (merged into default_branch)
|
|
1302
|
-
result = run_command(
|
|
1605
|
+
result = run_command(
|
|
1606
|
+
["git", "branch", "--merged", default_branch], cwd=base_dir, check=False
|
|
1607
|
+
)
|
|
1303
1608
|
if result.returncode == 0:
|
|
1304
|
-
for line in result.stdout.split(
|
|
1609
|
+
for line in result.stdout.split("\n"):
|
|
1305
1610
|
# Extract branch name, removing '*', '+', and whitespace
|
|
1306
1611
|
line = line.strip()
|
|
1307
|
-
if line.startswith(
|
|
1612
|
+
if line.startswith("* ") or line.startswith("+ "):
|
|
1308
1613
|
line = line[2:].strip()
|
|
1309
1614
|
if line:
|
|
1310
1615
|
merged_branches.add(line)
|
|
1311
|
-
|
|
1616
|
+
|
|
1312
1617
|
# GitHub merged PRs
|
|
1313
1618
|
if shutil.which("gh"):
|
|
1314
1619
|
import json
|
|
1620
|
+
|
|
1315
1621
|
# Get last 100 merged PRs
|
|
1316
|
-
pr_cmd = [
|
|
1622
|
+
pr_cmd = [
|
|
1623
|
+
"gh",
|
|
1624
|
+
"pr",
|
|
1625
|
+
"list",
|
|
1626
|
+
"--state",
|
|
1627
|
+
"merged",
|
|
1628
|
+
"--limit",
|
|
1629
|
+
"100",
|
|
1630
|
+
"--json",
|
|
1631
|
+
"headRefName",
|
|
1632
|
+
]
|
|
1317
1633
|
pr_res = run_command(pr_cmd, cwd=base_dir, check=False)
|
|
1318
1634
|
if pr_res.returncode == 0:
|
|
1319
1635
|
try:
|
|
1320
1636
|
pr_data = json.loads(pr_res.stdout)
|
|
1321
1637
|
for pr in pr_data:
|
|
1322
|
-
merged_pr_branches.add(pr[
|
|
1638
|
+
merged_pr_branches.add(pr["headRefName"])
|
|
1323
1639
|
except:
|
|
1324
1640
|
pass
|
|
1325
1641
|
|
|
@@ -1328,14 +1644,25 @@ def cmd_clean(args: list[str]):
|
|
|
1328
1644
|
if clean_closed:
|
|
1329
1645
|
if shutil.which("gh"):
|
|
1330
1646
|
import json
|
|
1647
|
+
|
|
1331
1648
|
# Get last 100 closed PRs
|
|
1332
|
-
pr_cmd = [
|
|
1649
|
+
pr_cmd = [
|
|
1650
|
+
"gh",
|
|
1651
|
+
"pr",
|
|
1652
|
+
"list",
|
|
1653
|
+
"--state",
|
|
1654
|
+
"closed",
|
|
1655
|
+
"--limit",
|
|
1656
|
+
"100",
|
|
1657
|
+
"--json",
|
|
1658
|
+
"headRefName",
|
|
1659
|
+
]
|
|
1333
1660
|
pr_res = run_command(pr_cmd, cwd=base_dir, check=False)
|
|
1334
1661
|
if pr_res.returncode == 0:
|
|
1335
1662
|
try:
|
|
1336
1663
|
pr_data = json.loads(pr_res.stdout)
|
|
1337
1664
|
for pr in pr_data:
|
|
1338
|
-
closed_pr_branches.add(pr[
|
|
1665
|
+
closed_pr_branches.add(pr["headRefName"])
|
|
1339
1666
|
except:
|
|
1340
1667
|
pass
|
|
1341
1668
|
|
|
@@ -1344,7 +1671,7 @@ def cmd_clean(args: list[str]):
|
|
|
1344
1671
|
now = datetime.now()
|
|
1345
1672
|
|
|
1346
1673
|
for wt in worktrees:
|
|
1347
|
-
path = Path(wt[
|
|
1674
|
+
path = Path(wt["path"])
|
|
1348
1675
|
|
|
1349
1676
|
# base (git root) は除外
|
|
1350
1677
|
if path == base_dir:
|
|
@@ -1356,28 +1683,33 @@ def cmd_clean(args: list[str]):
|
|
|
1356
1683
|
|
|
1357
1684
|
reason = None
|
|
1358
1685
|
# マージ済みの場合は無条件で対象(ただし dirty でないこと)
|
|
1359
|
-
is_merged = (
|
|
1686
|
+
is_merged = (
|
|
1687
|
+
wt.get("branch") in merged_branches
|
|
1688
|
+
or wt.get("branch") in merged_pr_branches
|
|
1689
|
+
)
|
|
1360
1690
|
if clean_merged and is_merged:
|
|
1361
1691
|
# Check safeguard: if branch points to same SHA as default branch and NOT in merged_pr_branches
|
|
1362
1692
|
# it might be a new branch that hasn't diverged yet.
|
|
1363
|
-
if default_branch_sha and wt.get(
|
|
1364
|
-
wt_sha = run_command(
|
|
1693
|
+
if default_branch_sha and wt.get("branch") not in merged_pr_branches:
|
|
1694
|
+
wt_sha = run_command(
|
|
1695
|
+
["git", "rev-parse", wt.get("branch")], cwd=base_dir, check=False
|
|
1696
|
+
).stdout.strip()
|
|
1365
1697
|
if wt_sha == default_branch_sha:
|
|
1366
1698
|
# Skip deletion for fresh branches
|
|
1367
1699
|
continue
|
|
1368
1700
|
|
|
1369
|
-
if wt.get(
|
|
1701
|
+
if wt.get("is_clean"):
|
|
1370
1702
|
reason = "merged"
|
|
1371
1703
|
|
|
1372
|
-
is_closed = wt.get(
|
|
1704
|
+
is_closed = wt.get("branch") in closed_pr_branches
|
|
1373
1705
|
if not reason and clean_closed and is_closed:
|
|
1374
|
-
if wt.get(
|
|
1706
|
+
if wt.get("is_clean"):
|
|
1375
1707
|
reason = "closed"
|
|
1376
|
-
|
|
1708
|
+
|
|
1377
1709
|
# 通常のクリーンアップ対象
|
|
1378
|
-
if not reason and wt.get(
|
|
1710
|
+
if not reason and wt.get("is_clean"):
|
|
1379
1711
|
if days is not None:
|
|
1380
|
-
created = wt.get(
|
|
1712
|
+
created = wt.get("created")
|
|
1381
1713
|
if created:
|
|
1382
1714
|
age_days = (now - created).days
|
|
1383
1715
|
if age_days >= days:
|
|
@@ -1386,24 +1718,28 @@ def cmd_clean(args: list[str]):
|
|
|
1386
1718
|
reason = "clean"
|
|
1387
1719
|
|
|
1388
1720
|
if reason:
|
|
1389
|
-
wt[
|
|
1721
|
+
wt["reason"] = reason
|
|
1390
1722
|
targets.append(wt)
|
|
1391
1723
|
|
|
1392
1724
|
if not targets:
|
|
1393
|
-
print(msg(
|
|
1725
|
+
print(msg("no_clean_targets"), file=sys.stderr)
|
|
1394
1726
|
return
|
|
1395
1727
|
|
|
1396
1728
|
# 削除対象を表示
|
|
1397
1729
|
for wt in targets:
|
|
1398
|
-
path = Path(wt[
|
|
1399
|
-
created =
|
|
1400
|
-
|
|
1730
|
+
path = Path(wt["path"])
|
|
1731
|
+
created = (
|
|
1732
|
+
wt.get("created").strftime("%Y-%m-%d %H:%M") if wt.get("created") else "N/A"
|
|
1733
|
+
)
|
|
1734
|
+
print(
|
|
1735
|
+
f"{path.name} (reason: {wt['reason']}, created: {created})", file=sys.stderr
|
|
1736
|
+
)
|
|
1401
1737
|
|
|
1402
1738
|
# 確認
|
|
1403
1739
|
if not clean_all:
|
|
1404
1740
|
try:
|
|
1405
|
-
response = input(msg(
|
|
1406
|
-
if response.lower() not in [
|
|
1741
|
+
response = input(msg("clean_confirm", len(targets)))
|
|
1742
|
+
if response.lower() not in ["y", "yes"]:
|
|
1407
1743
|
print("Cancelled.")
|
|
1408
1744
|
return
|
|
1409
1745
|
except (EOFError, KeyboardInterrupt):
|
|
@@ -1412,12 +1748,10 @@ def cmd_clean(args: list[str]):
|
|
|
1412
1748
|
|
|
1413
1749
|
# 削除実行
|
|
1414
1750
|
for wt in targets:
|
|
1415
|
-
path = Path(wt[
|
|
1416
|
-
print(msg(
|
|
1751
|
+
path = Path(wt["path"])
|
|
1752
|
+
print(msg("removing_worktree", path.name), file=sys.stderr)
|
|
1417
1753
|
result = run_command(
|
|
1418
|
-
["git", "worktree", "remove", str(path)],
|
|
1419
|
-
cwd=base_dir,
|
|
1420
|
-
check=False
|
|
1754
|
+
["git", "worktree", "remove", str(path)], cwd=base_dir, check=False
|
|
1421
1755
|
)
|
|
1422
1756
|
|
|
1423
1757
|
if result.returncode == 0:
|
|
@@ -1426,106 +1760,17 @@ def cmd_clean(args: list[str]):
|
|
|
1426
1760
|
if result.stderr:
|
|
1427
1761
|
print(result.stderr, file=sys.stderr)
|
|
1428
1762
|
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
def cmd_sync(args: list[str]):
|
|
1435
|
-
"""wt sync [files...] [--from <name>] [--to <name>] - Sync files between worktrees"""
|
|
1436
|
-
base_dir = find_base_dir()
|
|
1437
|
-
if not base_dir:
|
|
1438
|
-
print(msg('error', msg('base_not_found')), file=sys.stderr)
|
|
1439
|
-
sys.exit(1)
|
|
1440
|
-
|
|
1441
|
-
config = load_config(base_dir)
|
|
1442
|
-
files_to_sync = []
|
|
1443
|
-
from_name = None
|
|
1444
|
-
to_name = None
|
|
1445
|
-
|
|
1446
|
-
# 引数解析
|
|
1447
|
-
i = 0
|
|
1448
|
-
while i < len(args):
|
|
1449
|
-
if args[i] == '--from' and i + 1 < len(args):
|
|
1450
|
-
from_name = args[i+1]
|
|
1451
|
-
i += 2
|
|
1452
|
-
elif args[i] == '--to' and i + 1 < len(args):
|
|
1453
|
-
to_name = args[i+1]
|
|
1454
|
-
i += 2
|
|
1455
|
-
else:
|
|
1456
|
-
files_to_sync.append(args[i])
|
|
1457
|
-
i += 1
|
|
1458
|
-
|
|
1459
|
-
if not files_to_sync:
|
|
1460
|
-
files_to_sync = config.get("sync_files", [])
|
|
1461
|
-
|
|
1462
|
-
if not files_to_sync:
|
|
1463
|
-
return
|
|
1464
|
-
|
|
1465
|
-
worktrees = get_worktree_info(base_dir)
|
|
1466
|
-
|
|
1467
|
-
# 送信元と送信先のパスを決定
|
|
1468
|
-
from_path = base_dir
|
|
1469
|
-
if from_name:
|
|
1470
|
-
found = False
|
|
1471
|
-
for wt in worktrees:
|
|
1472
|
-
if Path(wt['path']).name == from_name:
|
|
1473
|
-
from_path = Path(wt['path'])
|
|
1474
|
-
found = True
|
|
1475
|
-
break
|
|
1476
|
-
if not found:
|
|
1477
|
-
print(msg('error', f"Worktree not found: {from_name}"), file=sys.stderr)
|
|
1478
|
-
sys.exit(1)
|
|
1479
|
-
|
|
1480
|
-
dest_paths = []
|
|
1481
|
-
if to_name:
|
|
1482
|
-
if to_name == "main":
|
|
1483
|
-
dest_paths = [base_dir]
|
|
1484
|
-
else:
|
|
1485
|
-
found = False
|
|
1486
|
-
for wt in worktrees:
|
|
1487
|
-
if Path(wt['path']).name == to_name:
|
|
1488
|
-
dest_paths = [Path(wt['path'])]
|
|
1489
|
-
found = True
|
|
1490
|
-
break
|
|
1491
|
-
if not found:
|
|
1492
|
-
print(msg('error', f"Worktree not found: {to_name}"), file=sys.stderr)
|
|
1493
|
-
sys.exit(1)
|
|
1494
|
-
else:
|
|
1495
|
-
# 指定がない場合は現在のディレクトリが worktree ならそこへ、そうでなければ全自動(通常は base -> current)
|
|
1496
|
-
current_dir = Path.cwd()
|
|
1497
|
-
if current_dir != base_dir and any(current_dir.is_relative_to(Path(wt['path'])) for wt in worktrees):
|
|
1498
|
-
dest_paths = [current_dir]
|
|
1499
|
-
else:
|
|
1500
|
-
# base から全 worktree へ(安全のため、ユーザーが現在の worktree にいる場合はそこだけにするのが一般的だが、ここでは全 worktree とした)
|
|
1501
|
-
dest_paths = [Path(wt['path']) for wt in worktrees if Path(wt['path']) != base_dir]
|
|
1502
|
-
|
|
1503
|
-
import shutil
|
|
1504
|
-
count = 0
|
|
1505
|
-
for dst_root in dest_paths:
|
|
1506
|
-
if dst_root == from_path:
|
|
1507
|
-
continue
|
|
1508
|
-
for f in files_to_sync:
|
|
1509
|
-
src = from_path / f
|
|
1510
|
-
dst = dst_root / f
|
|
1511
|
-
if src.exists():
|
|
1512
|
-
print(msg('syncing', src, dst), file=sys.stderr)
|
|
1513
|
-
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
1514
|
-
shutil.copy2(src, dst)
|
|
1515
|
-
count += 1
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
1763
|
def cmd_passthrough(args: list[str]):
|
|
1519
1764
|
"""Passthrough other git worktree commands"""
|
|
1520
1765
|
base_dir = find_base_dir()
|
|
1521
1766
|
if not base_dir:
|
|
1522
|
-
print(msg(
|
|
1767
|
+
print(msg("error", msg("base_not_found")), file=sys.stderr)
|
|
1523
1768
|
sys.exit(1)
|
|
1524
1769
|
|
|
1525
1770
|
result = run_command(["git", "worktree"] + args, cwd=base_dir, check=False)
|
|
1526
|
-
print(result.stdout, end=
|
|
1771
|
+
print(result.stdout, end="")
|
|
1527
1772
|
if result.stderr:
|
|
1528
|
-
print(result.stderr, end=
|
|
1773
|
+
print(result.stderr, end="", file=sys.stderr)
|
|
1529
1774
|
sys.exit(result.returncode)
|
|
1530
1775
|
|
|
1531
1776
|
|
|
@@ -1540,13 +1785,28 @@ def show_help():
|
|
|
1540
1785
|
print("コマンド:")
|
|
1541
1786
|
print(f" {'clone <repository_url>':<55} - リポジトリをクローン")
|
|
1542
1787
|
print(f" {'init':<55} - 既存リポジトリをメインリポジトリとして構成")
|
|
1543
|
-
print(
|
|
1788
|
+
print(
|
|
1789
|
+
f" {'add (ad) <作業名> [<base_branch>]':<55} - worktree を追加(デフォルト: 新規ブランチ作成)"
|
|
1790
|
+
)
|
|
1791
|
+
print(
|
|
1792
|
+
f" {'select (sl) [<作業名>|-]':<55} - 作業ディレクトリを切り替え(fzf対応)"
|
|
1793
|
+
)
|
|
1544
1794
|
print(f" {'list (ls) [--pr]':<55} - worktree 一覧を表示")
|
|
1545
|
-
print(f" {'
|
|
1546
|
-
print(f" {'
|
|
1547
|
-
print(
|
|
1548
|
-
|
|
1549
|
-
|
|
1795
|
+
print(f" {'co/checkout <作業名>':<55} - worktree のパスを表示")
|
|
1796
|
+
print(f" {'current (cur)':<55} - 現在の worktree 名を表示")
|
|
1797
|
+
print(
|
|
1798
|
+
f" {'stash (st) <作業名> [<base_branch>]':<55} - 現在の変更をスタッシュして新規 worktree に移動"
|
|
1799
|
+
)
|
|
1800
|
+
print(
|
|
1801
|
+
f" {'pr add <番号>':<55} - GitHub PR を取得して worktree を作成/パス表示"
|
|
1802
|
+
)
|
|
1803
|
+
print(f" {'rm/remove <作業名> [-f|--force]':<55} - worktree を削除")
|
|
1804
|
+
print(
|
|
1805
|
+
f" {'clean (cl) [--days N] [--merged] [--closed]':<55} - 不要な worktree を削除"
|
|
1806
|
+
)
|
|
1807
|
+
print(
|
|
1808
|
+
f" {'setup (su)':<55} - 作業ディレクトリを初期化(ファイルコピー・フック実行)"
|
|
1809
|
+
)
|
|
1550
1810
|
print()
|
|
1551
1811
|
print("オプション:")
|
|
1552
1812
|
print(f" {'-h, --help':<55} - このヘルプメッセージを表示")
|
|
@@ -1560,13 +1820,26 @@ def show_help():
|
|
|
1560
1820
|
print("Commands:")
|
|
1561
1821
|
print(f" {'clone <repository_url>':<55} - Clone a repository")
|
|
1562
1822
|
print(f" {'init':<55} - Configure existing repository as main")
|
|
1563
|
-
print(
|
|
1823
|
+
print(
|
|
1824
|
+
f" {'add (ad) <work_name> [<base_branch>]':<55} - Add a worktree (default: create new branch)"
|
|
1825
|
+
)
|
|
1826
|
+
print(
|
|
1827
|
+
f" {'select (sl) [<name>|-]':<55} - Switch worktree selection (fzf support)"
|
|
1828
|
+
)
|
|
1564
1829
|
print(f" {'list (ls) [--pr]':<55} - List worktrees")
|
|
1565
|
-
print(f" {'
|
|
1830
|
+
print(f" {'co/checkout <work_name>':<55} - Show path to a worktree")
|
|
1831
|
+
print(f" {'current (cur)':<55} - Show current worktree name")
|
|
1832
|
+
print(
|
|
1833
|
+
f" {'stash (st) <work_name> [<base_branch>]':<55} - Stash current changes and move to new worktree"
|
|
1834
|
+
)
|
|
1566
1835
|
print(f" {'pr add <number>':<55} - Manage GitHub PRs as worktrees")
|
|
1567
|
-
print(f" {'rm/remove <work_name>':<55} - Remove a worktree")
|
|
1568
|
-
print(
|
|
1569
|
-
|
|
1836
|
+
print(f" {'rm/remove <work_name> [-f|--force]':<55} - Remove a worktree")
|
|
1837
|
+
print(
|
|
1838
|
+
f" {'clean (cl) [--days N] [--merged] [--closed]':<55} - Remove unused/merged worktrees"
|
|
1839
|
+
)
|
|
1840
|
+
print(
|
|
1841
|
+
f" {'setup (su)':<55} - Setup worktree (copy files and run hooks)"
|
|
1842
|
+
)
|
|
1570
1843
|
print()
|
|
1571
1844
|
print("Options:")
|
|
1572
1845
|
print(f" {'-h, --help':<55} - Show this help message")
|
|
@@ -1575,7 +1848,7 @@ def show_help():
|
|
|
1575
1848
|
|
|
1576
1849
|
def show_version():
|
|
1577
1850
|
"""Show version information"""
|
|
1578
|
-
print("easy-worktree version 0.1.
|
|
1851
|
+
print("easy-worktree version 0.1.1")
|
|
1579
1852
|
|
|
1580
1853
|
|
|
1581
1854
|
def main():
|
|
@@ -1610,12 +1883,18 @@ def main():
|
|
|
1610
1883
|
cmd_remove(args)
|
|
1611
1884
|
elif command in ["clean", "cl"]:
|
|
1612
1885
|
cmd_clean(args)
|
|
1613
|
-
elif command in ["
|
|
1614
|
-
|
|
1886
|
+
elif command in ["setup", "su"]:
|
|
1887
|
+
cmd_setup(args)
|
|
1615
1888
|
elif command in ["stash", "st"]:
|
|
1616
1889
|
cmd_stash(args)
|
|
1617
1890
|
elif command == "pr":
|
|
1618
1891
|
cmd_pr(args)
|
|
1892
|
+
elif command == "select" or command == "sl":
|
|
1893
|
+
cmd_select(args)
|
|
1894
|
+
elif command in ["current", "cur"]:
|
|
1895
|
+
cmd_current(args)
|
|
1896
|
+
elif command in ["co", "checkout"]:
|
|
1897
|
+
cmd_checkout(args)
|
|
1619
1898
|
else:
|
|
1620
1899
|
# その他のコマンドは git worktree にパススルー
|
|
1621
1900
|
cmd_passthrough([command] + args)
|