easy-worktree 0.1.0__py3-none-any.whl → 0.1.2__py3-none-any.whl

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