easy-worktree 0.1.0__py3-none-any.whl → 0.1.1__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 +582 -457
- {easy_worktree-0.1.0.dist-info → easy_worktree-0.1.1.dist-info}/METADATA +1 -1
- easy_worktree-0.1.1.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.1.dist-info}/WHEEL +0 -0
- {easy_worktree-0.1.0.dist-info → easy_worktree-0.1.1.dist-info}/entry_points.txt +0 -0
- {easy_worktree-0.1.0.dist-info → easy_worktree-0.1.1.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,180 @@ 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_sync": {
|
|
135
|
+
"en": "Usage: wt sync (sy) [files...] [--from <name>] [--to <name>]",
|
|
136
|
+
"ja": "使用方法: wt sync (sy) [files...] [--from <name>] [--to <name>]",
|
|
151
137
|
},
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
138
|
+
"usage_pr": {
|
|
139
|
+
"en": "Usage: wt pr add <number>",
|
|
140
|
+
"ja": "使用方法: wt pr add <number>",
|
|
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
|
-
'changes_label': {
|
|
173
|
-
'en': 'Changes',
|
|
174
|
-
'ja': '変更'
|
|
175
|
-
},
|
|
176
|
-
'syncing': {
|
|
177
|
-
'en': 'Syncing: {} -> {}',
|
|
178
|
-
'ja': '同期中: {} -> {}'
|
|
179
|
-
},
|
|
180
|
-
'completed_sync': {
|
|
181
|
-
'en': 'Completed sync of {} files',
|
|
182
|
-
'ja': '{} 個のファイルを同期しました'
|
|
183
|
-
},
|
|
184
|
-
'usage_sync': {
|
|
185
|
-
'en': 'Usage: wt sync (sy) [files...] [--from <name>] [--to <name>]',
|
|
186
|
-
'ja': '使用方法: wt sync (sy) [files...] [--from <name>] [--to <name>]'
|
|
187
|
-
},
|
|
188
|
-
'usage_pr': {
|
|
189
|
-
'en': 'Usage: wt pr add <number>',
|
|
190
|
-
'ja': '使用方法: wt pr add <number>'
|
|
191
|
-
},
|
|
192
|
-
'usage_stash': {
|
|
193
|
-
'en': 'Usage: wt stash (st) <work_name> [<base_branch>]',
|
|
194
|
-
'ja': '使用方法: wt stash (st) <work_name> [<base_branch>]'
|
|
195
|
-
},
|
|
196
|
-
'stashing_changes': {
|
|
197
|
-
'en': 'Stashing local changes...',
|
|
198
|
-
'ja': 'ローカルの変更をスタッシュ中...'
|
|
199
|
-
},
|
|
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
158
|
}
|
|
209
159
|
|
|
210
160
|
|
|
211
161
|
def msg(key: str, *args) -> str:
|
|
212
162
|
"""言語に応じたメッセージを取得"""
|
|
213
|
-
lang =
|
|
163
|
+
lang = "ja" if is_japanese() else "en"
|
|
214
164
|
message = MESSAGES.get(key, {}).get(lang, key)
|
|
215
165
|
if args:
|
|
216
166
|
return message.format(*args)
|
|
217
167
|
return message
|
|
218
168
|
|
|
219
169
|
|
|
220
|
-
def run_command(
|
|
170
|
+
def run_command(
|
|
171
|
+
cmd: list[str], cwd: Path = None, check: bool = True
|
|
172
|
+
) -> subprocess.CompletedProcess:
|
|
221
173
|
"""コマンドを実行"""
|
|
222
174
|
try:
|
|
223
175
|
# print(f"DEBUG: Running command: {cmd} cwd={cwd}", file=sys.stderr)
|
|
224
176
|
result = subprocess.run(
|
|
225
|
-
cmd,
|
|
226
|
-
cwd=cwd,
|
|
227
|
-
capture_output=True,
|
|
228
|
-
text=True,
|
|
229
|
-
check=check
|
|
177
|
+
cmd, cwd=cwd, capture_output=True, text=True, check=check
|
|
230
178
|
)
|
|
231
179
|
return result
|
|
232
180
|
except subprocess.CalledProcessError as e:
|
|
233
|
-
print(msg(
|
|
181
|
+
print(msg("error", e.stderr), file=sys.stderr)
|
|
234
182
|
sys.exit(1)
|
|
235
183
|
|
|
236
184
|
|
|
237
185
|
def get_repository_name(url: str) -> str:
|
|
238
186
|
"""リポジトリ URL から名前を抽出"""
|
|
239
187
|
# URL から .git を削除して最後の部分を取得
|
|
240
|
-
match = re.search(r
|
|
188
|
+
match = re.search(r"/([^/]+?)(?:\.git)?$", url)
|
|
241
189
|
if match:
|
|
242
190
|
name = match.group(1)
|
|
243
191
|
# サービス名などが含まれる場合のクリーンアップ
|
|
244
|
-
return name.split(
|
|
192
|
+
return name.split(":")[-1]
|
|
245
193
|
# ローカルパスの場合
|
|
246
194
|
return Path(url).stem
|
|
247
195
|
|
|
@@ -252,17 +200,17 @@ def load_config(base_dir: Path) -> dict:
|
|
|
252
200
|
default_config = {
|
|
253
201
|
"worktrees_dir": ".worktrees",
|
|
254
202
|
"sync_files": [".env"],
|
|
255
|
-
"auto_copy_on_add": True
|
|
203
|
+
"auto_copy_on_add": True,
|
|
256
204
|
}
|
|
257
|
-
|
|
205
|
+
|
|
258
206
|
if config_file.exists():
|
|
259
207
|
try:
|
|
260
|
-
with open(config_file,
|
|
208
|
+
with open(config_file, "r", encoding="utf-8") as f:
|
|
261
209
|
user_config = toml.load(f)
|
|
262
210
|
default_config.update(user_config)
|
|
263
211
|
except Exception as e:
|
|
264
|
-
print(msg(
|
|
265
|
-
|
|
212
|
+
print(msg("error", f"Failed to load config: {e}"), file=sys.stderr)
|
|
213
|
+
|
|
266
214
|
return default_config
|
|
267
215
|
|
|
268
216
|
|
|
@@ -271,8 +219,8 @@ def save_config(base_dir: Path, config: dict):
|
|
|
271
219
|
wt_dir = base_dir / ".wt"
|
|
272
220
|
wt_dir.mkdir(exist_ok=True)
|
|
273
221
|
config_file = wt_dir / "config.toml"
|
|
274
|
-
|
|
275
|
-
with open(config_file,
|
|
222
|
+
|
|
223
|
+
with open(config_file, "w", encoding="utf-8") as f:
|
|
276
224
|
toml.dump(config, f)
|
|
277
225
|
|
|
278
226
|
|
|
@@ -286,32 +234,35 @@ def create_hook_template(base_dir: Path):
|
|
|
286
234
|
# config.toml
|
|
287
235
|
config_file = wt_dir / "config.toml"
|
|
288
236
|
if not config_file.exists():
|
|
289
|
-
save_config(
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
237
|
+
save_config(
|
|
238
|
+
base_dir,
|
|
239
|
+
{
|
|
240
|
+
"worktrees_dir": ".worktrees",
|
|
241
|
+
"sync_files": [".env"],
|
|
242
|
+
"auto_copy_on_add": True,
|
|
243
|
+
},
|
|
244
|
+
)
|
|
294
245
|
|
|
295
246
|
# .gitignore (repository root) に worktrees_dir を追加
|
|
296
247
|
config = load_config(base_dir)
|
|
297
248
|
worktrees_dir_name = config.get("worktrees_dir", ".worktrees")
|
|
298
249
|
root_gitignore = base_dir / ".gitignore"
|
|
299
|
-
|
|
250
|
+
|
|
300
251
|
entries = [f"{worktrees_dir_name}/"]
|
|
301
|
-
|
|
252
|
+
|
|
302
253
|
if root_gitignore.exists():
|
|
303
|
-
content = root_gitignore.read_text(encoding=
|
|
254
|
+
content = root_gitignore.read_text(encoding="utf-8")
|
|
304
255
|
updated = False
|
|
305
256
|
for entry in entries:
|
|
306
257
|
if entry not in content:
|
|
307
|
-
if content and not content.endswith(
|
|
308
|
-
content +=
|
|
258
|
+
if content and not content.endswith("\n"):
|
|
259
|
+
content += "\n"
|
|
309
260
|
content += f"{entry}\n"
|
|
310
261
|
updated = True
|
|
311
262
|
if updated:
|
|
312
|
-
root_gitignore.write_text(content, encoding=
|
|
263
|
+
root_gitignore.write_text(content, encoding="utf-8")
|
|
313
264
|
else:
|
|
314
|
-
root_gitignore.write_text("\n".join(entries) + "\n", encoding=
|
|
265
|
+
root_gitignore.write_text("\n".join(entries) + "\n", encoding="utf-8")
|
|
315
266
|
|
|
316
267
|
# post-add hook テンプレート
|
|
317
268
|
hook_file = wt_dir / "post-add"
|
|
@@ -530,10 +481,7 @@ The `post-add` hook is a script that runs automatically after creating a worktre
|
|
|
530
481
|
def find_base_dir() -> Path | None:
|
|
531
482
|
"""現在のディレクトリまたは親ディレクトリから git root を探す"""
|
|
532
483
|
try:
|
|
533
|
-
result = run_command(
|
|
534
|
-
["git", "rev-parse", "--show-toplevel"],
|
|
535
|
-
check=False
|
|
536
|
-
)
|
|
484
|
+
result = run_command(["git", "rev-parse", "--show-toplevel"], check=False)
|
|
537
485
|
if result.returncode == 0:
|
|
538
486
|
return Path(result.stdout.strip())
|
|
539
487
|
except Exception:
|
|
@@ -551,21 +499,21 @@ def find_base_dir() -> Path | None:
|
|
|
551
499
|
def cmd_clone(args: list[str]):
|
|
552
500
|
"""wt clone <repository_url> [dest_dir] - Clone a repository"""
|
|
553
501
|
if len(args) < 1:
|
|
554
|
-
print(msg(
|
|
502
|
+
print(msg("usage"), file=sys.stderr)
|
|
555
503
|
sys.exit(1)
|
|
556
504
|
|
|
557
505
|
repo_url = args[0]
|
|
558
506
|
repo_name = get_repository_name(repo_url)
|
|
559
|
-
|
|
507
|
+
|
|
560
508
|
dest_dir = Path(args[1]) if len(args) > 1 else Path(repo_name)
|
|
561
509
|
|
|
562
510
|
if dest_dir.exists():
|
|
563
|
-
print(msg(
|
|
511
|
+
print(msg("error", msg("already_exists", dest_dir)), file=sys.stderr)
|
|
564
512
|
sys.exit(1)
|
|
565
513
|
|
|
566
|
-
print(msg(
|
|
514
|
+
print(msg("cloning", repo_url, dest_dir), file=sys.stderr)
|
|
567
515
|
run_command(["git", "clone", repo_url, str(dest_dir)])
|
|
568
|
-
print(msg(
|
|
516
|
+
print(msg("completed_clone", dest_dir), file=sys.stderr)
|
|
569
517
|
|
|
570
518
|
# post-add hook と設定ファイルを作成
|
|
571
519
|
create_hook_template(dest_dir)
|
|
@@ -577,13 +525,11 @@ def cmd_init(args: list[str]):
|
|
|
577
525
|
|
|
578
526
|
# 現在のディレクトリが git リポジトリか確認
|
|
579
527
|
result = run_command(
|
|
580
|
-
["git", "rev-parse", "--show-toplevel"],
|
|
581
|
-
cwd=current_dir,
|
|
582
|
-
check=False
|
|
528
|
+
["git", "rev-parse", "--show-toplevel"], cwd=current_dir, check=False
|
|
583
529
|
)
|
|
584
530
|
|
|
585
531
|
if result.returncode != 0:
|
|
586
|
-
print(msg(
|
|
532
|
+
print(msg("error", msg("not_git_repo")), file=sys.stderr)
|
|
587
533
|
sys.exit(1)
|
|
588
534
|
|
|
589
535
|
git_root = Path(result.stdout.strip())
|
|
@@ -595,24 +541,35 @@ def cmd_init(args: list[str]):
|
|
|
595
541
|
def get_default_branch(base_dir: Path) -> str:
|
|
596
542
|
"""Detect default branch (main/master)"""
|
|
597
543
|
# 1. Try origin/HEAD
|
|
598
|
-
result = run_command(
|
|
544
|
+
result = run_command(
|
|
545
|
+
["git", "rev-parse", "--abbrev-ref", "origin/HEAD"], cwd=base_dir, check=False
|
|
546
|
+
)
|
|
599
547
|
if result.returncode == 0:
|
|
600
548
|
return result.stdout.strip().replace("origin/", "")
|
|
601
|
-
|
|
549
|
+
|
|
602
550
|
# 2. Try common names
|
|
603
551
|
for b in ["main", "master"]:
|
|
604
|
-
if
|
|
552
|
+
if (
|
|
553
|
+
run_command(
|
|
554
|
+
["git", "rev-parse", "--verify", b], cwd=base_dir, check=False
|
|
555
|
+
).returncode
|
|
556
|
+
== 0
|
|
557
|
+
):
|
|
605
558
|
return b
|
|
606
559
|
|
|
607
560
|
# 3. Fallback to current HEAD
|
|
608
|
-
result = run_command(
|
|
561
|
+
result = run_command(
|
|
562
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=base_dir, check=False
|
|
563
|
+
)
|
|
609
564
|
if result.returncode == 0:
|
|
610
565
|
return result.stdout.strip()
|
|
611
|
-
|
|
566
|
+
|
|
612
567
|
return None
|
|
613
568
|
|
|
614
569
|
|
|
615
|
-
def run_post_add_hook(
|
|
570
|
+
def run_post_add_hook(
|
|
571
|
+
worktree_path: Path, work_name: str, base_dir: Path, branch: str = None
|
|
572
|
+
):
|
|
616
573
|
"""worktree 作成後の hook を実行"""
|
|
617
574
|
# .wt/post-add を探す
|
|
618
575
|
hook_path = base_dir / ".wt" / "post-add"
|
|
@@ -621,20 +578,22 @@ def run_post_add_hook(worktree_path: Path, work_name: str, base_dir: Path, branc
|
|
|
621
578
|
return # hook がなければ何もしない
|
|
622
579
|
|
|
623
580
|
if not os.access(hook_path, os.X_OK):
|
|
624
|
-
print(msg(
|
|
581
|
+
print(msg("hook_not_executable", hook_path), file=sys.stderr)
|
|
625
582
|
return
|
|
626
583
|
|
|
627
584
|
# 環境変数を設定
|
|
628
585
|
env = os.environ.copy()
|
|
629
|
-
env.update(
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
586
|
+
env.update(
|
|
587
|
+
{
|
|
588
|
+
"WT_WORKTREE_PATH": str(worktree_path),
|
|
589
|
+
"WT_WORKTREE_NAME": work_name,
|
|
590
|
+
"WT_BASE_DIR": str(base_dir),
|
|
591
|
+
"WT_BRANCH": branch or work_name,
|
|
592
|
+
"WT_ACTION": "add",
|
|
593
|
+
}
|
|
594
|
+
)
|
|
595
|
+
|
|
596
|
+
print(msg("running_hook", hook_path), file=sys.stderr)
|
|
638
597
|
try:
|
|
639
598
|
result = subprocess.run(
|
|
640
599
|
[str(hook_path)],
|
|
@@ -642,22 +601,27 @@ def run_post_add_hook(worktree_path: Path, work_name: str, base_dir: Path, branc
|
|
|
642
601
|
env=env,
|
|
643
602
|
stdout=sys.stderr, # stdout を stderr にリダイレクト (cd 連携のため)
|
|
644
603
|
stderr=sys.stderr,
|
|
645
|
-
check=False
|
|
604
|
+
check=False,
|
|
646
605
|
)
|
|
647
606
|
|
|
648
607
|
if result.returncode != 0:
|
|
649
|
-
print(msg(
|
|
608
|
+
print(msg("hook_failed", result.returncode), file=sys.stderr)
|
|
650
609
|
except Exception as e:
|
|
651
|
-
print(msg(
|
|
610
|
+
print(msg("error", str(e)), file=sys.stderr)
|
|
652
611
|
|
|
653
612
|
|
|
654
|
-
def add_worktree(
|
|
613
|
+
def add_worktree(
|
|
614
|
+
work_name: str,
|
|
615
|
+
branch_to_use: str = None,
|
|
616
|
+
new_branch_base: str = None,
|
|
617
|
+
base_dir: Path = None,
|
|
618
|
+
) -> Path:
|
|
655
619
|
"""Core logic to add a worktree, reused by cmd_add and cmd_stash"""
|
|
656
620
|
if not base_dir:
|
|
657
621
|
base_dir = find_base_dir()
|
|
658
622
|
if not base_dir:
|
|
659
|
-
print(msg(
|
|
660
|
-
print(msg(
|
|
623
|
+
print(msg("error", msg("base_not_found")), file=sys.stderr)
|
|
624
|
+
print(msg("run_in_wt_dir"), file=sys.stderr)
|
|
661
625
|
sys.exit(1)
|
|
662
626
|
|
|
663
627
|
# 設定を読み込む
|
|
@@ -670,48 +634,58 @@ def add_worktree(work_name: str, branch_to_use: str = None, new_branch_base: str
|
|
|
670
634
|
worktree_path = worktrees_dir / work_name
|
|
671
635
|
|
|
672
636
|
if worktree_path.exists():
|
|
673
|
-
print(msg(
|
|
637
|
+
print(msg("error", msg("already_exists", worktree_path)), file=sys.stderr)
|
|
674
638
|
sys.exit(1)
|
|
675
639
|
|
|
676
640
|
# ブランチを最新に更新
|
|
677
|
-
print(msg(
|
|
641
|
+
print(msg("fetching"), file=sys.stderr)
|
|
678
642
|
run_command(["git", "fetch", "--all"], cwd=base_dir)
|
|
679
643
|
|
|
680
644
|
# 本体 (main) を base branch の最新に更新
|
|
681
645
|
result = run_command(
|
|
682
|
-
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
683
|
-
cwd=base_dir,
|
|
684
|
-
check=False
|
|
646
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=base_dir, check=False
|
|
685
647
|
)
|
|
686
648
|
if result.returncode == 0:
|
|
687
649
|
current_branch = result.stdout.strip()
|
|
688
650
|
result = run_command(
|
|
689
651
|
["git", "rev-parse", "--verify", f"origin/{current_branch}"],
|
|
690
652
|
cwd=base_dir,
|
|
691
|
-
check=False
|
|
653
|
+
check=False,
|
|
692
654
|
)
|
|
693
655
|
if result.returncode == 0:
|
|
694
|
-
run_command(
|
|
656
|
+
run_command(
|
|
657
|
+
["git", "pull", "origin", current_branch], cwd=base_dir, check=False
|
|
658
|
+
)
|
|
695
659
|
|
|
696
660
|
# ブランチ作成/チェックアウト
|
|
697
661
|
final_branch_name = None
|
|
698
662
|
if new_branch_base:
|
|
699
663
|
# 新しいブランチをベースから作成
|
|
700
664
|
final_branch_name = work_name
|
|
701
|
-
print(
|
|
665
|
+
print(
|
|
666
|
+
msg("creating_branch", final_branch_name, new_branch_base), file=sys.stderr
|
|
667
|
+
)
|
|
702
668
|
result = run_command(
|
|
703
|
-
[
|
|
669
|
+
[
|
|
670
|
+
"git",
|
|
671
|
+
"worktree",
|
|
672
|
+
"add",
|
|
673
|
+
"-b",
|
|
674
|
+
final_branch_name,
|
|
675
|
+
str(worktree_path),
|
|
676
|
+
new_branch_base,
|
|
677
|
+
],
|
|
704
678
|
cwd=base_dir,
|
|
705
|
-
check=False
|
|
679
|
+
check=False,
|
|
706
680
|
)
|
|
707
681
|
elif branch_to_use:
|
|
708
682
|
# 指定されたブランチをチェックアウト
|
|
709
683
|
final_branch_name = branch_to_use
|
|
710
|
-
print(msg(
|
|
684
|
+
print(msg("creating_worktree", worktree_path), file=sys.stderr)
|
|
711
685
|
result = run_command(
|
|
712
686
|
["git", "worktree", "add", str(worktree_path), final_branch_name],
|
|
713
687
|
cwd=base_dir,
|
|
714
|
-
check=False
|
|
688
|
+
check=False,
|
|
715
689
|
)
|
|
716
690
|
else:
|
|
717
691
|
# 自動判定
|
|
@@ -721,35 +695,41 @@ def add_worktree(work_name: str, branch_to_use: str = None, new_branch_base: str
|
|
|
721
695
|
check_local = run_command(
|
|
722
696
|
["git", "rev-parse", "--verify", final_branch_name],
|
|
723
697
|
cwd=base_dir,
|
|
724
|
-
check=False
|
|
698
|
+
check=False,
|
|
725
699
|
)
|
|
726
700
|
check_remote = run_command(
|
|
727
701
|
["git", "rev-parse", "--verify", f"origin/{final_branch_name}"],
|
|
728
702
|
cwd=base_dir,
|
|
729
|
-
check=False
|
|
703
|
+
check=False,
|
|
730
704
|
)
|
|
731
705
|
|
|
732
706
|
if check_local.returncode == 0 or check_remote.returncode == 0:
|
|
733
707
|
if check_remote.returncode == 0:
|
|
734
|
-
print(msg(
|
|
708
|
+
print(msg("creating_worktree", worktree_path), file=sys.stderr)
|
|
735
709
|
result = run_command(
|
|
736
|
-
[
|
|
710
|
+
[
|
|
711
|
+
"git",
|
|
712
|
+
"worktree",
|
|
713
|
+
"add",
|
|
714
|
+
str(worktree_path),
|
|
715
|
+
f"origin/{final_branch_name}",
|
|
716
|
+
],
|
|
737
717
|
cwd=base_dir,
|
|
738
|
-
check=False
|
|
718
|
+
check=False,
|
|
739
719
|
)
|
|
740
720
|
else:
|
|
741
|
-
print(msg(
|
|
721
|
+
print(msg("creating_worktree", worktree_path), file=sys.stderr)
|
|
742
722
|
result = run_command(
|
|
743
723
|
["git", "worktree", "add", str(worktree_path), final_branch_name],
|
|
744
724
|
cwd=base_dir,
|
|
745
|
-
check=False
|
|
725
|
+
check=False,
|
|
746
726
|
)
|
|
747
727
|
else:
|
|
748
728
|
# デフォルトブランチを探す
|
|
749
729
|
result_sym = run_command(
|
|
750
730
|
["git", "symbolic-ref", "refs/remotes/origin/HEAD", "--short"],
|
|
751
731
|
cwd=base_dir,
|
|
752
|
-
check=False
|
|
732
|
+
check=False,
|
|
753
733
|
)
|
|
754
734
|
|
|
755
735
|
detected_base = None
|
|
@@ -758,25 +738,49 @@ def add_worktree(work_name: str, branch_to_use: str = None, new_branch_base: str
|
|
|
758
738
|
else:
|
|
759
739
|
# remote/local main/master の順に探す
|
|
760
740
|
for b in ["origin/main", "origin/master", "main", "master"]:
|
|
761
|
-
if
|
|
741
|
+
if (
|
|
742
|
+
run_command(
|
|
743
|
+
["git", "rev-parse", "--verify", b],
|
|
744
|
+
cwd=base_dir,
|
|
745
|
+
check=False,
|
|
746
|
+
).returncode
|
|
747
|
+
== 0
|
|
748
|
+
):
|
|
762
749
|
detected_base = b
|
|
763
750
|
break
|
|
764
|
-
|
|
751
|
+
|
|
765
752
|
if not detected_base:
|
|
766
753
|
# 最終手段として現在のブランチを使用
|
|
767
|
-
res_curr = run_command(
|
|
754
|
+
res_curr = run_command(
|
|
755
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
756
|
+
cwd=base_dir,
|
|
757
|
+
check=False,
|
|
758
|
+
)
|
|
768
759
|
if res_curr.returncode == 0:
|
|
769
760
|
detected_base = res_curr.stdout.strip()
|
|
770
761
|
|
|
771
762
|
if not detected_base:
|
|
772
|
-
print(
|
|
763
|
+
print(
|
|
764
|
+
msg("error", msg("default_branch_not_found")), file=sys.stderr
|
|
765
|
+
)
|
|
773
766
|
sys.exit(1)
|
|
774
767
|
|
|
775
|
-
print(
|
|
768
|
+
print(
|
|
769
|
+
msg("creating_branch", final_branch_name, detected_base),
|
|
770
|
+
file=sys.stderr,
|
|
771
|
+
)
|
|
776
772
|
result = run_command(
|
|
777
|
-
[
|
|
773
|
+
[
|
|
774
|
+
"git",
|
|
775
|
+
"worktree",
|
|
776
|
+
"add",
|
|
777
|
+
"-b",
|
|
778
|
+
final_branch_name,
|
|
779
|
+
str(worktree_path),
|
|
780
|
+
detected_base,
|
|
781
|
+
],
|
|
778
782
|
cwd=base_dir,
|
|
779
|
-
check=False
|
|
783
|
+
check=False,
|
|
780
784
|
)
|
|
781
785
|
|
|
782
786
|
if result.returncode == 0:
|
|
@@ -787,8 +791,9 @@ def add_worktree(work_name: str, branch_to_use: str = None, new_branch_base: str
|
|
|
787
791
|
src = base_dir / file_name
|
|
788
792
|
dst = worktree_path / file_name
|
|
789
793
|
if src.exists():
|
|
790
|
-
print(msg(
|
|
794
|
+
print(msg("syncing", src, dst), file=sys.stderr)
|
|
791
795
|
import shutil
|
|
796
|
+
|
|
792
797
|
shutil.copy2(src, dst)
|
|
793
798
|
|
|
794
799
|
# post-add hook
|
|
@@ -803,24 +808,24 @@ def add_worktree(work_name: str, branch_to_use: str = None, new_branch_base: str
|
|
|
803
808
|
def cmd_add(args: list[str]):
|
|
804
809
|
"""wt add <work_name> [<base_branch>] - Add a worktree"""
|
|
805
810
|
if len(args) < 1:
|
|
806
|
-
print(msg(
|
|
811
|
+
print(msg("usage_add"), file=sys.stderr)
|
|
807
812
|
sys.exit(1)
|
|
808
813
|
|
|
809
814
|
work_name = args[0]
|
|
810
815
|
branch_to_use = args[1] if len(args) >= 2 else None
|
|
811
|
-
|
|
816
|
+
|
|
812
817
|
add_worktree(work_name, branch_to_use=branch_to_use)
|
|
813
818
|
|
|
814
819
|
|
|
815
820
|
def cmd_stash(args: list[str]):
|
|
816
821
|
"""wt stash <work_name> [<base_branch>] - Stash changes and move to new worktree"""
|
|
817
822
|
if len(args) < 1:
|
|
818
|
-
print(msg(
|
|
823
|
+
print(msg("usage_stash"), file=sys.stderr)
|
|
819
824
|
sys.exit(1)
|
|
820
825
|
|
|
821
826
|
base_dir = find_base_dir()
|
|
822
827
|
if not base_dir:
|
|
823
|
-
print(msg(
|
|
828
|
+
print(msg("error", msg("base_not_found")), file=sys.stderr)
|
|
824
829
|
sys.exit(1)
|
|
825
830
|
|
|
826
831
|
# 変更があるかチェック
|
|
@@ -828,59 +833,67 @@ def cmd_stash(args: list[str]):
|
|
|
828
833
|
has_changes = bool(result.stdout.strip())
|
|
829
834
|
|
|
830
835
|
if has_changes:
|
|
831
|
-
print(msg(
|
|
836
|
+
print(msg("stashing_changes"), file=sys.stderr)
|
|
832
837
|
# stash する
|
|
833
838
|
# -u (include untracked)
|
|
834
|
-
run_command(
|
|
839
|
+
run_command(
|
|
840
|
+
["git", "stash", "push", "-u", "-m", f"easy-worktree stash for {args[0]}"],
|
|
841
|
+
cwd=base_dir,
|
|
842
|
+
)
|
|
835
843
|
else:
|
|
836
|
-
print(msg(
|
|
844
|
+
print(msg("nothing_to_stash"), file=sys.stderr)
|
|
837
845
|
|
|
838
846
|
# 新しい worktree を作成
|
|
839
847
|
work_name = args[0]
|
|
840
|
-
|
|
848
|
+
|
|
841
849
|
# base_branch が指定されていない場合は現在のブランチをベースにする
|
|
842
850
|
# 指定されている場合はそれをベースにする
|
|
843
851
|
new_branch_base = args[1] if len(args) >= 2 else None
|
|
844
852
|
if not new_branch_base:
|
|
845
|
-
res = run_command(
|
|
853
|
+
res = run_command(
|
|
854
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=base_dir, check=False
|
|
855
|
+
)
|
|
846
856
|
if res.returncode == 0:
|
|
847
857
|
new_branch_base = res.stdout.strip()
|
|
848
858
|
|
|
849
859
|
# aliasはサポートしないでおく(とりあえずシンプルに)
|
|
850
860
|
# wt stash は常に新しいブランチを作成する振る舞いにする
|
|
851
|
-
wt_path = add_worktree(
|
|
861
|
+
wt_path = add_worktree(
|
|
862
|
+
work_name, new_branch_base=new_branch_base, base_dir=base_dir
|
|
863
|
+
)
|
|
852
864
|
|
|
853
865
|
if has_changes and wt_path:
|
|
854
|
-
print(msg(
|
|
866
|
+
print(msg("popping_stash"), file=sys.stderr)
|
|
855
867
|
# 新しい worktree で stash pop
|
|
856
868
|
run_command(["git", "stash", "pop"], cwd=wt_path)
|
|
857
869
|
|
|
858
870
|
|
|
859
|
-
|
|
860
|
-
|
|
861
871
|
def cmd_pr(args: list[str]):
|
|
862
872
|
"""wt pr <add|co> <number> - Pull Request management"""
|
|
863
873
|
if len(args) < 2:
|
|
864
|
-
print(msg(
|
|
874
|
+
print(msg("usage_pr"), file=sys.stderr)
|
|
865
875
|
sys.exit(1)
|
|
866
876
|
|
|
867
877
|
subcommand = args[0]
|
|
868
878
|
pr_number = args[1]
|
|
869
|
-
|
|
879
|
+
|
|
870
880
|
# Ensure pr_number is a digit
|
|
871
881
|
if not pr_number.isdigit():
|
|
872
|
-
print(msg(
|
|
882
|
+
print(msg("error", f"PR number must be a digit: {pr_number}"), file=sys.stderr)
|
|
873
883
|
sys.exit(1)
|
|
874
884
|
|
|
875
885
|
base_dir = find_base_dir()
|
|
876
886
|
if not base_dir:
|
|
877
|
-
print(msg(
|
|
887
|
+
print(msg("error", msg("base_not_found")), file=sys.stderr)
|
|
878
888
|
sys.exit(1)
|
|
879
889
|
|
|
880
890
|
if subcommand == "add":
|
|
881
891
|
# Check if gh command exists
|
|
882
892
|
if shutil.which("gh") is None:
|
|
883
|
-
print(
|
|
893
|
+
print(
|
|
894
|
+
msg("error", "GitHub CLI (gh) is required for this command"),
|
|
895
|
+
file=sys.stderr,
|
|
896
|
+
)
|
|
884
897
|
sys.exit(1)
|
|
885
898
|
|
|
886
899
|
print(f"Verifying PR #{pr_number}...", file=sys.stderr)
|
|
@@ -888,108 +901,103 @@ def cmd_pr(args: list[str]):
|
|
|
888
901
|
verify_cmd = ["gh", "pr", "view", pr_number, "--json", "number"]
|
|
889
902
|
result = run_command(verify_cmd, cwd=base_dir, check=False)
|
|
890
903
|
if result.returncode != 0:
|
|
891
|
-
print(
|
|
904
|
+
print(
|
|
905
|
+
msg("error", f"PR #{pr_number} not found (or access denied)"),
|
|
906
|
+
file=sys.stderr,
|
|
907
|
+
)
|
|
892
908
|
sys.exit(1)
|
|
893
909
|
|
|
894
910
|
branch_name = f"pr-{pr_number}"
|
|
895
911
|
worktree_name = f"pr@{pr_number}"
|
|
896
|
-
|
|
912
|
+
|
|
897
913
|
print(f"Fetching PR #{pr_number} contents...", file=sys.stderr)
|
|
898
914
|
# Fetch PR head to a local branch
|
|
899
915
|
# git fetch origin pull/ID/head:local-branch
|
|
900
916
|
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,
|
|
917
|
+
# We might want to handle case where origin doesn't exist or pull ref is different,
|
|
902
918
|
# but origin pull/ID/head is standard for GitHub.
|
|
903
919
|
run_command(fetch_cmd, cwd=base_dir)
|
|
904
|
-
|
|
920
|
+
|
|
905
921
|
print(f"Creating worktree {worktree_name}...", file=sys.stderr)
|
|
906
922
|
add_worktree(worktree_name, branch_to_use=branch_name, base_dir=base_dir)
|
|
907
|
-
|
|
923
|
+
|
|
908
924
|
elif subcommand == "co":
|
|
909
925
|
# Just a shortcut for checkout pr@<number>
|
|
910
926
|
cmd_checkout([f"pr@{pr_number}"])
|
|
911
927
|
else:
|
|
912
|
-
print(msg(
|
|
928
|
+
print(msg("usage_pr"), file=sys.stderr)
|
|
913
929
|
sys.exit(1)
|
|
914
930
|
|
|
915
931
|
|
|
916
932
|
def get_worktree_info(base_dir: Path) -> list[dict]:
|
|
917
933
|
"""worktree の詳細情報を取得"""
|
|
918
|
-
result = run_command(
|
|
919
|
-
["git", "worktree", "list", "--porcelain"],
|
|
920
|
-
cwd=base_dir
|
|
921
|
-
)
|
|
934
|
+
result = run_command(["git", "worktree", "list", "--porcelain"], cwd=base_dir)
|
|
922
935
|
|
|
923
936
|
worktrees = []
|
|
924
937
|
current = {}
|
|
925
938
|
|
|
926
|
-
for line in result.stdout.strip().split(
|
|
939
|
+
for line in result.stdout.strip().split("\n"):
|
|
927
940
|
if not line:
|
|
928
941
|
if current:
|
|
929
942
|
worktrees.append(current)
|
|
930
943
|
current = {}
|
|
931
944
|
continue
|
|
932
945
|
|
|
933
|
-
if line.startswith(
|
|
934
|
-
current[
|
|
935
|
-
elif line.startswith(
|
|
936
|
-
current[
|
|
937
|
-
elif line.startswith(
|
|
938
|
-
current[
|
|
939
|
-
elif line.startswith(
|
|
940
|
-
current[
|
|
946
|
+
if line.startswith("worktree "):
|
|
947
|
+
current["path"] = line.split(" ", 1)[1]
|
|
948
|
+
elif line.startswith("HEAD "):
|
|
949
|
+
current["head"] = line.split(" ", 1)[1]
|
|
950
|
+
elif line.startswith("branch "):
|
|
951
|
+
current["branch"] = line.split(" ", 1)[1].replace("refs/heads/", "")
|
|
952
|
+
elif line.startswith("detached"):
|
|
953
|
+
current["branch"] = "DETACHED"
|
|
941
954
|
|
|
942
955
|
if current:
|
|
943
956
|
worktrees.append(current)
|
|
944
957
|
|
|
945
958
|
# 各 worktree の詳細情報を取得
|
|
946
959
|
for wt in worktrees:
|
|
947
|
-
path = Path(wt[
|
|
960
|
+
path = Path(wt["path"])
|
|
948
961
|
|
|
949
962
|
# 作成日時(ディレクトリの作成時刻)
|
|
950
963
|
if path.exists():
|
|
951
964
|
stat_info = path.stat()
|
|
952
|
-
wt[
|
|
965
|
+
wt["created"] = datetime.fromtimestamp(stat_info.st_ctime)
|
|
953
966
|
|
|
954
967
|
# 最終コミット日時
|
|
955
968
|
result = run_command(
|
|
956
|
-
["git", "log", "-1", "--format=%ct", wt.get(
|
|
969
|
+
["git", "log", "-1", "--format=%ct", wt.get("head", "HEAD")],
|
|
957
970
|
cwd=base_dir,
|
|
958
|
-
check=False
|
|
971
|
+
check=False,
|
|
959
972
|
)
|
|
960
973
|
if result.returncode == 0 and result.stdout.strip():
|
|
961
|
-
wt[
|
|
974
|
+
wt["last_commit"] = datetime.fromtimestamp(int(result.stdout.strip()))
|
|
962
975
|
|
|
963
976
|
# 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
|
|
977
|
+
result = run_command(["git", "status", "--porcelain"], cwd=path, check=False)
|
|
978
|
+
wt["is_clean"] = result.returncode == 0 and not result.stdout.strip()
|
|
979
|
+
wt["has_untracked"] = "??" in result.stdout
|
|
971
980
|
|
|
972
981
|
# Diff stats取得
|
|
973
982
|
result_diff = run_command(
|
|
974
|
-
["git", "diff", "HEAD", "--shortstat"],
|
|
975
|
-
cwd=path,
|
|
976
|
-
check=False
|
|
983
|
+
["git", "diff", "HEAD", "--shortstat"], cwd=path, check=False
|
|
977
984
|
)
|
|
978
|
-
|
|
985
|
+
|
|
979
986
|
insertions = 0
|
|
980
987
|
deletions = 0
|
|
981
988
|
if result_diff.returncode == 0 and result_diff.stdout.strip():
|
|
982
989
|
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
|
|
990
|
+
m_plus = re.search(r"(\d+) insertion", out)
|
|
991
|
+
m_minus = re.search(r"(\d+) deletion", out)
|
|
992
|
+
if m_plus:
|
|
993
|
+
insertions = int(m_plus.group(1))
|
|
994
|
+
if m_minus:
|
|
995
|
+
deletions = int(m_minus.group(1))
|
|
990
996
|
|
|
991
|
-
|
|
997
|
+
wt["insertions"] = insertions
|
|
998
|
+
wt["deletions"] = deletions
|
|
992
999
|
|
|
1000
|
+
return worktrees
|
|
993
1001
|
|
|
994
1002
|
if result.returncode == 0:
|
|
995
1003
|
return result.stdout.strip()
|
|
@@ -998,65 +1006,76 @@ def get_worktree_info(base_dir: Path) -> list[dict]:
|
|
|
998
1006
|
|
|
999
1007
|
def get_pr_info(branch: str, cwd: Path = None) -> str:
|
|
1000
1008
|
"""Get rich GitHub PR information for the branch"""
|
|
1001
|
-
if not branch or branch ==
|
|
1009
|
+
if not branch or branch == "HEAD" or branch == "DETACHED":
|
|
1002
1010
|
return ""
|
|
1003
|
-
|
|
1011
|
+
|
|
1004
1012
|
# Check if gh command exists
|
|
1005
1013
|
if shutil.which("gh") is None:
|
|
1006
1014
|
return ""
|
|
1007
|
-
|
|
1015
|
+
|
|
1008
1016
|
import json
|
|
1009
|
-
|
|
1017
|
+
|
|
1018
|
+
cmd = [
|
|
1019
|
+
"gh",
|
|
1020
|
+
"pr",
|
|
1021
|
+
"list",
|
|
1022
|
+
"--head",
|
|
1023
|
+
branch,
|
|
1024
|
+
"--state",
|
|
1025
|
+
"all",
|
|
1026
|
+
"--json",
|
|
1027
|
+
"state,isDraft,url,createdAt,number",
|
|
1028
|
+
]
|
|
1010
1029
|
result = run_command(cmd, cwd=cwd, check=False)
|
|
1011
|
-
|
|
1030
|
+
|
|
1012
1031
|
if result.returncode != 0 or not result.stdout.strip():
|
|
1013
1032
|
return ""
|
|
1014
|
-
|
|
1033
|
+
|
|
1015
1034
|
try:
|
|
1016
1035
|
prs = json.loads(result.stdout)
|
|
1017
1036
|
if not prs:
|
|
1018
1037
|
return ""
|
|
1019
|
-
|
|
1038
|
+
|
|
1020
1039
|
pr = prs[0]
|
|
1021
|
-
state = pr[
|
|
1022
|
-
is_draft = pr[
|
|
1023
|
-
url = pr[
|
|
1024
|
-
created_at_str = pr[
|
|
1025
|
-
number = pr[
|
|
1026
|
-
|
|
1040
|
+
state = pr["state"]
|
|
1041
|
+
is_draft = pr["isDraft"]
|
|
1042
|
+
url = pr["url"]
|
|
1043
|
+
created_at_str = pr["createdAt"]
|
|
1044
|
+
number = pr["number"]
|
|
1045
|
+
|
|
1027
1046
|
# Parse created_at
|
|
1028
1047
|
# ISO format: 2024-03-20T12:00:00Z
|
|
1029
1048
|
try:
|
|
1030
1049
|
# Localize to local timezone
|
|
1031
|
-
dt_aware = datetime.fromisoformat(created_at_str.replace(
|
|
1050
|
+
dt_aware = datetime.fromisoformat(created_at_str.replace("Z", "+00:00"))
|
|
1032
1051
|
dt_local = dt_aware.astimezone().replace(tzinfo=None)
|
|
1033
1052
|
rel_time = get_relative_time(dt_local)
|
|
1034
1053
|
except Exception:
|
|
1035
1054
|
rel_time = "N/A"
|
|
1036
|
-
|
|
1055
|
+
|
|
1037
1056
|
# Symbols and Colors
|
|
1038
1057
|
GREEN = "\033[32m"
|
|
1039
1058
|
GRAY = "\033[90m"
|
|
1040
1059
|
MAGENTA = "\033[35m"
|
|
1041
1060
|
RED = "\033[31m"
|
|
1042
1061
|
RESET = "\033[0m"
|
|
1043
|
-
|
|
1062
|
+
|
|
1044
1063
|
if is_draft:
|
|
1045
1064
|
symbol = f"{GRAY}◌{RESET}"
|
|
1046
1065
|
elif state == "OPEN":
|
|
1047
1066
|
symbol = f"{GREEN}●{RESET}"
|
|
1048
1067
|
elif state == "MERGED":
|
|
1049
1068
|
symbol = f"{MAGENTA}✔{RESET}"
|
|
1050
|
-
else:
|
|
1069
|
+
else: # CLOSED
|
|
1051
1070
|
symbol = f"{RED}✘{RESET}"
|
|
1052
|
-
|
|
1071
|
+
|
|
1053
1072
|
# Hyperlink for #NUMBER
|
|
1054
1073
|
# ANSI sequence for hyperlink: ESC ] 8 ; ; URL ESC \ TEXT ESC ] 8 ; ; ESC \
|
|
1055
1074
|
link_start = f"\x1b]8;;{url}\x1b\\"
|
|
1056
1075
|
link_end = "\x1b]8;;\x1b\\"
|
|
1057
|
-
|
|
1076
|
+
|
|
1058
1077
|
return f"{symbol} {link_start}#{number}{link_end} ({rel_time})"
|
|
1059
|
-
|
|
1078
|
+
|
|
1060
1079
|
except Exception:
|
|
1061
1080
|
return ""
|
|
1062
1081
|
|
|
@@ -1065,16 +1084,16 @@ def get_relative_time(dt: datetime) -> str:
|
|
|
1065
1084
|
"""Get relative time string"""
|
|
1066
1085
|
if not dt:
|
|
1067
1086
|
return "N/A"
|
|
1068
|
-
|
|
1087
|
+
|
|
1069
1088
|
now = datetime.now()
|
|
1070
1089
|
diff = now - dt
|
|
1071
|
-
|
|
1090
|
+
|
|
1072
1091
|
seconds = diff.total_seconds()
|
|
1073
1092
|
days = diff.days
|
|
1074
|
-
|
|
1093
|
+
|
|
1075
1094
|
if days < 0:
|
|
1076
1095
|
return "just now"
|
|
1077
|
-
|
|
1096
|
+
|
|
1078
1097
|
if days == 0:
|
|
1079
1098
|
if seconds < 60:
|
|
1080
1099
|
return "just now"
|
|
@@ -1083,17 +1102,17 @@ def get_relative_time(dt: datetime) -> str:
|
|
|
1083
1102
|
return f"{minutes}m ago"
|
|
1084
1103
|
hours = int(seconds / 3600)
|
|
1085
1104
|
return f"{hours}h ago"
|
|
1086
|
-
|
|
1105
|
+
|
|
1087
1106
|
if days == 1:
|
|
1088
1107
|
return "yesterday"
|
|
1089
|
-
|
|
1108
|
+
|
|
1090
1109
|
if days < 30:
|
|
1091
1110
|
return f"{days}d ago"
|
|
1092
|
-
|
|
1111
|
+
|
|
1093
1112
|
if days < 365:
|
|
1094
1113
|
months = int(days / 30)
|
|
1095
1114
|
return f"{months}mo ago"
|
|
1096
|
-
|
|
1115
|
+
|
|
1097
1116
|
years = int(days / 365)
|
|
1098
1117
|
return f"{years}y ago"
|
|
1099
1118
|
|
|
@@ -1102,32 +1121,36 @@ def cmd_list(args: list[str]):
|
|
|
1102
1121
|
"""wt list - List worktrees"""
|
|
1103
1122
|
base_dir = find_base_dir()
|
|
1104
1123
|
if not base_dir:
|
|
1105
|
-
print(msg(
|
|
1124
|
+
print(msg("error", msg("base_not_found")), file=sys.stderr)
|
|
1106
1125
|
sys.exit(1)
|
|
1107
1126
|
|
|
1108
1127
|
# --quiet / -q オプション(xargs 用)
|
|
1109
|
-
quiet =
|
|
1110
|
-
show_pr =
|
|
1128
|
+
quiet = "--quiet" in args or "-q" in args
|
|
1129
|
+
show_pr = "--pr" in args
|
|
1111
1130
|
|
|
1112
1131
|
worktrees = get_worktree_info(base_dir)
|
|
1113
1132
|
|
|
1114
1133
|
# ソート: 作成日時の降順(最新が上)
|
|
1115
|
-
worktrees.sort(key=lambda x: x.get(
|
|
1134
|
+
worktrees.sort(key=lambda x: x.get("created", datetime.min), reverse=True)
|
|
1116
1135
|
|
|
1117
1136
|
# PR infoの取得
|
|
1118
1137
|
if show_pr:
|
|
1119
1138
|
for wt in worktrees:
|
|
1120
|
-
branch = wt.get(
|
|
1139
|
+
branch = wt.get("branch", "")
|
|
1121
1140
|
if branch:
|
|
1122
|
-
wt[
|
|
1141
|
+
wt["pr_info"] = get_pr_info(branch, cwd=base_dir)
|
|
1123
1142
|
|
|
1124
1143
|
# 相対時間の計算
|
|
1125
1144
|
for wt in worktrees:
|
|
1126
|
-
wt[
|
|
1145
|
+
wt["relative_time"] = get_relative_time(wt.get("created"))
|
|
1127
1146
|
|
|
1128
1147
|
if quiet:
|
|
1129
1148
|
for wt in worktrees:
|
|
1130
|
-
print(
|
|
1149
|
+
print(
|
|
1150
|
+
Path(wt["path"]).name
|
|
1151
|
+
if Path(wt["path"]).name != base_dir.name
|
|
1152
|
+
else "main"
|
|
1153
|
+
)
|
|
1131
1154
|
return
|
|
1132
1155
|
|
|
1133
1156
|
# "Changes" カラムの表示文字列作成
|
|
@@ -1135,12 +1158,12 @@ def cmd_list(args: list[str]):
|
|
|
1135
1158
|
RED = "\033[31m"
|
|
1136
1159
|
GRAY = "\033[90m"
|
|
1137
1160
|
RESET = "\033[0m"
|
|
1138
|
-
|
|
1161
|
+
|
|
1139
1162
|
for wt in worktrees:
|
|
1140
|
-
plus = wt.get(
|
|
1141
|
-
minus = wt.get(
|
|
1142
|
-
untracked = wt.get(
|
|
1143
|
-
|
|
1163
|
+
plus = wt.get("insertions", 0)
|
|
1164
|
+
minus = wt.get("deletions", 0)
|
|
1165
|
+
untracked = wt.get("has_untracked", False)
|
|
1166
|
+
|
|
1144
1167
|
parts = []
|
|
1145
1168
|
clean_parts = []
|
|
1146
1169
|
if plus > 0:
|
|
@@ -1152,25 +1175,51 @@ def cmd_list(args: list[str]):
|
|
|
1152
1175
|
if untracked:
|
|
1153
1176
|
parts.append(f"{GRAY}??{RESET}")
|
|
1154
1177
|
clean_parts.append("??")
|
|
1155
|
-
|
|
1178
|
+
|
|
1156
1179
|
if not parts:
|
|
1157
|
-
wt[
|
|
1158
|
-
wt[
|
|
1180
|
+
wt["changes_display"] = "-"
|
|
1181
|
+
wt["changes_clean_len"] = 1
|
|
1159
1182
|
else:
|
|
1160
|
-
wt[
|
|
1161
|
-
wt[
|
|
1183
|
+
wt["changes_display"] = " ".join(parts)
|
|
1184
|
+
wt["changes_clean_len"] = len(" ".join(clean_parts))
|
|
1162
1185
|
|
|
1163
1186
|
# カラム幅の計算
|
|
1164
|
-
name_w =
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1187
|
+
name_w = (
|
|
1188
|
+
max(
|
|
1189
|
+
len(msg("worktree_name")),
|
|
1190
|
+
max((len(Path(wt["path"]).name) for wt in worktrees), default=0),
|
|
1191
|
+
)
|
|
1192
|
+
+ 2
|
|
1193
|
+
)
|
|
1194
|
+
branch_w = (
|
|
1195
|
+
max(
|
|
1196
|
+
len(msg("branch_name")),
|
|
1197
|
+
max((len(wt.get("branch", "N/A")) for wt in worktrees), default=0),
|
|
1198
|
+
)
|
|
1199
|
+
+ 2
|
|
1200
|
+
)
|
|
1201
|
+
time_w = (
|
|
1202
|
+
max(
|
|
1203
|
+
len("Created"),
|
|
1204
|
+
max((len(wt.get("relative_time", "")) for wt in worktrees), default=0),
|
|
1205
|
+
)
|
|
1206
|
+
+ 2
|
|
1207
|
+
)
|
|
1208
|
+
status_w = (
|
|
1209
|
+
max(
|
|
1210
|
+
len(msg("changes_label")),
|
|
1211
|
+
max((wt["changes_clean_len"] for wt in worktrees), default=0),
|
|
1212
|
+
)
|
|
1213
|
+
+ 2
|
|
1214
|
+
)
|
|
1168
1215
|
pr_w = 0
|
|
1169
1216
|
if show_pr:
|
|
1170
1217
|
# PR info contains ANSI codes, so calculate real length
|
|
1171
1218
|
import re
|
|
1219
|
+
|
|
1172
1220
|
# More robust ANSI escape regex including hyperlinks
|
|
1173
|
-
ansi_escape = re.compile(
|
|
1221
|
+
ansi_escape = re.compile(
|
|
1222
|
+
r"""
|
|
1174
1223
|
\x1B(?:
|
|
1175
1224
|
[@-Z\\-_]
|
|
1176
1225
|
|
|
|
@@ -1178,17 +1227,26 @@ def cmd_list(args: list[str]):
|
|
|
1178
1227
|
|
|
|
1179
1228
|
\][0-9]*;.*?(\x1B\\|\x07)
|
|
1180
1229
|
)
|
|
1181
|
-
|
|
1230
|
+
""",
|
|
1231
|
+
re.VERBOSE,
|
|
1232
|
+
)
|
|
1233
|
+
|
|
1182
1234
|
def clean_len(s):
|
|
1183
|
-
return len(ansi_escape.sub(
|
|
1184
|
-
|
|
1185
|
-
pr_w =
|
|
1235
|
+
return len(ansi_escape.sub("", s))
|
|
1236
|
+
|
|
1237
|
+
pr_w = (
|
|
1238
|
+
max(
|
|
1239
|
+
3,
|
|
1240
|
+
max((clean_len(wt.get("pr_info", "")) for wt in worktrees), default=0),
|
|
1241
|
+
)
|
|
1242
|
+
+ 2
|
|
1243
|
+
)
|
|
1186
1244
|
|
|
1187
1245
|
# ヘッダー (色付き)
|
|
1188
1246
|
CYAN = "\033[36m"
|
|
1189
1247
|
RESET = "\033[0m"
|
|
1190
1248
|
BOLD = "\033[1m"
|
|
1191
|
-
|
|
1249
|
+
|
|
1192
1250
|
base_header = f"{msg('worktree_name'):<{name_w}} {msg('branch_name'):<{branch_w}} {'Created':<{time_w}} {msg('changes_label'):<{status_w}}"
|
|
1193
1251
|
if show_pr:
|
|
1194
1252
|
header = f"{BOLD}{base_header} PR{RESET}"
|
|
@@ -1196,49 +1254,64 @@ def cmd_list(args: list[str]):
|
|
|
1196
1254
|
else:
|
|
1197
1255
|
header = f"{BOLD}{base_header.rstrip()}{RESET}"
|
|
1198
1256
|
separator_len = len(base_header.rstrip())
|
|
1199
|
-
|
|
1257
|
+
|
|
1200
1258
|
print(header)
|
|
1201
1259
|
print("-" * separator_len)
|
|
1202
1260
|
|
|
1203
1261
|
for wt in worktrees:
|
|
1204
|
-
path = Path(wt[
|
|
1262
|
+
path = Path(wt["path"])
|
|
1205
1263
|
name_display = path.name if path != base_dir else f"{CYAN}(main){RESET}"
|
|
1206
1264
|
name_clean_len = len(path.name) if path != base_dir else 6
|
|
1207
1265
|
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
|
-
|
|
1266
|
+
|
|
1267
|
+
branch = wt.get("branch", "N/A")
|
|
1268
|
+
rel_time = wt.get("relative_time", "N/A")
|
|
1269
|
+
changes_display = wt.get("changes_display", "no changes")
|
|
1270
|
+
changes_clean_len = wt.get("changes_clean_len", 1)
|
|
1271
|
+
|
|
1214
1272
|
# ANSI コード分を補正して表示
|
|
1215
1273
|
changes_padding = " " * (status_w - changes_clean_len)
|
|
1216
|
-
|
|
1217
|
-
print(
|
|
1274
|
+
|
|
1275
|
+
print(
|
|
1276
|
+
f"{name_display}{name_padding} {branch:<{branch_w}} {rel_time:<{time_w}} {changes_display}{changes_padding}",
|
|
1277
|
+
end="",
|
|
1278
|
+
)
|
|
1218
1279
|
if show_pr:
|
|
1219
|
-
|
|
1280
|
+
print(f" {wt.get('pr_info', '')}", end="")
|
|
1220
1281
|
print()
|
|
1221
1282
|
|
|
1222
1283
|
|
|
1223
1284
|
def cmd_remove(args: list[str]):
|
|
1224
1285
|
"""wt rm/remove <work_name> - Remove a worktree"""
|
|
1225
1286
|
if len(args) < 1:
|
|
1226
|
-
print(msg(
|
|
1287
|
+
print(msg("usage_rm"), file=sys.stderr)
|
|
1227
1288
|
sys.exit(1)
|
|
1228
1289
|
|
|
1229
1290
|
base_dir = find_base_dir()
|
|
1230
1291
|
if not base_dir:
|
|
1231
|
-
print(msg(
|
|
1292
|
+
print(msg("error", msg("base_not_found")), file=sys.stderr)
|
|
1232
1293
|
sys.exit(1)
|
|
1233
1294
|
|
|
1234
|
-
|
|
1295
|
+
# Parse flags and find worktree name
|
|
1296
|
+
flags = []
|
|
1297
|
+
work_name = None
|
|
1298
|
+
for arg in args:
|
|
1299
|
+
if arg in ["-f", "--force"]:
|
|
1300
|
+
flags.append(arg)
|
|
1301
|
+
elif not work_name:
|
|
1302
|
+
work_name = arg
|
|
1303
|
+
else:
|
|
1304
|
+
# Additional non-flag arguments are currently ignored or could be treated as error
|
|
1305
|
+
pass
|
|
1306
|
+
|
|
1307
|
+
if not work_name:
|
|
1308
|
+
print(msg("usage_rm"), file=sys.stderr)
|
|
1309
|
+
sys.exit(1)
|
|
1235
1310
|
|
|
1236
1311
|
# worktree を削除
|
|
1237
|
-
print(msg(
|
|
1312
|
+
print(msg("removing_worktree", work_name), file=sys.stderr)
|
|
1238
1313
|
result = run_command(
|
|
1239
|
-
["git", "worktree", "remove"
|
|
1240
|
-
cwd=base_dir,
|
|
1241
|
-
check=False
|
|
1314
|
+
["git", "worktree", "remove"] + flags + [work_name], cwd=base_dir, check=False
|
|
1242
1315
|
)
|
|
1243
1316
|
|
|
1244
1317
|
if result.returncode == 0:
|
|
@@ -1253,22 +1326,22 @@ def cmd_clean(args: list[str]):
|
|
|
1253
1326
|
"""wt clean - Remove old/unused/merged worktrees"""
|
|
1254
1327
|
base_dir = find_base_dir()
|
|
1255
1328
|
if not base_dir:
|
|
1256
|
-
print(msg(
|
|
1329
|
+
print(msg("error", msg("base_not_found")), file=sys.stderr)
|
|
1257
1330
|
sys.exit(1)
|
|
1258
1331
|
|
|
1259
1332
|
# オプションを解析
|
|
1260
1333
|
# オプションを解析
|
|
1261
|
-
clean_all =
|
|
1262
|
-
clean_merged =
|
|
1263
|
-
clean_closed =
|
|
1334
|
+
clean_all = "--all" in args
|
|
1335
|
+
clean_merged = "--merged" in args
|
|
1336
|
+
clean_closed = "--closed" in args
|
|
1264
1337
|
days = None
|
|
1265
1338
|
|
|
1266
1339
|
for i, arg in enumerate(args):
|
|
1267
|
-
if arg ==
|
|
1340
|
+
if arg == "--days" and i + 1 < len(args):
|
|
1268
1341
|
try:
|
|
1269
1342
|
days = int(args[i + 1])
|
|
1270
1343
|
except ValueError:
|
|
1271
|
-
print(msg(
|
|
1344
|
+
print(msg("error", "Invalid days value"), file=sys.stderr)
|
|
1272
1345
|
sys.exit(1)
|
|
1273
1346
|
|
|
1274
1347
|
# worktree 情報を取得
|
|
@@ -1288,38 +1361,53 @@ def cmd_clean(args: list[str]):
|
|
|
1288
1361
|
# マージ済みブランチを取得
|
|
1289
1362
|
merged_branches = set()
|
|
1290
1363
|
merged_pr_branches = set()
|
|
1291
|
-
|
|
1364
|
+
|
|
1292
1365
|
# デフォルトブランチを取得して、それに対してマージされているかを確認
|
|
1293
1366
|
default_branch = get_default_branch(base_dir)
|
|
1294
1367
|
default_branch_sha = None
|
|
1295
1368
|
if default_branch:
|
|
1296
|
-
res_sha = run_command(
|
|
1369
|
+
res_sha = run_command(
|
|
1370
|
+
["git", "rev-parse", default_branch], cwd=base_dir, check=False
|
|
1371
|
+
)
|
|
1297
1372
|
if res_sha.returncode == 0:
|
|
1298
1373
|
default_branch_sha = res_sha.stdout.strip()
|
|
1299
|
-
|
|
1374
|
+
|
|
1300
1375
|
if clean_merged and default_branch:
|
|
1301
1376
|
# Local merged branches (merged into default_branch)
|
|
1302
|
-
result = run_command(
|
|
1377
|
+
result = run_command(
|
|
1378
|
+
["git", "branch", "--merged", default_branch], cwd=base_dir, check=False
|
|
1379
|
+
)
|
|
1303
1380
|
if result.returncode == 0:
|
|
1304
|
-
for line in result.stdout.split(
|
|
1381
|
+
for line in result.stdout.split("\n"):
|
|
1305
1382
|
# Extract branch name, removing '*', '+', and whitespace
|
|
1306
1383
|
line = line.strip()
|
|
1307
|
-
if line.startswith(
|
|
1384
|
+
if line.startswith("* ") or line.startswith("+ "):
|
|
1308
1385
|
line = line[2:].strip()
|
|
1309
1386
|
if line:
|
|
1310
1387
|
merged_branches.add(line)
|
|
1311
|
-
|
|
1388
|
+
|
|
1312
1389
|
# GitHub merged PRs
|
|
1313
1390
|
if shutil.which("gh"):
|
|
1314
1391
|
import json
|
|
1392
|
+
|
|
1315
1393
|
# Get last 100 merged PRs
|
|
1316
|
-
pr_cmd = [
|
|
1394
|
+
pr_cmd = [
|
|
1395
|
+
"gh",
|
|
1396
|
+
"pr",
|
|
1397
|
+
"list",
|
|
1398
|
+
"--state",
|
|
1399
|
+
"merged",
|
|
1400
|
+
"--limit",
|
|
1401
|
+
"100",
|
|
1402
|
+
"--json",
|
|
1403
|
+
"headRefName",
|
|
1404
|
+
]
|
|
1317
1405
|
pr_res = run_command(pr_cmd, cwd=base_dir, check=False)
|
|
1318
1406
|
if pr_res.returncode == 0:
|
|
1319
1407
|
try:
|
|
1320
1408
|
pr_data = json.loads(pr_res.stdout)
|
|
1321
1409
|
for pr in pr_data:
|
|
1322
|
-
merged_pr_branches.add(pr[
|
|
1410
|
+
merged_pr_branches.add(pr["headRefName"])
|
|
1323
1411
|
except:
|
|
1324
1412
|
pass
|
|
1325
1413
|
|
|
@@ -1328,14 +1416,25 @@ def cmd_clean(args: list[str]):
|
|
|
1328
1416
|
if clean_closed:
|
|
1329
1417
|
if shutil.which("gh"):
|
|
1330
1418
|
import json
|
|
1419
|
+
|
|
1331
1420
|
# Get last 100 closed PRs
|
|
1332
|
-
pr_cmd = [
|
|
1421
|
+
pr_cmd = [
|
|
1422
|
+
"gh",
|
|
1423
|
+
"pr",
|
|
1424
|
+
"list",
|
|
1425
|
+
"--state",
|
|
1426
|
+
"closed",
|
|
1427
|
+
"--limit",
|
|
1428
|
+
"100",
|
|
1429
|
+
"--json",
|
|
1430
|
+
"headRefName",
|
|
1431
|
+
]
|
|
1333
1432
|
pr_res = run_command(pr_cmd, cwd=base_dir, check=False)
|
|
1334
1433
|
if pr_res.returncode == 0:
|
|
1335
1434
|
try:
|
|
1336
1435
|
pr_data = json.loads(pr_res.stdout)
|
|
1337
1436
|
for pr in pr_data:
|
|
1338
|
-
closed_pr_branches.add(pr[
|
|
1437
|
+
closed_pr_branches.add(pr["headRefName"])
|
|
1339
1438
|
except:
|
|
1340
1439
|
pass
|
|
1341
1440
|
|
|
@@ -1344,7 +1443,7 @@ def cmd_clean(args: list[str]):
|
|
|
1344
1443
|
now = datetime.now()
|
|
1345
1444
|
|
|
1346
1445
|
for wt in worktrees:
|
|
1347
|
-
path = Path(wt[
|
|
1446
|
+
path = Path(wt["path"])
|
|
1348
1447
|
|
|
1349
1448
|
# base (git root) は除外
|
|
1350
1449
|
if path == base_dir:
|
|
@@ -1356,28 +1455,33 @@ def cmd_clean(args: list[str]):
|
|
|
1356
1455
|
|
|
1357
1456
|
reason = None
|
|
1358
1457
|
# マージ済みの場合は無条件で対象(ただし dirty でないこと)
|
|
1359
|
-
is_merged = (
|
|
1458
|
+
is_merged = (
|
|
1459
|
+
wt.get("branch") in merged_branches
|
|
1460
|
+
or wt.get("branch") in merged_pr_branches
|
|
1461
|
+
)
|
|
1360
1462
|
if clean_merged and is_merged:
|
|
1361
1463
|
# Check safeguard: if branch points to same SHA as default branch and NOT in merged_pr_branches
|
|
1362
1464
|
# it might be a new branch that hasn't diverged yet.
|
|
1363
|
-
if default_branch_sha and wt.get(
|
|
1364
|
-
wt_sha = run_command(
|
|
1465
|
+
if default_branch_sha and wt.get("branch") not in merged_pr_branches:
|
|
1466
|
+
wt_sha = run_command(
|
|
1467
|
+
["git", "rev-parse", wt.get("branch")], cwd=base_dir, check=False
|
|
1468
|
+
).stdout.strip()
|
|
1365
1469
|
if wt_sha == default_branch_sha:
|
|
1366
1470
|
# Skip deletion for fresh branches
|
|
1367
1471
|
continue
|
|
1368
1472
|
|
|
1369
|
-
if wt.get(
|
|
1473
|
+
if wt.get("is_clean"):
|
|
1370
1474
|
reason = "merged"
|
|
1371
1475
|
|
|
1372
|
-
is_closed = wt.get(
|
|
1476
|
+
is_closed = wt.get("branch") in closed_pr_branches
|
|
1373
1477
|
if not reason and clean_closed and is_closed:
|
|
1374
|
-
if wt.get(
|
|
1478
|
+
if wt.get("is_clean"):
|
|
1375
1479
|
reason = "closed"
|
|
1376
|
-
|
|
1480
|
+
|
|
1377
1481
|
# 通常のクリーンアップ対象
|
|
1378
|
-
if not reason and wt.get(
|
|
1482
|
+
if not reason and wt.get("is_clean"):
|
|
1379
1483
|
if days is not None:
|
|
1380
|
-
created = wt.get(
|
|
1484
|
+
created = wt.get("created")
|
|
1381
1485
|
if created:
|
|
1382
1486
|
age_days = (now - created).days
|
|
1383
1487
|
if age_days >= days:
|
|
@@ -1386,24 +1490,28 @@ def cmd_clean(args: list[str]):
|
|
|
1386
1490
|
reason = "clean"
|
|
1387
1491
|
|
|
1388
1492
|
if reason:
|
|
1389
|
-
wt[
|
|
1493
|
+
wt["reason"] = reason
|
|
1390
1494
|
targets.append(wt)
|
|
1391
1495
|
|
|
1392
1496
|
if not targets:
|
|
1393
|
-
print(msg(
|
|
1497
|
+
print(msg("no_clean_targets"), file=sys.stderr)
|
|
1394
1498
|
return
|
|
1395
1499
|
|
|
1396
1500
|
# 削除対象を表示
|
|
1397
1501
|
for wt in targets:
|
|
1398
|
-
path = Path(wt[
|
|
1399
|
-
created =
|
|
1400
|
-
|
|
1502
|
+
path = Path(wt["path"])
|
|
1503
|
+
created = (
|
|
1504
|
+
wt.get("created").strftime("%Y-%m-%d %H:%M") if wt.get("created") else "N/A"
|
|
1505
|
+
)
|
|
1506
|
+
print(
|
|
1507
|
+
f"{path.name} (reason: {wt['reason']}, created: {created})", file=sys.stderr
|
|
1508
|
+
)
|
|
1401
1509
|
|
|
1402
1510
|
# 確認
|
|
1403
1511
|
if not clean_all:
|
|
1404
1512
|
try:
|
|
1405
|
-
response = input(msg(
|
|
1406
|
-
if response.lower() not in [
|
|
1513
|
+
response = input(msg("clean_confirm", len(targets)))
|
|
1514
|
+
if response.lower() not in ["y", "yes"]:
|
|
1407
1515
|
print("Cancelled.")
|
|
1408
1516
|
return
|
|
1409
1517
|
except (EOFError, KeyboardInterrupt):
|
|
@@ -1412,12 +1520,10 @@ def cmd_clean(args: list[str]):
|
|
|
1412
1520
|
|
|
1413
1521
|
# 削除実行
|
|
1414
1522
|
for wt in targets:
|
|
1415
|
-
path = Path(wt[
|
|
1416
|
-
print(msg(
|
|
1523
|
+
path = Path(wt["path"])
|
|
1524
|
+
print(msg("removing_worktree", path.name), file=sys.stderr)
|
|
1417
1525
|
result = run_command(
|
|
1418
|
-
["git", "worktree", "remove", str(path)],
|
|
1419
|
-
cwd=base_dir,
|
|
1420
|
-
check=False
|
|
1526
|
+
["git", "worktree", "remove", str(path)], cwd=base_dir, check=False
|
|
1421
1527
|
)
|
|
1422
1528
|
|
|
1423
1529
|
if result.returncode == 0:
|
|
@@ -1427,15 +1533,11 @@ def cmd_clean(args: list[str]):
|
|
|
1427
1533
|
print(result.stderr, file=sys.stderr)
|
|
1428
1534
|
|
|
1429
1535
|
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
1536
|
def cmd_sync(args: list[str]):
|
|
1435
1537
|
"""wt sync [files...] [--from <name>] [--to <name>] - Sync files between worktrees"""
|
|
1436
1538
|
base_dir = find_base_dir()
|
|
1437
1539
|
if not base_dir:
|
|
1438
|
-
print(msg(
|
|
1540
|
+
print(msg("error", msg("base_not_found")), file=sys.stderr)
|
|
1439
1541
|
sys.exit(1)
|
|
1440
1542
|
|
|
1441
1543
|
config = load_config(base_dir)
|
|
@@ -1446,11 +1548,11 @@ def cmd_sync(args: list[str]):
|
|
|
1446
1548
|
# 引数解析
|
|
1447
1549
|
i = 0
|
|
1448
1550
|
while i < len(args):
|
|
1449
|
-
if args[i] ==
|
|
1450
|
-
from_name = args[i+1]
|
|
1551
|
+
if args[i] == "--from" and i + 1 < len(args):
|
|
1552
|
+
from_name = args[i + 1]
|
|
1451
1553
|
i += 2
|
|
1452
|
-
elif args[i] ==
|
|
1453
|
-
to_name = args[i+1]
|
|
1554
|
+
elif args[i] == "--to" and i + 1 < len(args):
|
|
1555
|
+
to_name = args[i + 1]
|
|
1454
1556
|
i += 2
|
|
1455
1557
|
else:
|
|
1456
1558
|
files_to_sync.append(args[i])
|
|
@@ -1463,18 +1565,18 @@ def cmd_sync(args: list[str]):
|
|
|
1463
1565
|
return
|
|
1464
1566
|
|
|
1465
1567
|
worktrees = get_worktree_info(base_dir)
|
|
1466
|
-
|
|
1568
|
+
|
|
1467
1569
|
# 送信元と送信先のパスを決定
|
|
1468
1570
|
from_path = base_dir
|
|
1469
1571
|
if from_name:
|
|
1470
1572
|
found = False
|
|
1471
1573
|
for wt in worktrees:
|
|
1472
|
-
if Path(wt[
|
|
1473
|
-
from_path = Path(wt[
|
|
1574
|
+
if Path(wt["path"]).name == from_name:
|
|
1575
|
+
from_path = Path(wt["path"])
|
|
1474
1576
|
found = True
|
|
1475
1577
|
break
|
|
1476
1578
|
if not found:
|
|
1477
|
-
print(msg(
|
|
1579
|
+
print(msg("error", f"Worktree not found: {from_name}"), file=sys.stderr)
|
|
1478
1580
|
sys.exit(1)
|
|
1479
1581
|
|
|
1480
1582
|
dest_paths = []
|
|
@@ -1484,23 +1586,28 @@ def cmd_sync(args: list[str]):
|
|
|
1484
1586
|
else:
|
|
1485
1587
|
found = False
|
|
1486
1588
|
for wt in worktrees:
|
|
1487
|
-
if Path(wt[
|
|
1488
|
-
dest_paths = [Path(wt[
|
|
1589
|
+
if Path(wt["path"]).name == to_name:
|
|
1590
|
+
dest_paths = [Path(wt["path"])]
|
|
1489
1591
|
found = True
|
|
1490
1592
|
break
|
|
1491
1593
|
if not found:
|
|
1492
|
-
print(msg(
|
|
1594
|
+
print(msg("error", f"Worktree not found: {to_name}"), file=sys.stderr)
|
|
1493
1595
|
sys.exit(1)
|
|
1494
1596
|
else:
|
|
1495
1597
|
# 指定がない場合は現在のディレクトリが worktree ならそこへ、そうでなければ全自動(通常は base -> current)
|
|
1496
1598
|
current_dir = Path.cwd()
|
|
1497
|
-
if current_dir != base_dir and any(
|
|
1599
|
+
if current_dir != base_dir and any(
|
|
1600
|
+
current_dir.is_relative_to(Path(wt["path"])) for wt in worktrees
|
|
1601
|
+
):
|
|
1498
1602
|
dest_paths = [current_dir]
|
|
1499
1603
|
else:
|
|
1500
1604
|
# base から全 worktree へ(安全のため、ユーザーが現在の worktree にいる場合はそこだけにするのが一般的だが、ここでは全 worktree とした)
|
|
1501
|
-
dest_paths = [
|
|
1605
|
+
dest_paths = [
|
|
1606
|
+
Path(wt["path"]) for wt in worktrees if Path(wt["path"]) != base_dir
|
|
1607
|
+
]
|
|
1502
1608
|
|
|
1503
1609
|
import shutil
|
|
1610
|
+
|
|
1504
1611
|
count = 0
|
|
1505
1612
|
for dst_root in dest_paths:
|
|
1506
1613
|
if dst_root == from_path:
|
|
@@ -1509,7 +1616,7 @@ def cmd_sync(args: list[str]):
|
|
|
1509
1616
|
src = from_path / f
|
|
1510
1617
|
dst = dst_root / f
|
|
1511
1618
|
if src.exists():
|
|
1512
|
-
print(msg(
|
|
1619
|
+
print(msg("syncing", src, dst), file=sys.stderr)
|
|
1513
1620
|
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
1514
1621
|
shutil.copy2(src, dst)
|
|
1515
1622
|
count += 1
|
|
@@ -1519,13 +1626,13 @@ def cmd_passthrough(args: list[str]):
|
|
|
1519
1626
|
"""Passthrough other git worktree commands"""
|
|
1520
1627
|
base_dir = find_base_dir()
|
|
1521
1628
|
if not base_dir:
|
|
1522
|
-
print(msg(
|
|
1629
|
+
print(msg("error", msg("base_not_found")), file=sys.stderr)
|
|
1523
1630
|
sys.exit(1)
|
|
1524
1631
|
|
|
1525
1632
|
result = run_command(["git", "worktree"] + args, cwd=base_dir, check=False)
|
|
1526
|
-
print(result.stdout, end=
|
|
1633
|
+
print(result.stdout, end="")
|
|
1527
1634
|
if result.stderr:
|
|
1528
|
-
print(result.stderr, end=
|
|
1635
|
+
print(result.stderr, end="", file=sys.stderr)
|
|
1529
1636
|
sys.exit(result.returncode)
|
|
1530
1637
|
|
|
1531
1638
|
|
|
@@ -1540,13 +1647,23 @@ def show_help():
|
|
|
1540
1647
|
print("コマンド:")
|
|
1541
1648
|
print(f" {'clone <repository_url>':<55} - リポジトリをクローン")
|
|
1542
1649
|
print(f" {'init':<55} - 既存リポジトリをメインリポジトリとして構成")
|
|
1543
|
-
print(
|
|
1650
|
+
print(
|
|
1651
|
+
f" {'add (ad) <作業名> [<base_branch>]':<55} - worktree を追加(デフォルト: 新規ブランチ作成)"
|
|
1652
|
+
)
|
|
1544
1653
|
print(f" {'list (ls) [--pr]':<55} - worktree 一覧を表示")
|
|
1545
|
-
print(
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
print(
|
|
1549
|
-
|
|
1654
|
+
print(
|
|
1655
|
+
f" {'stash (st) <作業名> [<base_branch>]':<55} - 現在の変更をスタッシュして新規 worktree に移動"
|
|
1656
|
+
)
|
|
1657
|
+
print(
|
|
1658
|
+
f" {'pr add <番号>':<55} - GitHub PR を取得して worktree を作成/パス表示"
|
|
1659
|
+
)
|
|
1660
|
+
print(f" {'rm/remove <作業名> [-f|--force]':<55} - worktree を削除")
|
|
1661
|
+
print(
|
|
1662
|
+
f" {'clean (cl) [--days N] [--merged] [--closed]':<55} - 不要な worktree を削除"
|
|
1663
|
+
)
|
|
1664
|
+
print(
|
|
1665
|
+
f" {'sync (sy) [files...] [--from <名>] [--to <名>]':<55} - ファイル(.env等)を同期"
|
|
1666
|
+
)
|
|
1550
1667
|
print()
|
|
1551
1668
|
print("オプション:")
|
|
1552
1669
|
print(f" {'-h, --help':<55} - このヘルプメッセージを表示")
|
|
@@ -1560,13 +1677,21 @@ def show_help():
|
|
|
1560
1677
|
print("Commands:")
|
|
1561
1678
|
print(f" {'clone <repository_url>':<55} - Clone a repository")
|
|
1562
1679
|
print(f" {'init':<55} - Configure existing repository as main")
|
|
1563
|
-
print(
|
|
1680
|
+
print(
|
|
1681
|
+
f" {'add (ad) <work_name> [<base_branch>]':<55} - Add a worktree (default: create new branch)"
|
|
1682
|
+
)
|
|
1564
1683
|
print(f" {'list (ls) [--pr]':<55} - List worktrees")
|
|
1565
|
-
print(
|
|
1684
|
+
print(
|
|
1685
|
+
f" {'stash (st) <work_name> [<base_branch>]':<55} - Stash current changes and move to new worktree"
|
|
1686
|
+
)
|
|
1566
1687
|
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
|
-
|
|
1688
|
+
print(f" {'rm/remove <work_name> [-f|--force]':<55} - Remove a worktree")
|
|
1689
|
+
print(
|
|
1690
|
+
f" {'clean (cl) [--days N] [--merged] [--closed]':<55} - Remove unused/merged worktrees"
|
|
1691
|
+
)
|
|
1692
|
+
print(
|
|
1693
|
+
f" {'sync (sy) [files...] [--from <name>] [--to <name>]':<55} - Sync files (.env, etc.)"
|
|
1694
|
+
)
|
|
1570
1695
|
print()
|
|
1571
1696
|
print("Options:")
|
|
1572
1697
|
print(f" {'-h, --help':<55} - Show this help message")
|
|
@@ -1575,7 +1700,7 @@ def show_help():
|
|
|
1575
1700
|
|
|
1576
1701
|
def show_version():
|
|
1577
1702
|
"""Show version information"""
|
|
1578
|
-
print("easy-worktree version 0.1.
|
|
1703
|
+
print("easy-worktree version 0.1.1")
|
|
1579
1704
|
|
|
1580
1705
|
|
|
1581
1706
|
def main():
|