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 +988 -399
- easy_worktree/__init__.py_snippet_helper +18 -0
- easy_worktree-0.1.0.dist-info/METADATA +180 -0
- easy_worktree-0.1.0.dist-info/RECORD +7 -0
- {easy_worktree-0.0.5.dist-info → easy_worktree-0.1.0.dist-info}/WHEEL +1 -1
- easy_worktree-0.0.5.dist-info/METADATA +0 -237
- easy_worktree-0.0.5.dist-info/RECORD +0 -6
- {easy_worktree-0.0.5.dist-info → easy_worktree-0.1.0.dist-info}/entry_points.txt +0 -0
- {easy_worktree-0.0.5.dist-info → easy_worktree-0.1.0.dist-info}/licenses/LICENSE +0 -0
easy_worktree/__init__.py
CHANGED
|
@@ -4,11 +4,12 @@ Git worktree を簡単に管理するための CLI ツール
|
|
|
4
4
|
"""
|
|
5
5
|
import os
|
|
6
6
|
import subprocess
|
|
7
|
+
import shutil
|
|
7
8
|
import sys
|
|
8
9
|
from pathlib import Path
|
|
9
10
|
import re
|
|
10
|
-
from datetime import datetime
|
|
11
|
-
import
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
import toml
|
|
12
13
|
|
|
13
14
|
|
|
14
15
|
# 言語判定
|
|
@@ -29,16 +30,16 @@ MESSAGES = {
|
|
|
29
30
|
'ja': '使用方法: wt clone <repository_url>'
|
|
30
31
|
},
|
|
31
32
|
'usage_add': {
|
|
32
|
-
'en': 'Usage: wt add <work_name> [<base_branch>]
|
|
33
|
-
'ja': '使用方法: wt add <作業名> [<base_branch>]
|
|
33
|
+
'en': 'Usage: wt add (ad) <work_name> [<base_branch>]',
|
|
34
|
+
'ja': '使用方法: wt add (ad) <作業名> [<base_branch>]'
|
|
34
35
|
},
|
|
35
36
|
'usage_rm': {
|
|
36
37
|
'en': 'Usage: wt rm <work_name>',
|
|
37
38
|
'ja': '使用方法: wt rm <作業名>'
|
|
38
39
|
},
|
|
39
40
|
'base_not_found': {
|
|
40
|
-
'en': '
|
|
41
|
-
'ja': '
|
|
41
|
+
'en': 'Main repository directory not found',
|
|
42
|
+
'ja': 'メインリポジトリのディレクトリが見つかりません'
|
|
42
43
|
},
|
|
43
44
|
'run_in_wt_dir': {
|
|
44
45
|
'en': 'Please run inside WT_<repository_name>/ directory',
|
|
@@ -102,7 +103,7 @@ MESSAGES = {
|
|
|
102
103
|
},
|
|
103
104
|
'creating_branch': {
|
|
104
105
|
'en': "Creating new branch '{}' from '{}'",
|
|
105
|
-
'ja': "'{}'
|
|
106
|
+
'ja': "ブランチ '{}' を '{}' から作成しています"
|
|
106
107
|
},
|
|
107
108
|
'default_branch_not_found': {
|
|
108
109
|
'en': 'Could not find default branch (main/master)',
|
|
@@ -121,12 +122,8 @@ MESSAGES = {
|
|
|
121
122
|
'ja': '警告: hook が終了コード {} で終了しました'
|
|
122
123
|
},
|
|
123
124
|
'usage_clean': {
|
|
124
|
-
'en': 'Usage: wt clean [--
|
|
125
|
-
'ja': '使用方法: wt clean [--
|
|
126
|
-
},
|
|
127
|
-
'usage_alias': {
|
|
128
|
-
'en': 'Usage: wt alias <name> <worktree> | wt alias --list | wt alias --remove <name>',
|
|
129
|
-
'ja': '使用方法: wt alias <名前> <worktree> | wt alias --list | wt alias --remove <名前>'
|
|
125
|
+
'en': 'Usage: wt clean (cl) [--days N] [--merged] [--closed] [--all]',
|
|
126
|
+
'ja': '使用方法: wt clean (cl) [--days N] [--merged] [--closed] [--all]'
|
|
130
127
|
},
|
|
131
128
|
'alias_updated': {
|
|
132
129
|
'en': 'Updated alias: {} -> {}',
|
|
@@ -144,10 +141,6 @@ MESSAGES = {
|
|
|
144
141
|
'en': 'Remove {} worktree(s)? [y/N]: ',
|
|
145
142
|
'ja': '{} 個の worktree を削除しますか? [y/N]: '
|
|
146
143
|
},
|
|
147
|
-
'alias_created': {
|
|
148
|
-
'en': 'Created alias: {} -> {}',
|
|
149
|
-
'ja': 'エイリアスを作成しました: {} -> {}'
|
|
150
|
-
},
|
|
151
144
|
'alias_removed': {
|
|
152
145
|
'en': 'Removed alias: {}',
|
|
153
146
|
'ja': 'エイリアスを削除しました: {}'
|
|
@@ -175,6 +168,42 @@ MESSAGES = {
|
|
|
175
168
|
'status_label': {
|
|
176
169
|
'en': 'Status',
|
|
177
170
|
'ja': '状態'
|
|
171
|
+
},
|
|
172
|
+
'changes_label': {
|
|
173
|
+
'en': 'Changes',
|
|
174
|
+
'ja': '変更'
|
|
175
|
+
},
|
|
176
|
+
'syncing': {
|
|
177
|
+
'en': 'Syncing: {} -> {}',
|
|
178
|
+
'ja': '同期中: {} -> {}'
|
|
179
|
+
},
|
|
180
|
+
'completed_sync': {
|
|
181
|
+
'en': 'Completed sync of {} files',
|
|
182
|
+
'ja': '{} 個のファイルを同期しました'
|
|
183
|
+
},
|
|
184
|
+
'usage_sync': {
|
|
185
|
+
'en': 'Usage: wt sync (sy) [files...] [--from <name>] [--to <name>]',
|
|
186
|
+
'ja': '使用方法: wt sync (sy) [files...] [--from <name>] [--to <name>]'
|
|
187
|
+
},
|
|
188
|
+
'usage_pr': {
|
|
189
|
+
'en': 'Usage: wt pr add <number>',
|
|
190
|
+
'ja': '使用方法: wt pr add <number>'
|
|
191
|
+
},
|
|
192
|
+
'usage_stash': {
|
|
193
|
+
'en': 'Usage: wt stash (st) <work_name> [<base_branch>]',
|
|
194
|
+
'ja': '使用方法: wt stash (st) <work_name> [<base_branch>]'
|
|
195
|
+
},
|
|
196
|
+
'stashing_changes': {
|
|
197
|
+
'en': 'Stashing local changes...',
|
|
198
|
+
'ja': 'ローカルの変更をスタッシュ中...'
|
|
199
|
+
},
|
|
200
|
+
'popping_stash': {
|
|
201
|
+
'en': 'Moving changes to new worktree...',
|
|
202
|
+
'ja': '変更を新しい worktree に移動中...'
|
|
203
|
+
},
|
|
204
|
+
'nothing_to_stash': {
|
|
205
|
+
'en': 'No local changes to stash.',
|
|
206
|
+
'ja': 'スタッシュする変更がありません'
|
|
178
207
|
}
|
|
179
208
|
}
|
|
180
209
|
|
|
@@ -191,6 +220,7 @@ def msg(key: str, *args) -> str:
|
|
|
191
220
|
def run_command(cmd: list[str], cwd: Path = None, check: bool = True) -> subprocess.CompletedProcess:
|
|
192
221
|
"""コマンドを実行"""
|
|
193
222
|
try:
|
|
223
|
+
# print(f"DEBUG: Running command: {cmd} cwd={cwd}", file=sys.stderr)
|
|
194
224
|
result = subprocess.run(
|
|
195
225
|
cmd,
|
|
196
226
|
cwd=cwd,
|
|
@@ -209,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
|
-
|
|
242
|
+
name = match.group(1)
|
|
243
|
+
# サービス名などが含まれる場合のクリーンアップ
|
|
244
|
+
return name.split(':')[-1]
|
|
213
245
|
# ローカルパスの場合
|
|
214
|
-
return Path(url).
|
|
246
|
+
return Path(url).stem
|
|
215
247
|
|
|
216
248
|
|
|
217
|
-
def
|
|
218
|
-
"""
|
|
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
|
-
|
|
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
|
-
|
|
224
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
292
|
-
|
|
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
|
-
|
|
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
|
-
|
|
302
|
-
|
|
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 -
|
|
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
|
-
|
|
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
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
|
407
|
-
"""
|
|
408
|
-
if
|
|
409
|
-
|
|
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
|
-
#
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
|
434
|
-
worktree_path =
|
|
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
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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", "
|
|
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
|
-
|
|
719
|
+
final_branch_name = work_name
|
|
459
720
|
|
|
460
|
-
# ローカルまたはリモートにブランチが既に存在するかチェック
|
|
461
721
|
check_local = run_command(
|
|
462
|
-
["git", "rev-parse", "--verify",
|
|
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/{
|
|
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/{
|
|
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),
|
|
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
|
-
|
|
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
|
-
|
|
500
|
-
|
|
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
|
-
#
|
|
503
|
-
|
|
504
|
-
["git", "rev-parse", "--verify",
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
cwd=base_dir,
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
if
|
|
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',
|
|
775
|
+
print(msg('creating_branch', final_branch_name, detected_base), file=sys.stderr)
|
|
523
776
|
result = run_command(
|
|
524
|
-
["git", "worktree", "add", "-b",
|
|
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
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
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
|
-
# --
|
|
632
|
-
|
|
633
|
-
|
|
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
|
-
|
|
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
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
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
|
|
663
|
-
|
|
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
|
-
|
|
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
|
-
|
|
672
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1278
|
+
# 今回の構成では root 内のシンボリックリンクを探す
|
|
729
1279
|
aliased_worktrees = set()
|
|
730
|
-
for item in
|
|
731
|
-
if item.is_symlink()
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
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
|
-
#
|
|
743
|
-
if path
|
|
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
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
1424
|
+
pass
|
|
801
1425
|
else:
|
|
802
1426
|
if result.stderr:
|
|
803
1427
|
print(result.stderr, file=sys.stderr)
|
|
804
1428
|
|
|
805
1429
|
|
|
806
|
-
|
|
807
|
-
|
|
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
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
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
|
-
|
|
829
|
-
|
|
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
|
-
|
|
845
|
-
|
|
846
|
-
sys.exit(1)
|
|
1459
|
+
if not files_to_sync:
|
|
1460
|
+
files_to_sync = config.get("sync_files", [])
|
|
847
1461
|
|
|
848
|
-
|
|
849
|
-
print(msg('alias_removed', alias_name))
|
|
1462
|
+
if not files_to_sync:
|
|
850
1463
|
return
|
|
851
1464
|
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
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
|
|
870
|
-
if
|
|
871
|
-
|
|
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
|
-
|
|
876
|
-
|
|
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
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
base_dir = find_base_dir()
|
|
886
|
-
if not base_dir:
|
|
887
|
-
print(msg('error', msg('base_not_found')), file=sys.stderr)
|
|
888
|
-
sys.exit(1)
|
|
889
|
-
|
|
890
|
-
# オプション
|
|
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
|
-
|
|
900
|
-
|
|
1503
|
+
import shutil
|
|
1504
|
+
count = 0
|
|
1505
|
+
for dst_root in dest_paths:
|
|
1506
|
+
if dst_root == from_path:
|
|
901
1507
|
continue
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
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
|
|
950
|
-
print(" add <作業名> [<base_branch>]
|
|
951
|
-
print(" list
|
|
952
|
-
print("
|
|
953
|
-
print("
|
|
954
|
-
print("
|
|
955
|
-
print("
|
|
956
|
-
print("
|
|
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>
|
|
972
|
-
print(" init
|
|
973
|
-
print(" add <work_name> [<base_branch>]
|
|
974
|
-
print(" list [--
|
|
975
|
-
print("
|
|
976
|
-
print("
|
|
977
|
-
print("
|
|
978
|
-
print("
|
|
979
|
-
print("
|
|
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
|
|
986
|
-
print(" -v, --version
|
|
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
|
|
1578
|
+
print("easy-worktree version 0.1.0")
|
|
992
1579
|
|
|
993
1580
|
|
|
994
1581
|
def main():
|
|
995
1582
|
"""メインエントリポイント"""
|
|
996
|
-
#
|
|
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
|
|
1605
|
+
elif command in ["add", "ad"]:
|
|
1019
1606
|
cmd_add(args)
|
|
1020
|
-
elif command
|
|
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
|
|
1611
|
+
elif command in ["clean", "cl"]:
|
|
1025
1612
|
cmd_clean(args)
|
|
1026
|
-
elif command
|
|
1027
|
-
|
|
1028
|
-
elif command
|
|
1029
|
-
|
|
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)
|