easy-worktree 0.0.7__py3-none-any.whl → 0.1.0__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 +791 -393
- easy_worktree/__init__.py_snippet_helper +18 -0
- easy_worktree-0.1.0.dist-info/METADATA +180 -0
- easy_worktree-0.1.0.dist-info/RECORD +7 -0
- {easy_worktree-0.0.7.dist-info → easy_worktree-0.1.0.dist-info}/WHEEL +1 -1
- easy_worktree-0.0.7.dist-info/METADATA +0 -237
- easy_worktree-0.0.7.dist-info/RECORD +0 -6
- {easy_worktree-0.0.7.dist-info → easy_worktree-0.1.0.dist-info}/entry_points.txt +0 -0
- {easy_worktree-0.0.7.dist-info → easy_worktree-0.1.0.dist-info}/licenses/LICENSE +0 -0
easy_worktree/__init__.py
CHANGED
|
@@ -4,11 +4,12 @@ Git worktree を簡単に管理するための CLI ツール
|
|
|
4
4
|
"""
|
|
5
5
|
import os
|
|
6
6
|
import subprocess
|
|
7
|
+
import shutil
|
|
7
8
|
import sys
|
|
8
9
|
from pathlib import Path
|
|
9
10
|
import re
|
|
10
|
-
from datetime import datetime
|
|
11
|
-
import
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
import toml
|
|
12
13
|
|
|
13
14
|
|
|
14
15
|
# 言語判定
|
|
@@ -29,16 +30,16 @@ MESSAGES = {
|
|
|
29
30
|
'ja': '使用方法: wt clone <repository_url>'
|
|
30
31
|
},
|
|
31
32
|
'usage_add': {
|
|
32
|
-
'en': 'Usage: wt add <work_name> [<base_branch>]
|
|
33
|
-
'ja': '使用方法: wt add <作業名> [<base_branch>]
|
|
33
|
+
'en': 'Usage: wt add (ad) <work_name> [<base_branch>]',
|
|
34
|
+
'ja': '使用方法: wt add (ad) <作業名> [<base_branch>]'
|
|
34
35
|
},
|
|
35
36
|
'usage_rm': {
|
|
36
37
|
'en': 'Usage: wt rm <work_name>',
|
|
37
38
|
'ja': '使用方法: wt rm <作業名>'
|
|
38
39
|
},
|
|
39
40
|
'base_not_found': {
|
|
40
|
-
'en': '
|
|
41
|
-
'ja': '
|
|
41
|
+
'en': 'Main repository directory not found',
|
|
42
|
+
'ja': 'メインリポジトリのディレクトリが見つかりません'
|
|
42
43
|
},
|
|
43
44
|
'run_in_wt_dir': {
|
|
44
45
|
'en': 'Please run inside WT_<repository_name>/ directory',
|
|
@@ -102,7 +103,7 @@ MESSAGES = {
|
|
|
102
103
|
},
|
|
103
104
|
'creating_branch': {
|
|
104
105
|
'en': "Creating new branch '{}' from '{}'",
|
|
105
|
-
'ja': "'{}'
|
|
106
|
+
'ja': "ブランチ '{}' を '{}' から作成しています"
|
|
106
107
|
},
|
|
107
108
|
'default_branch_not_found': {
|
|
108
109
|
'en': 'Could not find default branch (main/master)',
|
|
@@ -121,12 +122,8 @@ MESSAGES = {
|
|
|
121
122
|
'ja': '警告: hook が終了コード {} で終了しました'
|
|
122
123
|
},
|
|
123
124
|
'usage_clean': {
|
|
124
|
-
'en': 'Usage: wt clean [--
|
|
125
|
-
'ja': '使用方法: wt clean [--
|
|
126
|
-
},
|
|
127
|
-
'usage_alias': {
|
|
128
|
-
'en': 'Usage: wt alias <name> <worktree> | wt alias --list | wt alias --remove <name>',
|
|
129
|
-
'ja': '使用方法: wt alias <名前> <worktree> | wt alias --list | wt alias --remove <名前>'
|
|
125
|
+
'en': 'Usage: wt clean (cl) [--days N] [--merged] [--closed] [--all]',
|
|
126
|
+
'ja': '使用方法: wt clean (cl) [--days N] [--merged] [--closed] [--all]'
|
|
130
127
|
},
|
|
131
128
|
'alias_updated': {
|
|
132
129
|
'en': 'Updated alias: {} -> {}',
|
|
@@ -144,10 +141,6 @@ MESSAGES = {
|
|
|
144
141
|
'en': 'Remove {} worktree(s)? [y/N]: ',
|
|
145
142
|
'ja': '{} 個の worktree を削除しますか? [y/N]: '
|
|
146
143
|
},
|
|
147
|
-
'alias_created': {
|
|
148
|
-
'en': 'Created alias: {} -> {}',
|
|
149
|
-
'ja': 'エイリアスを作成しました: {} -> {}'
|
|
150
|
-
},
|
|
151
144
|
'alias_removed': {
|
|
152
145
|
'en': 'Removed alias: {}',
|
|
153
146
|
'ja': 'エイリアスを削除しました: {}'
|
|
@@ -175,6 +168,42 @@ MESSAGES = {
|
|
|
175
168
|
'status_label': {
|
|
176
169
|
'en': 'Status',
|
|
177
170
|
'ja': '状態'
|
|
171
|
+
},
|
|
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': 'スタッシュする変更がありません'
|
|
178
207
|
}
|
|
179
208
|
}
|
|
180
209
|
|
|
@@ -191,6 +220,7 @@ def msg(key: str, *args) -> str:
|
|
|
191
220
|
def run_command(cmd: list[str], cwd: Path = None, check: bool = True) -> subprocess.CompletedProcess:
|
|
192
221
|
"""コマンドを実行"""
|
|
193
222
|
try:
|
|
223
|
+
# print(f"DEBUG: Running command: {cmd} cwd={cwd}", file=sys.stderr)
|
|
194
224
|
result = subprocess.run(
|
|
195
225
|
cmd,
|
|
196
226
|
cwd=cwd,
|
|
@@ -209,9 +239,41 @@ def get_repository_name(url: str) -> str:
|
|
|
209
239
|
# URL から .git を削除して最後の部分を取得
|
|
210
240
|
match = re.search(r'/([^/]+?)(?:\.git)?$', url)
|
|
211
241
|
if match:
|
|
212
|
-
|
|
242
|
+
name = match.group(1)
|
|
243
|
+
# サービス名などが含まれる場合のクリーンアップ
|
|
244
|
+
return name.split(':')[-1]
|
|
213
245
|
# ローカルパスの場合
|
|
214
|
-
return Path(url).
|
|
246
|
+
return Path(url).stem
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def load_config(base_dir: Path) -> dict:
|
|
250
|
+
"""設定ファイルを読み込む"""
|
|
251
|
+
config_file = base_dir / ".wt" / "config.toml"
|
|
252
|
+
default_config = {
|
|
253
|
+
"worktrees_dir": ".worktrees",
|
|
254
|
+
"sync_files": [".env"],
|
|
255
|
+
"auto_copy_on_add": True
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if config_file.exists():
|
|
259
|
+
try:
|
|
260
|
+
with open(config_file, 'r', encoding='utf-8') as f:
|
|
261
|
+
user_config = toml.load(f)
|
|
262
|
+
default_config.update(user_config)
|
|
263
|
+
except Exception as e:
|
|
264
|
+
print(msg('error', f"Failed to load config: {e}"), file=sys.stderr)
|
|
265
|
+
|
|
266
|
+
return default_config
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def save_config(base_dir: Path, config: dict):
|
|
270
|
+
"""設定ファイルを保存する"""
|
|
271
|
+
wt_dir = base_dir / ".wt"
|
|
272
|
+
wt_dir.mkdir(exist_ok=True)
|
|
273
|
+
config_file = wt_dir / "config.toml"
|
|
274
|
+
|
|
275
|
+
with open(config_file, 'w', encoding='utf-8') as f:
|
|
276
|
+
toml.dump(config, f)
|
|
215
277
|
|
|
216
278
|
|
|
217
279
|
def create_hook_template(base_dir: Path):
|
|
@@ -221,6 +283,36 @@ def create_hook_template(base_dir: Path):
|
|
|
221
283
|
# .wt ディレクトリを作成
|
|
222
284
|
wt_dir.mkdir(exist_ok=True)
|
|
223
285
|
|
|
286
|
+
# config.toml
|
|
287
|
+
config_file = wt_dir / "config.toml"
|
|
288
|
+
if not config_file.exists():
|
|
289
|
+
save_config(base_dir, {
|
|
290
|
+
"worktrees_dir": ".worktrees",
|
|
291
|
+
"sync_files": [".env"],
|
|
292
|
+
"auto_copy_on_add": True
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
# .gitignore (repository root) に worktrees_dir を追加
|
|
296
|
+
config = load_config(base_dir)
|
|
297
|
+
worktrees_dir_name = config.get("worktrees_dir", ".worktrees")
|
|
298
|
+
root_gitignore = base_dir / ".gitignore"
|
|
299
|
+
|
|
300
|
+
entries = [f"{worktrees_dir_name}/"]
|
|
301
|
+
|
|
302
|
+
if root_gitignore.exists():
|
|
303
|
+
content = root_gitignore.read_text(encoding='utf-8')
|
|
304
|
+
updated = False
|
|
305
|
+
for entry in entries:
|
|
306
|
+
if entry not in content:
|
|
307
|
+
if content and not content.endswith('\n'):
|
|
308
|
+
content += '\n'
|
|
309
|
+
content += f"{entry}\n"
|
|
310
|
+
updated = True
|
|
311
|
+
if updated:
|
|
312
|
+
root_gitignore.write_text(content, encoding='utf-8')
|
|
313
|
+
else:
|
|
314
|
+
root_gitignore.write_text("\n".join(entries) + "\n", encoding='utf-8')
|
|
315
|
+
|
|
224
316
|
# post-add hook テンプレート
|
|
225
317
|
hook_file = wt_dir / "post-add"
|
|
226
318
|
if not hook_file.exists():
|
|
@@ -231,7 +323,7 @@ def create_hook_template(base_dir: Path):
|
|
|
231
323
|
# Available environment variables:
|
|
232
324
|
# WT_WORKTREE_PATH - Path to the created worktree
|
|
233
325
|
# WT_WORKTREE_NAME - Name of the worktree
|
|
234
|
-
# WT_BASE_DIR - Path to the
|
|
326
|
+
# WT_BASE_DIR - Path to the main repository directory
|
|
235
327
|
# WT_BRANCH - Branch name
|
|
236
328
|
# WT_ACTION - Action name (add)
|
|
237
329
|
#
|
|
@@ -339,7 +431,7 @@ wt add feature-b --alias current
|
|
|
339
431
|
|
|
340
432
|
- `WT_WORKTREE_PATH`: 作成された worktree のパス
|
|
341
433
|
- `WT_WORKTREE_NAME`: worktree の名前
|
|
342
|
-
- `WT_BASE_DIR`:
|
|
434
|
+
- `WT_BASE_DIR`: メインリポジトリディレクトリのパス
|
|
343
435
|
- `WT_BRANCH`: ブランチ名
|
|
344
436
|
- `WT_ACTION`: アクション名(常に "add")
|
|
345
437
|
|
|
@@ -422,7 +514,7 @@ The `post-add` hook is a script that runs automatically after creating a worktre
|
|
|
422
514
|
|
|
423
515
|
- `WT_WORKTREE_PATH`: Path to the created worktree
|
|
424
516
|
- `WT_WORKTREE_NAME`: Name of the worktree
|
|
425
|
-
- `WT_BASE_DIR`: Path to the
|
|
517
|
+
- `WT_BASE_DIR`: Path to the main repository directory
|
|
426
518
|
- `WT_BRANCH`: Branch name
|
|
427
519
|
- `WT_ACTION`: Action name (always "add")
|
|
428
520
|
|
|
@@ -436,51 +528,51 @@ The `post-add` hook is a script that runs automatically after creating a worktre
|
|
|
436
528
|
|
|
437
529
|
|
|
438
530
|
def find_base_dir() -> Path | None:
|
|
439
|
-
"""現在のディレクトリまたは親ディレクトリから
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
531
|
+
"""現在のディレクトリまたは親ディレクトリから git root を探す"""
|
|
532
|
+
try:
|
|
533
|
+
result = run_command(
|
|
534
|
+
["git", "rev-parse", "--show-toplevel"],
|
|
535
|
+
check=False
|
|
536
|
+
)
|
|
537
|
+
if result.returncode == 0:
|
|
538
|
+
return Path(result.stdout.strip())
|
|
539
|
+
except Exception:
|
|
540
|
+
pass
|
|
446
541
|
|
|
447
|
-
#
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
542
|
+
# git コマンドが失敗した場合、.git ディレクトリを探す
|
|
543
|
+
current = Path.cwd()
|
|
544
|
+
for parent in [current] + list(current.parents):
|
|
545
|
+
if (parent / ".git").exists():
|
|
546
|
+
return parent
|
|
451
547
|
|
|
452
548
|
return None
|
|
453
549
|
|
|
454
550
|
|
|
455
551
|
def cmd_clone(args: list[str]):
|
|
456
|
-
"""wt clone <repository_url> - Clone a repository"""
|
|
552
|
+
"""wt clone <repository_url> [dest_dir] - Clone a repository"""
|
|
457
553
|
if len(args) < 1:
|
|
458
554
|
print(msg('usage'), file=sys.stderr)
|
|
459
555
|
sys.exit(1)
|
|
460
556
|
|
|
461
557
|
repo_url = args[0]
|
|
462
558
|
repo_name = get_repository_name(repo_url)
|
|
559
|
+
|
|
560
|
+
dest_dir = Path(args[1]) if len(args) > 1 else Path(repo_name)
|
|
463
561
|
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
base_dir = parent_dir / "_base"
|
|
467
|
-
|
|
468
|
-
if base_dir.exists():
|
|
469
|
-
print(msg('error', msg('already_exists', base_dir)), file=sys.stderr)
|
|
562
|
+
if dest_dir.exists():
|
|
563
|
+
print(msg('error', msg('already_exists', dest_dir)), file=sys.stderr)
|
|
470
564
|
sys.exit(1)
|
|
471
565
|
|
|
472
|
-
|
|
566
|
+
print(msg('cloning', repo_url, dest_dir), file=sys.stderr)
|
|
567
|
+
run_command(["git", "clone", repo_url, str(dest_dir)])
|
|
568
|
+
print(msg('completed_clone', dest_dir), file=sys.stderr)
|
|
473
569
|
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
print(msg('completed_clone', base_dir))
|
|
477
|
-
|
|
478
|
-
# post-add hook テンプレートを作成
|
|
479
|
-
create_hook_template(base_dir)
|
|
570
|
+
# post-add hook と設定ファイルを作成
|
|
571
|
+
create_hook_template(dest_dir)
|
|
480
572
|
|
|
481
573
|
|
|
482
574
|
def cmd_init(args: list[str]):
|
|
483
|
-
"""wt init -
|
|
575
|
+
"""wt init - Initialize easy-worktree in current git repository"""
|
|
484
576
|
current_dir = Path.cwd()
|
|
485
577
|
|
|
486
578
|
# 現在のディレクトリが git リポジトリか確認
|
|
@@ -496,47 +588,28 @@ def cmd_init(args: list[str]):
|
|
|
496
588
|
|
|
497
589
|
git_root = Path(result.stdout.strip())
|
|
498
590
|
|
|
499
|
-
#
|
|
500
|
-
|
|
501
|
-
print(msg('error', msg('run_at_root', git_root)), file=sys.stderr)
|
|
502
|
-
sys.exit(1)
|
|
503
|
-
|
|
504
|
-
# リポジトリ名を取得(remote origin から、なければディレクトリ名)
|
|
505
|
-
result = run_command(
|
|
506
|
-
["git", "remote", "get-url", "origin"],
|
|
507
|
-
cwd=current_dir,
|
|
508
|
-
check=False
|
|
509
|
-
)
|
|
510
|
-
|
|
511
|
-
if result.returncode == 0 and result.stdout.strip():
|
|
512
|
-
repo_name = get_repository_name(result.stdout.strip())
|
|
513
|
-
else:
|
|
514
|
-
# リモートがない場合は現在のディレクトリ名を使用
|
|
515
|
-
repo_name = current_dir.name
|
|
591
|
+
# post-add hook と設定ファイルを作成
|
|
592
|
+
create_hook_template(git_root)
|
|
516
593
|
|
|
517
|
-
# 親ディレクトリと新しいパスを決定
|
|
518
|
-
parent_of_current = current_dir.parent
|
|
519
|
-
wt_parent_dir = parent_of_current / f"WT_{repo_name}"
|
|
520
|
-
new_base_dir = wt_parent_dir / "_base"
|
|
521
594
|
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
595
|
+
def get_default_branch(base_dir: Path) -> str:
|
|
596
|
+
"""Detect default branch (main/master)"""
|
|
597
|
+
# 1. Try origin/HEAD
|
|
598
|
+
result = run_command(["git", "rev-parse", "--abbrev-ref", "origin/HEAD"], cwd=base_dir, check=False)
|
|
599
|
+
if result.returncode == 0:
|
|
600
|
+
return result.stdout.strip().replace("origin/", "")
|
|
601
|
+
|
|
602
|
+
# 2. Try common names
|
|
603
|
+
for b in ["main", "master"]:
|
|
604
|
+
if run_command(["git", "rev-parse", "--verify", b], cwd=base_dir, check=False).returncode == 0:
|
|
605
|
+
return b
|
|
606
|
+
|
|
607
|
+
# 3. Fallback to current HEAD
|
|
608
|
+
result = run_command(["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=base_dir, check=False)
|
|
609
|
+
if result.returncode == 0:
|
|
610
|
+
return result.stdout.strip()
|
|
611
|
+
|
|
612
|
+
return None
|
|
540
613
|
|
|
541
614
|
|
|
542
615
|
def run_post_add_hook(worktree_path: Path, work_name: str, base_dir: Path, branch: str = None):
|
|
@@ -561,12 +634,14 @@ def run_post_add_hook(worktree_path: Path, work_name: str, base_dir: Path, branc
|
|
|
561
634
|
'WT_ACTION': 'add'
|
|
562
635
|
})
|
|
563
636
|
|
|
564
|
-
print(msg('running_hook', hook_path))
|
|
637
|
+
print(msg('running_hook', hook_path), file=sys.stderr)
|
|
565
638
|
try:
|
|
566
639
|
result = subprocess.run(
|
|
567
640
|
[str(hook_path)],
|
|
568
641
|
cwd=worktree_path, # worktree 内で実行
|
|
569
642
|
env=env,
|
|
643
|
+
stdout=sys.stderr, # stdout を stderr にリダイレクト (cd 連携のため)
|
|
644
|
+
stderr=sys.stderr,
|
|
570
645
|
check=False
|
|
571
646
|
)
|
|
572
647
|
|
|
@@ -576,46 +651,33 @@ def run_post_add_hook(worktree_path: Path, work_name: str, base_dir: Path, branc
|
|
|
576
651
|
print(msg('error', str(e)), file=sys.stderr)
|
|
577
652
|
|
|
578
653
|
|
|
579
|
-
def
|
|
580
|
-
"""
|
|
581
|
-
if
|
|
582
|
-
|
|
583
|
-
sys.exit(1)
|
|
584
|
-
|
|
585
|
-
base_dir = find_base_dir()
|
|
654
|
+
def add_worktree(work_name: str, branch_to_use: str = None, new_branch_base: str = None, base_dir: Path = None) -> Path:
|
|
655
|
+
"""Core logic to add a worktree, reused by cmd_add and cmd_stash"""
|
|
656
|
+
if not base_dir:
|
|
657
|
+
base_dir = find_base_dir()
|
|
586
658
|
if not base_dir:
|
|
587
659
|
print(msg('error', msg('base_not_found')), file=sys.stderr)
|
|
588
660
|
print(msg('run_in_wt_dir'), file=sys.stderr)
|
|
589
661
|
sys.exit(1)
|
|
590
662
|
|
|
591
|
-
#
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
alias_name = args[alias_index + 1]
|
|
597
|
-
# --alias とその値を削除
|
|
598
|
-
args.pop(alias_index)
|
|
599
|
-
args.pop(alias_index)
|
|
600
|
-
else:
|
|
601
|
-
print(msg('error', 'Missing alias name after --alias'), file=sys.stderr)
|
|
602
|
-
sys.exit(1)
|
|
663
|
+
# 設定を読み込む
|
|
664
|
+
config = load_config(base_dir)
|
|
665
|
+
worktrees_dir_name = config.get("worktrees_dir", ".worktrees")
|
|
666
|
+
worktrees_dir = base_dir / worktrees_dir_name
|
|
667
|
+
worktrees_dir.mkdir(exist_ok=True)
|
|
603
668
|
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
# worktree のパスを決定(_base の親ディレクトリに作成)
|
|
607
|
-
worktree_path = base_dir.parent / work_name
|
|
669
|
+
# worktree のパスを決定
|
|
670
|
+
worktree_path = worktrees_dir / work_name
|
|
608
671
|
|
|
609
672
|
if worktree_path.exists():
|
|
610
673
|
print(msg('error', msg('already_exists', worktree_path)), file=sys.stderr)
|
|
611
674
|
sys.exit(1)
|
|
612
675
|
|
|
613
676
|
# ブランチを最新に更新
|
|
614
|
-
print(msg('fetching'))
|
|
677
|
+
print(msg('fetching'), file=sys.stderr)
|
|
615
678
|
run_command(["git", "fetch", "--all"], cwd=base_dir)
|
|
616
679
|
|
|
617
|
-
#
|
|
618
|
-
# 現在のブランチを取得
|
|
680
|
+
# 本体 (main) を base branch の最新に更新
|
|
619
681
|
result = run_command(
|
|
620
682
|
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
621
683
|
cwd=base_dir,
|
|
@@ -623,7 +685,6 @@ def cmd_add(args: list[str]):
|
|
|
623
685
|
)
|
|
624
686
|
if result.returncode == 0:
|
|
625
687
|
current_branch = result.stdout.strip()
|
|
626
|
-
# リモートブランチが存在する場合は pull
|
|
627
688
|
result = run_command(
|
|
628
689
|
["git", "rev-parse", "--verify", f"origin/{current_branch}"],
|
|
629
690
|
cwd=base_dir,
|
|
@@ -632,128 +693,226 @@ def cmd_add(args: list[str]):
|
|
|
632
693
|
if result.returncode == 0:
|
|
633
694
|
run_command(["git", "pull", "origin", current_branch], cwd=base_dir, check=False)
|
|
634
695
|
|
|
635
|
-
#
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
696
|
+
# ブランチ作成/チェックアウト
|
|
697
|
+
final_branch_name = None
|
|
698
|
+
if new_branch_base:
|
|
699
|
+
# 新しいブランチをベースから作成
|
|
700
|
+
final_branch_name = work_name
|
|
701
|
+
print(msg('creating_branch', final_branch_name, new_branch_base), file=sys.stderr)
|
|
702
|
+
result = run_command(
|
|
703
|
+
["git", "worktree", "add", "-b", final_branch_name, str(worktree_path), new_branch_base],
|
|
704
|
+
cwd=base_dir,
|
|
705
|
+
check=False
|
|
706
|
+
)
|
|
707
|
+
elif branch_to_use:
|
|
708
|
+
# 指定されたブランチをチェックアウト
|
|
709
|
+
final_branch_name = branch_to_use
|
|
710
|
+
print(msg('creating_worktree', worktree_path), file=sys.stderr)
|
|
642
711
|
result = run_command(
|
|
643
|
-
["git", "worktree", "add", str(worktree_path),
|
|
712
|
+
["git", "worktree", "add", str(worktree_path), final_branch_name],
|
|
644
713
|
cwd=base_dir,
|
|
645
714
|
check=False
|
|
646
715
|
)
|
|
647
716
|
else:
|
|
717
|
+
# 自動判定
|
|
648
718
|
# ブランチ名として work_name を使用
|
|
649
|
-
|
|
719
|
+
final_branch_name = work_name
|
|
650
720
|
|
|
651
|
-
# ローカルまたはリモートにブランチが既に存在するかチェック
|
|
652
721
|
check_local = run_command(
|
|
653
|
-
["git", "rev-parse", "--verify",
|
|
722
|
+
["git", "rev-parse", "--verify", final_branch_name],
|
|
654
723
|
cwd=base_dir,
|
|
655
724
|
check=False
|
|
656
725
|
)
|
|
657
726
|
check_remote = run_command(
|
|
658
|
-
["git", "rev-parse", "--verify", f"origin/{
|
|
727
|
+
["git", "rev-parse", "--verify", f"origin/{final_branch_name}"],
|
|
659
728
|
cwd=base_dir,
|
|
660
729
|
check=False
|
|
661
730
|
)
|
|
662
731
|
|
|
663
732
|
if check_local.returncode == 0 or check_remote.returncode == 0:
|
|
664
|
-
# 既存ブランチを使用
|
|
665
733
|
if check_remote.returncode == 0:
|
|
666
|
-
|
|
667
|
-
print(msg('creating_worktree', worktree_path))
|
|
734
|
+
print(msg('creating_worktree', worktree_path), file=sys.stderr)
|
|
668
735
|
result = run_command(
|
|
669
|
-
["git", "worktree", "add", str(worktree_path), f"origin/{
|
|
736
|
+
["git", "worktree", "add", str(worktree_path), f"origin/{final_branch_name}"],
|
|
670
737
|
cwd=base_dir,
|
|
671
738
|
check=False
|
|
672
739
|
)
|
|
673
740
|
else:
|
|
674
|
-
|
|
675
|
-
print(msg('creating_worktree', worktree_path))
|
|
741
|
+
print(msg('creating_worktree', worktree_path), file=sys.stderr)
|
|
676
742
|
result = run_command(
|
|
677
|
-
["git", "worktree", "add", str(worktree_path),
|
|
743
|
+
["git", "worktree", "add", str(worktree_path), final_branch_name],
|
|
678
744
|
cwd=base_dir,
|
|
679
745
|
check=False
|
|
680
746
|
)
|
|
681
747
|
else:
|
|
682
|
-
#
|
|
683
|
-
|
|
684
|
-
result = run_command(
|
|
748
|
+
# デフォルトブランチを探す
|
|
749
|
+
result_sym = run_command(
|
|
685
750
|
["git", "symbolic-ref", "refs/remotes/origin/HEAD", "--short"],
|
|
686
751
|
cwd=base_dir,
|
|
687
752
|
check=False
|
|
688
753
|
)
|
|
689
754
|
|
|
690
|
-
|
|
691
|
-
|
|
755
|
+
detected_base = None
|
|
756
|
+
if result_sym.returncode == 0 and result_sym.stdout.strip():
|
|
757
|
+
detected_base = result_sym.stdout.strip()
|
|
692
758
|
else:
|
|
693
|
-
#
|
|
694
|
-
|
|
695
|
-
["git", "rev-parse", "--verify",
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
cwd=base_dir,
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
if
|
|
706
|
-
base_branch = "origin/main"
|
|
707
|
-
elif result_master.returncode == 0:
|
|
708
|
-
base_branch = "origin/master"
|
|
709
|
-
else:
|
|
759
|
+
# remote/local main/master の順に探す
|
|
760
|
+
for b in ["origin/main", "origin/master", "main", "master"]:
|
|
761
|
+
if run_command(["git", "rev-parse", "--verify", b], cwd=base_dir, check=False).returncode == 0:
|
|
762
|
+
detected_base = b
|
|
763
|
+
break
|
|
764
|
+
|
|
765
|
+
if not detected_base:
|
|
766
|
+
# 最終手段として現在のブランチを使用
|
|
767
|
+
res_curr = run_command(["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=base_dir, check=False)
|
|
768
|
+
if res_curr.returncode == 0:
|
|
769
|
+
detected_base = res_curr.stdout.strip()
|
|
770
|
+
|
|
771
|
+
if not detected_base:
|
|
710
772
|
print(msg('error', msg('default_branch_not_found')), file=sys.stderr)
|
|
711
773
|
sys.exit(1)
|
|
712
774
|
|
|
713
|
-
print(msg('creating_branch',
|
|
775
|
+
print(msg('creating_branch', final_branch_name, detected_base), file=sys.stderr)
|
|
714
776
|
result = run_command(
|
|
715
|
-
["git", "worktree", "add", "-b",
|
|
777
|
+
["git", "worktree", "add", "-b", final_branch_name, str(worktree_path), detected_base],
|
|
716
778
|
cwd=base_dir,
|
|
717
779
|
check=False
|
|
718
780
|
)
|
|
719
781
|
|
|
720
782
|
if result.returncode == 0:
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
print(msg('error', f'{alias_name} exists but is not a symlink'), file=sys.stderr)
|
|
736
|
-
# post-add hook を実行
|
|
737
|
-
run_post_add_hook(worktree_path, work_name, base_dir, branch_name)
|
|
738
|
-
sys.exit(0) # worktree は作成できたので正常終了
|
|
739
|
-
|
|
740
|
-
# シンボリックリンクを作成
|
|
741
|
-
alias_path.symlink_to(worktree_path, target_is_directory=True)
|
|
742
|
-
|
|
743
|
-
if is_updating:
|
|
744
|
-
print(msg('alias_updated', alias_name, work_name))
|
|
745
|
-
else:
|
|
746
|
-
print(msg('alias_created', alias_name, work_name))
|
|
747
|
-
|
|
748
|
-
# post-add hook を実行
|
|
749
|
-
run_post_add_hook(worktree_path, work_name, base_dir, branch_name)
|
|
783
|
+
# 自動同期
|
|
784
|
+
if config.get("auto_copy_on_add"):
|
|
785
|
+
sync_files = config.get("sync_files", [])
|
|
786
|
+
for file_name in sync_files:
|
|
787
|
+
src = base_dir / file_name
|
|
788
|
+
dst = worktree_path / file_name
|
|
789
|
+
if src.exists():
|
|
790
|
+
print(msg('syncing', src, dst), file=sys.stderr)
|
|
791
|
+
import shutil
|
|
792
|
+
shutil.copy2(src, dst)
|
|
793
|
+
|
|
794
|
+
# post-add hook
|
|
795
|
+
run_post_add_hook(worktree_path, work_name, base_dir, final_branch_name)
|
|
796
|
+
return worktree_path
|
|
750
797
|
else:
|
|
751
|
-
# エラーメッセージを表示
|
|
752
798
|
if result.stderr:
|
|
753
799
|
print(result.stderr, file=sys.stderr)
|
|
754
800
|
sys.exit(1)
|
|
755
801
|
|
|
756
802
|
|
|
803
|
+
def cmd_add(args: list[str]):
|
|
804
|
+
"""wt add <work_name> [<base_branch>] - Add a worktree"""
|
|
805
|
+
if len(args) < 1:
|
|
806
|
+
print(msg('usage_add'), file=sys.stderr)
|
|
807
|
+
sys.exit(1)
|
|
808
|
+
|
|
809
|
+
work_name = args[0]
|
|
810
|
+
branch_to_use = args[1] if len(args) >= 2 else None
|
|
811
|
+
|
|
812
|
+
add_worktree(work_name, branch_to_use=branch_to_use)
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
def cmd_stash(args: list[str]):
|
|
816
|
+
"""wt stash <work_name> [<base_branch>] - Stash changes and move to new worktree"""
|
|
817
|
+
if len(args) < 1:
|
|
818
|
+
print(msg('usage_stash'), file=sys.stderr)
|
|
819
|
+
sys.exit(1)
|
|
820
|
+
|
|
821
|
+
base_dir = find_base_dir()
|
|
822
|
+
if not base_dir:
|
|
823
|
+
print(msg('error', msg('base_not_found')), file=sys.stderr)
|
|
824
|
+
sys.exit(1)
|
|
825
|
+
|
|
826
|
+
# 変更があるかチェック
|
|
827
|
+
result = run_command(["git", "status", "--porcelain"], cwd=base_dir, check=False)
|
|
828
|
+
has_changes = bool(result.stdout.strip())
|
|
829
|
+
|
|
830
|
+
if has_changes:
|
|
831
|
+
print(msg('stashing_changes'), file=sys.stderr)
|
|
832
|
+
# stash する
|
|
833
|
+
# -u (include untracked)
|
|
834
|
+
run_command(["git", "stash", "push", "-u", "-m", f"easy-worktree stash for {args[0]}"], cwd=base_dir)
|
|
835
|
+
else:
|
|
836
|
+
print(msg('nothing_to_stash'), file=sys.stderr)
|
|
837
|
+
|
|
838
|
+
# 新しい worktree を作成
|
|
839
|
+
work_name = args[0]
|
|
840
|
+
|
|
841
|
+
# base_branch が指定されていない場合は現在のブランチをベースにする
|
|
842
|
+
# 指定されている場合はそれをベースにする
|
|
843
|
+
new_branch_base = args[1] if len(args) >= 2 else None
|
|
844
|
+
if not new_branch_base:
|
|
845
|
+
res = run_command(["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=base_dir, check=False)
|
|
846
|
+
if res.returncode == 0:
|
|
847
|
+
new_branch_base = res.stdout.strip()
|
|
848
|
+
|
|
849
|
+
# aliasはサポートしないでおく(とりあえずシンプルに)
|
|
850
|
+
# wt stash は常に新しいブランチを作成する振る舞いにする
|
|
851
|
+
wt_path = add_worktree(work_name, new_branch_base=new_branch_base, base_dir=base_dir)
|
|
852
|
+
|
|
853
|
+
if has_changes and wt_path:
|
|
854
|
+
print(msg('popping_stash'), file=sys.stderr)
|
|
855
|
+
# 新しい worktree で stash pop
|
|
856
|
+
run_command(["git", "stash", "pop"], cwd=wt_path)
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
|
|
861
|
+
def cmd_pr(args: list[str]):
|
|
862
|
+
"""wt pr <add|co> <number> - Pull Request management"""
|
|
863
|
+
if len(args) < 2:
|
|
864
|
+
print(msg('usage_pr'), file=sys.stderr)
|
|
865
|
+
sys.exit(1)
|
|
866
|
+
|
|
867
|
+
subcommand = args[0]
|
|
868
|
+
pr_number = args[1]
|
|
869
|
+
|
|
870
|
+
# Ensure pr_number is a digit
|
|
871
|
+
if not pr_number.isdigit():
|
|
872
|
+
print(msg('error', f"PR number must be a digit: {pr_number}"), file=sys.stderr)
|
|
873
|
+
sys.exit(1)
|
|
874
|
+
|
|
875
|
+
base_dir = find_base_dir()
|
|
876
|
+
if not base_dir:
|
|
877
|
+
print(msg('error', msg('base_not_found')), file=sys.stderr)
|
|
878
|
+
sys.exit(1)
|
|
879
|
+
|
|
880
|
+
if subcommand == "add":
|
|
881
|
+
# Check if gh command exists
|
|
882
|
+
if shutil.which("gh") is None:
|
|
883
|
+
print(msg('error', "GitHub CLI (gh) is required for this command"), file=sys.stderr)
|
|
884
|
+
sys.exit(1)
|
|
885
|
+
|
|
886
|
+
print(f"Verifying PR #{pr_number}...", file=sys.stderr)
|
|
887
|
+
# Verify PR exists using gh
|
|
888
|
+
verify_cmd = ["gh", "pr", "view", pr_number, "--json", "number"]
|
|
889
|
+
result = run_command(verify_cmd, cwd=base_dir, check=False)
|
|
890
|
+
if result.returncode != 0:
|
|
891
|
+
print(msg('error', f"PR #{pr_number} not found (or access denied)"), file=sys.stderr)
|
|
892
|
+
sys.exit(1)
|
|
893
|
+
|
|
894
|
+
branch_name = f"pr-{pr_number}"
|
|
895
|
+
worktree_name = f"pr@{pr_number}"
|
|
896
|
+
|
|
897
|
+
print(f"Fetching PR #{pr_number} contents...", file=sys.stderr)
|
|
898
|
+
# Fetch PR head to a local branch
|
|
899
|
+
# git fetch origin pull/ID/head:local-branch
|
|
900
|
+
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,
|
|
902
|
+
# but origin pull/ID/head is standard for GitHub.
|
|
903
|
+
run_command(fetch_cmd, cwd=base_dir)
|
|
904
|
+
|
|
905
|
+
print(f"Creating worktree {worktree_name}...", file=sys.stderr)
|
|
906
|
+
add_worktree(worktree_name, branch_to_use=branch_name, base_dir=base_dir)
|
|
907
|
+
|
|
908
|
+
elif subcommand == "co":
|
|
909
|
+
# Just a shortcut for checkout pr@<number>
|
|
910
|
+
cmd_checkout([f"pr@{pr_number}"])
|
|
911
|
+
else:
|
|
912
|
+
print(msg('usage_pr'), file=sys.stderr)
|
|
913
|
+
sys.exit(1)
|
|
914
|
+
|
|
915
|
+
|
|
757
916
|
def get_worktree_info(base_dir: Path) -> list[dict]:
|
|
758
917
|
"""worktree の詳細情報を取得"""
|
|
759
918
|
result = run_command(
|
|
@@ -808,10 +967,137 @@ def get_worktree_info(base_dir: Path) -> list[dict]:
|
|
|
808
967
|
check=False
|
|
809
968
|
)
|
|
810
969
|
wt['is_clean'] = result.returncode == 0 and not result.stdout.strip()
|
|
970
|
+
wt['has_untracked'] = '??' in result.stdout
|
|
971
|
+
|
|
972
|
+
# Diff stats取得
|
|
973
|
+
result_diff = run_command(
|
|
974
|
+
["git", "diff", "HEAD", "--shortstat"],
|
|
975
|
+
cwd=path,
|
|
976
|
+
check=False
|
|
977
|
+
)
|
|
978
|
+
|
|
979
|
+
insertions = 0
|
|
980
|
+
deletions = 0
|
|
981
|
+
if result_diff.returncode == 0 and result_diff.stdout.strip():
|
|
982
|
+
out = result_diff.stdout.strip()
|
|
983
|
+
m_plus = re.search(r'(\d+) insertion', out)
|
|
984
|
+
m_minus = re.search(r'(\d+) deletion', out)
|
|
985
|
+
if m_plus: insertions = int(m_plus.group(1))
|
|
986
|
+
if m_minus: deletions = int(m_minus.group(1))
|
|
987
|
+
|
|
988
|
+
wt['insertions'] = insertions
|
|
989
|
+
wt['deletions'] = deletions
|
|
811
990
|
|
|
812
991
|
return worktrees
|
|
813
992
|
|
|
814
993
|
|
|
994
|
+
if result.returncode == 0:
|
|
995
|
+
return result.stdout.strip()
|
|
996
|
+
return ""
|
|
997
|
+
|
|
998
|
+
|
|
999
|
+
def get_pr_info(branch: str, cwd: Path = None) -> str:
|
|
1000
|
+
"""Get rich GitHub PR information for the branch"""
|
|
1001
|
+
if not branch or branch == 'HEAD' or branch == 'DETACHED':
|
|
1002
|
+
return ""
|
|
1003
|
+
|
|
1004
|
+
# Check if gh command exists
|
|
1005
|
+
if shutil.which("gh") is None:
|
|
1006
|
+
return ""
|
|
1007
|
+
|
|
1008
|
+
import json
|
|
1009
|
+
cmd = ["gh", "pr", "list", "--head", branch, "--state", "all", "--json", "state,isDraft,url,createdAt,number"]
|
|
1010
|
+
result = run_command(cmd, cwd=cwd, check=False)
|
|
1011
|
+
|
|
1012
|
+
if result.returncode != 0 or not result.stdout.strip():
|
|
1013
|
+
return ""
|
|
1014
|
+
|
|
1015
|
+
try:
|
|
1016
|
+
prs = json.loads(result.stdout)
|
|
1017
|
+
if not prs:
|
|
1018
|
+
return ""
|
|
1019
|
+
|
|
1020
|
+
pr = prs[0]
|
|
1021
|
+
state = pr['state']
|
|
1022
|
+
is_draft = pr['isDraft']
|
|
1023
|
+
url = pr['url']
|
|
1024
|
+
created_at_str = pr['createdAt']
|
|
1025
|
+
number = pr['number']
|
|
1026
|
+
|
|
1027
|
+
# Parse created_at
|
|
1028
|
+
# ISO format: 2024-03-20T12:00:00Z
|
|
1029
|
+
try:
|
|
1030
|
+
# Localize to local timezone
|
|
1031
|
+
dt_aware = datetime.fromisoformat(created_at_str.replace('Z', '+00:00'))
|
|
1032
|
+
dt_local = dt_aware.astimezone().replace(tzinfo=None)
|
|
1033
|
+
rel_time = get_relative_time(dt_local)
|
|
1034
|
+
except Exception:
|
|
1035
|
+
rel_time = "N/A"
|
|
1036
|
+
|
|
1037
|
+
# Symbols and Colors
|
|
1038
|
+
GREEN = "\033[32m"
|
|
1039
|
+
GRAY = "\033[90m"
|
|
1040
|
+
MAGENTA = "\033[35m"
|
|
1041
|
+
RED = "\033[31m"
|
|
1042
|
+
RESET = "\033[0m"
|
|
1043
|
+
|
|
1044
|
+
if is_draft:
|
|
1045
|
+
symbol = f"{GRAY}◌{RESET}"
|
|
1046
|
+
elif state == "OPEN":
|
|
1047
|
+
symbol = f"{GREEN}●{RESET}"
|
|
1048
|
+
elif state == "MERGED":
|
|
1049
|
+
symbol = f"{MAGENTA}✔{RESET}"
|
|
1050
|
+
else: # CLOSED
|
|
1051
|
+
symbol = f"{RED}✘{RESET}"
|
|
1052
|
+
|
|
1053
|
+
# Hyperlink for #NUMBER
|
|
1054
|
+
# ANSI sequence for hyperlink: ESC ] 8 ; ; URL ESC \ TEXT ESC ] 8 ; ; ESC \
|
|
1055
|
+
link_start = f"\x1b]8;;{url}\x1b\\"
|
|
1056
|
+
link_end = "\x1b]8;;\x1b\\"
|
|
1057
|
+
|
|
1058
|
+
return f"{symbol} {link_start}#{number}{link_end} ({rel_time})"
|
|
1059
|
+
|
|
1060
|
+
except Exception:
|
|
1061
|
+
return ""
|
|
1062
|
+
|
|
1063
|
+
|
|
1064
|
+
def get_relative_time(dt: datetime) -> str:
|
|
1065
|
+
"""Get relative time string"""
|
|
1066
|
+
if not dt:
|
|
1067
|
+
return "N/A"
|
|
1068
|
+
|
|
1069
|
+
now = datetime.now()
|
|
1070
|
+
diff = now - dt
|
|
1071
|
+
|
|
1072
|
+
seconds = diff.total_seconds()
|
|
1073
|
+
days = diff.days
|
|
1074
|
+
|
|
1075
|
+
if days < 0:
|
|
1076
|
+
return "just now"
|
|
1077
|
+
|
|
1078
|
+
if days == 0:
|
|
1079
|
+
if seconds < 60:
|
|
1080
|
+
return "just now"
|
|
1081
|
+
if seconds < 3600:
|
|
1082
|
+
minutes = int(seconds / 60)
|
|
1083
|
+
return f"{minutes}m ago"
|
|
1084
|
+
hours = int(seconds / 3600)
|
|
1085
|
+
return f"{hours}h ago"
|
|
1086
|
+
|
|
1087
|
+
if days == 1:
|
|
1088
|
+
return "yesterday"
|
|
1089
|
+
|
|
1090
|
+
if days < 30:
|
|
1091
|
+
return f"{days}d ago"
|
|
1092
|
+
|
|
1093
|
+
if days < 365:
|
|
1094
|
+
months = int(days / 30)
|
|
1095
|
+
return f"{months}mo ago"
|
|
1096
|
+
|
|
1097
|
+
years = int(days / 365)
|
|
1098
|
+
return f"{years}y ago"
|
|
1099
|
+
|
|
1100
|
+
|
|
815
1101
|
def cmd_list(args: list[str]):
|
|
816
1102
|
"""wt list - List worktrees"""
|
|
817
1103
|
base_dir = find_base_dir()
|
|
@@ -819,48 +1105,119 @@ def cmd_list(args: list[str]):
|
|
|
819
1105
|
print(msg('error', msg('base_not_found')), file=sys.stderr)
|
|
820
1106
|
sys.exit(1)
|
|
821
1107
|
|
|
822
|
-
# --
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
# ソートオプションを取得
|
|
827
|
-
for i, arg in enumerate(args):
|
|
828
|
-
if arg == '--sort' and i + 1 < len(args):
|
|
829
|
-
sort_by = args[i + 1]
|
|
830
|
-
|
|
831
|
-
if not verbose and not sort_by:
|
|
832
|
-
# 通常の git worktree list を実行
|
|
833
|
-
result = run_command(["git", "worktree", "list"] + args, cwd=base_dir)
|
|
834
|
-
print(result.stdout, end='')
|
|
835
|
-
return
|
|
1108
|
+
# --quiet / -q オプション(xargs 用)
|
|
1109
|
+
quiet = '--quiet' in args or '-q' in args
|
|
1110
|
+
show_pr = '--pr' in args
|
|
836
1111
|
|
|
837
|
-
# 詳細情報を取得
|
|
838
1112
|
worktrees = get_worktree_info(base_dir)
|
|
839
1113
|
|
|
840
|
-
#
|
|
841
|
-
|
|
842
|
-
worktrees.sort(key=lambda x: x.get('created', datetime.min))
|
|
843
|
-
elif sort_by == 'name':
|
|
844
|
-
worktrees.sort(key=lambda x: Path(x['path']).name)
|
|
1114
|
+
# ソート: 作成日時の降順(最新が上)
|
|
1115
|
+
worktrees.sort(key=lambda x: x.get('created', datetime.min), reverse=True)
|
|
845
1116
|
|
|
846
|
-
#
|
|
847
|
-
if
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
1117
|
+
# PR infoの取得
|
|
1118
|
+
if show_pr:
|
|
1119
|
+
for wt in worktrees:
|
|
1120
|
+
branch = wt.get('branch', '')
|
|
1121
|
+
if branch:
|
|
1122
|
+
wt['pr_info'] = get_pr_info(branch, cwd=base_dir)
|
|
1123
|
+
|
|
1124
|
+
# 相対時間の計算
|
|
1125
|
+
for wt in worktrees:
|
|
1126
|
+
wt['relative_time'] = get_relative_time(wt.get('created'))
|
|
851
1127
|
|
|
1128
|
+
if quiet:
|
|
852
1129
|
for wt in worktrees:
|
|
853
|
-
name
|
|
854
|
-
|
|
855
|
-
created = wt.get('created').strftime('%Y-%m-%d %H:%M') if wt.get('created') else 'N/A'
|
|
856
|
-
last_commit = wt.get('last_commit').strftime('%Y-%m-%d %H:%M') if wt.get('last_commit') else 'N/A'
|
|
857
|
-
status = 'clean' if wt.get('is_clean') else 'modified'
|
|
1130
|
+
print(Path(wt['path']).name if Path(wt['path']).name != base_dir.name else "main")
|
|
1131
|
+
return
|
|
858
1132
|
|
|
859
|
-
|
|
1133
|
+
# "Changes" カラムの表示文字列作成
|
|
1134
|
+
GREEN = "\033[32m"
|
|
1135
|
+
RED = "\033[31m"
|
|
1136
|
+
GRAY = "\033[90m"
|
|
1137
|
+
RESET = "\033[0m"
|
|
1138
|
+
|
|
1139
|
+
for wt in worktrees:
|
|
1140
|
+
plus = wt.get('insertions', 0)
|
|
1141
|
+
minus = wt.get('deletions', 0)
|
|
1142
|
+
untracked = wt.get('has_untracked', False)
|
|
1143
|
+
|
|
1144
|
+
parts = []
|
|
1145
|
+
clean_parts = []
|
|
1146
|
+
if plus > 0:
|
|
1147
|
+
parts.append(f"{GREEN}+{plus}{RESET}")
|
|
1148
|
+
clean_parts.append(f"+{plus}")
|
|
1149
|
+
if minus > 0:
|
|
1150
|
+
parts.append(f"{RED}-{minus}{RESET}")
|
|
1151
|
+
clean_parts.append(f"-{minus}")
|
|
1152
|
+
if untracked:
|
|
1153
|
+
parts.append(f"{GRAY}??{RESET}")
|
|
1154
|
+
clean_parts.append("??")
|
|
1155
|
+
|
|
1156
|
+
if not parts:
|
|
1157
|
+
wt['changes_display'] = "-"
|
|
1158
|
+
wt['changes_clean_len'] = 1
|
|
1159
|
+
else:
|
|
1160
|
+
wt['changes_display'] = " ".join(parts)
|
|
1161
|
+
wt['changes_clean_len'] = len(" ".join(clean_parts))
|
|
1162
|
+
|
|
1163
|
+
# カラム幅の計算
|
|
1164
|
+
name_w = max(len(msg('worktree_name')), max((len(Path(wt['path']).name) for wt in worktrees), default=0)) + 2
|
|
1165
|
+
branch_w = max(len(msg('branch_name')), max((len(wt.get('branch', 'N/A')) for wt in worktrees), default=0)) + 2
|
|
1166
|
+
time_w = max(len("Created"), max((len(wt.get('relative_time', '')) for wt in worktrees), default=0)) + 2
|
|
1167
|
+
status_w = max(len(msg('changes_label')), max((wt['changes_clean_len'] for wt in worktrees), default=0)) + 2
|
|
1168
|
+
pr_w = 0
|
|
1169
|
+
if show_pr:
|
|
1170
|
+
# PR info contains ANSI codes, so calculate real length
|
|
1171
|
+
import re
|
|
1172
|
+
# More robust ANSI escape regex including hyperlinks
|
|
1173
|
+
ansi_escape = re.compile(r'''
|
|
1174
|
+
\x1B(?:
|
|
1175
|
+
[@-Z\\-_]
|
|
1176
|
+
|
|
|
1177
|
+
\[[0-9]*[ -/]*[@-~]
|
|
1178
|
+
|
|
|
1179
|
+
\][0-9]*;.*?(\x1B\\|\x07)
|
|
1180
|
+
)
|
|
1181
|
+
''', re.VERBOSE)
|
|
1182
|
+
def clean_len(s):
|
|
1183
|
+
return len(ansi_escape.sub('', s))
|
|
1184
|
+
|
|
1185
|
+
pr_w = max(3, max((clean_len(wt.get('pr_info', '')) for wt in worktrees), default=0)) + 2
|
|
1186
|
+
|
|
1187
|
+
# ヘッダー (色付き)
|
|
1188
|
+
CYAN = "\033[36m"
|
|
1189
|
+
RESET = "\033[0m"
|
|
1190
|
+
BOLD = "\033[1m"
|
|
1191
|
+
|
|
1192
|
+
base_header = f"{msg('worktree_name'):<{name_w}} {msg('branch_name'):<{branch_w}} {'Created':<{time_w}} {msg('changes_label'):<{status_w}}"
|
|
1193
|
+
if show_pr:
|
|
1194
|
+
header = f"{BOLD}{base_header} PR{RESET}"
|
|
1195
|
+
separator_len = len(base_header) + 5
|
|
860
1196
|
else:
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
1197
|
+
header = f"{BOLD}{base_header.rstrip()}{RESET}"
|
|
1198
|
+
separator_len = len(base_header.rstrip())
|
|
1199
|
+
|
|
1200
|
+
print(header)
|
|
1201
|
+
print("-" * separator_len)
|
|
1202
|
+
|
|
1203
|
+
for wt in worktrees:
|
|
1204
|
+
path = Path(wt['path'])
|
|
1205
|
+
name_display = path.name if path != base_dir else f"{CYAN}(main){RESET}"
|
|
1206
|
+
name_clean_len = len(path.name) if path != base_dir else 6
|
|
1207
|
+
name_padding = " " * (name_w - name_clean_len)
|
|
1208
|
+
|
|
1209
|
+
branch = wt.get('branch', 'N/A')
|
|
1210
|
+
rel_time = wt.get('relative_time', 'N/A')
|
|
1211
|
+
changes_display = wt.get('changes_display', 'no changes')
|
|
1212
|
+
changes_clean_len = wt.get('changes_clean_len', 1)
|
|
1213
|
+
|
|
1214
|
+
# ANSI コード分を補正して表示
|
|
1215
|
+
changes_padding = " " * (status_w - changes_clean_len)
|
|
1216
|
+
|
|
1217
|
+
print(f"{name_display}{name_padding} {branch:<{branch_w}} {rel_time:<{time_w}} {changes_display}{changes_padding}", end='')
|
|
1218
|
+
if show_pr:
|
|
1219
|
+
print(f" {wt.get('pr_info', '')}", end='')
|
|
1220
|
+
print()
|
|
864
1221
|
|
|
865
1222
|
|
|
866
1223
|
def cmd_remove(args: list[str]):
|
|
@@ -877,7 +1234,7 @@ def cmd_remove(args: list[str]):
|
|
|
877
1234
|
work_name = args[0]
|
|
878
1235
|
|
|
879
1236
|
# worktree を削除
|
|
880
|
-
print(msg('removing_worktree', work_name))
|
|
1237
|
+
print(msg('removing_worktree', work_name), file=sys.stderr)
|
|
881
1238
|
result = run_command(
|
|
882
1239
|
["git", "worktree", "remove", work_name],
|
|
883
1240
|
cwd=base_dir,
|
|
@@ -885,7 +1242,7 @@ def cmd_remove(args: list[str]):
|
|
|
885
1242
|
)
|
|
886
1243
|
|
|
887
1244
|
if result.returncode == 0:
|
|
888
|
-
|
|
1245
|
+
pass
|
|
889
1246
|
else:
|
|
890
1247
|
if result.stderr:
|
|
891
1248
|
print(result.stderr, file=sys.stderr)
|
|
@@ -893,15 +1250,17 @@ def cmd_remove(args: list[str]):
|
|
|
893
1250
|
|
|
894
1251
|
|
|
895
1252
|
def cmd_clean(args: list[str]):
|
|
896
|
-
"""wt clean - Remove old/unused worktrees"""
|
|
1253
|
+
"""wt clean - Remove old/unused/merged worktrees"""
|
|
897
1254
|
base_dir = find_base_dir()
|
|
898
1255
|
if not base_dir:
|
|
899
1256
|
print(msg('error', msg('base_not_found')), file=sys.stderr)
|
|
900
1257
|
sys.exit(1)
|
|
901
1258
|
|
|
902
1259
|
# オプションを解析
|
|
903
|
-
|
|
1260
|
+
# オプションを解析
|
|
904
1261
|
clean_all = '--all' in args
|
|
1262
|
+
clean_merged = '--merged' in args
|
|
1263
|
+
clean_closed = '--closed' in args
|
|
905
1264
|
days = None
|
|
906
1265
|
|
|
907
1266
|
for i, arg in enumerate(args):
|
|
@@ -916,55 +1275,129 @@ def cmd_clean(args: list[str]):
|
|
|
916
1275
|
worktrees = get_worktree_info(base_dir)
|
|
917
1276
|
|
|
918
1277
|
# エイリアスで使われている worktree を取得
|
|
919
|
-
|
|
1278
|
+
# 今回の構成では root 内のシンボリックリンクを探す
|
|
920
1279
|
aliased_worktrees = set()
|
|
921
|
-
for item in
|
|
922
|
-
if item.is_symlink()
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
1280
|
+
for item in base_dir.iterdir():
|
|
1281
|
+
if item.is_symlink():
|
|
1282
|
+
try:
|
|
1283
|
+
target = item.resolve()
|
|
1284
|
+
aliased_worktrees.add(target)
|
|
1285
|
+
except Exception:
|
|
1286
|
+
pass
|
|
1287
|
+
|
|
1288
|
+
# マージ済みブランチを取得
|
|
1289
|
+
merged_branches = set()
|
|
1290
|
+
merged_pr_branches = set()
|
|
1291
|
+
|
|
1292
|
+
# デフォルトブランチを取得して、それに対してマージされているかを確認
|
|
1293
|
+
default_branch = get_default_branch(base_dir)
|
|
1294
|
+
default_branch_sha = None
|
|
1295
|
+
if default_branch:
|
|
1296
|
+
res_sha = run_command(["git", "rev-parse", default_branch], cwd=base_dir, check=False)
|
|
1297
|
+
if res_sha.returncode == 0:
|
|
1298
|
+
default_branch_sha = res_sha.stdout.strip()
|
|
1299
|
+
|
|
1300
|
+
if clean_merged and default_branch:
|
|
1301
|
+
# Local merged branches (merged into default_branch)
|
|
1302
|
+
result = run_command(["git", "branch", "--merged", default_branch], cwd=base_dir, check=False)
|
|
1303
|
+
if result.returncode == 0:
|
|
1304
|
+
for line in result.stdout.split('\n'):
|
|
1305
|
+
# Extract branch name, removing '*', '+', and whitespace
|
|
1306
|
+
line = line.strip()
|
|
1307
|
+
if line.startswith('* ') or line.startswith('+ '):
|
|
1308
|
+
line = line[2:].strip()
|
|
1309
|
+
if line:
|
|
1310
|
+
merged_branches.add(line)
|
|
1311
|
+
|
|
1312
|
+
# GitHub merged PRs
|
|
1313
|
+
if shutil.which("gh"):
|
|
1314
|
+
import json
|
|
1315
|
+
# Get last 100 merged PRs
|
|
1316
|
+
pr_cmd = ["gh", "pr", "list", "--state", "merged", "--limit", "100", "--json", "headRefName"]
|
|
1317
|
+
pr_res = run_command(pr_cmd, cwd=base_dir, check=False)
|
|
1318
|
+
if pr_res.returncode == 0:
|
|
1319
|
+
try:
|
|
1320
|
+
pr_data = json.loads(pr_res.stdout)
|
|
1321
|
+
for pr in pr_data:
|
|
1322
|
+
merged_pr_branches.add(pr['headRefName'])
|
|
1323
|
+
except:
|
|
1324
|
+
pass
|
|
1325
|
+
|
|
1326
|
+
# Closed PRs
|
|
1327
|
+
closed_pr_branches = set()
|
|
1328
|
+
if clean_closed:
|
|
1329
|
+
if shutil.which("gh"):
|
|
1330
|
+
import json
|
|
1331
|
+
# Get last 100 closed PRs
|
|
1332
|
+
pr_cmd = ["gh", "pr", "list", "--state", "closed", "--limit", "100", "--json", "headRefName"]
|
|
1333
|
+
pr_res = run_command(pr_cmd, cwd=base_dir, check=False)
|
|
1334
|
+
if pr_res.returncode == 0:
|
|
1335
|
+
try:
|
|
1336
|
+
pr_data = json.loads(pr_res.stdout)
|
|
1337
|
+
for pr in pr_data:
|
|
1338
|
+
closed_pr_branches.add(pr['headRefName'])
|
|
1339
|
+
except:
|
|
1340
|
+
pass
|
|
1341
|
+
|
|
1342
|
+
# 削除対象を抽出
|
|
927
1343
|
targets = []
|
|
928
1344
|
now = datetime.now()
|
|
929
1345
|
|
|
930
1346
|
for wt in worktrees:
|
|
931
1347
|
path = Path(wt['path'])
|
|
932
1348
|
|
|
933
|
-
#
|
|
934
|
-
if path
|
|
1349
|
+
# base (git root) は除外
|
|
1350
|
+
if path == base_dir:
|
|
935
1351
|
continue
|
|
936
1352
|
|
|
937
1353
|
# エイリアスで使われている worktree は除外
|
|
938
1354
|
if path in aliased_worktrees:
|
|
939
1355
|
continue
|
|
940
1356
|
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
1357
|
+
reason = None
|
|
1358
|
+
# マージ済みの場合は無条件で対象(ただし dirty でないこと)
|
|
1359
|
+
is_merged = (wt.get('branch') in merged_branches or wt.get('branch') in merged_pr_branches)
|
|
1360
|
+
if clean_merged and is_merged:
|
|
1361
|
+
# Check safeguard: if branch points to same SHA as default branch and NOT in merged_pr_branches
|
|
1362
|
+
# it might be a new branch that hasn't diverged yet.
|
|
1363
|
+
if default_branch_sha and wt.get('branch') not in merged_pr_branches:
|
|
1364
|
+
wt_sha = run_command(["git", "rev-parse", wt.get('branch')], cwd=base_dir, check=False).stdout.strip()
|
|
1365
|
+
if wt_sha == default_branch_sha:
|
|
1366
|
+
# Skip deletion for fresh branches
|
|
951
1367
|
continue
|
|
952
1368
|
|
|
953
|
-
|
|
1369
|
+
if wt.get('is_clean'):
|
|
1370
|
+
reason = "merged"
|
|
1371
|
+
|
|
1372
|
+
is_closed = wt.get('branch') in closed_pr_branches
|
|
1373
|
+
if not reason and clean_closed and is_closed:
|
|
1374
|
+
if wt.get('is_clean'):
|
|
1375
|
+
reason = "closed"
|
|
1376
|
+
|
|
1377
|
+
# 通常のクリーンアップ対象
|
|
1378
|
+
if not reason and wt.get('is_clean'):
|
|
1379
|
+
if days is not None:
|
|
1380
|
+
created = wt.get('created')
|
|
1381
|
+
if created:
|
|
1382
|
+
age_days = (now - created).days
|
|
1383
|
+
if age_days >= days:
|
|
1384
|
+
reason = f"older than {days} days"
|
|
1385
|
+
elif clean_all:
|
|
1386
|
+
reason = "clean"
|
|
1387
|
+
|
|
1388
|
+
if reason:
|
|
1389
|
+
wt['reason'] = reason
|
|
1390
|
+
targets.append(wt)
|
|
954
1391
|
|
|
955
1392
|
if not targets:
|
|
956
|
-
print(msg('no_clean_targets'))
|
|
1393
|
+
print(msg('no_clean_targets'), file=sys.stderr)
|
|
957
1394
|
return
|
|
958
1395
|
|
|
959
1396
|
# 削除対象を表示
|
|
960
1397
|
for wt in targets:
|
|
961
1398
|
path = Path(wt['path'])
|
|
962
1399
|
created = wt.get('created').strftime('%Y-%m-%d %H:%M') if wt.get('created') else 'N/A'
|
|
963
|
-
print(
|
|
964
|
-
|
|
965
|
-
if dry_run:
|
|
966
|
-
print(f"\n(--dry-run mode, no changes made)")
|
|
967
|
-
return
|
|
1400
|
+
print(f"{path.name} (reason: {wt['reason']}, created: {created})", file=sys.stderr)
|
|
968
1401
|
|
|
969
1402
|
# 確認
|
|
970
1403
|
if not clean_all:
|
|
@@ -980,7 +1413,7 @@ def cmd_clean(args: list[str]):
|
|
|
980
1413
|
# 削除実行
|
|
981
1414
|
for wt in targets:
|
|
982
1415
|
path = Path(wt['path'])
|
|
983
|
-
print(msg('removing_worktree', path.name))
|
|
1416
|
+
print(msg('removing_worktree', path.name), file=sys.stderr)
|
|
984
1417
|
result = run_command(
|
|
985
1418
|
["git", "worktree", "remove", str(path)],
|
|
986
1419
|
cwd=base_dir,
|
|
@@ -988,129 +1421,98 @@ def cmd_clean(args: list[str]):
|
|
|
988
1421
|
)
|
|
989
1422
|
|
|
990
1423
|
if result.returncode == 0:
|
|
991
|
-
|
|
1424
|
+
pass
|
|
992
1425
|
else:
|
|
993
1426
|
if result.stderr:
|
|
994
1427
|
print(result.stderr, file=sys.stderr)
|
|
995
1428
|
|
|
996
1429
|
|
|
997
|
-
|
|
998
|
-
|
|
1430
|
+
|
|
1431
|
+
|
|
1432
|
+
|
|
1433
|
+
|
|
1434
|
+
def cmd_sync(args: list[str]):
|
|
1435
|
+
"""wt sync [files...] [--from <name>] [--to <name>] - Sync files between worktrees"""
|
|
999
1436
|
base_dir = find_base_dir()
|
|
1000
1437
|
if not base_dir:
|
|
1001
1438
|
print(msg('error', msg('base_not_found')), file=sys.stderr)
|
|
1002
1439
|
sys.exit(1)
|
|
1003
1440
|
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
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
|
|
1018
1455
|
else:
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
# --remove オプション
|
|
1023
|
-
if '--remove' in args:
|
|
1024
|
-
if len(args) < 2:
|
|
1025
|
-
print(msg('usage_alias'), file=sys.stderr)
|
|
1026
|
-
sys.exit(1)
|
|
1456
|
+
files_to_sync.append(args[i])
|
|
1457
|
+
i += 1
|
|
1027
1458
|
|
|
1028
|
-
|
|
1029
|
-
|
|
1459
|
+
if not files_to_sync:
|
|
1460
|
+
files_to_sync = config.get("sync_files", [])
|
|
1030
1461
|
|
|
1031
|
-
|
|
1032
|
-
print(msg('error', msg('alias_not_found', alias_name)), file=sys.stderr)
|
|
1033
|
-
sys.exit(1)
|
|
1034
|
-
|
|
1035
|
-
if not alias_path.is_symlink():
|
|
1036
|
-
print(msg('error', f'{alias_name} is not an alias'), file=sys.stderr)
|
|
1037
|
-
sys.exit(1)
|
|
1038
|
-
|
|
1039
|
-
alias_path.unlink()
|
|
1040
|
-
print(msg('alias_removed', alias_name))
|
|
1462
|
+
if not files_to_sync:
|
|
1041
1463
|
return
|
|
1042
1464
|
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
sys.exit(1)
|
|
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)
|
|
1058
1479
|
|
|
1059
|
-
|
|
1060
|
-
if
|
|
1061
|
-
if
|
|
1062
|
-
|
|
1063
|
-
alias_path.symlink_to(worktree_path, target_is_directory=True)
|
|
1064
|
-
print(msg('alias_updated', alias_name, worktree_name))
|
|
1480
|
+
dest_paths = []
|
|
1481
|
+
if to_name:
|
|
1482
|
+
if to_name == "main":
|
|
1483
|
+
dest_paths = [base_dir]
|
|
1065
1484
|
else:
|
|
1066
|
-
|
|
1067
|
-
|
|
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)
|
|
1068
1494
|
else:
|
|
1069
|
-
#
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
base_dir = find_base_dir()
|
|
1077
|
-
if not base_dir:
|
|
1078
|
-
print(msg('error', msg('base_not_found')), file=sys.stderr)
|
|
1079
|
-
sys.exit(1)
|
|
1080
|
-
|
|
1081
|
-
# オプション
|
|
1082
|
-
show_dirty_only = '--dirty' in args
|
|
1083
|
-
short = '--short' in args
|
|
1084
|
-
|
|
1085
|
-
worktrees = get_worktree_info(base_dir)
|
|
1086
|
-
|
|
1087
|
-
for wt in worktrees:
|
|
1088
|
-
path = Path(wt['path'])
|
|
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]
|
|
1089
1502
|
|
|
1090
|
-
|
|
1091
|
-
|
|
1503
|
+
import shutil
|
|
1504
|
+
count = 0
|
|
1505
|
+
for dst_root in dest_paths:
|
|
1506
|
+
if dst_root == from_path:
|
|
1092
1507
|
continue
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
status_output = result.stdout.strip()
|
|
1102
|
-
|
|
1103
|
-
# ヘッダー
|
|
1104
|
-
print(f"\n{'='*60}")
|
|
1105
|
-
print(f"Worktree: {path.name}")
|
|
1106
|
-
print(f"Branch: {wt.get('branch', 'N/A')}")
|
|
1107
|
-
print(f"Path: {path}")
|
|
1108
|
-
print(f"{'='*60}")
|
|
1109
|
-
|
|
1110
|
-
if status_output:
|
|
1111
|
-
print(status_output)
|
|
1112
|
-
else:
|
|
1113
|
-
print("(clean - no changes)")
|
|
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
|
|
1114
1516
|
|
|
1115
1517
|
|
|
1116
1518
|
def cmd_passthrough(args: list[str]):
|
|
@@ -1136,22 +1538,19 @@ def show_help():
|
|
|
1136
1538
|
print(" wt <command> [options]")
|
|
1137
1539
|
print()
|
|
1138
1540
|
print("コマンド:")
|
|
1139
|
-
print(" clone <repository_url>
|
|
1140
|
-
print(" init
|
|
1141
|
-
print(" add <作業名> [<base_branch>]
|
|
1142
|
-
print(" list
|
|
1143
|
-
print("
|
|
1144
|
-
print("
|
|
1145
|
-
print("
|
|
1146
|
-
print("
|
|
1147
|
-
print("
|
|
1148
|
-
print(" alias --remove <名前> - エイリアスを削除")
|
|
1149
|
-
print(" status [--dirty] [--short] - 全 worktree の状態を表示")
|
|
1150
|
-
print(" <git-worktree-command> - その他の git worktree コマンド")
|
|
1541
|
+
print(f" {'clone <repository_url>':<55} - リポジトリをクローン")
|
|
1542
|
+
print(f" {'init':<55} - 既存リポジトリをメインリポジトリとして構成")
|
|
1543
|
+
print(f" {'add (ad) <作業名> [<base_branch>]':<55} - worktree を追加(デフォルト: 新規ブランチ作成)")
|
|
1544
|
+
print(f" {'list (ls) [--pr]':<55} - worktree 一覧を表示")
|
|
1545
|
+
print(f" {'stash (st) <作業名> [<base_branch>]':<55} - 現在の変更をスタッシュして新規 worktree に移動")
|
|
1546
|
+
print(f" {'pr add <番号>':<55} - GitHub PR を取得して worktree を作成/パス表示")
|
|
1547
|
+
print(f" {'rm/remove <作業名>':<55} - worktree を削除")
|
|
1548
|
+
print(f" {'clean (cl) [--days N] [--merged] [--closed]':<55} - 不要な worktree を削除")
|
|
1549
|
+
print(f" {'sync (sy) [files...] [--from <名>] [--to <名>]':<55} - ファイル(.env等)を同期")
|
|
1151
1550
|
print()
|
|
1152
1551
|
print("オプション:")
|
|
1153
|
-
print(" -h, --help
|
|
1154
|
-
print(" -v, --version
|
|
1552
|
+
print(f" {'-h, --help':<55} - このヘルプメッセージを表示")
|
|
1553
|
+
print(f" {'-v, --version':<55} - バージョン情報を表示")
|
|
1155
1554
|
else:
|
|
1156
1555
|
print("easy-worktree - Simple CLI tool for managing Git worktrees")
|
|
1157
1556
|
print()
|
|
@@ -1159,32 +1558,29 @@ def show_help():
|
|
|
1159
1558
|
print(" wt <command> [options]")
|
|
1160
1559
|
print()
|
|
1161
1560
|
print("Commands:")
|
|
1162
|
-
print(" clone <repository_url>
|
|
1163
|
-
print(" init
|
|
1164
|
-
print(" add <work_name> [<base_branch>]
|
|
1165
|
-
print(" list [--
|
|
1166
|
-
print("
|
|
1167
|
-
print("
|
|
1168
|
-
print("
|
|
1169
|
-
print("
|
|
1170
|
-
print("
|
|
1171
|
-
print(" alias --remove <name> - Remove an alias")
|
|
1172
|
-
print(" status [--dirty] [--short] - Show status of all worktrees")
|
|
1173
|
-
print(" <git-worktree-command> - Other git worktree commands")
|
|
1561
|
+
print(f" {'clone <repository_url>':<55} - Clone a repository")
|
|
1562
|
+
print(f" {'init':<55} - Configure existing repository as main")
|
|
1563
|
+
print(f" {'add (ad) <work_name> [<base_branch>]':<55} - Add a worktree (default: create new branch)")
|
|
1564
|
+
print(f" {'list (ls) [--pr]':<55} - List worktrees")
|
|
1565
|
+
print(f" {'stash (st) <work_name> [<base_branch>]':<55} - Stash current changes and move to new worktree")
|
|
1566
|
+
print(f" {'pr add <number>':<55} - Manage GitHub PRs as worktrees")
|
|
1567
|
+
print(f" {'rm/remove <work_name>':<55} - Remove a worktree")
|
|
1568
|
+
print(f" {'clean (cl) [--days N] [--merged] [--closed]':<55} - Remove unused/merged worktrees")
|
|
1569
|
+
print(f" {'sync (sy) [files...] [--from <name>] [--to <name>]':<55} - Sync files (.env, etc.)")
|
|
1174
1570
|
print()
|
|
1175
1571
|
print("Options:")
|
|
1176
|
-
print(" -h, --help
|
|
1177
|
-
print(" -v, --version
|
|
1572
|
+
print(f" {'-h, --help':<55} - Show this help message")
|
|
1573
|
+
print(f" {'-v, --version':<55} - Show version information")
|
|
1178
1574
|
|
|
1179
1575
|
|
|
1180
1576
|
def show_version():
|
|
1181
1577
|
"""Show version information"""
|
|
1182
|
-
print("easy-worktree version 0.0
|
|
1578
|
+
print("easy-worktree version 0.1.0")
|
|
1183
1579
|
|
|
1184
1580
|
|
|
1185
1581
|
def main():
|
|
1186
1582
|
"""メインエントリポイント"""
|
|
1187
|
-
#
|
|
1583
|
+
# ヘルプとバージョンのオプションは設定なしでも動作する
|
|
1188
1584
|
if len(sys.argv) < 2:
|
|
1189
1585
|
show_help()
|
|
1190
1586
|
sys.exit(1)
|
|
@@ -1206,18 +1602,20 @@ def main():
|
|
|
1206
1602
|
cmd_clone(args)
|
|
1207
1603
|
elif command == "init":
|
|
1208
1604
|
cmd_init(args)
|
|
1209
|
-
elif command
|
|
1605
|
+
elif command in ["add", "ad"]:
|
|
1210
1606
|
cmd_add(args)
|
|
1211
|
-
elif command
|
|
1607
|
+
elif command in ["list", "ls"]:
|
|
1212
1608
|
cmd_list(args)
|
|
1213
1609
|
elif command in ["rm", "remove"]:
|
|
1214
1610
|
cmd_remove(args)
|
|
1215
|
-
elif command
|
|
1611
|
+
elif command in ["clean", "cl"]:
|
|
1216
1612
|
cmd_clean(args)
|
|
1217
|
-
elif command
|
|
1218
|
-
|
|
1219
|
-
elif command
|
|
1220
|
-
|
|
1613
|
+
elif command in ["sync", "sy"]:
|
|
1614
|
+
cmd_sync(args)
|
|
1615
|
+
elif command in ["stash", "st"]:
|
|
1616
|
+
cmd_stash(args)
|
|
1617
|
+
elif command == "pr":
|
|
1618
|
+
cmd_pr(args)
|
|
1221
1619
|
else:
|
|
1222
1620
|
# その他のコマンドは git worktree にパススルー
|
|
1223
1621
|
cmd_passthrough([command] + args)
|