diff --git a/README.md b/README.md index bf30326..0c1fee3 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.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 f71cc20..945c70d 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,19 +95,15 @@ 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) - 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) @@ -120,6 +118,39 @@ 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. 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 { + return launchInPlace(sel.Path, passthrough) + } + code, err := claude.Continue(sel.Path, 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 +} + func applyBulkDelete(mainRepo string, bulk picker.BulkDeletion) int { errs := 0 for _, p := range bulk.Paths { @@ -237,6 +268,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/docs/README.ja.md b/docs/README.ja.md index 93ceff6..58cd7fb 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.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 new file mode 100644 index 0000000..c5bc04c --- /dev/null +++ b/docs/superpowers/plans/2026-04-25-worktree-resume-integration.md @@ -0,0 +1,1776 @@ +# 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: + 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 + } + // Continue 埌にセッションが消倱した堎合のみフォヌルバック。 + // 通垞終了 (Ctrl+C) や transient ゚ラヌは exit code をそのたた返す。 + if code != 0 && !worktree.HasSession(sel.Path) { + name := worktreeName(sel.Path) + code, err = claude.LaunchInWorktree(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 を最終調敎する 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..944ed83 --- /dev/null +++ b/docs/superpowers/specs/2026-04-25-worktree-resume-integration-design.md @@ -0,0 +1,339 @@ +# 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` 远加、`~/.claude/projects/` 参照ロゞック远加 | +| `internal/picker/` | L2 4 行レむアりト、`💬 RESUME` / `⚡ NEW` バッゞ、footer TIPS | +| `internal/tips/`新芏| ランダム TIPS 文字列の管理 | +| `cmd/ccw/main.go` | `--continue` フォヌルバック | +| `docs/README.md` / `docs/README.ja.md` | 旧譊告撀去、新動䜜・呜名芏玄・TIPS 远加 | + +### セッション存圚刀定 + +Claude Code はセッションログを以䞋に保存する: + +```text +~/.claude/projects//.jsonl +``` + +`` は 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/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` | `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→ `~/.claude/projects//` の `os.ReadDir` のみ +- `claude` パッケヌゞは worktree 名を匕数で受け取る玔粋関数ずしお保぀ +- `tips` は他に䟝存しない単独パッケヌゞ + +## デヌタフロヌ + +### A. 新芏 worktree 䜜成 + +```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: claude の exit code を返しお終了 +``` + +ccw 偎で远加のファむル䜜成は䞍芁。次回 picker 起動時に `~/.claude/projects//` の存圚で resume 可吊を刀定する。 + +### B. 既存 worktree を遞択 → `[r] run` + +```text +1. ccw picker: worktree 䞀芧衚瀺HasSession で RESUME/NEW バッゞ刀定 +2. user が worktree 遞択 → [r] run +3. ccw: cd +4. if HasSession(worktree-path): + ccw: exec claude --continue + → 即時非れロ exit なら ccw: exec claude -n で再詊行 + else: + ccw: exec claude -n +5. claude が exit、ccw 終了 +``` + +### C. picker 衚瀺時の HasSession 刀定 + +```text +1. ccw: git worktree list で worktree 䞀芧取埗各 worktree の絶察 path を取埗 +2. 各 worktree に぀いお: + 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 を描画 +``` + +### 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. picker 次回起動時: 圓該 worktree は䞀芧に出ない + - ~/.claude/projects// は残るが、参照する偎の worktree が + 消えおいるので picker には圱響しない + - 過去 session ログのクリヌンアップは Claude Code の責務 + (ccw は觊らない) +``` + +## 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()` 事前刀定で原則ヒットせず +- 䞇が䞀 ReadDir が叀い情報を返した堎合、claude が即時非れロ exit すれば `claude -n ` で再詊行 +- claude が picker を開いた堎合は user に委ねる自動 fallback しない + +### E3. `~/.claude/projects/` のフォヌマット倉曎 + +- パス゚ンコヌド芏則 (`/` → `-`, `.` → `-`) は Claude Code の内郚実装。公匏ドキュメント化されおおらず、将来倉曎され埗る +- HasSession 刀定が倖れおも、`--continue` 自䜓は cwd 基準で claude 偎が解決するので動䜜は壊れないバッゞ衚瀺が `⚡ NEW` 固定になるだけ +- `CCW_DEBUG=1` で encoded path ずディレクトリ存圚をログ出力。違いに気付きやすくする + +### E4. `os.UserHomeDir()` 倱敗 / `~/.claude/projects/` 䞍圚 + +- HasSession は false 固定で続行臎呜的でない +- claude 未むンストヌル初回起動前は `~/.claude/` 自䜓が無い堎合あり → 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 名 / path に特殊文字 + +- 既存の worktree 呜名バリデヌション再利甚 +- ゚ンコヌドは絶察 path の `/` ず `.` を `-` に眮換するだけ。既存芏則に合わせお claude が同じパスから読み出すため、文字列䞀臎が成立すれば OK + +### 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/has_session_test.go`新芏** + +- `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`** + +- 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 バむナリでスタブ、`HOME` をテスト甚に差し替え: + +- 新芏 worktree 䜜成 → fake claude が `--worktree -n ` を受け取る +- HasSession=true の worktree で run → fake claude が `--continue` を受け取る +- HasSession=false の worktree で run → fake claude が `-n ` を受け取る +- `--continue` が exit 1 → ccw が `-n ` で再詊行 +- `~/.claude/projects//foo.jsonl` を配眮 → picker で `💬 RESUME` 衚瀺 + +### 手動怜蚌チェックリストPR に蚘茉 + +- [ ] `claude --worktree foo -n foo` が実機で動䜜 +- [ ] 同 worktree で 2 回目に `claude --continue` が前回䌚話を埩元 +- [ ] picker で RESUME / NEW バッゞが正しく分かれる +- [ ] `/rename` 埌も `--continue` で埩元できるE5 +- [ ] `~/.claude/projects//` の path ゚ンコヌド芏則が実機の Claude Code ず䞀臎 +- [ ] NO_COLOR=1 で衚瀺厩れなし +- [ ] 80 cols 端末で L2 が読める +- [ ] CCW_DEBUG=1 で encoded path ずディレクトリ存圚ログが出る +- [ ] 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 の旧譊告本仕様で撀去察象 diff --git a/internal/claude/claude.go b/internal/claude/claude.go index c1b04ae..37fe46d 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,40 @@ 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") +// 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)) + 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)) +} + +// 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)) } -// 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..8b9fff5 100644 --- a/internal/claude/claude_test.go +++ b/internal/claude/claude_test.go @@ -5,50 +5,66 @@ 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 TestBuildInWorktreeArgs_NameOnly(t *testing.T) { + got := BuildInWorktreeArgs("foo", "", nil) + want := []string{"--permission-mode", "auto", "-n", "foo"} if !reflect.DeepEqual(got, want) { - t.Errorf("BuildResumeArgs:\n got = %v\n want = %v", got, want) + t.Errorf("BuildInWorktreeArgs:\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 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("BuildResumeArgs extra:\n got = %v\n want = %v", 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"} + 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) } } 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) + } + } + } +} 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) } } diff --git a/internal/picker/model.go b/internal/picker/model.go index 3ea9c61..7f9cdeb 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" ) @@ -45,6 +47,7 @@ type Selection struct { Path string Branch string Status worktree.Status + HasSession bool ForceDelete bool } @@ -96,6 +99,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 +164,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/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/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) + } +} 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/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) + } +} 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..281985e --- /dev/null +++ b/internal/tips/tips_test.go @@ -0,0 +1,51 @@ +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) { + a, b := PickRandom(7), PickRandom(7) + if a != b { + t.Errorf("PickRandom(7) is non-deterministic: %q != %q", a, b) + } +} + +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") + } + } +} diff --git a/internal/worktree/has_session.go b/internal/worktree/has_session.go new file mode 100644 index 0000000..06b48e6 --- /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 := os.Getenv("HOME") + if 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)") + } +} 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) + } +} 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]) + } +}