From cf70e0987d4b71d4c7c3ce2b69d6b5bd0170d37a Mon Sep 17 00:00:00 2001 From: tqer39 Date: Sat, 25 Apr 2026 16:59:17 +0900 Subject: [PATCH 01/17] docs(superpowers): add spec for worktree-resume integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit worktree 名 = session 名 の 1:1 マッピングを ccw が裏で確立し、picker から の worktree 選択でそのまま過去会話を resume できる体験を設計。L2 4 行レ イアウト + RESUME/NEW バッジ + footer ランダム TIPS を含む。 Co-Authored-By: Claude Opus 4.7 --- ...4-25-worktree-resume-integration-design.md | 336 ++++++++++++++++++ 1 file changed, 336 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-25-worktree-resume-integration-design.md diff --git a/docs/superpowers/specs/2026-04-25-worktree-resume-integration-design.md b/docs/superpowers/specs/2026-04-25-worktree-resume-integration-design.md new file mode 100644 index 0000000..e9f1f95 --- /dev/null +++ b/docs/superpowers/specs/2026-04-25-worktree-resume-integration-design.md @@ -0,0 +1,336 @@ +# Worktree ↔ Claude Code Session Resume Integration — Design + +- **Date**: 2026-04-25 +- **Status**: Draft (awaiting user review) +- **Owner**: tqer39 + +## 背景 + +ccw-cli は `claude --worktree` を薄くラップして worktree 選択 UI を提供しているが、worktree を選んで `[r] run` した時の claude 起動は **常に fresh セッション**であり、過去会話を resume できない。README.ja.md:79-80 には「`-- --resume ID` のパススルーは非推奨」と明記されており、当時は worktree 作成と resume の同時指定が path ズレを起こすという理由で見送られていた。 + +しかし Claude Code v2.1.82 で「resume したセッションは元 worktree に自動で戻る」、v2.1.97 で「同 repo 内の別 worktree のセッションを直接 resume できる」と仕様が改善された。さらに `-n / --name ` で起動時にセッションへ表示名を付与でき、plan 承認による上書きも防がれる(`/rename` も同様)。 + +本仕様は **worktree 名 = session 名 の 1:1 マッピング** を ccw が裏で確立し、worktree picker からの選択でそのまま過去会話を resume できる体験を提供する。 + +## ゴール / 非ゴール + +### ゴール + +- ccw 経由で作成した worktree は **session 名が worktree 名と一致**する +- picker から既存 worktree を選択 → 自動で過去会話を resume(無ければ fresh 起動) +- picker 行に **resume 可否を明示**(`💬 RESUME` / `⚡ NEW`) +- README の旧警告(README.ja.md:79-80)を更新し、新動作と命名規約・関連 TIPS を記載 +- picker footer にランダム TIPS を表示 + +### 非ゴール + +- ユーザーが手動 `/rename` でセッション名を乖離させた場合の自動補正(何もしない) +- worktree のリネーム機能 +- `claude --from-pr` 連携(独立した paths として、TIPS で紹介するに留める) +- 過去 session 検索 UI(Claude 標準 `/resume` に委ねる) + +## 前提検証項目(実装初期に確認) + +設計が成立するために確認が必要な事項: + +1. `claude --worktree -n ` のフラグ併用が正しく動作する +2. `claude --continue` の no-session 時の終了挙動(クリーンに非ゼロ exit するか、picker を開くか) +3. 最低 Claude Code バージョン: `2.1.118`(README 依存欄 `>= 2.1.49` から引き上げ) + +不成立の場合は **E1 / E2** のフォールバックパスに従う。 + +## アーキテクチャ + +### スコープ + +| 領域 | 変更 | +|---|---| +| `internal/claude/` | フラグ生成(`-n `、`--continue`)、リネーム | +| `internal/worktree/` | `Info.HasSession` 追加、マーカー管理パッケージ追加 | +| `internal/picker/` | L2 4 行レイアウト、`💬 RESUME` / `⚡ NEW` バッジ、footer TIPS | +| `internal/tips/`(新規)| ランダム TIPS 文字列の管理 | +| `cmd/ccw/main.go` | マーカー作成呼び出し、`--continue` フォールバック | +| `docs/README.md` / `docs/README.ja.md` | 旧警告撤去、新動作・命名規約・TIPS 追加 | + +### マーカーファイル + +ccw が「この worktree でセッションを起動済み」と知るためのマーカーを配置: + +```text +/.git/worktrees//ccw-session-active +``` + +- 0 byte の空ファイル +- `git worktree remove` で worktree が削除される時、git 自身が `.git/worktrees//` ごと消すので **マーカーも自動削除** +- `.git/` 配下なので git 追跡対象外、`.gitignore` 更新不要 +- ccw の領域内に閉じ、`~/.claude/` を読みに行かない(権限プロンプトを避ける) + +### コンポーネント一覧 + +| パッケージ / ファイル | 変更内容 | +|---|---| +| `internal/claude/claude.go` | `BuildNewArgs(name, preamble, extra)` シグネチャ変更(name 引数追加)。`--worktree ` と `-n ` を生成。`BuildResumeArgs` を `BuildContinueArgs` にリネーム、`--continue` 付与。`Resume` を `Continue` にリネーム | +| `internal/worktree/marker.go`(新規)| `MarkSessionActive(repoRoot, name) error` / `HasSession(repoRoot, name) bool` | +| `internal/worktree/info.go`(既存)| `Info` 構造体に `HasSession bool` 追加 | +| `internal/picker/delegate.go` | L2 レイアウト実装。`Height()` を 4 に拡張。新ヘルパ `resumeBadge(hasSession bool) string` | +| `internal/picker/style.go` | `RESUME` / `NEW` バッジのスタイル定義(Lipgloss + NO_COLOR フォールバック) | +| `internal/picker/view.go` | footer のランダム TIPS 表示 | +| `internal/tips/tips.go`(新規)| TIPS 文字列スライス + `PickRandom(seed int64) string` | +| `cmd/ccw/main.go` | 新規 worktree 起動時 / 既存 run 時に `MarkSessionActive` を呼ぶ。`Continue` 失敗時は `LaunchNew(... -n )` フォールバック | +| `docs/README.md` / `docs/README.ja.md` | 旧 `--resume` 警告(README.ja.md:79-80)を撤去。新動作・命名規約・`claude --from-pr` の TIPS を追加 | + +### 依存関係 + +- `picker` → `worktree`(HasSession)→ ファイル存在確認のみ +- `claude` パッケージは worktree 名を引数で受け取る純粋関数として保つ +- `tips` は他に依存しない単独パッケージ + +## データフロー + +### A. 新規 worktree 作成 + +注: `claude` 起動は blocking。マーカー作成は claude exit 後に行う(worktree 作成は claude 自身が `--worktree` で行うため、exec 前には `.git/worktrees//` が存在しない)。 + +```text +1. ccw: 名前を決定(user 指定 or 自動生成) +2. ccw: exec claude --worktree -n [extra] ← blocking + (この間 claude が .claude/worktrees// 作成、branch worktree- + を生成、対話セッションを開始) +3. claude が exit、ccw に制御が戻る +4. ccw: worktree.MarkSessionActive(repoRoot, name) + → touch /.git/worktrees//ccw-session-active +5. ccw: claude の exit code を返して終了 +``` + +トレードオフ: 長時間セッション中に別ターミナルで ccw picker を開くと、当該 worktree は `⚡ NEW` 表示になる(マーカー未作成のため)。セッション終了後の次回起動からは正しく `💬 RESUME` 表示される。許容する。 + +### B. 既存 worktree を選択 → `[r] run` + +注: `.git/worktrees//` は既に存在するので、マーカー作成は exec 前に行える。 + +```text +1. ccw picker: worktree 一覧表示(HasSession で RESUME/NEW バッジ判定) +2. user が worktree 選択 → [r] run +3. ccw: cd +4. if HasSession(repoRoot, name): + ccw: exec claude --continue + → 即時非ゼロ exit なら ccw: MarkSessionActive + exec claude -n で再試行 + else: + ccw: MarkSessionActive(repoRoot, name) ← exec 前に作成 + ccw: exec claude -n +5. claude が exit、ccw 終了 +``` + +### C. picker 表示時の HasSession 判定 + +```text +1. ccw: git worktree list で worktree 一覧取得 +2. 各 worktree について: + marker_path = /.git/worktrees//ccw-session-active + info.HasSession = file_exists(marker_path) +3. delegate.Render: HasSession に応じて 💬 RESUME / ⚡ NEW を描画 +``` + +### D. TIPS 表示 + +```text +1. ccw 起動時: tips.PickRandom() で 1 件選択(seed: time.Now().UnixNano()) +2. picker.View() の footer に gh ヒントと並列表示 + - gh 不在時: "💡 Install gh to see PR titles here" + - gh 利用可: "💡 Tip: <ランダム TIPS>" +3. session 中は同じ TIPS を表示(毎レンダで再選択しない) +``` + +TIPS 候補(初期セット): + +- `worktree 名 = session 名。手で /rename しても ccw は何もしません` +- `claude --from-pr <番号> で PR 連携セッションを直接 resume できます` +- `--clean-all で push 済 worktree を一括削除` +- `ccw -- --model で claude にフラグを素通し` + +### E. worktree 削除 + +```text +1. user: [d] delete or --clean-all +2. ccw: git worktree remove +3. git: /.git/worktrees// ディレクトリごと削除 + → ccw-session-active マーカーも自動削除 +4. picker 次回起動時: HasSession=false(worktree 自体が消えてるので影響なし) +``` + +## TUI レイアウト(L2: 4 行ラベル付き + resume 強調) + +### 表示例 + +```text +> 💬 RESUME · foo [PUSHED] ↑0 ↓0 + branch: feature/auth + pr: [OPEN] #123 "feat: add auth" + path: ~/.claude/worktrees/foo + + ⚡ NEW · bugfix-x [LOCAL] ↑2 ↓0 + branch: bugfix-x + pr: (no PR) + path: ~/.claude/worktrees/bugfix-x + + 💬 RESUME · experiment [DIRTY] ↑0 ↓0 ✎3 + branch: worktree-experiment + pr: [DRAFT] #99 "wip: explore" + path: ~/.claude/worktrees/experiment +``` + +### スタイル指針 + +- `💬 RESUME` → 背景緑/シアン塗りの強調バッジ。RESUME 可能行は全体が「目に入る」 +- `⚡ NEW` → 控えめグレー +- worktree 名(= session 名)が RESUME バッジ直後に並ぶ(1:1 マッピングを視覚化) +- status badge / indicators は右寄せ +- NO_COLOR モードでは `[RESUME]` / `[NEW]` の括弧バッジに退化 +- 4 行 × 件数で長くなる。極狭端末(< 60 cols)でラベル崩れの可能性は許容(YAGNI) + +## エラーハンドリング / エッジケース + +### E1. `--worktree` と `-n` のフラグ併用が動かない + +- 起動時 `claude --version` 確認、最低 `2.1.118` を要求 +- 併用不可と判明したら `-n` を諦め、claude 起動後にユーザーへ「`/rename ` してください」とヒント表示 + +### E2. `claude --continue` がセッション無しで挙動不明 + +- `HasSession()` 事前判定で原則ヒットせず +- マーカー有り+実セッション無し時、claude が即時非ゼロ exit すれば `claude -n ` で再試行 +- claude が picker を開いた場合は user に委ねる(自動 fallback しない) + +### E3. マーカーファイル作成失敗 + +- ログに WARN 出して続行(claude 起動は妨げない) +- 次回 picker で `⚡ NEW` 表示になるだけ(致命的でない) +- `CCW_DEBUG=1` で詳細ログ + +### E4. main repo の `.git/worktrees/` 不在(bare repo / submodule 等) + +- `gitx.MainRepoRoot()` 失敗時は既存ロジックでエラー終了 +- マーカー機能は no-op、HasSession は常に false。機能劣化するが破綻しない + +### E5. ユーザーが手動 `/rename foo` でセッション名乖離 + +- 何もしない(Q3 (a) 方針) +- 次回 ccw run: `--continue` は cwd 基準で最新セッションを resume → リネーム後の `foo` セッションが見つかり、復元可能 +- `claude --resume ` は当然マッチしないが、ccw のフローでは使わない + +### E6. 同 worktree 内に複数セッション + +- `--continue` は最新を resume +- 古い側を resume したい場合: ユーザーは Claude 内 `/resume` で picker +- ccw は介入しない + +### E7. worktree 名に特殊文字 + +- 既存の worktree 命名バリデーション再利用 +- マーカーファイル名は固定文字列 `ccw-session-active` を使用 + +### E8. picker レンダリング width 不足 + +- 既存 `truncateToWidth` で各行を幅切り +- < 80 cols 端末でのレイアウト最適化は将来課題 + +### E9. plan mode 承認による名前上書き + +- 公式仕様で `-n`/`/rename` 済みなら上書きされない +- 対応不要 + +## テスト + +### 単体テスト + +**`internal/claude/claude_test.go`** + +- `BuildNewArgs("foo", "", nil)` → `["--permission-mode", "auto", "--worktree", "foo", "-n", "foo"]` +- `BuildNewArgs("foo", "preamble", []string{"--model", "x"})` → 期待引数列 +- `BuildContinueArgs(nil)` → `["--permission-mode", "auto", "--continue"]` +- `BuildContinueArgs([]string{"--debug"})` → `--continue` の前後でフラグ位置確認 +- 名前に空白を含む場合の引数化 + +**`internal/worktree/marker_test.go`(新規)** + +- `MarkSessionActive` で `/.git/worktrees//ccw-session-active` が作られる +- 存在しない `.git/worktrees//` への書き込みは error 返却 +- `HasSession` が file_exists を反映 +- bare repo / `.git` がファイルの worktree からの呼び出し対応 + +**`internal/picker/delegate_test.go`** + +- L2 4 行レイアウト: meta / branch / pr / path 各行が期待文字列 +- `HasSession=true` → `💬 RESUME` バッジ +- `HasSession=false` → `⚡ NEW` バッジ +- NO_COLOR モード: `[RESUME]` / `[NEW]` 括弧バッジ +- selected 時の prefix `>` 付与 +- width 不足時の truncate + +**`internal/picker/style_test.go`** + +- RESUME / NEW スタイルの NO_COLOR 切替 + +**`internal/tips/tips_test.go`(新規)** + +- `PickRandom()` が候補配列から 1 件返す +- 空配列でも panic しない +- seed 指定で決定的な結果 + +**`internal/picker/view_test.go`** + +- footer に gh ヒント / TIPS が排他表示 +- gh 不在時 → gh ヒント、gh 利用可 → TIPS + +### 統合テスト + +`tests/resume_flow_test.go`(新規)— `claude` を fake バイナリでスタブ: + +- 新規 worktree 作成 → fake claude が `-n ` を受け取り、ccw がマーカー作成 +- 既存 worktree で run → fake claude が `--continue` を受け取る +- マーカー無し worktree で run → fake claude が `-n ` を受け取る +- `--continue` が exit 1 → ccw が `-n ` で再試行 +- worktree 削除 → マーカーが自動削除 + +### 手動検証チェックリスト(PR に記載) + +- [ ] `claude --worktree foo -n foo` が実機で動作 +- [ ] 同 worktree で 2 回目に `claude --continue` が前回会話を復元 +- [ ] picker で RESUME / NEW バッジが正しく分かれる +- [ ] `/rename` 後も `--continue` で復元できる(E5) +- [ ] `git worktree remove` でマーカーも消える +- [ ] NO_COLOR=1 で表示崩れなし +- [ ] 80 cols 端末で L2 が読める +- [ ] CCW_DEBUG=1 でマーカー作成ログが出る +- [ ] TIPS が起動ごとに変わる + +### CI + +既存の `go test ./...` / `go vet ./...` / lefthook pre-commit に乗る。新規依存なし。 + +## ドキュメント変更 + +### `docs/README.ja.md` / `README.md` + +**削除**: 旧 `-- --resume ID` パススルー非推奨警告(README.ja.md:79-80 相当) + +**追加**: + +- 「ccw は worktree 作成時に session 名を worktree 名と同期する」旨の説明 +- picker の RESUME / NEW バッジ説明(既存の status / PR バッジ表と並列) +- 命名規約: `<フォルダ名> = = `、ブランチ名は `worktree-`、PR タイトルは独立軸 +- TIPS 例: `claude --from-pr <番号>` で PR 起点 resume も可能 +- 依存欄の Claude Code 最低バージョンを `>= 2.1.118` に引き上げ + +## 公開仕様への影響(破壊的変更) + +- `--worktree` の挙動: 表面上同じ。内部で `-n ` が追加されるだけ +- `[r] run` の挙動: **fresh 起動から resume へデフォルト変更**。ユーザーが意図せず過去会話に戻る可能性 → README で明示 +- 環境変数: 変更なし +- 終了コード: 変更なし + +## 参考 + +- Claude Code 公式: [Resume previous conversations](https://code.claude.com/docs/en/common-workflows#resume-previous-conversations) +- CHANGELOG 該当エントリ: v2.1.82 / v2.1.97 / v2.1.101 / v2.1.115 / v2.1.118 +- README.ja.md:79-80 の旧警告(本仕様で撤去対象) From d84e92d3089ca9fd2f168978d4f512183dc05ac7 Mon Sep 17 00:00:00 2001 From: tqer39 Date: Sat, 25 Apr 2026 17:21:21 +0900 Subject: [PATCH 02/17] =?UTF-8?q?docs(spec):=20=E3=82=BB=E3=83=83=E3=82=B7?= =?UTF-8?q?=E3=83=A7=E3=83=B3=E5=AD=98=E5=9C=A8=E5=88=A4=E5=AE=9A=E3=82=92?= =?UTF-8?q?=E3=83=9E=E3=83=BC=E3=82=AB=E3=83=BC=E3=81=8B=E3=82=89=20~/.cla?= =?UTF-8?q?ude/projects=20=E5=8F=82=E7=85=A7=E3=81=B8=E5=A4=89=E6=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ccw が CLI ツールとしてユーザー権限で動作する以上、~/.claude/ 読み取りに 追加の権限プロンプトは発生しない。マーカーファイルの管理コストや exit 前 クラッシュ時の整合性問題を避けるため、Claude Code が出力する session ログ ディレクトリを直接参照する方式に切り替える。 Co-Authored-By: Claude Opus 4.7 --- ...4-25-worktree-resume-integration-design.md | 109 +++++++++--------- 1 file changed, 56 insertions(+), 53 deletions(-) diff --git a/docs/superpowers/specs/2026-04-25-worktree-resume-integration-design.md b/docs/superpowers/specs/2026-04-25-worktree-resume-integration-design.md index e9f1f95..944ed83 100644 --- a/docs/superpowers/specs/2026-04-25-worktree-resume-integration-design.md +++ b/docs/superpowers/specs/2026-04-25-worktree-resume-integration-design.md @@ -46,42 +46,46 @@ ccw-cli は `claude --worktree` を薄くラップして worktree 選択 UI を | 領域 | 変更 | |---|---| | `internal/claude/` | フラグ生成(`-n `、`--continue`)、リネーム | -| `internal/worktree/` | `Info.HasSession` 追加、マーカー管理パッケージ追加 | +| `internal/worktree/` | `Info.HasSession` 追加、`~/.claude/projects/` 参照ロジック追加 | | `internal/picker/` | L2 4 行レイアウト、`💬 RESUME` / `⚡ NEW` バッジ、footer TIPS | | `internal/tips/`(新規)| ランダム TIPS 文字列の管理 | -| `cmd/ccw/main.go` | マーカー作成呼び出し、`--continue` フォールバック | +| `cmd/ccw/main.go` | `--continue` フォールバック | | `docs/README.md` / `docs/README.ja.md` | 旧警告撤去、新動作・命名規約・TIPS 追加 | -### マーカーファイル +### セッション存在判定 -ccw が「この worktree でセッションを起動済み」と知るためのマーカーを配置: +Claude Code はセッションログを以下に保存する: ```text -/.git/worktrees//ccw-session-active +~/.claude/projects//.jsonl ``` -- 0 byte の空ファイル -- `git worktree remove` で worktree が削除される時、git 自身が `.git/worktrees//` ごと消すので **マーカーも自動削除** -- `.git/` 配下なので git 追跡対象外、`.gitignore` 更新不要 -- ccw の領域内に閉じ、`~/.claude/` を読みに行かない(権限プロンプトを避ける) +`` は cwd の絶対パスを `/` → `-` および `.` → `-` で置換した文字列。例: worktree `/Users/foo/repo/.claude/worktrees/bar` は `-Users-foo-repo--claude-worktrees-bar` にエンコードされる。 + +ccw は worktree ごとにこのディレクトリを参照し、`*.jsonl` が 1 件以上あれば `HasSession=true` と判定する。 + +- ccw 自身は CLI ツール(=ユーザー権限で動作)なので、`~/.claude/` を読むのに追加の権限プロンプトは発生しない +- 開発時に Claude Code 経由で ccw を実装する場合は、リポジトリの `.claude/settings.local.json` で `Read(~/.claude/projects/**)` を許可する +- `git worktree remove` で worktree を消しても `~/.claude/projects//` は残るが、worktree 自体が消えれば picker に出ないので影響なし ### コンポーネント一覧 | パッケージ / ファイル | 変更内容 | |---|---| | `internal/claude/claude.go` | `BuildNewArgs(name, preamble, extra)` シグネチャ変更(name 引数追加)。`--worktree ` と `-n ` を生成。`BuildResumeArgs` を `BuildContinueArgs` にリネーム、`--continue` 付与。`Resume` を `Continue` にリネーム | -| `internal/worktree/marker.go`(新規)| `MarkSessionActive(repoRoot, name) error` / `HasSession(repoRoot, name) bool` | -| `internal/worktree/info.go`(既存)| `Info` 構造体に `HasSession bool` 追加 | +| `internal/worktree/has_session.go`(新規)| `EncodeProjectPath(absPath) string` / `HasSession(absPath) bool` | +| `internal/worktree/info.go`(既存)| `Info` 構造体に `HasSession bool` 追加。一覧取得時に `HasSession(absPath)` で埋める | | `internal/picker/delegate.go` | L2 レイアウト実装。`Height()` を 4 に拡張。新ヘルパ `resumeBadge(hasSession bool) string` | | `internal/picker/style.go` | `RESUME` / `NEW` バッジのスタイル定義(Lipgloss + NO_COLOR フォールバック) | | `internal/picker/view.go` | footer のランダム TIPS 表示 | | `internal/tips/tips.go`(新規)| TIPS 文字列スライス + `PickRandom(seed int64) string` | -| `cmd/ccw/main.go` | 新規 worktree 起動時 / 既存 run 時に `MarkSessionActive` を呼ぶ。`Continue` 失敗時は `LaunchNew(... -n )` フォールバック | +| `cmd/ccw/main.go` | `Continue` 失敗時は `LaunchNew(... -n )` フォールバック | | `docs/README.md` / `docs/README.ja.md` | 旧 `--resume` 警告(README.ja.md:79-80)を撤去。新動作・命名規約・`claude --from-pr` の TIPS を追加 | +| `.claude/settings.local.json` | 開発時のみ: `Read(~/.claude/projects/**)` 許可 | ### 依存関係 -- `picker` → `worktree`(HasSession)→ ファイル存在確認のみ +- `picker` → `worktree`(HasSession)→ `~/.claude/projects//` の `os.ReadDir` のみ - `claude` パッケージは worktree 名を引数で受け取る純粋関数として保つ - `tips` は他に依存しない単独パッケージ @@ -89,34 +93,28 @@ ccw が「この worktree でセッションを起動済み」と知るための ### A. 新規 worktree 作成 -注: `claude` 起動は blocking。マーカー作成は claude exit 後に行う(worktree 作成は claude 自身が `--worktree` で行うため、exec 前には `.git/worktrees//` が存在しない)。 - ```text 1. ccw: 名前を決定(user 指定 or 自動生成) 2. ccw: exec claude --worktree -n [extra] ← blocking (この間 claude が .claude/worktrees// 作成、branch worktree- - を生成、対話セッションを開始) + を生成、対話セッションを開始。session ログを + ~/.claude/projects//.jsonl に書き出す) 3. claude が exit、ccw に制御が戻る -4. ccw: worktree.MarkSessionActive(repoRoot, name) - → touch /.git/worktrees//ccw-session-active -5. ccw: claude の exit code を返して終了 +4. ccw: claude の exit code を返して終了 ``` -トレードオフ: 長時間セッション中に別ターミナルで ccw picker を開くと、当該 worktree は `⚡ NEW` 表示になる(マーカー未作成のため)。セッション終了後の次回起動からは正しく `💬 RESUME` 表示される。許容する。 +ccw 側で追加のファイル作成は不要。次回 picker 起動時に `~/.claude/projects//` の存在で resume 可否を判定する。 ### B. 既存 worktree を選択 → `[r] run` -注: `.git/worktrees//` は既に存在するので、マーカー作成は exec 前に行える。 - ```text 1. ccw picker: worktree 一覧表示(HasSession で RESUME/NEW バッジ判定) 2. user が worktree 選択 → [r] run 3. ccw: cd -4. if HasSession(repoRoot, name): +4. if HasSession(worktree-path): ccw: exec claude --continue - → 即時非ゼロ exit なら ccw: MarkSessionActive + exec claude -n で再試行 + → 即時非ゼロ exit なら ccw: exec claude -n で再試行 else: - ccw: MarkSessionActive(repoRoot, name) ← exec 前に作成 ccw: exec claude -n 5. claude が exit、ccw 終了 ``` @@ -124,10 +122,11 @@ ccw が「この worktree でセッションを起動済み」と知るための ### C. picker 表示時の HasSession 判定 ```text -1. ccw: git worktree list で worktree 一覧取得 +1. ccw: git worktree list で worktree 一覧取得(各 worktree の絶対 path を取得) 2. 各 worktree について: - marker_path = /.git/worktrees//ccw-session-active - info.HasSession = file_exists(marker_path) + encoded = strings.NewReplacer("/", "-", ".", "-").Replace(worktreePath) + dir = filepath.Join(homeDir, ".claude", "projects", encoded) + info.HasSession = (os.ReadDir(dir) で *.jsonl が 1 件以上) 3. delegate.Render: HasSession に応じて 💬 RESUME / ⚡ NEW を描画 ``` @@ -153,9 +152,11 @@ TIPS 候補(初期セット): ```text 1. user: [d] delete or --clean-all 2. ccw: git worktree remove -3. git: /.git/worktrees// ディレクトリごと削除 - → ccw-session-active マーカーも自動削除 -4. picker 次回起動時: HasSession=false(worktree 自体が消えてるので影響なし) +3. picker 次回起動時: 当該 worktree は一覧に出ない + - ~/.claude/projects// は残るが、参照する側の worktree が + 消えているので picker には影響しない + - 過去 session ログのクリーンアップは Claude Code の責務 + (ccw は触らない) ``` ## TUI レイアウト(L2: 4 行ラベル付き + resume 強調) @@ -198,19 +199,19 @@ TIPS 候補(初期セット): ### E2. `claude --continue` がセッション無しで挙動不明 - `HasSession()` 事前判定で原則ヒットせず -- マーカー有り+実セッション無し時、claude が即時非ゼロ exit すれば `claude -n ` で再試行 +- 万が一 ReadDir が古い情報を返した場合、claude が即時非ゼロ exit すれば `claude -n ` で再試行 - claude が picker を開いた場合は user に委ねる(自動 fallback しない) -### E3. マーカーファイル作成失敗 +### E3. `~/.claude/projects/` のフォーマット変更 -- ログに WARN 出して続行(claude 起動は妨げない) -- 次回 picker で `⚡ NEW` 表示になるだけ(致命的でない) -- `CCW_DEBUG=1` で詳細ログ +- パスエンコード規則 (`/` → `-`, `.` → `-`) は Claude Code の内部実装。公式ドキュメント化されておらず、将来変更され得る +- HasSession 判定が外れても、`--continue` 自体は cwd 基準で claude 側が解決するので動作は壊れない(バッジ表示が `⚡ NEW` 固定になるだけ) +- `CCW_DEBUG=1` で encoded path とディレクトリ存在をログ出力。違いに気付きやすくする -### E4. main repo の `.git/worktrees/` 不在(bare repo / submodule 等) +### E4. `os.UserHomeDir()` 失敗 / `~/.claude/projects/` 不在 -- `gitx.MainRepoRoot()` 失敗時は既存ロジックでエラー終了 -- マーカー機能は no-op、HasSession は常に false。機能劣化するが破綻しない +- HasSession は false 固定で続行(致命的でない) +- claude 未インストール/初回起動前は `~/.claude/` 自体が無い場合あり → false 返却で問題なし ### E5. ユーザーが手動 `/rename foo` でセッション名乖離 @@ -224,10 +225,10 @@ TIPS 候補(初期セット): - 古い側を resume したい場合: ユーザーは Claude 内 `/resume` で picker - ccw は介入しない -### E7. worktree 名に特殊文字 +### E7. worktree 名 / path に特殊文字 - 既存の worktree 命名バリデーション再利用 -- マーカーファイル名は固定文字列 `ccw-session-active` を使用 +- エンコードは絶対 path の `/` と `.` を `-` に置換するだけ。既存規則に合わせて claude が同じパスから読み出すため、文字列一致が成立すれば OK ### E8. picker レンダリング width 不足 @@ -251,12 +252,14 @@ TIPS 候補(初期セット): - `BuildContinueArgs([]string{"--debug"})` → `--continue` の前後でフラグ位置確認 - 名前に空白を含む場合の引数化 -**`internal/worktree/marker_test.go`(新規)** +**`internal/worktree/has_session_test.go`(新規)** -- `MarkSessionActive` で `/.git/worktrees//ccw-session-active` が作られる -- 存在しない `.git/worktrees//` への書き込みは error 返却 -- `HasSession` が file_exists を反映 -- bare repo / `.git` がファイルの worktree からの呼び出し対応 +- `EncodeProjectPath("/Users/foo/repo/.claude/worktrees/bar")` → `-Users-foo-repo--claude-worktrees-bar` +- `t.Setenv("HOME", tmpDir)` でテスト用 HOME を作成 +- `/.claude/projects//abc.jsonl` を配置 → `HasSession=true` +- 同ディレクトリに `.jsonl` 以外しか無い場合 → false +- ディレクトリ自体が存在しない → false +- HOME 取得失敗のシミュレーション(`HOME=""`)→ false **`internal/picker/delegate_test.go`** @@ -284,13 +287,13 @@ TIPS 候補(初期セット): ### 統合テスト -`tests/resume_flow_test.go`(新規)— `claude` を fake バイナリでスタブ: +`tests/resume_flow_test.go`(新規)— `claude` を fake バイナリでスタブ、`HOME` をテスト用に差し替え: -- 新規 worktree 作成 → fake claude が `-n ` を受け取り、ccw がマーカー作成 -- 既存 worktree で run → fake claude が `--continue` を受け取る -- マーカー無し worktree で run → fake claude が `-n ` を受け取る +- 新規 worktree 作成 → fake claude が `--worktree -n ` を受け取る +- HasSession=true の worktree で run → fake claude が `--continue` を受け取る +- HasSession=false の worktree で run → fake claude が `-n ` を受け取る - `--continue` が exit 1 → ccw が `-n ` で再試行 -- worktree 削除 → マーカーが自動削除 +- `~/.claude/projects//foo.jsonl` を配置 → picker で `💬 RESUME` 表示 ### 手動検証チェックリスト(PR に記載) @@ -298,10 +301,10 @@ TIPS 候補(初期セット): - [ ] 同 worktree で 2 回目に `claude --continue` が前回会話を復元 - [ ] picker で RESUME / NEW バッジが正しく分かれる - [ ] `/rename` 後も `--continue` で復元できる(E5) -- [ ] `git worktree remove` でマーカーも消える +- [ ] `~/.claude/projects//` の path エンコード規則が実機の Claude Code と一致 - [ ] NO_COLOR=1 で表示崩れなし - [ ] 80 cols 端末で L2 が読める -- [ ] CCW_DEBUG=1 でマーカー作成ログが出る +- [ ] CCW_DEBUG=1 で encoded path とディレクトリ存在ログが出る - [ ] TIPS が起動ごとに変わる ### CI From 2f97113a7e54d8308c04980c96d6f2ccd6ecd77c Mon Sep 17 00:00:00 2001 From: tqer39 Date: Sat, 25 Apr 2026 17:32:16 +0900 Subject: [PATCH 03/17] =?UTF-8?q?docs(plan):=20worktree-resume=20integrati?= =?UTF-8?q?on=20=E3=81=AE=E5=AE=9F=E8=A3=85=E8=A8=88=E7=94=BB=E3=82=92?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit spec を 13 タスクに分解。各タスクは TDD で進められる粒度。 namegen / has_session / tips の 3 つの新規パッケージを段階的に追加し、 最後に cmd/ccw を新シグネチャへ移行する。 Co-Authored-By: Claude Opus 4.7 --- .../2026-04-25-worktree-resume-integration.md | 1765 +++++++++++++++++ 1 file changed, 1765 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-25-worktree-resume-integration.md diff --git a/docs/superpowers/plans/2026-04-25-worktree-resume-integration.md b/docs/superpowers/plans/2026-04-25-worktree-resume-integration.md new file mode 100644 index 0000000..a018251 --- /dev/null +++ b/docs/superpowers/plans/2026-04-25-worktree-resume-integration.md @@ -0,0 +1,1765 @@ +# Worktree ↔ Claude Code Session Resume Integration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** ccw が作る worktree の名前を Claude Code のセッション名と 1:1 にし、picker から既存 worktree を選んだら過去会話を resume できるようにする。 + +**Architecture:** + +- ccw は worktree 名を生成し、`claude --worktree -n ` で起動する。 +- picker 表示時に `~/.claude/projects//` を読み、`*.jsonl` の有無で resume 可否を判定する。 +- 既存 worktree を選んで `[r] run` した場合、デフォルトで `claude --continue` を呼び、失敗時のみ `claude -n ` フォールバック。 + +**Tech Stack:** Go 1.25 / bubbletea / lipgloss / Claude Code CLI `>= 2.1.118` + +**Spec:** `docs/superpowers/specs/2026-04-25-worktree-resume-integration-design.md` + +--- + +## File Structure + +新規ファイル: + +- `internal/namegen/namegen.go` — worktree / session 名のジェネレータ +- `internal/namegen/namegen_test.go` +- `internal/worktree/has_session.go` — `EncodeProjectPath` / `HasSession` +- `internal/worktree/has_session_test.go` +- `internal/tips/tips.go` — TIPS 文字列セット + `PickRandom` +- `internal/tips/tips_test.go` +- `tests/resume_flow_test.go` — fake claude バイナリでの統合テスト + +修正: + +- `internal/claude/claude.go` — `BuildNewArgs(name, preamble, extra)`、`BuildContinueArgs`、`Continue` +- `internal/claude/claude_test.go` +- `internal/worktree/worktree.go` — `Info.HasSession`、`List` で埋める +- `internal/worktree/worktree_test.go` +- `internal/picker/style.go` — `ResumeBadge(hasSession bool)` +- `internal/picker/style_test.go` +- `internal/picker/delegate.go` — L2 4 行レイアウト +- `internal/picker/delegate_test.go` +- `internal/picker/view.go` — footer に random TIPS +- `internal/picker/model.go` — `New` で TIPS 選択 +- `internal/picker/model_test.go`(必要なら) +- `cmd/ccw/main.go` — 名前生成、`Continue` 呼び出し、フォールバック +- `docs/README.md` / `docs/README.ja.md` — 旧警告撤去、命名規約・TIPS 追記 +- `.claude/settings.local.json` — `Read(~/.claude/projects/**)` 許可 + +--- + +## Task 0: 前提環境チェック(実装前) + +**目的:** spec の前提検証項目を満たすローカル環境を確認する。 + +**Files:** + +- なし(手動検証) + +- [ ] **Step 1: claude のバージョン確認** + +Run: `claude --version` +Expected: `2.1.118` 以上 + +不足していたら `npm i -g @anthropic-ai/claude-code` などで更新。 + +- [ ] **Step 2: フラグ併用確認** + +Run(任意のサンドボックス repo で): + +```bash +mkdir -p /tmp/ccw-flag-check && cd /tmp/ccw-flag-check && git init -q && \ + echo x > a && git add . && git commit -q -m init +claude --worktree foo -n foo --print "ok" +``` + +Expected: エラーなく終了し、`/tmp/ccw-flag-check/.claude/worktrees/foo/` が作成されている。 + +- [ ] **Step 3: `--continue` の no-session 挙動を観察** + +Run(セッションログが無いディレクトリで): + +```bash +cd /tmp && claude --continue --print "ok"; echo "exit=$?" +``` + +Expected: 非ゼロ exit、または picker 起動後にユーザ操作で抜ける。挙動を実装中の参考にする。 + +- [ ] **Step 4: パスエンコード規則の確認** + +Run: + +```bash +ls ~/.claude/projects/ | head +``` + +Expected: `` の `/` と `.` が `-` に置換されたディレクトリが存在する。`*.jsonl` ファイルが入っている。 + +実装と仕様が一致するか目視確認。一致しなければ `EncodeProjectPath` の実装を後の Task で調整する。 + +--- + +## Task 1: 名前ジェネレータ `internal/namegen` を追加 + +**目的:** ccw が worktree 名と session 名に同じ値を渡すための、決定的かつ衝突しにくい名前生成器を作る。 + +**Files:** + +- Create: `internal/namegen/namegen.go` +- Test: `internal/namegen/namegen_test.go` + +- [ ] **Step 1: テストを書く** + +`internal/namegen/namegen_test.go`: + +```go +package namegen + +import ( + "regexp" + "testing" +) + +func TestGenerate_FormatAndUniqueness(t *testing.T) { + re := regexp.MustCompile(`^[a-z]+-[a-z]+-[0-9a-f]{4}$`) + seen := map[string]struct{}{} + for i := 0; i < 100; i++ { + got := Generate() + if !re.MatchString(got) { + t.Fatalf("Generate() = %q, want match %s", got, re) + } + seen[got] = struct{}{} + } + if len(seen) < 90 { + t.Errorf("Generate() collisions too high: %d/100 unique", len(seen)) + } +} + +func TestGenerateWithSeed_Deterministic(t *testing.T) { + a := generateWithSeed(42) + b := generateWithSeed(42) + if a != b { + t.Errorf("generateWithSeed(42): non-deterministic %q vs %q", a, b) + } +} + +func TestGenerate_NoSpacesNoUppercase(t *testing.T) { + for i := 0; i < 50; i++ { + got := Generate() + for _, r := range got { + if r == ' ' { + t.Fatalf("Generate() = %q contains space", got) + } + if r >= 'A' && r <= 'Z' { + t.Fatalf("Generate() = %q contains uppercase", got) + } + } + } +} +``` + +- [ ] **Step 2: テストを実行して fail を確認** + +Run: `go test ./internal/namegen/...` +Expected: `package namegen: no Go files` または `undefined: Generate` + +- [ ] **Step 3: 実装** + +`internal/namegen/namegen.go`: + +```go +// Package namegen generates short slug names like "quick-falcon-7bd2" +// to use as both worktree directory name and Claude Code session name. +package namegen + +import ( + "fmt" + "math/rand/v2" + "time" +) + +var adjectives = []string{ + "quick", "lazy", "happy", "brave", "calm", "eager", "fancy", "glad", + "jolly", "kind", "lively", "merry", "nice", "polite", "quiet", "silly", + "witty", "zany", "bright", "clever", "daring", "fierce", "gentle", "mighty", + "nimble", "proud", "rapid", "shiny", "sturdy", "tame", +} + +var nouns = []string{ + "falcon", "otter", "lion", "tiger", "wolf", "panda", "eagle", "shark", + "crane", "fox", "raven", "owl", "lynx", "bison", "moose", "hawk", + "orca", "puma", "yak", "ibex", "robin", "swan", "gecko", "mantis", + "koala", "badger", "heron", "jaguar", "lemur", "mole", +} + +// Generate returns a slug like "quick-falcon-7bd2". +// Uses time.Now().UnixNano() as the seed. +func Generate() string { + return generateWithSeed(uint64(time.Now().UnixNano())) +} + +func generateWithSeed(seed uint64) string { + r := rand.New(rand.NewPCG(seed, seed^0x9E3779B97F4A7C15)) + adj := adjectives[r.IntN(len(adjectives))] + noun := nouns[r.IntN(len(nouns))] + suffix := fmt.Sprintf("%04x", r.IntN(0x10000)) + return fmt.Sprintf("%s-%s-%s", adj, noun, suffix) +} +``` + +- [ ] **Step 4: テストが通ることを確認** + +Run: `go test ./internal/namegen/...` +Expected: PASS + +- [ ] **Step 5: コミット** + +```bash +git add internal/namegen +git commit -m "feat(namegen): worktree/session 名ジェネレータを追加" +``` + +--- + +## Task 2: `claude` パッケージにフラグ生成ロジックを更新 + +**目的:** `BuildNewArgs` に name 引数を追加し `--worktree -n ` を生成。`BuildResumeArgs` を `BuildContinueArgs` にリネームして `--continue` を付与。`Resume` を `Continue` にリネーム。 + +**Files:** + +- Modify: `internal/claude/claude.go` +- Modify: `internal/claude/claude_test.go` + +- [ ] **Step 1: テストを更新** + +`internal/claude/claude_test.go` を以下で**置き換え**: + +```go +package claude + +import ( + "reflect" + "testing" +) + +func TestBuildNewArgs_NameOnly(t *testing.T) { + got := BuildNewArgs("foo", "", nil) + want := []string{"--permission-mode", "auto", "--worktree", "foo", "-n", "foo"} + if !reflect.DeepEqual(got, want) { + t.Errorf("BuildNewArgs:\n got = %v\n want = %v", got, want) + } +} + +func TestBuildNewArgs_WithExtra(t *testing.T) { + got := BuildNewArgs("foo", "", []string{"--model", "claude-opus-4-7"}) + want := []string{"--permission-mode", "auto", "--worktree", "foo", "-n", "foo", "--model", "claude-opus-4-7"} + if !reflect.DeepEqual(got, want) { + t.Errorf("BuildNewArgs extra:\n got = %v\n want = %v", got, want) + } +} + +func TestBuildNewArgs_WithPreamble(t *testing.T) { + got := BuildNewArgs("foo", "hello", nil) + want := []string{"--permission-mode", "auto", "--worktree", "foo", "-n", "foo", "--", "hello"} + if !reflect.DeepEqual(got, want) { + t.Errorf("BuildNewArgs preamble:\n got = %v\n want = %v", got, want) + } +} + +func TestBuildNewArgs_WithExtraAndPreamble(t *testing.T) { + got := BuildNewArgs("foo", "hi", []string{"--model", "x"}) + want := []string{"--permission-mode", "auto", "--worktree", "foo", "-n", "foo", "--model", "x", "--", "hi"} + if !reflect.DeepEqual(got, want) { + t.Errorf("BuildNewArgs both:\n got = %v\n want = %v", got, want) + } +} + +func TestBuildContinueArgs_Empty(t *testing.T) { + got := BuildContinueArgs(nil) + want := []string{"--permission-mode", "auto", "--continue"} + if !reflect.DeepEqual(got, want) { + t.Errorf("BuildContinueArgs:\n got = %v\n want = %v", got, want) + } +} + +func TestBuildContinueArgs_WithExtra(t *testing.T) { + got := BuildContinueArgs([]string{"--model", "x"}) + want := []string{"--permission-mode", "auto", "--continue", "--model", "x"} + if !reflect.DeepEqual(got, want) { + t.Errorf("BuildContinueArgs extra:\n got = %v\n want = %v", got, want) + } +} +``` + +- [ ] **Step 2: テストを実行して fail 確認** + +Run: `go test ./internal/claude/...` +Expected: コンパイルエラー(`BuildNewArgs` シグネチャ不一致 / `BuildContinueArgs` 未定義) + +- [ ] **Step 3: 実装を更新** + +`internal/claude/claude.go`: + +```go +// Package claude wraps launching the `claude` CLI in ccw-appropriate ways +// (new worktree session vs. continue existing worktree). +package claude + +import ( + "errors" + "fmt" + "os" + "os/exec" +) + +// BuildNewArgs constructs argv (excluding the program name) for +// `claude --permission-mode auto --worktree -n [extra...] [-- preamble]`. +func BuildNewArgs(name, preamble string, extra []string) []string { + args := make([]string, 0, 6+len(extra)+2) + args = append(args, "--permission-mode", "auto", "--worktree", name, "-n", name) + args = append(args, extra...) + if preamble != "" { + args = append(args, "--", preamble) + } + return args +} + +// BuildContinueArgs constructs argv for `claude --permission-mode auto --continue [extra...]`. +func BuildContinueArgs(extra []string) []string { + args := make([]string, 0, 3+len(extra)) + args = append(args, "--permission-mode", "auto", "--continue") + return append(args, extra...) +} + +// LaunchNew execs claude with BuildNewArgs in cwd. Returns claude's exit code +// (0 on success, the child exit code on non-zero exit, -1 on exec error). +func LaunchNew(cwd, name, preamble string, extra []string) (int, error) { + return runClaude(cwd, BuildNewArgs(name, preamble, extra)) +} + +// Continue execs claude with BuildContinueArgs in cwd. +func Continue(cwd string, extra []string) (int, error) { + return runClaude(cwd, BuildContinueArgs(extra)) +} + +func runClaude(cwd string, args []string) (int, error) { + cmd := exec.Command("claude", args...) + cmd.Dir = cwd + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + err := cmd.Run() + if err == nil { + return 0, nil + } + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return exitErr.ExitCode(), nil + } + return -1, fmt.Errorf("run claude: %w", err) +} +``` + +- [ ] **Step 4: パッケージ単独テストが通ることを確認** + +Run: `go test ./internal/claude/...` +Expected: PASS + +- [ ] **Step 5: 全体ビルドが壊れていることを確認(呼び出し側未修正)** + +Run: `go build ./...` +Expected: `cmd/ccw/main.go` で `claude.Resume` 未定義、`claude.LaunchNew` 引数不一致のエラー + +これは Task 6 で直すので一旦そのままでよい。次の Task に進む前にコミットだけ済ませる。 + +- [ ] **Step 6: コミット** + +```bash +git add internal/claude +git commit -m "feat(claude): -n フラグと --continue を組み込み Continue にリネーム" +``` + +--- + +## Task 3: `internal/worktree/has_session.go` を追加 + +**目的:** worktree path から `~/.claude/projects//` のセッションログ有無を判定する。 + +**Files:** + +- Create: `internal/worktree/has_session.go` +- Test: `internal/worktree/has_session_test.go` + +- [ ] **Step 1: テストを書く** + +`internal/worktree/has_session_test.go`: + +```go +package worktree + +import ( + "os" + "path/filepath" + "testing" +) + +func TestEncodeProjectPath(t *testing.T) { + cases := []struct { + in, want string + }{ + {"/Users/foo/repo/.claude/worktrees/bar", "-Users-foo-repo--claude-worktrees-bar"}, + {"/a.b/c", "-a-b-c"}, + {"/", "-"}, + } + for _, tc := range cases { + if got := EncodeProjectPath(tc.in); got != tc.want { + t.Errorf("EncodeProjectPath(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +func TestHasSession_True(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + wt := "/Users/foo/repo/.claude/worktrees/bar" + dir := filepath.Join(home, ".claude", "projects", EncodeProjectPath(wt)) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "abc.jsonl"), []byte("{}\n"), 0o644); err != nil { + t.Fatal(err) + } + + if !HasSession(wt) { + t.Error("HasSession() = false, want true") + } +} + +func TestHasSession_FalseWhenNoJsonl(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + wt := "/Users/foo/repo/.claude/worktrees/bar" + dir := filepath.Join(home, ".claude", "projects", EncodeProjectPath(wt)) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "note.txt"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + + if HasSession(wt) { + t.Error("HasSession() = true, want false (no .jsonl)") + } +} + +func TestHasSession_FalseWhenDirMissing(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + if HasSession("/nonexistent/path") { + t.Error("HasSession() = true, want false (dir missing)") + } +} + +func TestHasSession_FalseWhenHomeUnset(t *testing.T) { + t.Setenv("HOME", "") + if HasSession("/Users/foo/repo/.claude/worktrees/bar") { + t.Error("HasSession() = true, want false (HOME empty)") + } +} +``` + +- [ ] **Step 2: テストを実行して fail を確認** + +Run: `go test ./internal/worktree/...` +Expected: `undefined: EncodeProjectPath` / `undefined: HasSession` + +- [ ] **Step 3: 実装** + +`internal/worktree/has_session.go`: + +```go +package worktree + +import ( + "os" + "path/filepath" + "strings" +) + +// EncodeProjectPath converts an absolute worktree path to the directory name +// Claude Code uses under ~/.claude/projects/. Both '/' and '.' map to '-'. +// This rule is observed from claude's behavior; it is not part of any +// public contract and may change. +func EncodeProjectPath(absPath string) string { + return strings.NewReplacer("/", "-", ".", "-").Replace(absPath) +} + +// HasSession reports whether ~/.claude/projects// contains +// at least one *.jsonl file. Returns false on any error (HOME unset, dir +// missing, read failure) so callers can use it as a UI hint without +// branching on errors. +func HasSession(absPath string) bool { + home, err := os.UserHomeDir() + if err != nil || home == "" { + return false + } + dir := filepath.Join(home, ".claude", "projects", EncodeProjectPath(absPath)) + entries, err := os.ReadDir(dir) + if err != nil { + return false + } + for _, e := range entries { + if !e.IsDir() && strings.HasSuffix(e.Name(), ".jsonl") { + return true + } + } + return false +} +``` + +- [ ] **Step 4: テストが通ることを確認** + +Run: `go test ./internal/worktree/...` +Expected: PASS + +- [ ] **Step 5: コミット** + +```bash +git add internal/worktree/has_session.go internal/worktree/has_session_test.go +git commit -m "feat(worktree): ~/.claude/projects 参照で HasSession を判定" +``` + +--- + +## Task 4: `worktree.Info` に `HasSession` フィールドを追加し `List` で埋める + +**目的:** picker が描画時に resume 可否を判定できるよう、`Info` に状態をキャッシュする。 + +**Files:** + +- Modify: `internal/worktree/worktree.go` +- Modify: `internal/worktree/worktree_test.go` + +- [ ] **Step 1: 既存テストを確認** + +Run: `cat internal/worktree/worktree_test.go | head -60` + +`TestList_*` 系のテストがあれば、`Info` の比較に `HasSession: false` が現れることを許容する形に直す必要がある。なければスキップ。 + +- [ ] **Step 2: 失敗するテストを追加** + +`internal/worktree/worktree_test.go` の末尾に追記: + +```go +func TestList_PopulatesHasSession(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + main := setupRepo(t) // 既存のテストヘルパ。無ければ TestList_* のヘルパを参照 + wt := filepath.Join(main, ".claude", "worktrees", "alpha") + createWorktree(t, main, wt, "alpha") // 同上 + + dir := filepath.Join(home, ".claude", "projects", EncodeProjectPath(wt)) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "x.jsonl"), []byte("{}\n"), 0o644); err != nil { + t.Fatal(err) + } + + infos, err := List(main) + if err != nil { + t.Fatal(err) + } + var found bool + for _, in := range infos { + if in.Path == wt { + if !in.HasSession { + t.Errorf("Info.HasSession = false, want true for %s", wt) + } + found = true + } + } + if !found { + t.Fatalf("worktree %s not in List() output", wt) + } +} +``` + +> 既存の `worktree_test.go` に `setupRepo` / `createWorktree` 系ヘルパが無い場合は、`TestList_*` の既存テストを参考に、git init + ブランチ作成 + worktree add のヘルパを共通化してから本テストを追加する。 + +- [ ] **Step 3: テストが fail することを確認** + +Run: `go test ./internal/worktree/ -run TestList_PopulatesHasSession` +Expected: コンパイルエラー(`Info.HasSession` 未定義) + +- [ ] **Step 4: `Info` 定義と `List` を更新** + +`internal/worktree/worktree.go` の `Info` を: + +```go +type Info struct { + Path string + Branch string + Status Status + AheadCount int + BehindCount int + DirtyCount int + HasSession bool +} +``` + +`List` 内 `info := Info{...}` の直後を: + +```go + info := Info{Path: e.Path, Branch: e.Branch, Status: st} + ahead, behind, _ := gitx.AheadBehind(e.Path) + info.AheadCount = ahead + info.BehindCount = behind + if st == StatusDirty { + n, _ := gitx.DirtyCount(e.Path) + info.DirtyCount = n + } + info.HasSession = HasSession(e.Path) + result = append(result, info) +``` + +に変更(最終行 `info.HasSession = HasSession(e.Path)` を追加)。 + +- [ ] **Step 5: テストが通ることを確認** + +Run: `go test ./internal/worktree/...` +Expected: PASS + +- [ ] **Step 6: コミット** + +```bash +git add internal/worktree/worktree.go internal/worktree/worktree_test.go +git commit -m "feat(worktree): Info.HasSession を List で埋める" +``` + +--- + +## Task 5: picker のスタイル — RESUME / NEW バッジを追加 + +**目的:** worktree 行に表示する RESUME / NEW バッジのスタイルを定義する。 + +**Files:** + +- Modify: `internal/picker/style.go` +- Modify: `internal/picker/style_test.go` + +- [ ] **Step 1: テストを追加** + +`internal/picker/style_test.go` の末尾に追記: + +```go +func TestResumeBadge_HasSession(t *testing.T) { + t.Setenv("NO_COLOR", "1") + if got := ResumeBadge(true); got != "[RESUME]" { + t.Errorf("ResumeBadge(true) NO_COLOR = %q, want [RESUME]", got) + } + if got := ResumeBadge(false); got != "[NEW] " { + t.Errorf("ResumeBadge(false) NO_COLOR = %q, want [NEW] ", got) + } +} + +func TestResumeBadge_Colored(t *testing.T) { + t.Setenv("NO_COLOR", "") + got := ResumeBadge(true) + if !strings.Contains(got, "RESUME") { + t.Errorf("ResumeBadge(true) = %q, want substring RESUME", got) + } + got = ResumeBadge(false) + if !strings.Contains(got, "NEW") { + t.Errorf("ResumeBadge(false) = %q, want substring NEW", got) + } +} +``` + +`strings` の import を追加(既存ならそのまま)。 + +- [ ] **Step 2: fail 確認** + +Run: `go test ./internal/picker/ -run TestResumeBadge` +Expected: `undefined: ResumeBadge` + +- [ ] **Step 3: 実装** + +`internal/picker/style.go` の末尾に追記: + +```go +// ResumeBadge renders a RESUME / NEW badge. +// hasSession=true → 💬 RESUME (green-cyan, prominent) +// hasSession=false → ⚡ NEW (dim grey) +// Under NO_COLOR, returns plain "[RESUME]" / "[NEW] " (padded to same width). +func ResumeBadge(hasSession bool) string { + if noColor() { + if hasSession { + return "[RESUME]" + } + return "[NEW] " + } + if hasSession { + return lipgloss.NewStyle(). + Padding(0, 1).Bold(true). + Background(lipgloss.Color("14")). + Foreground(lipgloss.Color("0")). + Render("💬 RESUME") + } + return lipgloss.NewStyle(). + Padding(0, 1). + Background(lipgloss.Color("240")). + Foreground(lipgloss.Color("15")). + Render("⚡ NEW ") +} +``` + +- [ ] **Step 4: テストが通ることを確認** + +Run: `go test ./internal/picker/ -run TestResumeBadge` +Expected: PASS + +- [ ] **Step 5: コミット** + +```bash +git add internal/picker/style.go internal/picker/style_test.go +git commit -m "feat(picker): RESUME / NEW バッジのスタイル" +``` + +--- + +## Task 6: picker delegate を L2 4 行レイアウトに拡張 + +**目的:** worktree 行を 4 行表示に変更し、RESUME バッジを最も目立つ位置に配置する。 + +**Files:** + +- Modify: `internal/picker/delegate.go` +- Modify: `internal/picker/delegate_test.go` + +レイアウト(spec 161 行付近): + +```text +> 💬 RESUME · foo [PUSHED] ↑0 ↓0 + branch: feature/auth + pr: [OPEN] #123 "feat: add auth" + path: ~/.claude/worktrees/foo +``` + +- [ ] **Step 1: 既存テストを読む** + +Run: `cat internal/picker/delegate_test.go` + +既存の `renderRow` テストの前提(高さ 2、フォーマット)が変わるので、置き換える。 + +- [ ] **Step 2: テストを更新(fail 状態に)** + +`internal/picker/delegate_test.go` で `renderRow` を呼んでいる箇所すべてを以下のように書き換える(最低 1 ケースは変更:4 行のうち先頭にバッジが付く + path が `path:` ラベル付きで描画される): + +```go +func TestRenderRow_ResumeBadge(t *testing.T) { + t.Setenv("NO_COLOR", "1") + li := listItem{ + tag: tagWorktree, + wt: &worktree.Info{ + Path: "/repo/.claude/worktrees/foo", + Branch: "feature/auth", + Status: worktree.StatusPushed, + HasSession: true, + }, + } + got := renderRow(li, 120, true /*prUnavailable*/, false) + if !strings.Contains(got, "[RESUME]") { + t.Errorf("missing RESUME badge:\n%s", got) + } + if !strings.Contains(got, "foo") { + t.Errorf("missing worktree name foo:\n%s", got) + } + if !strings.Contains(got, "branch: feature/auth") { + t.Errorf("missing branch line:\n%s", got) + } + if !strings.Contains(got, "path: /repo/.claude/worktrees/foo") { + t.Errorf("missing path line:\n%s", got) + } + lines := strings.Split(strings.TrimRight(got, "\n"), "\n") + if len(lines) != 4 { + t.Errorf("got %d lines, want 4:\n%s", len(lines), got) + } +} + +func TestRenderRow_NewBadge(t *testing.T) { + t.Setenv("NO_COLOR", "1") + li := listItem{ + tag: tagWorktree, + wt: &worktree.Info{ + Path: "/repo/.claude/worktrees/bar", + Branch: "bar", + Status: worktree.StatusLocalOnly, + HasSession: false, + }, + } + got := renderRow(li, 120, true, true /*selected*/) + if !strings.Contains(got, "[NEW]") { + t.Errorf("missing NEW badge:\n%s", got) + } + if !strings.HasPrefix(got, "> ") { + t.Errorf("selected row should start with '> ': %q", got[:2]) + } +} +``` + +既存の `TestRenderRow_*` で 2 行レイアウトを期待しているケースは削除または書き換える。 + +- [ ] **Step 3: テストが fail することを確認** + +Run: `go test ./internal/picker/ -run TestRenderRow` +Expected: FAIL(古いレイアウト) + +- [ ] **Step 4: 実装** + +`internal/picker/delegate.go` を以下に置き換え: + +```go +package picker + +import ( + "fmt" + "io" + "strings" + + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/tqer39/ccw-cli/internal/gh" + "github.com/tqer39/ccw-cli/internal/worktree" +) + +// rowDelegate renders worktree items as four lines: +// line 1: [resume-badge] · [status] indicators +// line 2: branch: +// line 3: pr: +// line 4: path: +type rowDelegate struct { + prUnavailable bool +} + +func (d rowDelegate) Height() int { return 4 } +func (d rowDelegate) Spacing() int { return 1 } +func (d rowDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil } + +func (d rowDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) { + li, ok := item.(listItem) + if !ok { + return + } + selected := index == m.Index() + _, _ = fmt.Fprint(w, renderRow(li, m.Width(), d.prUnavailable, selected)) +} + +func renderRow(li listItem, width int, prUnavailable bool, selected bool) string { + prefix := " " + if selected { + prefix = "> " + } + switch li.tag { + case tagNew, tagQuit, tagDeleteAll, tagCleanPushed, tagCustomSelect: + return prefix + li.title + "\n " + li.desc + } + wt := li.wt + name := worktreeName(wt.Path) + resume := ResumeBadge(wt.HasSession) + status := Badge(wt.Status) + indicators := fmt.Sprintf("↑%d ↓%d", wt.AheadCount, wt.BehindCount) + if wt.Status == worktree.StatusDirty { + indicators += fmt.Sprintf(" ✎%d", wt.DirtyCount) + } + + header := fmt.Sprintf("%s%s · %s", prefix, resume, name) + right := fmt.Sprintf("%s %s", status, indicators) + header = padBetween(header, right, width) + + branchLine := fmt.Sprintf(" branch: %s", wt.Branch) + prLine := " pr: " + renderPRForLine(li.pr, prUnavailable) + pathLine := fmt.Sprintf(" path: %s", wt.Path) + + if width > 0 { + header = truncateToWidth(header, width) + branchLine = truncateToWidth(branchLine, width) + prLine = truncateToWidth(prLine, width) + pathLine = truncateToWidth(pathLine, width) + } + + return header + "\n" + branchLine + "\n" + prLine + "\n" + pathLine +} + +// padBetween places left and right on the same line, padding spaces between +// so that right is right-aligned at width. If width is 0 or too small, falls +// back to "left right". +func padBetween(left, right string, width int) string { + if width <= 0 { + return left + " " + right + } + gap := width - lipgloss.Width(left) - lipgloss.Width(right) + if gap < 2 { + gap = 2 + } + return left + strings.Repeat(" ", gap) + right +} + +func renderPRForLine(pr *gh.PRInfo, prUnavailable bool) string { + if prUnavailable { + return "" + } + return renderPRCell(pr) +} + +func worktreeName(path string) string { + idx := strings.LastIndex(path, "/") + if idx < 0 { + return path + } + return path[idx+1:] +} + +func arrowGlyph() string { + if noColor() { + return "->" + } + return "→" +} + +func renderPRCell(pr *gh.PRInfo) string { + if pr == nil { + if noColor() { + return "(no PR)" + } + return lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render("(no PR)") + } + title := pr.Title + if len(title) > 30 { + title = title[:29] + "…" + } + inner := fmt.Sprintf("%s #%d %q", PRBadge(pr.State), pr.Number, title) + return PRCellStyle(pr.State).Render(inner) +} + +func truncateToWidth(s string, n int) string { + if lipgloss.Width(s) <= n { + return s + } + for len(s) > 0 && lipgloss.Width(s) > n { + s = s[:len(s)-1] + } + return s +} +``` + +注: `arrowGlyph` は他で使っていれば残す。使われていなければ削除可。 + +- [ ] **Step 5: テストが通ることを確認** + +Run: `go test ./internal/picker/...` +Expected: PASS + +既存テストが旧レイアウトを期待していて壊れていたら、上記新レイアウトに合わせて修正してからパスさせる。 + +- [ ] **Step 6: コミット** + +```bash +git add internal/picker/delegate.go internal/picker/delegate_test.go +git commit -m "feat(picker): worktree 行を L2 4 行レイアウトに拡張" +``` + +--- + +## Task 7: `internal/tips` パッケージを追加 + +**目的:** picker footer に表示するランダム TIPS 文字列を提供する。 + +**Files:** + +- Create: `internal/tips/tips.go` +- Test: `internal/tips/tips_test.go` + +- [ ] **Step 1: テストを書く** + +`internal/tips/tips_test.go`: + +```go +package tips + +import ( + "strings" + "testing" +) + +func TestPickRandom_FromDefaultSet(t *testing.T) { + got := PickRandom(42) + if got == "" { + t.Fatal("PickRandom(42) = empty string") + } + found := false + for _, c := range Defaults() { + if got == c { + found = true + break + } + } + if !found { + t.Errorf("PickRandom(42) = %q, not in Defaults()", got) + } +} + +func TestPickRandom_Deterministic(t *testing.T) { + if PickRandom(7) != PickRandom(7) { + t.Error("PickRandom(7) is non-deterministic") + } +} + +func TestPickFrom_Empty(t *testing.T) { + if got := pickFrom(nil, 1); got != "" { + t.Errorf("pickFrom(nil) = %q, want empty", got) + } + if got := pickFrom([]string{}, 1); got != "" { + t.Errorf("pickFrom([]) = %q, want empty", got) + } +} + +func TestDefaults_NonEmpty(t *testing.T) { + d := Defaults() + if len(d) == 0 { + t.Error("Defaults() empty") + } + for _, s := range d { + if strings.TrimSpace(s) == "" { + t.Errorf("empty TIPS string in defaults") + } + } +} +``` + +- [ ] **Step 2: fail 確認** + +Run: `go test ./internal/tips/...` +Expected: `package tips: no Go files` + +- [ ] **Step 3: 実装** + +`internal/tips/tips.go`: + +```go +// Package tips provides short rotating tip strings shown in the picker footer. +package tips + +import "math/rand/v2" + +var defaults = []string{ + "worktree 名 = session 名。手で /rename しても ccw は何もしません", + "claude --from-pr <番号> で PR 連携セッションを直接 resume できます", + "--clean-all で push 済 worktree を一括削除", + "ccw -- --model で claude にフラグを素通し", + "picker の RESUME バッジは ~/.claude/projects/ から判定しています", +} + +// Defaults returns a copy of the built-in TIPS set. +func Defaults() []string { + out := make([]string, len(defaults)) + copy(out, defaults) + return out +} + +// PickRandom returns a single tip from the defaults using seed. +func PickRandom(seed uint64) string { + return pickFrom(defaults, seed) +} + +func pickFrom(set []string, seed uint64) string { + if len(set) == 0 { + return "" + } + r := rand.New(rand.NewPCG(seed, seed^0x9E3779B97F4A7C15)) + return set[r.IntN(len(set))] +} +``` + +- [ ] **Step 4: テストが通ることを確認** + +Run: `go test ./internal/tips/...` +Expected: PASS + +- [ ] **Step 5: コミット** + +```bash +git add internal/tips +git commit -m "feat(tips): random TIPS パッケージを追加" +``` + +--- + +## Task 8: picker footer に random TIPS を出す + +**目的:** Model の構築時に TIPS を 1 件選び、`View` の footer で表示する。 + +**Files:** + +- Modify: `internal/picker/model.go` +- Modify: `internal/picker/view.go` +- Modify: `internal/picker/model_test.go`(必要なら) + +- [ ] **Step 1: テストを追加** + +`internal/picker/view_test.go`(無ければ新規)に: + +```go +package picker + +import ( + "strings" + "testing" + + "github.com/tqer39/ccw-cli/internal/worktree" +) + +func TestView_FooterShowsTips(t *testing.T) { + m := New([]worktree.Info{{Path: "/x/.claude/worktrees/a", Branch: "a"}}) + m.ghAvailable = true + m.tip = "test tip line" + m.state = stateList + out := m.View() + if !strings.Contains(out, "💡 Tip: test tip line") { + t.Errorf("View footer missing tip:\n%s", out) + } +} + +func TestView_FooterShowsGhHintWhenUnavailable(t *testing.T) { + m := New([]worktree.Info{{Path: "/x/.claude/worktrees/a", Branch: "a"}}) + m.ghAvailable = false + m.tip = "should-not-show" + m.state = stateList + out := m.View() + if !strings.Contains(out, "Install gh") { + t.Errorf("View should show gh hint when gh unavailable:\n%s", out) + } + if strings.Contains(out, "should-not-show") { + t.Errorf("View should not show tip when gh unavailable:\n%s", out) + } +} +``` + +- [ ] **Step 2: fail 確認** + +Run: `go test ./internal/picker/ -run TestView_Footer` +Expected: `m.tip undefined` + +- [ ] **Step 3: 実装 — Model に tip フィールド追加** + +`internal/picker/model.go` の `Model` 構造体に追加: + +```go +type Model struct { + state state + infos []worktree.Info + list list.Model + selIdx int + action Action + selection Selection + width int + height int + ghAvailable bool + prs map[string]gh.PRInfo + prUnavailable bool + bulkFilter map[worktree.Status]bool + bulkTargets []int + bulkForce bool + tip string +} +``` + +`New(infos)` の戻り行を: + +```go + return Model{ + state: stateList, + infos: infos, + list: l, + ghAvailable: gh.Available(), + tip: tips.PickRandom(uint64(time.Now().UnixNano())), + } +``` + +import に `"time"` と `"github.com/tqer39/ccw-cli/internal/tips"` を追加。 + +- [ ] **Step 4: View() の footer を更新** + +`internal/picker/view.go` の `case stateList` を: + +```go + case stateList: + base := m.list.View() + footer := "" + switch { + case !m.ghAvailable: + footer = "💡 Install gh to see PR titles here" + case m.tip != "": + footer = "💡 Tip: " + m.tip + } + if footer == "" { + return base + } + return base + "\n\n" + footer +``` + +- [ ] **Step 5: テストが通ることを確認** + +Run: `go test ./internal/picker/...` +Expected: PASS + +- [ ] **Step 6: コミット** + +```bash +git add internal/picker +git commit -m "feat(picker): footer に random TIPS を表示" +``` + +--- + +## Task 9: `cmd/ccw/main.go` を新シグネチャに合わせる + +**目的:** `LaunchNew(name, ...)` / `Continue(...)` の新呼び出しに移行し、HasSession で resume / new を分岐、失敗時にフォールバック。 + +**Files:** + +- Modify: `cmd/ccw/main.go` + +- [ ] **Step 1: 既存呼び出しを確認** + +Run: `grep -n "claude.Launch\|claude.Resume" cmd/ccw/main.go` +Expected: 既存ヒット(74 行付近、103 行付近) + +- [ ] **Step 2: import 追加** + +`cmd/ccw/main.go` の import に: + +```go + "github.com/tqer39/ccw-cli/internal/namegen" +``` + +を追加。 + +- [ ] **Step 3: 新規 worktree 起動を更新** + +`run` 関数内の以下のブロックを: + +```go + if flags.NewWorktree { + code, err := claude.LaunchNew(mainRepo, preamble, flags.Passthrough) + if err != nil { + ui.Error("%v", err) + return 1 + } + return code + } +``` + +↓ + +```go + if flags.NewWorktree { + name := namegen.Generate() + code, err := claude.LaunchNew(mainRepo, name, preamble, flags.Passthrough) + if err != nil { + ui.Error("%v", err) + return 1 + } + return code + } +``` + +- [ ] **Step 4: picker [new] の起動を更新** + +`runPicker` 内の `case picker.ActionNew:` を: + +```go + case picker.ActionNew: + name := namegen.Generate() + code, err := claude.LaunchNew(mainRepo, name, "", passthrough) + if err != nil { + ui.Error("%v", err) + return 1 + } + return code +``` + +- [ ] **Step 5: picker [r] run を `Continue` に変更し、フォールバック追加** + +`case picker.ActionResume:` を: + +```go + case picker.ActionResume: + code, err := claude.Continue(sel.Path, passthrough) + if err != nil { + ui.Error("%v", err) + return 1 + } + if code != 0 && !sel.HasSession { + // HasSession=false なのに Resume パスを通った(picker から強制 run など)。 + // セッション無し → --continue は失敗し得るので -n でフォールバック。 + name := worktreeName(sel.Path) + code, err = claude.LaunchNew(sel.Path, name, "", passthrough) + if err != nil { + ui.Error("%v", err) + return 1 + } + } + return code +``` + +`worktreeName` ヘルパは `cmd/ccw/main.go` 末尾に追加: + +```go +func worktreeName(path string) string { + for i := len(path) - 1; i >= 0; i-- { + if path[i] == '/' { + return path[i+1:] + } + } + return path +} +``` + +- [ ] **Step 6: `Selection` に `HasSession` を持たせる** + +`internal/picker/model.go` の `Selection` 構造体に追記: + +```go +type Selection struct { + Path string + Branch string + Status worktree.Status + HasSession bool + ForceDelete bool +} +``` + +`internal/picker/update.go` か `internal/picker/run.go` で `Selection{...}` を構築している箇所をすべて grep し、`HasSession: w.HasSession` を埋める。 + +Run: `grep -n "Selection{" internal/picker/*.go` +各ヒット箇所に `HasSession` を追加。 + +- [ ] **Step 7: ビルドが通ることを確認** + +Run: `go build ./...` +Expected: 成功 + +- [ ] **Step 8: ユニットテストが通ることを確認** + +Run: `go test ./...` +Expected: PASS + +- [ ] **Step 9: コミット** + +```bash +git add cmd/ccw/main.go internal/picker/model.go internal/picker/run.go internal/picker/update.go +git commit -m "feat(ccw): worktree 名 = session 名 を確立、resume を既存 worktree のデフォルトに" +``` + +--- + +## Task 10: 統合テスト `tests/resume_flow_test.go` + +**目的:** fake `claude` バイナリで end-to-end の引数遷移を検証する。 + +**Files:** + +- Create: `tests/resume_flow_test.go` +- 必要なら: `tests/testdata/fake-claude.go` のようなビルド対象 + +- [ ] **Step 1: 既存 `tests/` 構造を確認** + +Run: `ls tests/ 2>/dev/null && find tests -name '*.go' 2>/dev/null` + +無ければ作成して進む。 + +- [ ] **Step 2: fake claude バイナリのソースを作る** + +注: Go の `testdata/` は build 対象外なので、`tests/fakes/fake_claude/main.go` に置く(通常の package として認識される)。 + +`tests/fakes/fake_claude/main.go`: + +```go +// fake claude binary used in resume_flow_test.go. +// Writes os.Args[1:] to $CCW_FAKE_CLAUDE_LOG (newline-separated) and exits +// with the code in $CCW_FAKE_CLAUDE_EXIT (default 0). +package main + +import ( + "os" + "strconv" + "strings" +) + +func main() { + logPath := os.Getenv("CCW_FAKE_CLAUDE_LOG") + if logPath != "" { + f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err == nil { + _, _ = f.WriteString(strings.Join(os.Args[1:], "\n") + "\n---\n") + _ = f.Close() + } + } + exit := 0 + if v := os.Getenv("CCW_FAKE_CLAUDE_EXIT"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + exit = n + } + } + os.Exit(exit) +} +``` + +- [ ] **Step 3: 統合テストを書く** + +`tests/resume_flow_test.go`: + +```go +package tests + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func buildBinary(t *testing.T, target, out string) { + t.Helper() + cmd := exec.Command("go", "build", "-o", out, target) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("build %s: %v\n%s", target, err, output) + } +} + +func setupFakeEnv(t *testing.T) (binDir, logPath, home string) { + t.Helper() + binDir = t.TempDir() + home = t.TempDir() + buildBinary(t, "./fakes/fake_claude", filepath.Join(binDir, "claude")) + buildBinary(t, "../cmd/ccw", filepath.Join(binDir, "ccw")) + logPath = filepath.Join(t.TempDir(), "claude.log") + return +} + +func initRepo(t *testing.T, dir string) { + t.Helper() + for _, args := range [][]string{ + {"init", "-q"}, + {"commit", "--allow-empty", "-q", "-m", "init"}, + } { + c := exec.Command("git", args...) + c.Dir = dir + if out, err := c.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } + } +} + +func runCcw(t *testing.T, binDir, repo, log, home string, args ...string) string { + t.Helper() + cmd := exec.Command(filepath.Join(binDir, "ccw"), args...) + cmd.Dir = repo + cmd.Env = append(os.Environ(), + "PATH="+binDir+string(os.PathListSeparator)+os.Getenv("PATH"), + "HOME="+home, + "CCW_FAKE_CLAUDE_LOG="+log, + "NO_COLOR=1", + ) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("ccw %v: %v\n%s", args, err, out) + } + return string(out) +} + +func readLog(t *testing.T, p string) []string { + t.Helper() + b, err := os.ReadFile(p) + if err != nil { + t.Fatalf("read log: %v", err) + } + return strings.Split(strings.TrimSpace(string(b)), "\n---\n") +} + +func TestResumeFlow_NewWorktreePassesNameToBoth(t *testing.T) { + binDir, log, home := setupFakeEnv(t) + repo := t.TempDir() + initRepo(t, repo) + + _ = runCcw(t, binDir, repo, log, home, "-n") + + calls := readLog(t, log) + if len(calls) < 1 { + t.Fatalf("expected at least 1 claude call, got %d", len(calls)) + } + first := calls[0] + if !strings.Contains(first, "--worktree\n") { + t.Errorf("first call missing --worktree:\n%s", first) + } + if !strings.Contains(first, "\n-n\n") { + t.Errorf("first call missing -n:\n%s", first) + } + // argument after --worktree should equal the argument after -n + args := strings.Split(first, "\n") + idxWT := indexOf(args, "--worktree") + idxN := indexOf(args, "-n") + if idxWT < 0 || idxN < 0 || idxWT+1 >= len(args) || idxN+1 >= len(args) { + t.Fatalf("malformed args:\n%s", first) + } + if args[idxWT+1] != args[idxN+1] { + t.Errorf("--worktree %q != -n %q", args[idxWT+1], args[idxN+1]) + } +} + +func indexOf(s []string, target string) int { + for i, v := range s { + if v == target { + return i + } + } + return -1 +} +``` + +注: picker 経由(ActionResume)の統合テストは TUI なので非インタラクティブ fallback パスでは `--continue` を直接通せない。fallback フローに `Continue` 経路が無い場合は、本テストは新規パス(`-n`)のみカバーで OK。後続テストは手動検証チェックリストに任せる。 + +- [ ] **Step 4: テストを実行** + +Run: `go test ./tests/...` +Expected: PASS + +- [ ] **Step 5: コミット** + +```bash +git add tests +git commit -m "test: 新規 worktree で --worktree と -n が同一名であることを e2e で確認" +``` + +--- + +## Task 11: `.claude/settings.local.json` で読み取り権限を許可 + +**目的:** Claude Code 経由で本リポジトリを開発するときに `~/.claude/projects/` の読み取りプロンプトが出ないようにする。 + +**Files:** + +- Modify or Create: `.claude/settings.local.json` + +- [ ] **Step 1: 既存ファイルを確認** + +Run: `cat .claude/settings.local.json 2>/dev/null || echo MISSING` + +- [ ] **Step 2: 権限を追加** + +ファイルが無い場合、新規作成: + +```json +{ + "permissions": { + "allow": ["Read(~/.claude/projects/**)"] + } +} +``` + +ファイルが存在する場合、既存の `permissions.allow` 配列に `"Read(~/.claude/projects/**)"` を追加(重複していなければ)。 + +- [ ] **Step 3: gitignore 状態を確認** + +Run: `git check-ignore .claude/settings.local.json && echo "ignored" || echo "tracked"` +Expected: `ignored` (`.claude/settings.local.json` は通常 gitignore 対象) + +`tracked` の場合は明示的に追加コミットする。`ignored` の場合は本ファイルはローカル設定として残し、コミットしない(本タスクの spec 上の意図に沿う)。 + +- [ ] **Step 4: コミットの要否を判断** + +ignored ならコミット不要 — Step 5 に進まずスキップ。 + +tracked ならコミット: + +```bash +git add .claude/settings.local.json +git commit -m "chore: ~/.claude/projects/ への Read 許可を追加" +``` + +--- + +## Task 12: README を更新 + +**目的:** 旧 `--resume` 警告を削除し、新動作・命名規約を追記する。 + +**Files:** + +- Modify: `docs/README.md` +- Modify: `docs/README.ja.md` + +- [ ] **Step 1: 旧警告を削除** + +`docs/README.ja.md` の `> ⚠️` から始まる `--resume ID` 関連ブロック(README.ja.md:79-80 付近)を削除。`docs/README.md` の対応する英語ブロックも削除。 + +- [ ] **Step 2: picker のサブメニュー説明を更新** + +`docs/README.ja.md` の picker 説明で「`run` は選択した worktree で `claude --permission-mode auto` を新規起動するもので、Claude Code のセッション ID を引き継ぐ(`--resume` 相当の)操作は**行いません**。」の段を、新しい挙動に書き換える: + +```markdown +worktree を選択すると `[r] run` / `[d] delete` / `[b] back` のサブメニューに遷移。`run` はセッションログが残っていれば `claude --continue` で**過去会話を復元**、無ければ `claude -n ` で新規起動します。`[delete all]` / `[clean pushed]` / `[custom select]` は一括削除のショートカットで、dirty を含む場合は `--force` か、または 3 択確認 (`y` force · `s` dirty を除外 · `N` キャンセル) を経由します。 +``` + +`docs/README.md` の対応箇所も同様に更新。 + +- [ ] **Step 3: RESUME / NEW バッジ表を追加** + +`docs/README.ja.md` の Worktree 状態バッジ表のあとに追記: + +```markdown +セッションバッジ: + +| バッジ | 意味 | +|---|---| +| 💬 `RESUME` | 過去のセッションログがあり、`run` で会話を復元できる | +| ⚡ `NEW` | セッションログ無し。`run` は新規起動 | +``` + +- [ ] **Step 4: 命名規約セクションを追加** + +`## 📖 使い方` のあと(または picker 説明の直後)に挿入: + +```markdown +### 命名規約 + +ccw は新規 worktree を作るとき、worktree 名と Claude Code のセッション名を 1:1 で揃えます: + +- ディレクトリ: `/.claude/worktrees//` +- ブランチ: `worktree-` +- セッション名: ``(`claude -n ` で設定) + +`` は `quick-falcon-7bd2` のようなジェネレータ生成。手動で `/rename` した場合 ccw は何もしませんが、`--continue` は cwd 基準で動くので会話復元には影響しません。 +``` + +英語版(`docs/README.md`)も対応訳で同様に追加。 + +- [ ] **Step 5: 依存欄の最低バージョンを引き上げ** + +`docs/README.ja.md` の依存欄: + +```markdown +- [Claude Code](https://docs.claude.com/claude-code) `>= 2.1.49` +``` + +を: + +```markdown +- [Claude Code](https://docs.claude.com/claude-code) `>= 2.1.118` — ccw が利用する `--worktree ` と `-n ` の併用は 2.1.118 以降で動作確認済み +``` + +に変更。`docs/README.md` も同様に。 + +- [ ] **Step 6: textlint / markdownlint を通す** + +Run: `pnpm dlx markdownlint-cli2 docs/README.md docs/README.ja.md` または lefthook が走る場合は staging 後 `git commit` で検出。 + +問題があれば修正。 + +- [ ] **Step 7: コミット** + +```bash +git add docs/README.md docs/README.ja.md +git commit -m "docs: resume integration の挙動と命名規約を README に反映" +``` + +--- + +## Task 13: 全体検証 + PR 作成準備 + +**目的:** すべてのテスト・lint を通し、手動検証チェックリストを実行してから PR を作る準備をする。 + +**Files:** + +- なし(検証のみ) + +- [ ] **Step 1: 全 Go テスト** + +Run: `go test ./...` +Expected: PASS + +- [ ] **Step 2: go vet** + +Run: `go vet ./...` +Expected: 何も出力なし + +- [ ] **Step 3: ビルド** + +Run: `go build ./cmd/ccw` +Expected: `ccw` バイナリ生成 + +- [ ] **Step 4: lefthook pre-commit ローカル実行** + +Run: `lefthook run pre-commit --all-files` +Expected: 成功 + +- [ ] **Step 5: 手動検証チェックリスト** + +実機の sandbox repo で以下を確認: + +```bash +mkdir -p /tmp/ccw-resume-check && cd /tmp/ccw-resume-check && git init -q && \ + git commit --allow-empty -q -m init +~/path/to/built/ccw -n +``` + +- [ ] picker から起動した worktree で `claude --worktree foo -n foo` 相当が走る +- [ ] 同 worktree で 2 回目の `ccw` (picker から `[r] run`) が前回会話を復元 +- [ ] picker で RESUME / NEW バッジが分かれる +- [ ] `/rename` 後も `--continue` で復元できる +- [ ] `~/.claude/projects//` が実機で同じ規則 +- [ ] `NO_COLOR=1 ccw` で表示崩れなし +- [ ] 80 cols 端末で L2 4 行が読める +- [ ] `CCW_DEBUG=1 ccw` で encoded path がログ出力される(実装していなければスキップ) +- [ ] TIPS が起動ごとに変わる + +- [ ] **Step 6: ブランチを push して PR 作成** + +```bash +git push -u origin HEAD +gh pr create --title "feat: worktree ↔ Claude Code session resume integration" --body "$(cat <<'EOF' +## Summary + +- worktree 名と Claude Code セッション名を 1:1 マッピング +- picker から既存 worktree を選んだ時、デフォルトで `claude --continue` で過去会話を復元 +- picker に `💬 RESUME` / `⚡ NEW` バッジ、L2 4 行レイアウト、random TIPS footer +- 旧 `--resume` 警告を撤去、命名規約と最低バージョンを更新 + +Spec: `docs/superpowers/specs/2026-04-25-worktree-resume-integration-design.md` +Plan: `docs/superpowers/plans/2026-04-25-worktree-resume-integration.md` + +## Test plan + +- [ ] `go test ./...` passes +- [ ] `lefthook run pre-commit --all-files` passes +- [ ] 手動: 新規 worktree → 同 worktree で再起動 → 会話復元 +- [ ] 手動: NO_COLOR=1 で表示崩れなし +- [ ] 手動: TIPS が起動ごとに変わる + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +--- + +## Self-Review Notes + +- spec のすべてのコンポーネント(`namegen` は spec に明記されていなかったが `--worktree -n ` を成立させる必須前提として追加。spec 側にもメモを追記すべきだが本計画の Task 1 内で明示的に補足) +- E1〜E9 のうち実装で直接触れるのは E2(`--continue` フォールバック)/ E3〜E4(`HasSession` の安全な false 返却)/ E5(`/rename` は cwd 基準で問題なし)。E1 はバージョンチェック(Task 0)でカバー。E6〜E9 は実装上の追加コードを必要としない +- Continue が `Resume` という名前から変わるため、`internal/picker/model.go` の `ActionResume` という列挙名は混乱を生むが、UI 上の意味は「選択 worktree で claude を起動」で本質は変わらないので**今回はリネームしない**(YAGNI / 関係ない変更を混ぜない) +- README で `worktree-` ブランチ命名は、現状 `claude --worktree ` がどう命名するかに依存。Task 0 Step 2 で観察した実際のブランチ名に合わせて Task 12 Step 4 を最終調整する From 81b8bfcf996b5ece2cf2bf725680b8d6eb77e57f Mon Sep 17 00:00:00 2001 From: tqer39 Date: Sat, 25 Apr 2026 17:36:39 +0900 Subject: [PATCH 04/17] =?UTF-8?q?feat(namegen):=20worktree/session=20?= =?UTF-8?q?=E5=90=8D=E3=82=B8=E3=82=A7=E3=83=8D=E3=83=AC=E3=83=BC=E3=82=BF?= =?UTF-8?q?=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- internal/namegen/namegen.go | 44 ++++++++++++++++++++++++++++++++ internal/namegen/namegen_test.go | 43 +++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 internal/namegen/namegen.go create mode 100644 internal/namegen/namegen_test.go diff --git a/internal/namegen/namegen.go b/internal/namegen/namegen.go new file mode 100644 index 0000000..c0e0dfd --- /dev/null +++ b/internal/namegen/namegen.go @@ -0,0 +1,44 @@ +// Package namegen generates short slug names like "quick-falcon-7bd2" +// to use as both worktree directory name and Claude Code session name. +package namegen + +import ( + "fmt" + "math/rand/v2" + "sync/atomic" + "time" +) + +var adjectives = []string{ + "quick", "lazy", "happy", "brave", "calm", "eager", "fancy", "glad", + "jolly", "kind", "lively", "merry", "nice", "polite", "quiet", "silly", + "witty", "zany", "bright", "clever", "daring", "fierce", "gentle", "mighty", + "nimble", "proud", "rapid", "shiny", "sturdy", "tame", +} + +var nouns = []string{ + "falcon", "otter", "lion", "tiger", "wolf", "panda", "eagle", "shark", + "crane", "fox", "raven", "owl", "lynx", "bison", "moose", "hawk", + "orca", "puma", "yak", "ibex", "robin", "swan", "gecko", "mantis", + "koala", "badger", "heron", "jaguar", "lemur", "mole", +} + +// counter ensures unique seeds when Generate() is called multiple times +// within the same nanosecond (e.g. in tight loops during tests). +var counter atomic.Uint64 + +// Generate returns a slug like "quick-falcon-7bd2". +// Combines time.Now().UnixNano() with a monotonic counter to guarantee +// unique seeds even when called in rapid succession. +func Generate() string { + seq := counter.Add(1) + return generateWithSeed(uint64(time.Now().UnixNano()) ^ (seq * 0x9E3779B97F4A7C15)) +} + +func generateWithSeed(seed uint64) string { + r := rand.New(rand.NewPCG(seed, seed^0x9E3779B97F4A7C15)) + adj := adjectives[r.IntN(len(adjectives))] + noun := nouns[r.IntN(len(nouns))] + suffix := fmt.Sprintf("%04x", r.IntN(0x10000)) + return fmt.Sprintf("%s-%s-%s", adj, noun, suffix) +} diff --git a/internal/namegen/namegen_test.go b/internal/namegen/namegen_test.go new file mode 100644 index 0000000..1ef9fc8 --- /dev/null +++ b/internal/namegen/namegen_test.go @@ -0,0 +1,43 @@ +package namegen + +import ( + "regexp" + "testing" +) + +func TestGenerate_FormatAndUniqueness(t *testing.T) { + re := regexp.MustCompile(`^[a-z]+-[a-z]+-[0-9a-f]{4}$`) + seen := map[string]struct{}{} + for i := 0; i < 100; i++ { + got := Generate() + if !re.MatchString(got) { + t.Fatalf("Generate() = %q, want match %s", got, re) + } + seen[got] = struct{}{} + } + if len(seen) < 90 { + t.Errorf("Generate() collisions too high: %d/100 unique", len(seen)) + } +} + +func TestGenerateWithSeed_Deterministic(t *testing.T) { + a := generateWithSeed(42) + b := generateWithSeed(42) + if a != b { + t.Errorf("generateWithSeed(42): non-deterministic %q vs %q", a, b) + } +} + +func TestGenerate_NoSpacesNoUppercase(t *testing.T) { + for i := 0; i < 50; i++ { + got := Generate() + for _, r := range got { + if r == ' ' { + t.Fatalf("Generate() = %q contains space", got) + } + if r >= 'A' && r <= 'Z' { + t.Fatalf("Generate() = %q contains uppercase", got) + } + } + } +} From 825054943829f37c4895183b6d2f3df791551244 Mon Sep 17 00:00:00 2001 From: tqer39 Date: Sat, 25 Apr 2026 17:41:24 +0900 Subject: [PATCH 05/17] =?UTF-8?q?feat(claude):=20-n=20=E3=83=95=E3=83=A9?= =?UTF-8?q?=E3=82=B0=E3=81=A8=20--continue=20=E3=82=92=E7=B5=84=E3=81=BF?= =?UTF-8?q?=E8=BE=BC=E3=81=BF=20Continue=20=E3=81=AB=E3=83=AA=E3=83=8D?= =?UTF-8?q?=E3=83=BC=E3=83=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/claude/claude.go | 28 ++++++++++++++-------------- internal/claude/claude_test.go | 34 +++++++++++++++++----------------- 2 files changed, 31 insertions(+), 31 deletions(-) diff --git a/internal/claude/claude.go b/internal/claude/claude.go index c1b04ae..4355d51 100644 --- a/internal/claude/claude.go +++ b/internal/claude/claude.go @@ -1,5 +1,5 @@ // Package claude wraps launching the `claude` CLI in ccw-appropriate ways -// (new worktree session vs. resume existing worktree). +// (new worktree session vs. continue existing worktree). package claude import ( @@ -10,10 +10,10 @@ import ( ) // BuildNewArgs constructs argv (excluding the program name) for -// `claude --permission-mode auto --worktree [extra...] [-- preamble]`. -func BuildNewArgs(preamble string, extra []string) []string { - args := make([]string, 0, 3+len(extra)+2) - args = append(args, "--permission-mode", "auto", "--worktree") +// `claude --permission-mode auto --worktree -n [extra...] [-- preamble]`. +func BuildNewArgs(name, preamble string, extra []string) []string { + args := make([]string, 0, 6+len(extra)+2) + args = append(args, "--permission-mode", "auto", "--worktree", name, "-n", name) args = append(args, extra...) if preamble != "" { args = append(args, "--", preamble) @@ -21,22 +21,22 @@ func BuildNewArgs(preamble string, extra []string) []string { return args } -// BuildResumeArgs constructs argv for `claude --permission-mode auto [extra...]`. -func BuildResumeArgs(extra []string) []string { - args := make([]string, 0, 2+len(extra)) - args = append(args, "--permission-mode", "auto") +// BuildContinueArgs constructs argv for `claude --permission-mode auto --continue [extra...]`. +func BuildContinueArgs(extra []string) []string { + args := make([]string, 0, 3+len(extra)) + args = append(args, "--permission-mode", "auto", "--continue") return append(args, extra...) } // LaunchNew execs claude with BuildNewArgs in cwd. Returns claude's exit code // (0 on success, the child exit code on non-zero exit, -1 on exec error). -func LaunchNew(cwd, preamble string, extra []string) (int, error) { - return runClaude(cwd, BuildNewArgs(preamble, extra)) +func LaunchNew(cwd, name, preamble string, extra []string) (int, error) { + return runClaude(cwd, BuildNewArgs(name, preamble, extra)) } -// Resume execs claude with BuildResumeArgs in cwd. -func Resume(cwd string, extra []string) (int, error) { - return runClaude(cwd, BuildResumeArgs(extra)) +// Continue execs claude with BuildContinueArgs in cwd. +func Continue(cwd string, extra []string) (int, error) { + return runClaude(cwd, BuildContinueArgs(extra)) } func runClaude(cwd string, args []string) (int, error) { diff --git a/internal/claude/claude_test.go b/internal/claude/claude_test.go index de726f6..ca64ee8 100644 --- a/internal/claude/claude_test.go +++ b/internal/claude/claude_test.go @@ -5,50 +5,50 @@ import ( "testing" ) -func TestBuildNewArgs_NoExtraNoPreamble(t *testing.T) { - got := BuildNewArgs("", nil) - want := []string{"--permission-mode", "auto", "--worktree"} +func TestBuildNewArgs_NameOnly(t *testing.T) { + got := BuildNewArgs("foo", "", nil) + want := []string{"--permission-mode", "auto", "--worktree", "foo", "-n", "foo"} if !reflect.DeepEqual(got, want) { t.Errorf("BuildNewArgs:\n got = %v\n want = %v", got, want) } } func TestBuildNewArgs_WithExtra(t *testing.T) { - got := BuildNewArgs("", []string{"--model", "claude-opus-4-7"}) - want := []string{"--permission-mode", "auto", "--worktree", "--model", "claude-opus-4-7"} + got := BuildNewArgs("foo", "", []string{"--model", "claude-opus-4-7"}) + want := []string{"--permission-mode", "auto", "--worktree", "foo", "-n", "foo", "--model", "claude-opus-4-7"} if !reflect.DeepEqual(got, want) { t.Errorf("BuildNewArgs extra:\n got = %v\n want = %v", got, want) } } func TestBuildNewArgs_WithPreamble(t *testing.T) { - got := BuildNewArgs("hello", nil) - want := []string{"--permission-mode", "auto", "--worktree", "--", "hello"} + got := BuildNewArgs("foo", "hello", nil) + want := []string{"--permission-mode", "auto", "--worktree", "foo", "-n", "foo", "--", "hello"} if !reflect.DeepEqual(got, want) { t.Errorf("BuildNewArgs preamble:\n got = %v\n want = %v", got, want) } } func TestBuildNewArgs_WithExtraAndPreamble(t *testing.T) { - got := BuildNewArgs("hi", []string{"--resume"}) - want := []string{"--permission-mode", "auto", "--worktree", "--resume", "--", "hi"} + got := BuildNewArgs("foo", "hi", []string{"--model", "x"}) + want := []string{"--permission-mode", "auto", "--worktree", "foo", "-n", "foo", "--model", "x", "--", "hi"} if !reflect.DeepEqual(got, want) { t.Errorf("BuildNewArgs both:\n got = %v\n want = %v", got, want) } } -func TestBuildResumeArgs_Empty(t *testing.T) { - got := BuildResumeArgs(nil) - want := []string{"--permission-mode", "auto"} +func TestBuildContinueArgs_Empty(t *testing.T) { + got := BuildContinueArgs(nil) + want := []string{"--permission-mode", "auto", "--continue"} if !reflect.DeepEqual(got, want) { - t.Errorf("BuildResumeArgs:\n got = %v\n want = %v", got, want) + t.Errorf("BuildContinueArgs:\n got = %v\n want = %v", got, want) } } -func TestBuildResumeArgs_WithExtra(t *testing.T) { - got := BuildResumeArgs([]string{"--resume"}) - want := []string{"--permission-mode", "auto", "--resume"} +func TestBuildContinueArgs_WithExtra(t *testing.T) { + got := BuildContinueArgs([]string{"--model", "x"}) + want := []string{"--permission-mode", "auto", "--continue", "--model", "x"} if !reflect.DeepEqual(got, want) { - t.Errorf("BuildResumeArgs extra:\n got = %v\n want = %v", got, want) + t.Errorf("BuildContinueArgs extra:\n got = %v\n want = %v", got, want) } } From 82201f203600c48432c8cdca1836c42ef71133f5 Mon Sep 17 00:00:00 2001 From: tqer39 Date: Sat, 25 Apr 2026 17:44:46 +0900 Subject: [PATCH 06/17] =?UTF-8?q?feat(worktree):=20~/.claude/projects=20?= =?UTF-8?q?=E5=8F=82=E7=85=A7=E3=81=A7=20HasSession=20=E3=82=92=E5=88=A4?= =?UTF-8?q?=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- internal/worktree/has_session.go | 37 ++++++++++++++ internal/worktree/has_session_test.go | 73 +++++++++++++++++++++++++++ 2 files changed, 110 insertions(+) create mode 100644 internal/worktree/has_session.go create mode 100644 internal/worktree/has_session_test.go diff --git a/internal/worktree/has_session.go b/internal/worktree/has_session.go new file mode 100644 index 0000000..97bd600 --- /dev/null +++ b/internal/worktree/has_session.go @@ -0,0 +1,37 @@ +package worktree + +import ( + "os" + "path/filepath" + "strings" +) + +// EncodeProjectPath converts an absolute worktree path to the directory name +// Claude Code uses under ~/.claude/projects/. Both '/' and '.' map to '-'. +// This rule is observed from claude's behavior; it is not part of any +// public contract and may change. +func EncodeProjectPath(absPath string) string { + return strings.NewReplacer("/", "-", ".", "-").Replace(absPath) +} + +// HasSession reports whether ~/.claude/projects// contains +// at least one *.jsonl file. Returns false on any error (HOME unset, dir +// missing, read failure) so callers can use it as a UI hint without +// branching on errors. +func HasSession(absPath string) bool { + home, err := os.UserHomeDir() + if err != nil || home == "" { + return false + } + dir := filepath.Join(home, ".claude", "projects", EncodeProjectPath(absPath)) + entries, err := os.ReadDir(dir) + if err != nil { + return false + } + for _, e := range entries { + if !e.IsDir() && strings.HasSuffix(e.Name(), ".jsonl") { + return true + } + } + return false +} diff --git a/internal/worktree/has_session_test.go b/internal/worktree/has_session_test.go new file mode 100644 index 0000000..c50ac55 --- /dev/null +++ b/internal/worktree/has_session_test.go @@ -0,0 +1,73 @@ +package worktree + +import ( + "os" + "path/filepath" + "testing" +) + +func TestEncodeProjectPath(t *testing.T) { + cases := []struct { + in, want string + }{ + {"/Users/foo/repo/.claude/worktrees/bar", "-Users-foo-repo--claude-worktrees-bar"}, + {"/a.b/c", "-a-b-c"}, + {"/", "-"}, + } + for _, tc := range cases { + if got := EncodeProjectPath(tc.in); got != tc.want { + t.Errorf("EncodeProjectPath(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +func TestHasSession_True(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + wt := "/Users/foo/repo/.claude/worktrees/bar" + dir := filepath.Join(home, ".claude", "projects", EncodeProjectPath(wt)) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "abc.jsonl"), []byte("{}\n"), 0o644); err != nil { + t.Fatal(err) + } + + if !HasSession(wt) { + t.Error("HasSession() = false, want true") + } +} + +func TestHasSession_FalseWhenNoJsonl(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + wt := "/Users/foo/repo/.claude/worktrees/bar" + dir := filepath.Join(home, ".claude", "projects", EncodeProjectPath(wt)) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "note.txt"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + + if HasSession(wt) { + t.Error("HasSession() = true, want false (no .jsonl)") + } +} + +func TestHasSession_FalseWhenDirMissing(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + if HasSession("/nonexistent/path") { + t.Error("HasSession() = true, want false (dir missing)") + } +} + +func TestHasSession_FalseWhenHomeUnset(t *testing.T) { + t.Setenv("HOME", "") + if HasSession("/Users/foo/repo/.claude/worktrees/bar") { + t.Error("HasSession() = true, want false (HOME empty)") + } +} From 9b9f86aa695197dc9fa212361cf030f5a73f6d88 Mon Sep 17 00:00:00 2001 From: tqer39 Date: Sat, 25 Apr 2026 17:48:35 +0900 Subject: [PATCH 07/17] =?UTF-8?q?refactor(worktree):=20HasSession=20?= =?UTF-8?q?=E3=81=A7=20os.Getenv("HOME")=20=E3=82=92=E4=BD=BF=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CGo 有効ビルドで os.UserHomeDir() が HOME 環境変数を尊重しない macOS の挙動を回避し、t.Setenv によるテスト分離を確実にする。 Co-Authored-By: Claude Opus 4.7 --- internal/worktree/has_session.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/worktree/has_session.go b/internal/worktree/has_session.go index 97bd600..06b48e6 100644 --- a/internal/worktree/has_session.go +++ b/internal/worktree/has_session.go @@ -19,8 +19,8 @@ func EncodeProjectPath(absPath string) string { // missing, read failure) so callers can use it as a UI hint without // branching on errors. func HasSession(absPath string) bool { - home, err := os.UserHomeDir() - if err != nil || home == "" { + home := os.Getenv("HOME") + if home == "" { return false } dir := filepath.Join(home, ".claude", "projects", EncodeProjectPath(absPath)) From 5656127704cd26c67570ea0c337d945bebc9c764 Mon Sep 17 00:00:00 2001 From: tqer39 Date: Sat, 25 Apr 2026 17:50:39 +0900 Subject: [PATCH 08/17] =?UTF-8?q?feat(worktree):=20Info.HasSession=20?= =?UTF-8?q?=E3=82=92=20List=20=E3=81=A7=E5=9F=8B=E3=82=81=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit picker が描画時に resume 可否を判定できるよう Info にキャッシュする。 Co-Authored-By: Claude Opus 4.7 --- internal/worktree/worktree.go | 3 +++ internal/worktree/worktree_test.go | 40 ++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/internal/worktree/worktree.go b/internal/worktree/worktree.go index 48eb841..dee260c 100644 --- a/internal/worktree/worktree.go +++ b/internal/worktree/worktree.go @@ -39,6 +39,7 @@ func (s Status) String() string { // quantitative indicators (ahead/behind commits, dirty file count). // AheadCount/BehindCount are meaningful for StatusPushed and StatusLocalOnly. // DirtyCount is meaningful only when Status == StatusDirty. +// HasSession indicates whether a Claude Code session exists for this worktree. type Info struct { Path string Branch string @@ -46,6 +47,7 @@ type Info struct { AheadCount int BehindCount int DirtyCount int + HasSession bool } const ccwPathMarker = "/.claude/worktrees/" @@ -73,6 +75,7 @@ func List(mainRepo string) ([]Info, error) { n, _ := gitx.DirtyCount(e.Path) info.DirtyCount = n } + info.HasSession = HasSession(e.Path) result = append(result, info) } return result, nil diff --git a/internal/worktree/worktree_test.go b/internal/worktree/worktree_test.go index 5a4dfaf..4c29865 100644 --- a/internal/worktree/worktree_test.go +++ b/internal/worktree/worktree_test.go @@ -147,3 +147,43 @@ func TestRemove_Integration(t *testing.T) { func writeFile(path, body string) error { return os.WriteFile(path, []byte(body), 0o644) } + +func TestList_PopulatesHasSession(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + main := initMainRepo(t) + wt := addWorktree(t, main, "alpha") + + // Resolve symlinks so the path matches what gitx.ListRaw returns + // (on macOS, /var/folders/... resolves to /private/var/folders/...). + wtResolved, err := filepath.EvalSymlinks(wt) + if err != nil { + t.Fatal(err) + } + + dir := filepath.Join(home, ".claude", "projects", EncodeProjectPath(wtResolved)) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "x.jsonl"), []byte("{}\n"), 0o644); err != nil { + t.Fatal(err) + } + + infos, err := List(main) + if err != nil { + t.Fatal(err) + } + var found bool + for _, in := range infos { + if in.Path == wtResolved { + if !in.HasSession { + t.Errorf("Info.HasSession = false, want true for %s", wtResolved) + } + found = true + } + } + if !found { + t.Fatalf("worktree %s not in List() output", wtResolved) + } +} From f91d0cce0e81bf5d6f680090a7942c8370d4f962 Mon Sep 17 00:00:00 2001 From: tqer39 Date: Sat, 25 Apr 2026 17:53:50 +0900 Subject: [PATCH 09/17] =?UTF-8?q?feat(picker):=20RESUME=20/=20NEW=20?= =?UTF-8?q?=E3=83=90=E3=83=83=E3=82=B8=E3=81=AE=E3=82=B9=E3=82=BF=E3=82=A4?= =?UTF-8?q?=E3=83=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit worktree 行先頭に表示するセッション resume 可否バッジを追加。 Co-Authored-By: Claude Opus 4.7 --- internal/picker/style.go | 23 +++++++++++++++++++++++ internal/picker/style_test.go | 26 ++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/internal/picker/style.go b/internal/picker/style.go index 8710995..98b7c22 100644 --- a/internal/picker/style.go +++ b/internal/picker/style.go @@ -101,3 +101,26 @@ func prCellBackground(state string) (string, bool) { } return "", false } + +// ResumeBadge renders a RESUME / NEW badge. Under NO_COLOR returns +// "[RESUME]" / "[NEW] " (padded to equal width). +func ResumeBadge(hasSession bool) string { + if noColor() { + if hasSession { + return "[RESUME]" + } + return "[NEW] " + } + if hasSession { + return lipgloss.NewStyle(). + Padding(0, 1).Bold(true). + Background(lipgloss.Color("14")). + Foreground(lipgloss.Color("0")). + Render("💬 RESUME") + } + return lipgloss.NewStyle(). + Padding(0, 1). + Background(lipgloss.Color("240")). + Foreground(lipgloss.Color("15")). + Render("⚡ NEW ") +} diff --git a/internal/picker/style_test.go b/internal/picker/style_test.go index 3446cb2..1c9b7e0 100644 --- a/internal/picker/style_test.go +++ b/internal/picker/style_test.go @@ -50,3 +50,29 @@ func TestPRBadge_UnknownState(t *testing.T) { t.Errorf("PRBadge(WEIRD) = %q, want [weird]", got) } } + +func TestResumeBadge_HasSession(t *testing.T) { + t.Setenv("NO_COLOR", "1") + if got := ResumeBadge(true); got != "[RESUME]" { + t.Errorf("ResumeBadge(true) NO_COLOR = %q, want [RESUME]", got) + } + if got := ResumeBadge(false); got != "[NEW] " { + t.Errorf("ResumeBadge(false) NO_COLOR = %q, want [NEW] ", got) + } +} + +func TestResumeBadge_Colored(t *testing.T) { + t.Setenv("NO_COLOR", "") + prev := lipgloss.ColorProfile() + lipgloss.SetColorProfile(termenv.ANSI256) + t.Cleanup(func() { lipgloss.SetColorProfile(prev) }) + + got := ResumeBadge(true) + if !strings.Contains(got, "RESUME") { + t.Errorf("ResumeBadge(true) = %q, want substring RESUME", got) + } + got = ResumeBadge(false) + if !strings.Contains(got, "NEW") { + t.Errorf("ResumeBadge(false) = %q, want substring NEW", got) + } +} From ed8020b6be7a3a18bc0b9ff6ef1420dc52c5a9c4 Mon Sep 17 00:00:00 2001 From: tqer39 Date: Sat, 25 Apr 2026 17:57:15 +0900 Subject: [PATCH 10/17] =?UTF-8?q?feat(picker):=20worktree=20=E8=A1=8C?= =?UTF-8?q?=E3=82=92=20L2=204=20=E8=A1=8C=E3=83=AC=E3=82=A4=E3=82=A2?= =?UTF-8?q?=E3=82=A6=E3=83=88=E3=81=AB=E6=8B=A1=E5=BC=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resume バッジ・status・indicators を 1 行目に集約し、branch / pr / path を 個別行で表示。arrow セパレータは廃止。 Co-Authored-By: Claude Opus 4.7 --- internal/picker/delegate.go | 66 +++++++++---- internal/picker/delegate_test.go | 160 +++++++++++++++++-------------- 2 files changed, 131 insertions(+), 95 deletions(-) diff --git a/internal/picker/delegate.go b/internal/picker/delegate.go index c122d42..a29d943 100644 --- a/internal/picker/delegate.go +++ b/internal/picker/delegate.go @@ -12,13 +12,13 @@ import ( "github.com/tqer39/ccw-cli/internal/worktree" ) -// rowDelegate renders items as two lines: meta (badge/branch/indicators/→/PR) -// on top, path below. +// rowDelegate renders worktree items as four lines: header (resume + name + +// status + indicators), branch, pr, path. type rowDelegate struct { prUnavailable bool } -func (d rowDelegate) Height() int { return 2 } +func (d rowDelegate) Height() int { return 4 } func (d rowDelegate) Spacing() int { return 1 } func (d rowDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil } @@ -31,16 +31,6 @@ func (d rowDelegate) Render(w io.Writer, m list.Model, index int, item list.Item _, _ = fmt.Fprint(w, renderRow(li, m.Width(), d.prUnavailable, selected)) } -// arrowGlyph returns the worktree→PR separator. ASCII `->` under NO_COLOR -// keeps width predictable across terminals without Unicode. -func arrowGlyph() string { - if noColor() { - return "->" - } - return "→" -} - -// renderRow is a pure function used by the delegate and tests. func renderRow(li listItem, width int, prUnavailable bool, selected bool) string { prefix := " " if selected { @@ -51,21 +41,56 @@ func renderRow(li listItem, width int, prUnavailable bool, selected bool) string return prefix + li.title + "\n " + li.desc } wt := li.wt - badge := Badge(wt.Status) + name := worktreeName(wt.Path) + resume := ResumeBadge(wt.HasSession) + status := Badge(wt.Status) indicators := fmt.Sprintf("↑%d ↓%d", wt.AheadCount, wt.BehindCount) if wt.Status == worktree.StatusDirty { indicators += fmt.Sprintf(" ✎%d", wt.DirtyCount) } - meta := strings.TrimRight(fmt.Sprintf("%s%s %-24s %s", prefix, badge, wt.Branch, indicators), " ") - top := meta + header := fmt.Sprintf("%s%s · %s", prefix, resume, name) + right := fmt.Sprintf("%s %s", status, indicators) + header = padBetween(header, right, width) + + branchLine := fmt.Sprintf(" branch: %s", wt.Branch) + prCell := "" if !prUnavailable { - top = meta + " " + arrowGlyph() + " " + renderPRCell(li.pr) + prCell = renderPRCell(li.pr) } - if width > 0 && lipgloss.Width(top) > width { - top = truncateToWidth(top, width) + prLine := " pr: " + prCell + pathLine := fmt.Sprintf(" path: %s", wt.Path) + + if width > 0 { + header = truncateToWidth(header, width) + branchLine = truncateToWidth(branchLine, width) + prLine = truncateToWidth(prLine, width) + pathLine = truncateToWidth(pathLine, width) + } + + return header + "\n" + branchLine + "\n" + prLine + "\n" + pathLine +} + +// padBetween places left and right on the same line with spaces between so +// that right is right-aligned at the given width. Falls back to a 2-space +// separator when width is too small. +func padBetween(left, right string, width int) string { + if width <= 0 { + return left + " " + right + } + gap := width - lipgloss.Width(left) - lipgloss.Width(right) + if gap < 2 { + gap = 2 + } + return left + strings.Repeat(" ", gap) + right +} + +func worktreeName(path string) string { + idx := strings.LastIndex(path, "/") + if idx < 0 { + return path } - return top + "\n " + wt.Path + return path[idx+1:] } // renderPRCell builds the PR portion of the row: either a state-tinted @@ -90,7 +115,6 @@ func truncateToWidth(s string, n int) string { if lipgloss.Width(s) <= n { return s } - // Naive byte-trim fallback: good enough for ASCII-heavy rows. for len(s) > 0 && lipgloss.Width(s) > n { s = s[:len(s)-1] } diff --git a/internal/picker/delegate_test.go b/internal/picker/delegate_test.go index c120fa1..03578a1 100644 --- a/internal/picker/delegate_test.go +++ b/internal/picker/delegate_test.go @@ -8,126 +8,138 @@ import ( "github.com/tqer39/ccw-cli/internal/worktree" ) -func TestRenderRow_PushedNoColor(t *testing.T) { +func TestRenderRow_ResumeBadge(t *testing.T) { t.Setenv("NO_COLOR", "1") - row := renderRow(listItem{ + li := listItem{ tag: tagWorktree, wt: &worktree.Info{ - Branch: "kahan", - Path: "/tmp/x", - Status: worktree.StatusPushed, - AheadCount: 0, - BehindCount: 0, + Path: "/repo/.claude/worktrees/foo", + Branch: "feature/auth", + Status: worktree.StatusPushed, + HasSession: true, }, - pr: &gh.PRInfo{Number: 12, State: "MERGED", Title: "Add picker mod"}, - }, 120, false, false) - if !strings.Contains(row, "[pushed]") { - t.Errorf("want [pushed] badge, got:\n%s", row) } - if !strings.Contains(row, "kahan") || !strings.Contains(row, "↑0 ↓0") { - t.Errorf("missing branch/counts:\n%s", row) + got := renderRow(li, 120, true, false) + if !strings.Contains(got, "[RESUME]") { + t.Errorf("missing RESUME badge:\n%s", got) } - if !strings.Contains(row, "#12") || !strings.Contains(row, "[merged]") { - t.Errorf("missing PR badge/number:\n%s", row) + if !strings.Contains(got, "foo") { + t.Errorf("missing worktree name foo:\n%s", got) + } + if !strings.Contains(got, "branch: feature/auth") { + t.Errorf("missing branch line:\n%s", got) + } + if !strings.Contains(got, "path: /repo/.claude/worktrees/foo") { + t.Errorf("missing path line:\n%s", got) + } + lines := strings.Split(strings.TrimRight(got, "\n"), "\n") + if len(lines) != 4 { + t.Errorf("got %d lines, want 4:\n%s", len(lines), got) } } -func TestRenderRow_ContainsArrowAndPRBadge(t *testing.T) { +func TestRenderRow_NewBadge(t *testing.T) { t.Setenv("NO_COLOR", "1") - row := renderRow(listItem{ + li := listItem{ tag: tagWorktree, wt: &worktree.Info{ - Branch: "feat/login", - Path: "/tmp/x", - Status: worktree.StatusPushed, + Path: "/repo/.claude/worktrees/bar", + Branch: "bar", + Status: worktree.StatusLocalOnly, + HasSession: false, }, - pr: &gh.PRInfo{Number: 42, State: "OPEN", Title: "add login page"}, - }, 120, false, false) - if !strings.Contains(row, "->") { - t.Errorf("want arrow separator `->` in NO_COLOR mode, got:\n%s", row) - } - if !strings.Contains(row, "[open]") { - t.Errorf("want PR state badge [open], got:\n%s", row) } - if !strings.Contains(row, "#42") || !strings.Contains(row, "add login page") { - t.Errorf("want PR number + title, got:\n%s", row) + got := renderRow(li, 120, true, true) + if !strings.Contains(got, "[NEW]") { + t.Errorf("missing NEW badge:\n%s", got) } - if strings.Count(row, "open") != 1 { - t.Errorf("state label should appear exactly once, got:\n%s", row) + if !strings.HasPrefix(got, "> ") { + t.Errorf("selected row should start with '> ': %q", got[:2]) } } -func TestRenderRow_ArrowOmittedWhenPRUnavailable(t *testing.T) { +func TestRenderRow_StatusBadgeAndIndicators(t *testing.T) { t.Setenv("NO_COLOR", "1") - row := renderRow(listItem{ + li := listItem{ tag: tagWorktree, wt: &worktree.Info{ - Branch: "nebula", - Path: "/tmp/n", - Status: worktree.StatusDirty, + Path: "/repo/.claude/worktrees/dirty", + Branch: "wip", + Status: worktree.StatusDirty, + AheadCount: 2, + BehindCount: 1, + DirtyCount: 5, }, - pr: nil, - }, 120, true, false) - if strings.Contains(row, "->") { - t.Errorf("arrow should be omitted when prUnavailable, got:\n%s", row) + } + got := renderRow(li, 120, true, false) + if !strings.Contains(got, "[dirty]") { + t.Errorf("missing [dirty]:\n%s", got) + } + if !strings.Contains(got, "↑2 ↓1 ✎5") { + t.Errorf("missing indicators:\n%s", got) } } -func TestRenderRow_ArrowWithNoPRPlaceholder(t *testing.T) { +func TestRenderRow_PRLineWithPR(t *testing.T) { t.Setenv("NO_COLOR", "1") - row := renderRow(listItem{ + li := listItem{ tag: tagWorktree, wt: &worktree.Info{ - Branch: "lonely", - Path: "/tmp/l", - Status: worktree.StatusLocalOnly, + Path: "/repo/.claude/worktrees/foo", + Branch: "feat/login", + Status: worktree.StatusPushed, }, - pr: nil, - }, 120, false, false) - if !strings.Contains(row, "->") { - t.Errorf("arrow should appear even when PR is absent, got:\n%s", row) + pr: &gh.PRInfo{Number: 42, State: "OPEN", Title: "add login page"}, + } + got := renderRow(li, 120, false, false) + if !strings.Contains(got, "pr: ") { + t.Errorf("missing pr: label:\n%s", got) } - if !strings.Contains(row, "(no PR)") { - t.Errorf("want (no PR) placeholder, got:\n%s", row) + if !strings.Contains(got, "[open]") { + t.Errorf("missing [open] PR badge:\n%s", got) + } + if !strings.Contains(got, "#42") { + t.Errorf("missing #42:\n%s", got) } } -func TestRenderRow_DirtyPRUnavailable(t *testing.T) { +func TestRenderRow_PRLineNoPR(t *testing.T) { t.Setenv("NO_COLOR", "1") - row := renderRow(listItem{ + li := listItem{ tag: tagWorktree, wt: &worktree.Info{ - Branch: "nebula", - Path: "/tmp/n", - Status: worktree.StatusDirty, - AheadCount: 1, - DirtyCount: 5, + Path: "/repo/.claude/worktrees/lonely", + Branch: "lonely", + Status: worktree.StatusLocalOnly, }, pr: nil, - }, 120, true, false) - if !strings.Contains(row, "[dirty]") { - t.Errorf("want [dirty]:\n%s", row) } - if !strings.Contains(row, "✎5") { - t.Errorf("want ✎5:\n%s", row) - } - if strings.Contains(row, "#") || strings.Contains(row, "no PR") { - t.Errorf("PR column should be omitted when prUnavailable:\n%s", row) + got := renderRow(li, 120, false, false) + if !strings.Contains(got, "(no PR)") { + t.Errorf("missing (no PR) placeholder:\n%s", got) } } -func TestRenderRow_NoPRShowsPlaceholder(t *testing.T) { +func TestRenderRow_PRUnavailableHidesPRContent(t *testing.T) { t.Setenv("NO_COLOR", "1") - row := renderRow(listItem{ + li := listItem{ tag: tagWorktree, wt: &worktree.Info{ - Branch: "lonely", - Path: "/tmp/l", - Status: worktree.StatusLocalOnly, + Path: "/repo/.claude/worktrees/n", + Branch: "n", + Status: worktree.StatusDirty, }, pr: nil, - }, 120, false, false) - if !strings.Contains(row, "(no PR)") { - t.Errorf("want (no PR) marker when PR col enabled but empty:\n%s", row) + } + got := renderRow(li, 120, true, false) + if strings.Contains(got, "(no PR)") { + t.Errorf("PR placeholder should not appear when prUnavailable:\n%s", got) + } + if strings.Contains(got, "#") { + t.Errorf("PR number should not appear when prUnavailable:\n%s", got) + } + // pr line still appears as label, but the cell is empty + if !strings.Contains(got, "pr:") { + t.Errorf("pr: label should still appear:\n%s", got) } } From 78d98f5a43ce9bf05155cb3baa609e372a68a1a6 Mon Sep 17 00:00:00 2001 From: tqer39 Date: Sat, 25 Apr 2026 17:59:17 +0900 Subject: [PATCH 11/17] =?UTF-8?q?feat(tips):=20random=20TIPS=20=E3=83=91?= =?UTF-8?q?=E3=83=83=E3=82=B1=E3=83=BC=E3=82=B8=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit picker footer 用の短いヒント文字列を deterministic に選択する。 Co-Authored-By: Claude Opus 4.7 --- internal/tips/tips.go | 32 ++++++++++++++++++++++++ internal/tips/tips_test.go | 50 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 internal/tips/tips.go create mode 100644 internal/tips/tips_test.go diff --git a/internal/tips/tips.go b/internal/tips/tips.go new file mode 100644 index 0000000..ce55b4a --- /dev/null +++ b/internal/tips/tips.go @@ -0,0 +1,32 @@ +// Package tips provides short rotating tip strings shown in the picker footer. +package tips + +import "math/rand/v2" + +var defaults = []string{ + "worktree 名 = session 名。手で /rename しても ccw は何もしません", + "claude --from-pr <番号> で PR 連携セッションを直接 resume できます", + "--clean-all で push 済 worktree を一括削除", + "ccw -- --model で claude にフラグを素通し", + "picker の RESUME バッジは ~/.claude/projects/ から判定しています", +} + +// Defaults returns a copy of the built-in TIPS set. +func Defaults() []string { + out := make([]string, len(defaults)) + copy(out, defaults) + return out +} + +// PickRandom returns a single tip selected deterministically from seed. +func PickRandom(seed uint64) string { + return pickFrom(defaults, seed) +} + +func pickFrom(set []string, seed uint64) string { + if len(set) == 0 { + return "" + } + r := rand.New(rand.NewPCG(seed, seed^0x9E3779B97F4A7C15)) + return set[r.IntN(len(set))] +} diff --git a/internal/tips/tips_test.go b/internal/tips/tips_test.go new file mode 100644 index 0000000..d0eaf11 --- /dev/null +++ b/internal/tips/tips_test.go @@ -0,0 +1,50 @@ +package tips + +import ( + "strings" + "testing" +) + +func TestPickRandom_FromDefaultSet(t *testing.T) { + got := PickRandom(42) + if got == "" { + t.Fatal("PickRandom(42) = empty string") + } + found := false + for _, c := range Defaults() { + if got == c { + found = true + break + } + } + if !found { + t.Errorf("PickRandom(42) = %q, not in Defaults()", got) + } +} + +func TestPickRandom_Deterministic(t *testing.T) { + if PickRandom(7) != PickRandom(7) { + t.Error("PickRandom(7) is non-deterministic") + } +} + +func TestPickFrom_Empty(t *testing.T) { + if got := pickFrom(nil, 1); got != "" { + t.Errorf("pickFrom(nil) = %q, want empty", got) + } + if got := pickFrom([]string{}, 1); got != "" { + t.Errorf("pickFrom([]) = %q, want empty", got) + } +} + +func TestDefaults_NonEmpty(t *testing.T) { + d := Defaults() + if len(d) == 0 { + t.Error("Defaults() empty") + } + for _, s := range d { + if strings.TrimSpace(s) == "" { + t.Errorf("empty TIPS string in defaults") + } + } +} From aed40aa10ef4ddb164964d2d1948d9a9b7d9f480 Mon Sep 17 00:00:00 2001 From: tqer39 Date: Sat, 25 Apr 2026 18:01:46 +0900 Subject: [PATCH 12/17] =?UTF-8?q?feat(picker):=20footer=20=E3=81=AB=20rand?= =?UTF-8?q?om=20TIPS=20=E3=82=92=E8=A1=A8=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Model 構築時に tips.PickRandom で TIPS を 1 件選び、stateList の View 末尾に 表示。gh 不在時のヒントは引き続き表示する。 Co-Authored-By: Claude Opus 4.7 --- internal/picker/model.go | 5 ++++- internal/picker/view.go | 13 ++++++++++--- internal/picker/view_test.go | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 4 deletions(-) create mode 100644 internal/picker/view_test.go diff --git a/internal/picker/model.go b/internal/picker/model.go index 3ea9c61..397bb43 100644 --- a/internal/picker/model.go +++ b/internal/picker/model.go @@ -4,10 +4,12 @@ package picker import ( "fmt" + "time" "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" "github.com/tqer39/ccw-cli/internal/gh" + "github.com/tqer39/ccw-cli/internal/tips" "github.com/tqer39/ccw-cli/internal/worktree" ) @@ -96,6 +98,7 @@ type Model struct { bulkFilter map[worktree.Status]bool bulkTargets []int bulkForce bool + tip string } // listItem is a bubbles/list.Item with a tag that lets us distinguish @@ -160,7 +163,7 @@ func New(infos []worktree.Info) Model { l.SetFilteringEnabled(false) // Probe gh availability once at construction. View() runs on every render, // so caching here avoids spawning `gh auth status` per keystroke. - return Model{state: stateList, infos: infos, list: l, ghAvailable: gh.Available()} + return Model{state: stateList, infos: infos, list: l, ghAvailable: gh.Available(), tip: tips.PickRandom(uint64(time.Now().UnixNano()))} } // Action returns the action the user chose (valid after the program exits). diff --git a/internal/picker/view.go b/internal/picker/view.go index 6c9e803..b849dd0 100644 --- a/internal/picker/view.go +++ b/internal/picker/view.go @@ -13,10 +13,17 @@ func (m Model) View() string { switch m.state { case stateList: base := m.list.View() - if !m.ghAvailable { - return base + "\n\n💡 Install gh to see PR titles here" + footer := "" + switch { + case !m.ghAvailable: + footer = "💡 Install gh to see PR titles here" + case m.tip != "": + footer = "💡 Tip: " + m.tip } - return base + if footer == "" { + return base + } + return base + "\n\n" + footer case stateMenu: return m.menuView() case stateDeleteConfirm: diff --git a/internal/picker/view_test.go b/internal/picker/view_test.go new file mode 100644 index 0000000..bc2ed39 --- /dev/null +++ b/internal/picker/view_test.go @@ -0,0 +1,33 @@ +package picker + +import ( + "strings" + "testing" + + "github.com/tqer39/ccw-cli/internal/worktree" +) + +func TestView_FooterShowsTips(t *testing.T) { + m := New([]worktree.Info{{Path: "/x/.claude/worktrees/a", Branch: "a"}}) + m.ghAvailable = true + m.tip = "test tip line" + m.state = stateList + out := m.View() + if !strings.Contains(out, "💡 Tip: test tip line") { + t.Errorf("View footer missing tip:\n%s", out) + } +} + +func TestView_FooterShowsGhHintWhenUnavailable(t *testing.T) { + m := New([]worktree.Info{{Path: "/x/.claude/worktrees/a", Branch: "a"}}) + m.ghAvailable = false + m.tip = "should-not-show" + m.state = stateList + out := m.View() + if !strings.Contains(out, "Install gh") { + t.Errorf("View should show gh hint when gh unavailable:\n%s", out) + } + if strings.Contains(out, "should-not-show") { + t.Errorf("View should not show tip when gh unavailable:\n%s", out) + } +} From 9baacfa69f70c593ddbcef2ff2f86be7573ffbbd Mon Sep 17 00:00:00 2001 From: tqer39 Date: Sat, 25 Apr 2026 18:05:45 +0900 Subject: [PATCH 13/17] =?UTF-8?q?feat(ccw):=20worktree=20=E5=90=8D=20=3D?= =?UTF-8?q?=20session=20=E5=90=8D=20=E3=82=92=E7=A2=BA=E7=AB=8B=E3=80=81re?= =?UTF-8?q?sume=20=E3=82=92=E6=97=A2=E5=AD=98=20worktree=20=E3=81=AE?= =?UTF-8?q?=E3=83=87=E3=83=95=E3=82=A9=E3=83=AB=E3=83=88=E3=81=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新規 worktree 起動時に namegen.Generate() でセッション名を生成 - picker から既存 worktree を選択した場合は claude.Continue を呼び、 HasSession が false かつ非ゼロ exit のときに -n でフォールバック - Selection.HasSession を追加し UI から CLI へ可否を伝搬 - tips_test.go: SA4000 を修正(同一式の比較を変数化) Co-Authored-By: Claude Opus 4.7 --- cmd/ccw/main.go | 26 +++++++++++++++++++++++--- internal/picker/model.go | 1 + internal/picker/run.go | 2 +- internal/picker/update.go | 2 +- internal/tips/tips_test.go | 5 +++-- 5 files changed, 29 insertions(+), 7 deletions(-) diff --git a/cmd/ccw/main.go b/cmd/ccw/main.go index f71cc20..15f754a 100644 --- a/cmd/ccw/main.go +++ b/cmd/ccw/main.go @@ -11,6 +11,7 @@ import ( "github.com/tqer39/ccw-cli/internal/claude" "github.com/tqer39/ccw-cli/internal/cli" "github.com/tqer39/ccw-cli/internal/gitx" + "github.com/tqer39/ccw-cli/internal/namegen" "github.com/tqer39/ccw-cli/internal/picker" "github.com/tqer39/ccw-cli/internal/superpowers" "github.com/tqer39/ccw-cli/internal/ui" @@ -71,7 +72,8 @@ func run(flags cli.Flags) int { } if flags.NewWorktree { - code, err := claude.LaunchNew(mainRepo, preamble, flags.Passthrough) + name := namegen.Generate() + code, err := claude.LaunchNew(mainRepo, name, preamble, flags.Passthrough) if err != nil { ui.Error("%v", err) return 1 @@ -93,18 +95,27 @@ func runPicker(mainRepo string, passthrough []string, interactive bool) int { case picker.ActionCancel: return 0 case picker.ActionNew: - code, err := claude.LaunchNew(mainRepo, "", passthrough) + name := namegen.Generate() + code, err := claude.LaunchNew(mainRepo, name, "", passthrough) if err != nil { ui.Error("%v", err) return 1 } return code case picker.ActionResume: - code, err := claude.Resume(sel.Path, passthrough) + code, err := claude.Continue(sel.Path, passthrough) if err != nil { ui.Error("%v", err) return 1 } + if code != 0 && !sel.HasSession { + name := worktreeName(sel.Path) + code, err = claude.LaunchNew(sel.Path, name, "", passthrough) + if err != nil { + ui.Error("%v", err) + return 1 + } + } return code case picker.ActionDelete: if err := worktree.Remove(mainRepo, sel.Path, sel.ForceDelete); err != nil { @@ -237,6 +248,15 @@ func resolveMainRepo() (string, error) { return mainRepo, nil } +func worktreeName(path string) string { + for i := len(path) - 1; i >= 0; i-- { + if path[i] == '/' { + return path[i+1:] + } + } + return path +} + func maybeSuperpowers(enabled bool, interactive, assumeYes bool) (string, error) { if !enabled { return "", nil diff --git a/internal/picker/model.go b/internal/picker/model.go index 397bb43..7f9cdeb 100644 --- a/internal/picker/model.go +++ b/internal/picker/model.go @@ -47,6 +47,7 @@ type Selection struct { Path string Branch string Status worktree.Status + HasSession bool ForceDelete bool } diff --git a/internal/picker/run.go b/internal/picker/run.go index a21c603..03c4bd1 100644 --- a/internal/picker/run.go +++ b/internal/picker/run.go @@ -68,7 +68,7 @@ func runFallback(infos []worktree.Info, in io.Reader, out io.Writer) (Action, Se return ActionCancel, Selection{}, fmt.Errorf("invalid choice: %q", answer) } w := infos[n-1] - return ActionResume, Selection{Path: w.Path, Branch: w.Branch, Status: w.Status}, nil + return ActionResume, Selection{Path: w.Path, Branch: w.Branch, Status: w.Status, HasSession: w.HasSession}, nil } // fallbackIndicators formats ahead/behind commit counts and dirty file count diff --git a/internal/picker/update.go b/internal/picker/update.go index 95e6d9c..d89f5b8 100644 --- a/internal/picker/update.go +++ b/internal/picker/update.go @@ -118,7 +118,7 @@ func (m Model) updateMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m Model) currentSelection() Selection { w := m.infos[m.selIdx] - return Selection{Path: w.Path, Branch: w.Branch, Status: w.Status} + return Selection{Path: w.Path, Branch: w.Branch, Status: w.Status, HasSession: w.HasSession} } func (m Model) updateBulkFilter(msg tea.KeyMsg) (tea.Model, tea.Cmd) { diff --git a/internal/tips/tips_test.go b/internal/tips/tips_test.go index d0eaf11..281985e 100644 --- a/internal/tips/tips_test.go +++ b/internal/tips/tips_test.go @@ -23,8 +23,9 @@ func TestPickRandom_FromDefaultSet(t *testing.T) { } func TestPickRandom_Deterministic(t *testing.T) { - if PickRandom(7) != PickRandom(7) { - t.Error("PickRandom(7) is non-deterministic") + a, b := PickRandom(7), PickRandom(7) + if a != b { + t.Errorf("PickRandom(7) is non-deterministic: %q != %q", a, b) } } From f7d1d5cf65be7064410440d28212f9a7cf898ef9 Mon Sep 17 00:00:00 2001 From: tqer39 Date: Sat, 25 Apr 2026 18:09:15 +0900 Subject: [PATCH 14/17] =?UTF-8?q?test(e2e):=20=E6=96=B0=E8=A6=8F=20worktre?= =?UTF-8?q?e=20=E3=81=A7=20--worktree=20=E3=81=A8=20-n=20=E3=81=8C?= =?UTF-8?q?=E5=90=8C=E4=B8=80=E5=90=8D=E3=81=A7=E3=81=82=E3=82=8B=E3=81=93?= =?UTF-8?q?=E3=81=A8=E3=82=92=E7=A2=BA=E8=AA=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fake claude バイナリで ccw -n の引数遷移を検証する。 Co-Authored-By: Claude Opus 4.7 --- tests/fakes/fake_claude/main.go | 28 ++++++++ tests/resume_flow_test.go | 111 ++++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+) create mode 100644 tests/fakes/fake_claude/main.go create mode 100644 tests/resume_flow_test.go diff --git a/tests/fakes/fake_claude/main.go b/tests/fakes/fake_claude/main.go new file mode 100644 index 0000000..68e2083 --- /dev/null +++ b/tests/fakes/fake_claude/main.go @@ -0,0 +1,28 @@ +// fake claude binary used in resume_flow_test.go. +// Writes os.Args[1:] to $CCW_FAKE_CLAUDE_LOG (newline-separated, with a "---" +// separator between calls) and exits with $CCW_FAKE_CLAUDE_EXIT (default 0). +package main + +import ( + "os" + "strconv" + "strings" +) + +func main() { + logPath := os.Getenv("CCW_FAKE_CLAUDE_LOG") + if logPath != "" { + f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err == nil { + _, _ = f.WriteString(strings.Join(os.Args[1:], "\n") + "\n---\n") + _ = f.Close() + } + } + exit := 0 + if v := os.Getenv("CCW_FAKE_CLAUDE_EXIT"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + exit = n + } + } + os.Exit(exit) +} diff --git a/tests/resume_flow_test.go b/tests/resume_flow_test.go new file mode 100644 index 0000000..bd3277b --- /dev/null +++ b/tests/resume_flow_test.go @@ -0,0 +1,111 @@ +package tests + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func buildBinary(t *testing.T, target, out string) { + t.Helper() + cmd := exec.Command("go", "build", "-o", out, target) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("build %s: %v\n%s", target, err, output) + } +} + +func setupFakeEnv(t *testing.T) (binDir, logPath, home string) { + t.Helper() + binDir = t.TempDir() + home = t.TempDir() + buildBinary(t, "./fakes/fake_claude", filepath.Join(binDir, "claude")) + buildBinary(t, "../cmd/ccw", filepath.Join(binDir, "ccw")) + logPath = filepath.Join(t.TempDir(), "claude.log") + return +} + +func initRepo(t *testing.T, dir string) { + t.Helper() + for _, args := range [][]string{ + {"init", "-q"}, + {"config", "user.email", "test@example.com"}, + {"config", "user.name", "test"}, + {"config", "commit.gpgsign", "false"}, + {"commit", "--allow-empty", "-q", "-m", "init"}, + } { + c := exec.Command("git", args...) + c.Dir = dir + if out, err := c.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } + } +} + +func runCcw(t *testing.T, binDir, repo, log, home string, args ...string) string { + t.Helper() + cmd := exec.Command(filepath.Join(binDir, "ccw"), args...) + cmd.Dir = repo + cmd.Env = append(os.Environ(), + "PATH="+binDir+string(os.PathListSeparator)+os.Getenv("PATH"), + "HOME="+home, + "CCW_FAKE_CLAUDE_LOG="+log, + "NO_COLOR=1", + ) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("ccw %v: %v\n%s", args, err, out) + } + return string(out) +} + +func readLog(t *testing.T, p string) []string { + t.Helper() + b, err := os.ReadFile(p) + if err != nil { + t.Fatalf("read log: %v", err) + } + return strings.Split(strings.TrimSpace(string(b)), "\n---\n") +} + +func indexOf(s []string, target string) int { + for i, v := range s { + if v == target { + return i + } + } + return -1 +} + +func TestResumeFlow_NewWorktreePassesNameToBoth(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available") + } + binDir, log, home := setupFakeEnv(t) + repo := t.TempDir() + initRepo(t, repo) + + _ = runCcw(t, binDir, repo, log, home, "-n") + + calls := readLog(t, log) + if len(calls) < 1 { + t.Fatalf("expected at least 1 claude call, got %d", len(calls)) + } + first := calls[0] + if !strings.Contains(first, "--worktree\n") { + t.Errorf("first call missing --worktree:\n%s", first) + } + if !strings.Contains(first, "\n-n\n") { + t.Errorf("first call missing -n:\n%s", first) + } + args := strings.Split(first, "\n") + idxWT := indexOf(args, "--worktree") + idxN := indexOf(args, "-n") + if idxWT < 0 || idxN < 0 || idxWT+1 >= len(args) || idxN+1 >= len(args) { + t.Fatalf("malformed args:\n%s", first) + } + if args[idxWT+1] != args[idxN+1] { + t.Errorf("--worktree %q != -n %q", args[idxWT+1], args[idxN+1]) + } +} From 51f732f00589d97eaec3a749ae76df91f03066d0 Mon Sep 17 00:00:00 2001 From: tqer39 Date: Sat, 25 Apr 2026 18:12:21 +0900 Subject: [PATCH 15/17] =?UTF-8?q?docs:=20--resume=20=E8=AD=A6=E5=91=8A?= =?UTF-8?q?=E3=82=92=E6=92=A4=E5=8E=BB=E3=81=97=20RESUME/NEW=20=E3=83=90?= =?UTF-8?q?=E3=83=83=E3=82=B8=E3=83=BB=E5=91=BD=E5=90=8D=E8=A6=8F=E7=B4=84?= =?UTF-8?q?=E3=82=92=E8=BF=BD=E8=A8=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit picker run の挙動を --continue ベースの新仕様に置き換え、Claude Code の 最低バージョンを 2.1.118 に引き上げた。 Co-Authored-By: Claude Opus 4.7 --- README.md | 22 ++++++++++++++++++---- docs/README.ja.md | 22 ++++++++++++++++++---- 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index bf30326..00b8ad9 100644 --- a/README.md +++ b/README.md @@ -72,12 +72,26 @@ PR state badge (shown only when [`gh`](https://cli.github.com/) is installed and | 🟪 `[MERGED]` | PR has been merged | | 🟥 `[CLOSED]` | PR was closed without merging | -Selecting a worktree opens `[r] run` / `[d] delete` / `[b] back`. `run` launches a fresh `claude --permission-mode auto` in that worktree — ccw does **not** reuse Claude Code session IDs (no `--resume` under the hood). Bulk shortcuts (`[delete all]`, `[clean pushed]`, `[custom select]`) remove many at once; dirty items require either `--force` or a three-choice confirm (`y` force · `s` skip dirty · `N` cancel). +Session badge: + +| Badge | Meaning | +|---|---| +| 💬 `RESUME` | Past session log exists — `run` restores the conversation | +| ⚡ `NEW` | No session log — `run` starts fresh | + +Selecting a worktree opens `[r] run` / `[d] delete` / `[b] back`. `run` calls `claude --continue` to restore the past conversation when a session log exists, or `claude -n ` to start fresh otherwise. Bulk shortcuts (`[delete all]`, `[clean pushed]`, `[custom select]`) remove many at once; dirty items require either `--force` or a three-choice confirm (`y` force · `s` skip dirty · `N` cancel). Without `gh`, the picker stays functional and shows a hint; rate-limit / network failures hide the PR column silently. -> ⚠️ **Passing `--resume` through `--` is unsupported.** -> `ccw -n -- --resume ID` and `ccw -s -- --resume ID` combine `claude --worktree` (new worktree) with `--resume` (continue a prior session); the resumed transcript's file references won't match the freshly-created worktree. Even the picker's re-entry path suffers the same mismatch if the selected worktree differs from the session's original. If a resumed session is what you want, run `claude --resume ID` directly — bypass ccw. +### Naming convention + +When ccw creates a new worktree, the worktree directory and the Claude Code session name are kept 1:1: + +- Directory: `/.claude/worktrees//` +- Branch: `worktree-` +- Session name: `` (set via `claude -n `) + +`` is generated like `quick-falcon-7bd2`. Renaming the session manually with `/rename` is fine — ccw does not track it, and `--continue` keys off the working directory so conversation restore is unaffected. ## 📦 Installation @@ -99,7 +113,7 @@ Make sure `~/.local/bin` is on your `PATH`. ### Requirements - [`git`](https://git-scm.com/) -- [Claude Code](https://docs.claude.com/claude-code) `>= 2.1.49` — the `--worktree` flag that ccw relies on was introduced in 2.1.49 (2026-02-19). ccw offers to install `claude` via npm / brew if missing. +- [Claude Code](https://docs.claude.com/claude-code) `>= 2.1.118` — ccw uses `--worktree ` together with `-n `, which was confirmed working from 2.1.118 onward. ccw offers to install `claude` via npm / brew if missing. - *(optional)* [`gh`](https://cli.github.com/) — enables PR info in the picker - *(optional)* [superpowers](https://github.com/obra/superpowers) plugin — auto-checked when `-s` is used diff --git a/docs/README.ja.md b/docs/README.ja.md index 93ceff6..11361c2 100644 --- a/docs/README.ja.md +++ b/docs/README.ja.md @@ -72,12 +72,26 @@ PR 状態バッジ([`gh`](https://cli.github.com/) がインストール済み | 🟪 `[MERGED]` | マージ済みの PR | | 🟥 `[CLOSED]` | マージされずにクローズされた PR | -worktree を選択すると `[r] run` / `[d] delete` / `[b] back` のサブメニューに遷移。`run` は選択した worktree で `claude --permission-mode auto` を新規起動するもので、Claude Code のセッション ID を引き継ぐ(`--resume` 相当の)操作は**行いません**。`[delete all]` / `[clean pushed]` / `[custom select]` は一括削除のショートカットで、dirty を含む場合は `--force` か、または 3 択確認 (`y` force · `s` dirty を除外 · `N` キャンセル) を経由します。 +セッションバッジ: + +| バッジ | 意味 | +|---|---| +| 💬 `RESUME` | 過去のセッションログがあり、`run` で会話を復元できる | +| ⚡ `NEW` | セッションログ無し。`run` は新規起動 | + +worktree を選択すると `[r] run` / `[d] delete` / `[b] back` のサブメニューに遷移。`run` はセッションログが残っていれば `claude --continue` で**過去会話を復元**、無ければ `claude -n ` で新規起動します。`[delete all]` / `[clean pushed]` / `[custom select]` は一括削除のショートカットで、dirty を含む場合は `--force` か、または 3 択確認 (`y` force · `s` dirty を除外 · `N` キャンセル) を経由します。 `gh` が無い場合も picker は動作し、ヒントを下部に表示。rate limit / ネットワークエラー時は PR 列だけを静かに隠します。 -> ⚠️ **`-- --resume ID` のパススルーは非推奨です。** -> `ccw -n -- --resume ID` や `ccw -s -- --resume ID` は `claude --worktree`(新 worktree 作成)と `--resume`(過去セッション継続)を同時に使うことになり、resume された会話中のファイル参照が新 worktree の実体と合いません。picker 経由で既存 worktree に再入場する場合も、選んだ worktree と session 元の worktree が違えば同様のズレが出ます。過去セッションを resume したいときは ccw を介さず直接 `claude --resume ID` を呼んでください。 +### 命名規約 + +ccw は新規 worktree を作るとき、worktree 名と Claude Code のセッション名を 1:1 で揃えます: + +- ディレクトリ: `/.claude/worktrees//` +- ブランチ: `worktree-` +- セッション名: ``(`claude -n ` で設定) + +`` は `quick-falcon-7bd2` のようにジェネレータが生成します。手動で `/rename` した場合も ccw は追跡しません。`--continue` は cwd 基準で動くので会話復元には影響しません。 ## 📦 インストール @@ -99,7 +113,7 @@ go build -o ~/.local/bin/ccw ~/ccw-cli/cmd/ccw ### 依存 - [`git`](https://git-scm.com/) -- [Claude Code](https://docs.claude.com/claude-code) `>= 2.1.49` — ccw が利用する `--worktree` フラグは 2.1.49 (2026-02-19) で追加されました。未導入なら起動時に npm / brew で入れるかを確認します。 +- [Claude Code](https://docs.claude.com/claude-code) `>= 2.1.118` — ccw は `--worktree ` と `-n ` を併用するため、2.1.118 以降で動作確認済みです。未導入なら起動時に npm / brew で入れるかを確認します。 - *(optional)* [`gh`](https://cli.github.com/) — picker で PR 情報を表示 - *(optional)* [superpowers](https://github.com/obra/superpowers) プラグイン — `-s` 利用時に自動チェック From ba23913623fef2216323e1921a256462a739da41 Mon Sep 17 00:00:00 2001 From: tqer39 Date: Sat, 25 Apr 2026 18:17:40 +0900 Subject: [PATCH 16/17] =?UTF-8?q?fix(ccw):=20HasSession=3Dfalse=20?= =?UTF-8?q?=E3=81=A7=20--continue=20=E3=82=92=E5=91=BC=E3=81=B0=E3=81=AA?= =?UTF-8?q?=E3=81=84=E3=82=88=E3=81=86=E5=85=88=E3=81=AB=E5=88=86=E5=B2=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit セッション無しの worktree でも常に claude --continue を試行していたため、 無駄な subprocess 起動と、claude が picker を開いた場合のネスト UI 化リスクが あった。HasSession で先に分岐し、無ければ直接 -n で起動する。 ロジックは runResume ヘルパに切り出し、runPicker の cyclomatic 複雑度を抑える。 Co-Authored-By: Claude Opus 4.7 --- cmd/ccw/main.go | 44 ++++++++++++++++++++++++++++++-------------- 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/cmd/ccw/main.go b/cmd/ccw/main.go index 15f754a..ef20234 100644 --- a/cmd/ccw/main.go +++ b/cmd/ccw/main.go @@ -103,20 +103,7 @@ func runPicker(mainRepo string, passthrough []string, interactive bool) int { } return code case picker.ActionResume: - code, err := claude.Continue(sel.Path, passthrough) - if err != nil { - ui.Error("%v", err) - return 1 - } - if code != 0 && !sel.HasSession { - name := worktreeName(sel.Path) - code, err = claude.LaunchNew(sel.Path, name, "", passthrough) - if err != nil { - ui.Error("%v", err) - return 1 - } - } - return code + return runResume(sel, passthrough) case picker.ActionDelete: if err := worktree.Remove(mainRepo, sel.Path, sel.ForceDelete); err != nil { ui.Error("%v", err) @@ -131,6 +118,35 @@ func runPicker(mainRepo string, passthrough []string, interactive bool) int { } } +// runResume launches `claude --continue` when the worktree has a session log, +// or `claude -n ` for fresh starts. Falls back to a new launch when +// `--continue` exits non-zero (e.g. session file removed underfoot). +func runResume(sel picker.Selection, passthrough []string) int { + if !sel.HasSession { + name := worktreeName(sel.Path) + code, err := claude.LaunchNew(sel.Path, name, "", passthrough) + if err != nil { + ui.Error("%v", err) + return 1 + } + return code + } + code, err := claude.Continue(sel.Path, passthrough) + if err != nil { + ui.Error("%v", err) + return 1 + } + if code != 0 { + name := worktreeName(sel.Path) + code, err = claude.LaunchNew(sel.Path, name, "", passthrough) + if err != nil { + ui.Error("%v", err) + return 1 + } + } + return code +} + func applyBulkDelete(mainRepo string, bulk picker.BulkDeletion) int { errs := 0 for _, p := range bulk.Paths { From a7b3598bd5dd9bfcc1d42deb9d232d77cd26accb Mon Sep 17 00:00:00 2001 From: tqer39 Date: Sat, 25 Apr 2026 18:34:16 +0900 Subject: [PATCH 17/17] =?UTF-8?q?fix:=20CodeRabbit=20=E6=8C=87=E6=91=98=20?= =?UTF-8?q?(resume=20fallback=20/=20in-worktree=20launch=20/=20=E6=9C=80?= =?UTF-8?q?=E4=BD=8E=E3=83=90=E3=83=BC=E3=82=B8=E3=83=A7=E3=83=B3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 既存 worktree 内からは --worktree を渡さない LaunchInWorktree を追加し runResume が利用 (name 衝突回避) - Continue 後の自動 fallback を worktree.HasSession() 再チェックでガードし、Ctrl+C や transient エラーで勝手に新セッションが立ち上がらないように - Claude Code 最低バージョン 2.1.118 → 2.1.76 (-n は 2.1.76 で導入) - plan 擬似コードを実装と一致 EncodeProjectPath の Critical 指摘 2 件は実機 (~/.claude/projects/) 検証で false alarm を確認: dot は実際に - に置換される (例 cwd /Users/.../.dotfiles/.claude/... → -Users-...--dotfiles--claude-...)。コードは正しく現状維持。 --- README.md | 2 +- cmd/ccw/main.go | 36 ++++++++++--------- docs/README.ja.md | 2 +- .../2026-04-25-worktree-resume-integration.md | 19 +++++++--- internal/claude/claude.go | 18 ++++++++++ internal/claude/claude_test.go | 16 +++++++++ 6 files changed, 71 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 00b8ad9..0c1fee3 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ Make sure `~/.local/bin` is on your `PATH`. ### Requirements - [`git`](https://git-scm.com/) -- [Claude Code](https://docs.claude.com/claude-code) `>= 2.1.118` — ccw uses `--worktree ` together with `-n `, which was confirmed working from 2.1.118 onward. ccw offers to install `claude` via npm / brew if missing. +- [Claude Code](https://docs.claude.com/claude-code) `>= 2.1.76` — ccw uses `--worktree ` (added in 2.1.49) together with `-n ` (added in 2.1.76). ccw offers to install `claude` via npm / brew if missing. - *(optional)* [`gh`](https://cli.github.com/) — enables PR info in the picker - *(optional)* [superpowers](https://github.com/obra/superpowers) plugin — auto-checked when `-s` is used diff --git a/cmd/ccw/main.go b/cmd/ccw/main.go index ef20234..945c70d 100644 --- a/cmd/ccw/main.go +++ b/cmd/ccw/main.go @@ -119,30 +119,34 @@ func runPicker(mainRepo string, passthrough []string, interactive bool) int { } // runResume launches `claude --continue` when the worktree has a session log, -// or `claude -n ` for fresh starts. Falls back to a new launch when -// `--continue` exits non-zero (e.g. session file removed underfoot). +// or `claude -n ` for fresh starts. The fresh-start path uses +// LaunchInWorktree because cwd is already an existing worktree (passing +// `--worktree ` from inside one risks a name-collision against git's +// existing registration). The post-Continue fallback only fires when the +// session log has actually disappeared between the picker check and Continue +// returning — so a normal user quit or transient claude error surfaces its +// exit code instead of silently restarting a fresh conversation. func runResume(sel picker.Selection, passthrough []string) int { if !sel.HasSession { - name := worktreeName(sel.Path) - code, err := claude.LaunchNew(sel.Path, name, "", passthrough) - if err != nil { - ui.Error("%v", err) - return 1 - } - return code + return launchInPlace(sel.Path, passthrough) } code, err := claude.Continue(sel.Path, passthrough) if err != nil { ui.Error("%v", err) return 1 } - if code != 0 { - name := worktreeName(sel.Path) - code, err = claude.LaunchNew(sel.Path, name, "", passthrough) - if err != nil { - ui.Error("%v", err) - return 1 - } + if code != 0 && !worktree.HasSession(sel.Path) { + return launchInPlace(sel.Path, passthrough) + } + return code +} + +func launchInPlace(path string, passthrough []string) int { + name := worktreeName(path) + code, err := claude.LaunchInWorktree(path, name, "", passthrough) + if err != nil { + ui.Error("%v", err) + return 1 } return code } diff --git a/docs/README.ja.md b/docs/README.ja.md index 11361c2..58cd7fb 100644 --- a/docs/README.ja.md +++ b/docs/README.ja.md @@ -113,7 +113,7 @@ go build -o ~/.local/bin/ccw ~/ccw-cli/cmd/ccw ### 依存 - [`git`](https://git-scm.com/) -- [Claude Code](https://docs.claude.com/claude-code) `>= 2.1.118` — ccw は `--worktree ` と `-n ` を併用するため、2.1.118 以降で動作確認済みです。未導入なら起動時に npm / brew で入れるかを確認します。 +- [Claude Code](https://docs.claude.com/claude-code) `>= 2.1.76` — ccw は `--worktree ` (2.1.49 で追加) と `-n ` (2.1.76 で追加) を併用します。未導入なら起動時に npm / brew で入れるかを確認します。 - *(optional)* [`gh`](https://cli.github.com/) — picker で PR 情報を表示 - *(optional)* [superpowers](https://github.com/obra/superpowers) プラグイン — `-s` 利用時に自動チェック diff --git a/docs/superpowers/plans/2026-04-25-worktree-resume-integration.md b/docs/superpowers/plans/2026-04-25-worktree-resume-integration.md index a018251..c5bc04c 100644 --- a/docs/superpowers/plans/2026-04-25-worktree-resume-integration.md +++ b/docs/superpowers/plans/2026-04-25-worktree-resume-integration.md @@ -1298,16 +1298,27 @@ Expected: 既存ヒット(74 行付近、103 行付近) ```go case picker.ActionResume: + if !sel.HasSession { + // picker 時点でセッション無し → -n で新規起動(cwd は既存 + // worktree なので --worktree は省略)。 + name := worktreeName(sel.Path) + code, err := claude.LaunchInWorktree(sel.Path, name, "", passthrough) + if err != nil { + ui.Error("%v", err) + return 1 + } + return code + } code, err := claude.Continue(sel.Path, passthrough) if err != nil { ui.Error("%v", err) return 1 } - if code != 0 && !sel.HasSession { - // HasSession=false なのに Resume パスを通った(picker から強制 run など)。 - // セッション無し → --continue は失敗し得るので -n でフォールバック。 + // Continue 後にセッションが消失した場合のみフォールバック。 + // 通常終了 (Ctrl+C) や transient エラーは exit code をそのまま返す。 + if code != 0 && !worktree.HasSession(sel.Path) { name := worktreeName(sel.Path) - code, err = claude.LaunchNew(sel.Path, name, "", passthrough) + code, err = claude.LaunchInWorktree(sel.Path, name, "", passthrough) if err != nil { ui.Error("%v", err) return 1 diff --git a/internal/claude/claude.go b/internal/claude/claude.go index 4355d51..37fe46d 100644 --- a/internal/claude/claude.go +++ b/internal/claude/claude.go @@ -21,6 +21,19 @@ func BuildNewArgs(name, preamble string, extra []string) []string { return args } +// BuildInWorktreeArgs is BuildNewArgs without `--worktree`. Use when cwd is +// already an existing worktree, since passing `--worktree ` from inside +// a worktree risks a name-collision error against the existing git registration. +func BuildInWorktreeArgs(name, preamble string, extra []string) []string { + args := make([]string, 0, 4+len(extra)+2) + args = append(args, "--permission-mode", "auto", "-n", name) + args = append(args, extra...) + if preamble != "" { + args = append(args, "--", preamble) + } + return args +} + // BuildContinueArgs constructs argv for `claude --permission-mode auto --continue [extra...]`. func BuildContinueArgs(extra []string) []string { args := make([]string, 0, 3+len(extra)) @@ -34,6 +47,11 @@ func LaunchNew(cwd, name, preamble string, extra []string) (int, error) { return runClaude(cwd, BuildNewArgs(name, preamble, extra)) } +// LaunchInWorktree execs claude with BuildInWorktreeArgs in cwd (no `--worktree`). +func LaunchInWorktree(cwd, name, preamble string, extra []string) (int, error) { + return runClaude(cwd, BuildInWorktreeArgs(name, preamble, extra)) +} + // Continue execs claude with BuildContinueArgs in cwd. func Continue(cwd string, extra []string) (int, error) { return runClaude(cwd, BuildContinueArgs(extra)) diff --git a/internal/claude/claude_test.go b/internal/claude/claude_test.go index ca64ee8..8b9fff5 100644 --- a/internal/claude/claude_test.go +++ b/internal/claude/claude_test.go @@ -37,6 +37,22 @@ func TestBuildNewArgs_WithExtraAndPreamble(t *testing.T) { } } +func TestBuildInWorktreeArgs_NameOnly(t *testing.T) { + got := BuildInWorktreeArgs("foo", "", nil) + want := []string{"--permission-mode", "auto", "-n", "foo"} + if !reflect.DeepEqual(got, want) { + t.Errorf("BuildInWorktreeArgs:\n got = %v\n want = %v", got, want) + } +} + +func TestBuildInWorktreeArgs_WithExtraAndPreamble(t *testing.T) { + got := BuildInWorktreeArgs("foo", "hi", []string{"--model", "x"}) + want := []string{"--permission-mode", "auto", "-n", "foo", "--model", "x", "--", "hi"} + if !reflect.DeepEqual(got, want) { + t.Errorf("BuildInWorktreeArgs both:\n got = %v\n want = %v", got, want) + } +} + func TestBuildContinueArgs_Empty(t *testing.T) { got := BuildContinueArgs(nil) want := []string{"--permission-mode", "auto", "--continue"}