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 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 json
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>] [--alias <name>]',
33
- 'ja': '使用方法: wt add <作業名> [<base_branch>] [--alias <名前>]'
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': '_base/ directory not found',
41
- 'ja': '_base/ ディレクトリが見つかりません'
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 [--dry-run] [--days N] [--all]',
125
- 'ja': '使用方法: wt clean [--dry-run] [--days N] [--all]'
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
- return match.group(1)
242
+ name = match.group(1)
243
+ # サービス名などが含まれる場合のクリーンアップ
244
+ return name.split(':')[-1]
213
245
  # ローカルパスの場合
214
- return Path(url).name
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 _base/ directory
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`: _base/ ディレクトリのパス
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 _base/ directory
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
- """現在のディレクトリまたは親ディレクトリから _base/ を探す"""
440
- current = Path.cwd()
441
-
442
- # 現在のディレクトリに _base/ がある場合
443
- base_dir = current / "_base"
444
- if base_dir.exists() and base_dir.is_dir():
445
- return base_dir
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
- # 親ディレクトリに _base/ がある場合(worktree の中にいる場合)
448
- base_dir = current.parent / "_base"
449
- if base_dir.exists() and base_dir.is_dir():
450
- return base_dir
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
- # WT_<repository_name>/_base にクローン
465
- parent_dir = Path(f"WT_{repo_name}")
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
- parent_dir.mkdir(exist_ok=True)
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
- print(msg('cloning', repo_url, base_dir))
475
- run_command(["git", "clone", repo_url, str(base_dir)])
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 - Move existing git repository to WT_<repo>/_base/"""
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
- if git_root != current_dir:
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
- # すでに WT_<repo> が存在するかチェック
523
- if wt_parent_dir.exists():
524
- print(msg('error', msg('already_exists', wt_parent_dir)), file=sys.stderr)
525
- sys.exit(1)
526
-
527
- # WT_<repo>/ ディレクトリを作成
528
- print(msg('creating_dir', wt_parent_dir))
529
- wt_parent_dir.mkdir(exist_ok=True)
530
-
531
- # 現在のディレクトリを WT_<repo>/_base/ に移動
532
- print(msg('moving', current_dir, new_base_dir))
533
- current_dir.rename(new_base_dir)
534
-
535
- print(msg('completed_move', new_base_dir))
536
- print(msg('use_wt_from', wt_parent_dir))
537
-
538
- # post-add hook テンプレートを作成
539
- create_hook_template(new_base_dir)
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 cmd_add(args: list[str]):
580
- """wt add <work_name> [<base_branch>] - Add a worktree"""
581
- if len(args) < 1:
582
- print(msg('usage_add'), file=sys.stderr)
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
- # --alias オプションをチェック
592
- alias_name = None
593
- if '--alias' in args:
594
- alias_index = args.index('--alias')
595
- if alias_index + 1 < len(args):
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
- work_name = args[0]
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
- # _base/ を base branch の最新に更新
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
- branch_name = None
638
- if len(args) >= 2:
639
- # 既存ブランチをチェックアウト
640
- branch_name = args[1]
641
- print(msg('creating_worktree', worktree_path))
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), branch_name],
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
- branch_name = work_name
719
+ final_branch_name = work_name
650
720
 
651
- # ローカルまたはリモートにブランチが既に存在するかチェック
652
721
  check_local = run_command(
653
- ["git", "rev-parse", "--verify", branch_name],
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/{branch_name}"],
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/{branch_name}"],
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), branch_name],
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
- # デフォルトブランチを探す(origin/main または origin/master)
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
- if result.returncode == 0 and result.stdout.strip():
691
- base_branch = result.stdout.strip()
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
- # symbolic-ref が失敗した場合は手動でチェック
694
- result_main = run_command(
695
- ["git", "rev-parse", "--verify", "origin/main"],
696
- cwd=base_dir,
697
- check=False
698
- )
699
- result_master = run_command(
700
- ["git", "rev-parse", "--verify", "origin/master"],
701
- cwd=base_dir,
702
- check=False
703
- )
704
-
705
- if result_main.returncode == 0:
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', base_branch, work_name))
775
+ print(msg('creating_branch', final_branch_name, detected_base), file=sys.stderr)
714
776
  result = run_command(
715
- ["git", "worktree", "add", "-b", work_name, str(worktree_path), base_branch],
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
- print(msg('completed_worktree', worktree_path))
722
-
723
- # エイリアスを作成
724
- if alias_name:
725
- alias_path = base_dir.parent / alias_name
726
-
727
- # 既存かどうかをチェック
728
- is_updating = alias_path.is_symlink()
729
-
730
- # 既存のシンボリックリンクを削除
731
- if alias_path.is_symlink():
732
- alias_path.unlink()
733
- elif alias_path.exists():
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
- # --verbose または --sort オプションがある場合は詳細表示
823
- verbose = '--verbose' in args or '-v' in args
824
- sort_by = None
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
- if sort_by == 'age' or sort_by == 'created':
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 verbose:
848
- # ヘッダー
849
- print(f"{msg('worktree_name'):<30} {msg('branch_name'):<25} {msg('created_at'):<20} {msg('last_commit'):<20} {msg('status_label')}")
850
- print("-" * 110)
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 = Path(wt['path']).name
854
- branch = wt.get('branch', 'N/A')
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
- print(f"{name:<30} {branch:<25} {created:<20} {last_commit:<20} {status}")
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
- for wt in worktrees:
863
- print(wt['path'])
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
- print(msg('completed_remove', work_name))
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
- dry_run = '--dry-run' in args
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
- parent_dir = base_dir.parent
1278
+ # 今回の構成では root 内のシンボリックリンクを探す
920
1279
  aliased_worktrees = set()
921
- for item in parent_dir.iterdir():
922
- if item.is_symlink() and item.name != '_base':
923
- target = item.resolve()
924
- aliased_worktrees.add(target)
925
-
926
- # 削除対象を抽出(_baseは除外)
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
- # _base は除外
934
- if path.name == '_base':
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
- # clean状態のものだけが対象
942
- if not wt.get('is_clean'):
943
- continue
944
-
945
- # 日数指定がある場合はチェック
946
- if days is not None:
947
- created = wt.get('created')
948
- if created:
949
- age_days = (now - created).days
950
- if age_days < days:
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
- targets.append(wt)
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(msg('clean_target', path.name, created))
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
- print(msg('completed_remove', path.name))
1424
+ pass
992
1425
  else:
993
1426
  if result.stderr:
994
1427
  print(result.stderr, file=sys.stderr)
995
1428
 
996
1429
 
997
- def cmd_alias(args: list[str]):
998
- """wt alias - Manage worktree aliases"""
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
- parent_dir = base_dir.parent
1005
-
1006
- # --list オプション
1007
- if '--list' in args or len(args) == 0:
1008
- # エイリアス一覧を表示(シンボリックリンクを探す)
1009
- aliases = []
1010
- for item in parent_dir.iterdir():
1011
- if item.is_symlink() and item.name != '_base':
1012
- target = item.resolve()
1013
- aliases.append((item.name, target.name))
1014
-
1015
- if aliases:
1016
- for alias, target in sorted(aliases):
1017
- print(f"{alias} -> {target}")
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
- print("No aliases found.")
1020
- return
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
- alias_name = args[args.index('--remove') + 1]
1029
- alias_path = parent_dir / alias_name
1459
+ if not files_to_sync:
1460
+ files_to_sync = config.get("sync_files", [])
1030
1461
 
1031
- if not alias_path.exists():
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
- if len(args) < 2:
1045
- print(msg('usage_alias'), file=sys.stderr)
1046
- sys.exit(1)
1047
-
1048
- alias_name = args[0]
1049
- worktree_name = args[1]
1050
-
1051
- alias_path = parent_dir / alias_name
1052
- worktree_path = parent_dir / worktree_name
1053
-
1054
- # worktree が存在するかチェック
1055
- if not worktree_path.exists():
1056
- print(msg('error', f'Worktree not found: {worktree_name}'), file=sys.stderr)
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 alias_path.exists():
1061
- if alias_path.is_symlink():
1062
- alias_path.unlink()
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
- print(msg('error', f'{alias_name} exists but is not a symlink'), file=sys.stderr)
1067
- sys.exit(1)
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
- alias_path.symlink_to(worktree_path, target_is_directory=True)
1071
- print(msg('alias_created', alias_name, worktree_name))
1072
-
1073
-
1074
- def cmd_status(args: list[str]):
1075
- """wt status - Show status of all worktrees"""
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
- # dirty only モードの場合、clean なものはスキップ
1091
- if show_dirty_only and wt.get('is_clean'):
1503
+ import shutil
1504
+ count = 0
1505
+ for dst_root in dest_paths:
1506
+ if dst_root == from_path:
1092
1507
  continue
1093
-
1094
- # git status を取得
1095
- result = run_command(
1096
- ["git", "status", "--short" if short else "--short"],
1097
- cwd=path,
1098
- check=False
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 - 既存リポジトリを WT_<repo>/_base/ に移動")
1141
- print(" add <作業名> [<base_branch>] [--alias <名前>] - worktree を追加(デフォルト: 新規ブランチ作成)")
1142
- print(" list [--verbose] [--sort age|name] - worktree 一覧を表示")
1143
- print(" rm <作業名> - worktree を削除")
1144
- print(" remove <作業名> - worktree を削除")
1145
- print(" clean [--dry-run] [--days N] - 未使用の worktree を削除")
1146
- print(" alias <名前> <worktree> - worktree のエイリアスを作成/更新")
1147
- print(" alias --list - エイリアス一覧を表示")
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> - Clone a repository")
1163
- print(" init - Move existing repo to WT_<repo>/_base/")
1164
- print(" add <work_name> [<base_branch>] [--alias <name>] - Add a worktree (default: create new branch)")
1165
- print(" list [--verbose] [--sort age|name] - List worktrees")
1166
- print(" rm <work_name> - Remove a worktree")
1167
- print(" remove <work_name> - Remove a worktree")
1168
- print(" clean [--dry-run] [--days N] - Remove unused worktrees")
1169
- print(" alias <name> <worktree> - Create or update an alias for a worktree")
1170
- print(" alias --list - List aliases")
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 - Show this help message")
1177
- print(" -v, --version - Show version information")
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.7")
1578
+ print("easy-worktree version 0.1.0")
1183
1579
 
1184
1580
 
1185
1581
  def main():
1186
1582
  """メインエントリポイント"""
1187
- # ヘルプとバージョンのオプションは _base/ なしでも動作する
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 == "add":
1605
+ elif command in ["add", "ad"]:
1210
1606
  cmd_add(args)
1211
- elif command == "list":
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 == "clean":
1611
+ elif command in ["clean", "cl"]:
1216
1612
  cmd_clean(args)
1217
- elif command == "alias":
1218
- cmd_alias(args)
1219
- elif command == "status":
1220
- cmd_status(args)
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)