easy-worktree 0.0.7__py3-none-any.whl → 0.1.1__py3-none-any.whl

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