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

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