# kizu v0.1 MVP — リアルタイム diff 監視 TUI の実装

この ExecPlan はリビングドキュメントです。`Progress` / `Surprises & Discoveries` / `Decision Log` / `Outcomes & Retrospective` の各セクションは作業進行に合わせて継続的に更新します。

## Purpose / Big Picture

このマイルストーンを完了すると、ユーザーは `cargo run` で kizu を起動したまま、別ペインで Claude Code 等のエージェントがファイルを書き換える様子を**リアルタイム**に観察できるようになります。具体的には:

- `cargo run` で kizu TUI が起動し、カレントディレクトリの git worktree を監視する
- 左ペインにセッション開始時 HEAD との差分がある変更ファイル一覧、右ペインに選択ファイルの unified diff、下部フッターにセッション統計が表示される
- 別ターミナルで `echo hello >> src/main.rs` のように書き換えると 300ms 以内に画面が更新される
- `j`/`k` でファイル選択、`f` でフォローモード切替、`q` で終了
- `R` (Shift+r) でセッションベースラインを現在の HEAD に即座にリセットする
- `git diff` がエラーを返した場合はフッタに赤い `×` とメッセージを出し、前回成功時の `files` を保ったまま運転を継続する
- 初回コミットが存在しない git リポでも空 tree SHA を baseline として正しく起動する（worktree 内ファイルがすべて追加扱いで表示される）
- バイナリファイルが変更された場合は左ペインのリストに `bin` として表示し、右ペインに `[binary file - diff suppressed]` を出す

加えて、tuistory + bun による e2e テスト 5 本（起動→q クリーン終了 / ファイル書き換え反応 / j/k+follow トグル / R リセット / added=緑・deleted=赤 色検証）が CI で自動実行され、これらの観察可能挙動を回帰から守ります。

scar 機能（`a`/`r`/`c`/`x`）、Claude Code フック、`--attach` 等は v0.2 以降のスコープとし、このマイルストーンには含めません。**純粋な観察ツールとして完結させる**ことがゴールです。

## Progress

作業を開始する前、完了直後、途中で停止する際に必ず更新してください。タイムスタンプは `(YYYY-MM-DD HH:MM:SSZ)` 形式です。

- [x] (2026-04-15 04:11:40Z) M1: `src/git.rs` の unified diff パーサを TDD で実装（Red → Green → Refactor）
- [x] (2026-04-15 04:11:40Z) M1: `src/git.rs` の `parse_unified_diff` でバイナリ差分行 `Binary files a/foo and b/foo differ` を `DiffContent::Binary` バリアントとして検出
- [x] (2026-04-15 04:11:40Z) M1: `src/git.rs` の `find_root` / `head_sha` を git CLI shell out で実装
- [x] (2026-04-15 04:11:40Z) M1: `head_sha` が `git rev-parse HEAD` 失敗時に空 tree SHA (`4b825dc642cb6eb9a060e54bf8d69288fbee4904`) を返す
- [x] (2026-04-15 04:11:40Z) M1: `src/git.rs` の `compute_diff` を `git diff --no-renames <baseline> --` shell out + パーサ結合で実装
- [x] (2026-04-15 04:11:40Z) M1: `git status --porcelain` による untracked ファイル合成 (8KB cap, NUL バイトでバイナリ判定)
- [x] (2026-04-15 04:11:40Z) M1: `src/git.rs` に `git_dir(root) -> Result<PathBuf>` を追加 (`git rev-parse --absolute-git-dir` shell out)
- [x] (2026-04-15 04:28:32Z) M2: `src/watcher.rs` を `notify-debouncer-full` で実装（worktree 300ms / git_dir 100ms）
- [x] (2026-04-15 04:28:32Z) M2: `start(root, git_dir)` に shape 変更し、worktree watcher のイベント絶対パスが `git_dir` のプレフィックスに一致したら除外する
- [x] (2026-04-15 08:10:00Z) M2: git state watcher は `HEAD` / `refs` / `packed-refs` に絞って watch する。macOS では `PollWatcher` fallback を使い、linked worktree では common git dir 側の `refs` を含めて監視する
- [x] (2026-04-15 04:28:32Z) M2: kizu 側の `.gitignore` フィルタは v0.1 では持たない (ADR-0005)。`git diff` の `.gitignore` 尊重と coalescing で吸収する
- [x] (2026-04-15 04:28:32Z) M2: `tokio::sync::mpsc::unbounded_channel::<WatchEvent>()` を使い、debouncer のコールバック (sync 文脈) から `UnboundedSender::send` でイベントを流す
- [x] (2026-04-15 04:38:33Z) M3: `src/app.rs` の `App` に `git_dir: PathBuf` / `last_error: Option<String>` / `selected_path: Option<PathBuf>` / `diff_scroll: usize` フィールドを追加
- [x] (2026-04-15 04:38:33Z) M3: `App::bootstrap` の中で `git::git_dir` を呼んで保持
- [x] (2026-04-15 04:38:33Z) M3: `app::run()` を `async fn` として実装 (M5 で `#[tokio::main(flavor = "current_thread")]` から起動予定)
- [x] (2026-04-15 04:38:33Z) M3: `crossterm::event::EventStream` と watcher `UnboundedReceiver` を `tokio::select!` で統合
- [x] (2026-04-15 04:38:33Z) M3: watcher イベント受信時は **drain して coalesce**: 1 件受信したら `try_recv` ループで残りを全部吸収し、`Worktree` フラグが立っていれば 1 回だけ `compute_diff` を呼ぶ (ADR-0005)
- [x] (2026-04-15 04:38:33Z) M3: `compute_diff` が Err を返したら `last_error` を埋めて前回 `files` を保持
- [x] (2026-04-15 04:38:33Z) M3: `compute_diff` 後に `files` を mtime 降順でソートし、各 `FileDiff.mtime` を `std::fs::metadata().modified()` で取得（取得不能時は `UNIX_EPOCH`）
- [x] (2026-04-15 04:38:33Z) M3: パス追跡セレクション: `selected_path` から新 `files` 内のインデックスを再計算、見つからなければ前回インデックスを clamp
- [x] (2026-04-15 04:38:33Z) M3: フォローモード（mtime 最新ファイルへの自動ジャンプ + 手動操作で `manual` ダウングレード + `f` で復帰）
- [x] (2026-04-15 04:38:33Z) M3: `R` (Shift+r) でベースライン再取得（`head_sha` 再実行）
- ~~[x] (2026-04-15 04:47:03Z) M4: `src/ui.rs` の ratatui レイアウト（ファイルリスト 30% / diff 70% / フッター）~~ 廃止 (ADR-0006)
- [ ] M4: `syntect` によるシンタックスハイライト（バイナリは対象外）— v0.1 では **延期**: added=緑/deleted=赤 の LineKind 配色のみで M6 e2e の color test を満たせるため。lazy-load syntect は v0.1.1 候補
- [x] (2026-04-15 05:00:00Z) M4r: `src/ui.rs` を **フルスクリーン縦巻物 + popup picker** に作り直し (ADR-0006)。`Layout::vertical([Min(0), Length(1)])` のみで全ファイルの全 hunk を 1 本に flatten した `Paragraph` を全幅描画。`render_file_list` / `render_diff` は廃止
- [x] (2026-04-15 05:00:00Z) M4r: `App` を `scroll: usize` / `anchor: HunkAnchor` / `picker: PickerState` / `layout: ScrollLayout` に再設計、selected/selected_path/diff_scroll は廃止
- [x] (2026-04-15 05:00:00Z) M4r: `Space` で起動する modal popup picker (Clear + Block + List + ListState)、substring match のフィルタ、mtime 降順
- [x] (2026-04-15 05:00:00Z) M4r: `J`/`K` で hunk 単位の前後 jump (ファイル境界を跨ぐ)、`g`/`G` で巻物の頭/尻
- [x] (2026-04-15 05:00:00Z) M4r: `handle_key` を 2 モード dispatch (通常 / picker)。picker は fzf 互換 (任意の char で query、`Ctrl-n/p/j/k`/`↑↓` でカーソル、`Enter` で jump、`Esc`/`Ctrl-C` で cancel)
- [x] (2026-04-15 04:47:03Z) M4: バイナリファイル選択時に `[binary file - diff suppressed]` プレースホルダーを表示 (新しい `BinaryNotice` row として巻物に埋め込み)
- [x] (2026-04-15 04:47:03Z) M4: 1 ファイル 2000 行を超える diff は末尾を truncate し `[+N more rows]` 表示 (M4r では巻物全体に `SCROLL_ROW_LIMIT = 2000` を適用)
- [x] (2026-04-15 04:11:40Z) M4: untracked ファイル合成は 8KB で cap (M1 で実装済み)
- [x] (2026-04-15 04:47:03Z) M4: 差分ゼロ時は中央揃え `No changes since baseline (HEAD: <short_sha>)`
- [x] (2026-04-15 04:47:03Z) M4: フッタ右端に `last_error` があれば赤の `×` + メッセージを表示
- [x] (2026-04-15 05:00:00Z) M4: crossterm キー入力（`j`/`k`/`↑`/`↓`/`J`/`K`/`Ctrl-d`/`Ctrl-u`/`g`/`G`/`f`/`Space`/`R`/`q`/`Ctrl-C`）+ picker 用 (`Ctrl-n/p`/`Backspace`/`Esc`/`Enter`/任意 char)
- [x] (2026-04-15 04:52:06Z) M5: `src/main.rs` を `#[tokio::main(flavor = "current_thread")]` 化し、`None => app::run().await` へ結線
- [x] (2026-04-15 04:52:06Z) M5: `ratatui::try_init` / `try_restore` で raw mode + alt screen + panic hook を一括設定 (panic 時 / draw 失敗時のいずれでも端末復旧を保証)
- [ ] M5: 手動動作確認（別ターミナルでファイル書き換え → 画面更新の観測） — ユーザー実機での確認待ち
- [x] (2026-04-15 04:52:06Z) M5: `cargo fmt --check` / `cargo clippy --all-targets -- -D warnings` / `cargo test --all-targets` / `cargo build --release` が全て通ること
- [x] (2026-04-15 08:10:00Z) M6: `tests/e2e/` を作成 (`package.json` / `tsconfig.json` / `helpers.ts` / `.gitignore`)
- [x] (2026-04-15 08:10:00Z) M6: `tests/e2e/helpers.ts` で `createTempRepo()` / `launchKizu()` ヘルパを実装
- [x] (2026-04-15 08:10:00Z) M6: e2e A: 起動→`q` でクリーン終了テスト (`smoke.test.ts`)
- [x] (2026-04-15 08:10:00Z) M6: e2e B: ファイル書き換え→画面更新テスト (`reactive.test.ts`)
- [x] (2026-04-15 08:10:00Z) M6: e2e C: j/k ナビゲーションと `[follow]`/`[manual]` トグルテスト (`navigation.test.ts`)
- [x] (2026-04-15 08:10:00Z) M6: e2e D: `R` で baseline リセットテスト (`baseline-reset.test.ts`)
- [x] (2026-04-15 08:10:00Z) M6: e2e E: added=緑 / deleted=赤 の色検証テスト (`colors.test.ts`)
- [x] (2026-04-15 08:10:00Z) M6: `.github/workflows/ci.yml` の bun + e2e ステップが CI で green になること

## Surprises & Discoveries

実装中に発見した予期しない挙動、バグ、最適化、性能トレードオフを証拠とともに記録します。

- macOS 実環境では `notify-debouncer-full + RecommendedWatcher` 単体 smoke test ですら create event が 10 秒届かなかった。`matcher` や tokio bridge の問題ではなく backend observability の破綻だったため、watcher 修正は path compare の局所 patch ではなく backend fallback まで踏み込む必要があった。
- poll fallback をそのまま `.git/` 全体に適用すると `objects/pack/**` や大きい `index` を高頻度 content compare し得る。linked worktree の branch 更新だけ直して満足すると性能地雷を埋め込むため、watch root を `HEAD` / `refs` / `packed-refs` に絞る再設計が必要だった。
- さらに赤テストで、`packed-refs` が起動後に生えたケースでは、その後の in-place rewrite を見落とすことが分かった。起動時点の file existence に依存して watch root を組み立てる設計はまだ不十分で、common git-dir root watch を常設に寄せる必要があった。
- さらに worktree 側でも、既存ファイルの same-size rewrite (`alpha` → `omega`) を metadata-only polling が取りこぼすことが赤テストで露出した。macOS poll fallback は correctness を優先して worktree でも content compare を有効にしないと成立しない。
- さらに health state も単一 enum では不十分で、`git.refs` watcher が死んだあとに worktree event が来るだけで footer warning が消える partial-recovery bug があった。複数 watcher source を持つ以上、health も source-aware に追跡する必要があった。

## Decision Log

実装中に行った設計判断は全てここに記録します。

- Decision: diff 計算はライブラリを使わず `git` CLI を shell out する
  Rationale: `--no-renames` 等の高度なフラグを再実装する手間を避け、git の挙動とのズレを生まない。詳細と代替案の却下理由は `docs/adr/0001-git-cli-shell-out.md` を参照。
  Date/Author: 2026-04-15 / Initial plan

- Decision: ファイル監視は `notify-debouncer-full 0.7` を使用する
  Rationale: worktree / `.git/HEAD` の分離デバウンス (300ms / 100ms) とバーストイベント集約を自前実装より安全に行える。詳細と代替案の却下理由は `docs/adr/0002-notify-debouncer-full.md` を参照。
  Date/Author: 2026-04-15 / Initial plan

- Decision: 非同期ランタイムとして tokio (`current_thread` flavor) を v0.1 から採用する
  Rationale: イベント駆動で `crossterm::event::EventStream` と watcher チャネルを `tokio::select!` で統合し、v0.2/v0.3 の hook / stream mode 拡張をシームレスにする。`compute_diff` 等の git shell out は `std::process::Command` のままとし、async の伝染を `app::run` の境界で止める。詳細は `docs/adr/0003-tokio-async-runtime.md` を参照。
  Date/Author: 2026-04-15 / Plan revision

- Decision: v0.1 では kizu 側に `.gitignore` フィルタを **持たない**。`git diff` 自体の `.gitignore` 尊重と watcher → app の coalescing で吸収する
  Rationale: Codex adversarial review で当初プランの `ignore` crate 採用が API 誤用 (`GitignoreBuilder::new` は何も読まない、ネスト `.gitignore` も `dir::Ignore` が `pub(crate)` で扱えない) と判明。フィルタを正しく実装するコストの方が、フィルタを持たないコスト (バーストで `compute_diff` が無駄に走る) より高い。coalescing を入れれば 50000 件のバーストが 1 回の `compute_diff` 呼び出しに吸収される。詳細は `docs/adr/0005-watcher-coalescing-no-ignore-filter.md`。
  Date/Author: 2026-04-15 / Plan revision (Codex review fix)

- Decision: git ディレクトリは `git rev-parse --absolute-git-dir` で解決し、`<root>/.git` をハードコードしない
  Rationale: linked worktree (`git worktree add` で作られたサブ worktree) では `.git` がファイルでありポインタ先の実体パスは別になる。当初プランの `<root>/.git/` プレフィックス除外と HEAD watcher は linked worktree で動かない。`src/git.rs::git_dir(root)` を新設して shell out で解決し、`App.git_dir` として保持する。
  Date/Author: 2026-04-15 / Plan revision (Codex review fix)

- Decision: watcher → app 境界に **drain ベースの coalescing** を入れる
  Rationale: `unbounded_channel` を読み出すたびに 1 件しか処理しないと、バーストイベント時にキューが伸び続けキー応答が遅延する。1 件受信したら `try_recv` ループで残りを全部吸収し、`Worktree` フラグが立っていれば `compute_diff` を 1 回だけ呼ぶ。`tokio::sync::Notify` や `watch::channel` への切り替えも検討したが、`Worktree`/`GitHead` の種別を残しつつ実装行数が最小になるのは drain パターン。詳細は ADR-0005。
  Date/Author: 2026-04-15 / Plan revision (Codex review fix)

- Decision: ci.yml の bun + e2e ステップは `tests/e2e/package.json` の存在ガードでスキップ可能にする
  Rationale: 当初プランは required check として bun ステップを入れたが、`tests/e2e/` の scaffold は M6 で land する。先に CI を入れると M1〜M5 を incremental に PR で land できなくなる (= 全 PR が即 fail)。`steps.e2e_scaffold.outputs.present == 'true'` でガードし、scaffold が存在しない間はステップ自体をスキップする。
  Date/Author: 2026-04-15 / Plan revision (Codex review fix)

- Decision: macOS の watcher backend は `PollWatcher` に切り替え、git state watch root を `HEAD` / `refs` / `packed-refs` に限定する
  Rationale: この環境では `RecommendedWatcher` が create event を返さず、正イベントテストが全滅した。poll fallback 自体は機能するが `.git` 全体を content compare すると `objects/pack/**` まで走査してコストが悪い。watch root を baseline を動かす path 群に限定すれば、linked worktree の branch 更新を拾いながら poll コストを制御できる。詳細は `docs/adr/0010-macos-poll-fallback-and-targeted-git-watch-roots.md`。
  Date/Author: 2026-04-15 / Codex adversarial review

- Decision: common git-dir root watcher は常設し、late packed-refs birth 後の rewrite を吸収する
  Rationale: `packed-refs` が起動時に存在しない場合だけ common root を fallback として持つ設計では、birth 後の in-place rewrite が見えなかった。common root を non-recursive + compare_contents=true で常設し、`HEAD` / `refs` / `packed-refs` の matcher で意味論を絞る方が単純で堅い。詳細は `docs/adr/0011-common-git-root-watch-for-late-packed-refs.md`。
  Date/Author: 2026-04-15 / Codex adversarial review

- Decision: macOS PollWatcher path の worktree 監視でも compare_contents を有効にする
  Rationale: 既存ファイルの same-size rewrite を metadata-only polling が見落とした。startup blind window の self-heal とは別の問題で、steady-state correctness のためには worktree 側も contents compare が必要。詳細は `docs/adr/0012-worktree-content-compare-on-macos-poll.md`。
  Date/Author: 2026-04-15 / Codex adversarial review

- Decision: watcher health は source-aware に追跡し、partial recovery で warning を消さない
  Rationale: worktree / git の複数 watcher source がある状態で単一 `Healthy | Failed` enum を使うと、別 source の live event が unrelated failure を消してしまう。`WatchSource` 単位で failure map を持つ方が監視ツールとして正直。詳細は `docs/adr/0013-source-aware-watcher-health.md`。
  Date/Author: 2026-04-15 / Codex adversarial review

- Decision: manual mode で hunk fingerprint が消えても、同じ file が残っている限り follow target へ戻さずその file に留まる
  Rationale: hunk merge/split や nearby edit で `old_start` が変わるだけで newest file に飛ぶのは manual mode の契約違反。anchor row → same file nearest hunk → clamped current row の順に fallback する方が user intent を保てる。
  Date/Author: 2026-04-15 / Codex adversarial review

- Decision: 純粋関数（diff パーサ）から TDD で起こし、watcher / TUI など I/O 層はテスト境界を切り出してから着手する
  Rationale: CLAUDE.md の t-wada 流 TDD 方針。純粋関数は Red/Green/Refactor を最小サイクルで回せるため実装速度が上がる。
  Date/Author: 2026-04-15 / Initial plan

- Decision: スクロールアニメーションは **state-based 補間 + dual-rate interval** で実装する。`App.visual_top: Cell<f32>` を最後に描画した viewport row として保持し、`scroll_to` で `ScrollAnim { from: visual_top, start: Instant::now(), dur: 150ms }` を開始、`ui::render_scroll` で `visual_viewport_top(height, now)` が `viewport_top(height)` を target として ease-out cubic で lerp する。`run_loop` は `tokio::time::interval(16ms)` を `select!` に追加し、`if app.anim.is_some()` ガードでアニメ中のみ frame tick を消費する。drawing 後に `tick_anim(now)` で期限切れの anim を retire する。
  Rationale: 調査で確認した 4 パターン (state-based / spring / blocking sleep / tachyonfx) のうち、kizu の現状アーキテクチャ (tokio::select + 自前 row render + cursor centering) に最もフィットするのは state-based。blocking sleep は watcher/Esc/Ctrl-C を遮断するので却下。tachyonfx はセル単位エフェクト用で scroll offset 補間 API を持たず、`translate_buf` も自前 row render と衝突する。spring は収束判定が煩雑で v0.1 で採る価値が薄い。dual-rate interval はアイドル時 0 fps を実現できるので常時 60 fps より CPU に優しい。target (viewport_top) を毎フレーム再評価する設計により、watcher 駆動の layout 変更中にアニメが走っても追従する。詳細は emergent-engine セッションの調査ログおよび `docs/adr/0003-tokio-async-runtime.md` を参照。
  Date/Author: 2026-04-15 / M7 (scroll animation)

- Decision: ベースラインリセットは `R` (Shift+r) 1 打で即時実行する（`r` 2 連打ではない）
  Rationale: SPEC.md の当初仕様は「`r` 2 連打」だったが、実装すると `reset_pending: Option<Instant>` を `App` に持たせる必要があり、誤爆検出の時間しきい値を選ぶ根拠も乏しい。`R` 1 打なら確認状態が不要で、誤爆しても次の `R` で現在の HEAD に再固定するだけなので副作用が小さい。SPEC.md と CLAUDE.md も同時に更新済み。
  Date/Author: 2026-04-15 / Plan revision

- Decision: `git diff` がエラーを返した場合は `App.last_error: Option<String>` に記録してフッタ右端に赤の `×` + メッセージを表示し、`files` は前回成功時の状態を保持する
  Rationale: panic させると Claude Code 作業中に kizu が一瞬で落ちて作業が中断する。`files` を空にすると「本当に diff なし」と区別できず誤認識の元。フッタ表示 + 前回状態保持なら「一時的な達なり」を見て見ぬふりできて、次の watcher イベントで自動リトライされる。成功したら `last_error` をクリア。bootstrap 段階のエラー（`find_root` / 初回 `compute_diff`）は stderr に吐いて exit する（fail-fast）。
  Date/Author: 2026-04-15 / Plan revision

- Decision: 初回 commit がない git リポでは empty tree SHA (`4b825dc642cb6eb9a060e54bf8d69288fbee4904`) を baseline として使う
  Rationale: `git rev-parse HEAD` が失敗するケースを特殊化するとコードパスが分岐する。空 tree SHA を baseline にすれば「全 worktree 内ファイルが追加扱い」として diff が計算され、コードパスが 1 本になる。`git init` 直後のスクラッチ作業もそのまま観察できる。
  Date/Author: 2026-04-15 / Plan revision

- Decision: バイナリファイルは `BinaryDiff` バリアントとしてリストに表示し、右ペインは `[binary file - diff suppressed]` プレースホルダー
  Rationale: リストから除外するとエージェントが PNG を誤って上書きしたケースを見逃す。`FileDiff` に `content: DiffContent` enum を持たせ、`Text(Vec<Hunk>)` と `Binary` を分ける。`+N/-M` 表示は `bin` に置き換える。
  Date/Author: 2026-04-15 / Plan revision

- Decision: e2e テストに tuistory + bun を v0.1 から主力採用する
  Rationale: kizu の価値は visual diff observation そのものなので、実 pty + 色フィルタ + key イベント配送 + raw mode 復旧を全てカバーできる tuistory が最適。`tests/e2e/` を新設し、bun:test の inline snapshot で 5 本 (smoke / reactive / navigation / R reset / colors) を書く。CI では既存の単一ジョブに bun ステップを追加。詳細と代替案の却下理由は `docs/adr/0004-tuistory-e2e.md` を参照。
  Date/Author: 2026-04-15 / Plan revision

- Decision: ファイル選択は **パス追跡** で行う。`App.selected_path: Option<PathBuf>` を保持し、recompute_diff のたびに新 `files` から同じ path を探してインデックスを再計算する
  Rationale: インデックス追跡だと watcher イベントで他のファイルが追加/削除されたときセレクションが暗黙にずれる。ユーザーの「これを見ている」コンテキストを坊さない方が観察ツールとして自然。同じ path が見えなくなったら直前のインデックスに clamp してフォールバック。
  Date/Author: 2026-04-15 / Plan revision

- Decision: フォローモードのターゲットは `files` の中で **mtime が最新** のファイル（`std::fs::metadata().modified()`）
  Rationale: 「最新の変更ファイル」を機械的に決める一番自然な定義。watcher イベントから path を伝搬させる方法は ignore フィルタとの順番バグを誘発するので避ける。deleted ファイルは mtime 取得不能なので follow 対象外（mtime 取得失敗時は `UNIX_EPOCH` 扱いでソート末尾に行く）。
  Date/Author: 2026-04-15 / Plan revision

- Decision: ファイルリストは **mtime 降順** でソートする
  Rationale: 「いま何が動いているか」が一目で分かるレイアウト。follow target も常にリスト先頭に見える。パス追跡セレクションと組み合わせると、選択中のファイルがリスト上を動くことは許容するが、ユーザーは「path を見ている」のでカーソル位置のずれは気にならない。
  Date/Author: 2026-04-15 / Plan revision

- Decision (M4r 改訂): scroll では **mtime 昇順** で並べ、picker では **mtime 降順** で並べる
  Rationale: M4r で UI が縦巻物に変わったため、「テキストは下に流れる」と「最新が上にいる」の矛盾が顕在化した。`tail -f` / chat log と同じく古い→新しいが top→bottom の方向に揃え、follow mode は巻物の底に張り付く形にする (`follow_target_row = files.last() の最後の hunk`)。一方 picker は離散的な選択 UI なので「最新ファイルが先頭」の慣例を優先し、`picker_results` だけ `(0..len).rev()` で newest-first にする。スクロールと picker の方向が逆になることは許容する。詳細は ADR-0006 の追記節。
  Date/Author: 2026-04-15 / M4r refinement

- Decision (M4v 視野階層): 巻物は常時全展開、注意の階層は **色の濃淡** で立てる
  Rationale: 折り畳みで情報を隠すと「視線を動かせば見える」性質が損なわれる。ratatui の `Modifier::DIM` を使い、selected hunk はフル彩度、unselected hunk は同色 + DIM で描画。視野の中心と周辺を色階層で物理的に分離し、視線スキャンで全 hunk の本文が拾える状態を保つ。さらに **ファイルパスの色 = ステータス** (Cyan=M / Green=A / Red=D / Yellow=??) で `M`/`A`/`D`/`??` ラベルを廃止し、file header に **mtime と +N -M を常時表示**。hunk header は git の xfuncname を `Hunk.context` として捕捉し `@@ fn verify_token(...) {` 形式で表示。詳細は ADR-0006 の「視野階層 (M4v 改訂)」節。
  Date/Author: 2026-04-15 / M4v refinement

- Decision: 1 ファイルの diff は **2000 行で truncate** し、末尾に `[+N more lines truncated]` を表示する
  Rationale: 10000 行の untracked / 5000 行の一括書き換えで syntect とラインベース描画が固まることを防ぐ。truncate は UI レンダリングレベルで行い、`FileDiff` 自体には全行入れる（将来 v0.1.1 で「展開キー」を実装するときの素地として）。untracked 内容は `std::fs::read_to_string` を 8KB で cap し、それ以上は `[+N more bytes from new file]` と表示する。
  Date/Author: 2026-04-15 / Plan revision

- Decision: 右ペイン in-file スクロールは **`Ctrl-d` / `Ctrl-u` (半ページ) + `g` / `G` (先頭/末尾)**、follow モード ON のときは recompute のたびに **最後の hunk へ自動スクロール**
  Rationale: lazygit / delta 慣習に近く、`j`/`k` はファイルリスト操作と切り分けられる。手動スクロールしたら follow を `manual` にダウングレードし、`f` で復帰する（ファイルレベルと同じセマンティクス）。「最後の hunk」とは `new_start` が最大の hunk。
  Date/Author: 2026-04-15 / Plan revision

- Decision: 30/70 縦 2 ペイン UI を廃止し、**フルスクリーン縦巻物 + popup picker** に作り直す
  Rationale: M5 完了直後の実機確認で、tmux/zellij で分割した 60〜90 列の pane では右ペインの diff が日常的に折り返し、`+`/`-` プレフィックスと連続行の対応が崩れて読めなくなることが判明。`Layout::vertical([Min(0), Length(1)])` だけにして全ファイルの全 hunk を 1 本の縦巻物に flatten し、ファイル選択は `Space` で modal popup picker にする。`App` の選択モデルも `selected` から `scroll: usize` + `anchor: HunkAnchor` + `picker: PickerState` に組み替えた。詳細と代替案 (タブバー / sparkline / kizu が pane を切る) の却下理由は `docs/adr/0006-scroll-with-popup-picker.md` を参照。
  Date/Author: 2026-04-15 / Plan revision (実機確認による設計転換)

- Decision: worktree が clean (差分ゼロ) のときは **両ペインに中央揃えで `No changes since baseline (HEAD: <short_sha>)`** を表示する
  Rationale: 「フリーズした」と「本当に何もない」を視覚的に区別する。e2e smoke test は `waitForText('No changes')` でこの状態を assert する。フッタは `[follow] -- | session: 0 files`。
  Date/Author: 2026-04-15 / Plan revision

## Outcomes & Retrospective

マイルストーン完了時、または全体完了時に当初の Purpose と結果を比較して記入します。

（未記入：v0.1 完了時に追記）

## Context and Orientation

### 用語の定義

- **kizu**: 本プロジェクト。Rust 製の単一バイナリ TUI で、AI コーディングエージェント（Claude Code 等）と並走してリアルタイムに diff を監視する。
- **worktree**: git リポジトリのうち、実ファイルが展開されているディレクトリツリー。`.git` を含まないユーザーが編集するファイル群。
- **baseline SHA**: kizu セッション開始時の HEAD コミット SHA。diff はこの SHA と現在の worktree の差分として計算される。
- **unified diff**: `git diff` が出力する標準形式。`@@ -old_start,old_count +new_start,new_count @@` の hunk ヘッダと、` `（context）/`+`（added）/`-`（deleted）の行接頭辞で構成される。
- **hunk**: unified diff における連続した変更ブロック。old/new の開始行と行数を持つ。
- **フォローモード**: 最新の変更ファイルに自動的にセレクションをジャンプさせるモード。手動ナビゲーションで一時停止、`f` キーで再開。
- **scar**: ファイル内に直接書き込む `@review:` インラインコメント（v0.2 以降）。v0.1 では実装しない。
- **デバウンス**: 短時間に連続して届くイベントを 1 つにまとめる処理。notify crate はファイルシステムイベントをバーストで受信するため必須。

### 関連ファイル（全てフルパスで記載）

- `/Users/annenpolka/ghq/github.com/annenpolka/kizu/Cargo.toml` — 依存定義。ratatui 0.30 / crossterm 0.29 / notify 8.2 / notify-debouncer-full 0.7 / syntect 5.3 / anyhow 1.0 / clap 4.6 / serde 1 / serde_json 1 を使用。`edition = "2024"`。
- `/Users/annenpolka/ghq/github.com/annenpolka/kizu/src/main.rs` — CLI エントリ。`Cli` 構造体と `Command` enum が v0.2 のサブコマンド（`init` / `teardown` / `hook-*`）を既に宣言済みだが、v0.1 では全て `unimplemented!()` のまま置く。`None` サブコマンドで `app::run()` を呼ぶ。
- `/Users/annenpolka/ghq/github.com/annenpolka/kizu/src/app.rs` — TUI アプリ状態。`App { baseline_sha, files, selected, follow_mode }` のスケルトンが存在。`run()` に TUI 起動ロジックを実装する。
- `/Users/annenpolka/ghq/github.com/annenpolka/kizu/src/git.rs` — 型定義 `FileDiff` / `FileStatus` / `Hunk` / `DiffLine` / `LineKind` と `compute_diff` / `head_sha` / `find_root` のスタブが存在。返り値は空ベクタ / 空文字列のダミー。これを TDD で本実装する。
- `/Users/annenpolka/ghq/github.com/annenpolka/kizu/src/watcher.rs` — `WatchEvent::Worktree` / `WatchEvent::GitHead` と `WatchHandle { events: Receiver<WatchEvent> }` のスケルトン。`start()` は空の `Receiver` を返すだけ。
- `/Users/annenpolka/ghq/github.com/annenpolka/kizu/src/ui.rs` — `render(_app: &App)` の空関数。ratatui レイアウトをここに実装する。
- `/Users/annenpolka/ghq/github.com/annenpolka/kizu/docs/SPEC.md` — 正準仕様。v0.1 スコープ、デバウンス値、キーバインド、hunk 境界の注意点を含む。
- `/Users/annenpolka/ghq/github.com/annenpolka/kizu/CLAUDE.md` — 開発スタイル（t-wada 流 TDD）、CI と一致したビルド検証フロー、コミット規約を定義。
- `/Users/annenpolka/ghq/github.com/annenpolka/kizu/.github/workflows/ci.yml` — CI は `cargo fmt --all -- --check` → `cargo clippy --all-targets -- -D warnings` → `cargo test --all-targets` → `cargo build --release` の順。ローカルでも同順で検証する。

### リポジトリ現状

`src/` 以下の各モジュールは**型定義とスタブのみ**で、`compute_diff` は空 Vec を返し、`watcher::start` は空の `Receiver` を返す。`cargo run` すると `kizu v0.1 skeleton — TUI not yet wired up.` という文字列を println! するだけで終了する。テストは一切存在しない（`tests/` ディレクトリなし、`#[cfg(test)]` モジュールなし）。

このマイルストーンで初めて実装とテストが書かれる。

## Plan of Work

以下の 6 マイルストーンを順に実施する。各マイルストーンは独立してコンパイル・テスト可能な状態で終える。M1〜M5 が Rust 本体実装、M6 が tuistory + bun による e2e テスト整備。

### Milestone 1: `src/git.rs` — diff 計算層（純粋関数 + shell out）

**t-wada 流 TDD で進める**。まず `src/git.rs` 内に `#[cfg(test)] mod tests` を切り、純粋関数である unified diff パーサから Red → Green → Refactor のサイクルを回す。

1. **型を更新**: `FileDiff` の中身を `content: DiffContent` enum に持たせ、`enum DiffContent { Text(Vec<Hunk>), Binary }` とする。これによりバイナリ差分を構造的に表現できる。
2. **Red**: `parse_unified_diff` 関数のテストを書く。固定の unified diff 文字列（1 ファイル / 1 hunk / 1 行 added）を入力にして、期待される `Vec<FileDiff>` を assert する。まだ関数が存在しないのでコンパイルエラー、これも Red。
3. **Green**: 最小実装。入力行を走査し、`diff --git a/... b/...` ヘッダで `FileDiff` を開始、`@@ -... +... @@` で `Hunk` を開始、` `/`+`/`-` 接頭辞で `DiffLine` を push する。仮実装でベタ書きしてよい。
4. **Refactor**: 行の分類ロジックを `LineKind::from_prefix` のような関数に切り出す。
5. **三角測量**: 2 ファイル / 複数 hunk / modified+deleted 混在のテストを追加し、実装を一般化する。`FileStatus` は diff の `new file mode` / `deleted file mode` ヘッダから決定する。
6. **バイナリ差分の検出**: `Binary files a/foo and b/foo differ` 行を検出したら `DiffContent::Binary` バリアントの `FileDiff` を追加する。テストで固定文字列を入力して assert。
7. **`added` / `deleted` カウント**: `DiffLine::kind` を集計して `FileDiff.added` / `FileDiff.deleted` を埋めるテストを追加。バイナリは `added=0/deleted=0` で `bin` 表示扱いとする。
8. **shell out 層**: `find_root` は `git rev-parse --show-toplevel`、`head_sha` は `git rev-parse HEAD`、`compute_diff` は `git diff --no-renames <baseline> --` を `std::process::Command` で実行し、stdout を `parse_unified_diff` に渡す。これらは外部プロセスを呼ぶためテスト境界を切る（= 本体は薄く保ち、パーサ側でテスト）。
9. **空 tree SHA フォールバック**: `head_sha` の中で `git rev-parse HEAD` が失敗（exit code != 0）した場合、空 tree SHA `4b825dc642cb6eb9a060e54bf8d69288fbee4904` を返す。テストで `tempfile::tempdir()` + `git init` 直後のリポを作り、`head_sha` がフォールバックを返すこと、および `compute_diff` が全 worktree 内ファイルを `Added` 行として返すことを assert。
10. **untracked 合成**: `git status --porcelain` から `??` エントリを抽出し、`FileStatus::Untracked` として `FileDiff` に合成する。untracked の hunk は「ファイル全行が追加」として表現する（`std::fs::read_to_string` で内容を読んで `Added` 行に変換）。バイナリ判定は `std::fs::read` の先頭 8KB に NUL バイトがあるかで簡易的に行い、見つかれば `DiffContent::Binary`。

**このマイルストーン終了時の成果**: `cargo test --all-targets` で `src/git.rs` 内の単体テストが全て通る。手動で `cargo run -- --help` は通るが TUI はまだ起動しない。

### Milestone 2: `src/watcher.rs` — ファイル監視 + デバウンス + git_dir 解決

`notify-debouncer-full` の `new_debouncer` で 2 系統の watcher を張る。kizu 側の `.gitignore` フィルタは持たない (ADR-0005)。

1. **git_dir の解決**: M1 で追加した `git::git_dir(root) -> Result<PathBuf>` を使い、絶対 git ディレクトリパスを取得する。通常リポなら `<root>/.git`、linked worktree なら `<main_repo>/.git/worktrees/<name>` などになる。`watcher::start(root, git_dir)` の引数として渡す。
2. **worktree watcher**: git worktree のルート（`find_root` の戻り値）を `RecursiveMode::Recursive` で監視。デバウンス 300ms。
3. **HEAD watcher**: `<git_dir>/HEAD` と `<git_dir>/refs/` を監視。デバウンス 100ms。linked worktree でも正しく動く。
4. **git_dir プレフィックス除外**: worktree watcher のコールバックで受け取ったイベント絶対パスについて、`git_dir` のプレフィックスに一致するものは無条件で破棄する (feedback loop 回避)。これだけがハードコード除外。
5. **`.gitignore` フィルタは持たない**: ADR-0005 通り、`target/` などのノイズは git diff 自体が `.gitignore` 尊重で吸収するので、watcher 側でフィルタしない。`compute_diff` の結果がゼロなら `App.files` も画面も変わらない。
6. **tokio チャネル**: `tokio::sync::mpsc::unbounded_channel::<WatchEvent>()` で `Sender` を debouncer のコールバックに渡す。`UnboundedSender::send` は sync 文脈から呼べるため bridge 不要。イベント種別に応じて `WatchEvent::Worktree` または `WatchEvent::GitHead` を送る。`WatchHandle` は `UnboundedReceiver<WatchEvent>` とデバウンサの所有権（drop で停止）を保持する。
7. **背圧 / coalescing は app 層で実施**: watcher 側はバッファせず素直にイベントを流す。drain ベースの coalescing は M3 の `app::run` ループ側で実装する (ADR-0005)。

**テスト戦略**: notify 周りは I/O が絡むため Rust 統合テスト 2 本に留める（実時間挙動の検証は M6 の tuistory e2e に移管）。

- `watcher::tests::worktree_event_is_received` — `tempfile::tempdir()` で一時的な git リポジトリを作り、`watcher::start` 後にファイル書き換えを行い、`tokio::time::timeout(Duration::from_secs(2), watch_rx.recv())` で `WatchEvent::Worktree` を受信できることを assert
- `watcher::tests::git_dir_writes_are_excluded` — tempdir + git init 後に `<git_dir>/test_lock` 等を直接書き換え、`watch_rx.recv()` が **`Worktree` を返さない** ことを `tokio::time::timeout(500ms)` で assert

`#[tokio::test(flavor = "current_thread")]` 属性を使う。

**このマイルストーン終了時の成果**: `cargo test --all-targets` で watcher の統合テストが 2 本追加され、全テストが通る。

### Milestone 3: `src/app.rs` — 状態管理 + tokio イベントループ

1. **初期化**: `App::bootstrap(root)` を追加し、`git::head_sha(&root)` でベースラインを取得 (失敗時は空 tree SHA フォールバック)、`git::compute_diff(&root, &baseline)` で初期 `files` を埋める。`bootstrap` は **sync 関数** のまま実装する（async 伝染を抑える境界）。bootstrap 自体が失敗した場合は anyhow::Error を上に伝搬し、`main` が stderr に吐いて exit する (fail-fast)。
2. **イベントループ**: `app::run()` を `async fn` として実装し、`#[tokio::main(flavor = "current_thread")]` から呼び出す。
   - `watcher::start(&root)` で `WatchHandle` を取得（`events: tokio::sync::mpsc::UnboundedReceiver<WatchEvent>`）
   - `crossterm::event::EventStream::new()` でキー入力ストリームを作る
   - `tokio::select!` で 2 つのストリームを統合:

         loop {
             tokio::select! {
                 Some(Ok(crossterm_ev)) = events.next() => {
                     if let Event::Key(k) = crossterm_ev { app.handle_key(k); }
                 }
                 Some(first) = watch_rx.recv() => {
                     // drain ベースの coalescing (ADR-0005)
                     let mut worktree = matches!(first, WatchEvent::Worktree);
                     let mut head     = matches!(first, WatchEvent::GitHead);
                     while let Ok(more) = watch_rx.try_recv() {
                         match more {
                             WatchEvent::Worktree => worktree = true,
                             WatchEvent::GitHead  => head     = true,
                         }
                     }
                     if worktree { app.recompute_diff(); }  // Err は last_error に格納、? は使わない
                     if head     { app.mark_head_dirty(); }
                 }
             }
             terminal.draw(|f| ui::render(f, &app))?;
             if app.should_quit { break; }
         }

   - **drain coalescing**: 1 件のイベントを受信したら同じターン内に積まれている残りを `try_recv` ループで全部吸い上げ、`Worktree` フラグが立っていれば `compute_diff` を **1 回だけ** 呼ぶ。これにより 50000 件のバーストイベント (例: `cargo clean && cargo build`) も 1 回の `compute_diff` 呼び出しに吸収され、キューが伸び続けることがない。
   - `WatchEvent::Worktree` 受信時: `App::recompute_diff` の中で `git::compute_diff` (sync) を再実行する。`Ok(files)` なら `App.files = files` に置き換え、`last_error = None` をクリアし、フォローモードが ON なら最後に変更されたファイルへセレクションを移動。`Err(e)` なら `last_error = Some(e.to_string())` に記録し `files` は前回成功時の状態を保持する。
   - `WatchEvent::GitHead` 受信時: 「HEAD が動いた」旨のインジケータを更新するのみ（ベースラインは `R` 手動操作でのみ再取得）
3. **キーバインド**:
   - `q` / `Ctrl-C`: 終了
   - `j` / `↓`: ファイル選択 +1、フォローモードを `manual` にダウングレード
   - `k` / `↑`: ファイル選択 -1、フォローモードを `manual` にダウングレード
   - `Ctrl-d`: 右ペイン diff を半ページ前進、フォローモードを `manual` にダウングレード
   - `Ctrl-u`: 右ペイン diff を半ページ後退、フォローモードを `manual` にダウングレード
   - `g`: 右ペイン diff を先頭にスクロール、フォローモードを `manual` にダウングレード
   - `G`: 右ペイン diff を末尾にスクロール、フォローモードを `manual` にダウングレード
   - `f`: フォローモード復帰（自動スクロールと自動セレクションが再開）
   - `R` (Shift+r): `baseline_sha = head_sha()` を即座に再取得し、`compute_diff` で `files` を再計算する。1 打で確定。
4. **パス追跡セレクション**: `App.selected_path: Option<PathBuf>` を保持し、`recompute_diff` の終わりで新 `files` から `position(|f| Some(&f.path) == selected_path.as_ref())` でインデックスを再計算する。見つからない場合は前回の `selected` インデックスを `min(files.len() - 1)` で clamp してフォールバック。これにより watcher イベントで他ファイルが追加/削除されてもユーザーが「見ているファイル」がずれない。
5. **mtime ソートと follow ターゲット**: `recompute_diff` 内で `compute_diff` の戻り値の各 `FileDiff` に `std::fs::metadata(&f.path).and_then(|m| m.modified()).unwrap_or(UNIX_EPOCH)` で `mtime` を埋め、`files.sort_by(|a, b| b.mtime.cmp(&a.mtime))` で降順ソート。フォローモード ON のときは `files[0]` (= mtime 最新) を `selected_path` にする。
6. **テスト**: 純粋なキー処理関数 `App::handle_key(&mut self, key: KeyEvent)` を切り出し、`j` でセレクションが進む / `f` でフォローが戻る / `Ctrl-d` で diff_scroll が増える / `R` で baseline 再取得フラグが立つ等を単体テストする。`recompute_diff` のパス追跡 / mtime ソート / エラー保持の振る舞いも `#[cfg(test)]` で小さな fixture を使って単体テストする。イベントループ本体（ratatui terminal loop）は M6 の tuistory e2e で検証。

### Milestone 4: `src/ui.rs` — ratatui レイアウト + シンタックスハイライト + バイナリ + エラー表示

1. **レイアウト**: `Layout::default().direction(Direction::Vertical)` で上下分割し、上段を `Layout::default().direction(Direction::Horizontal).constraints([Constraint::Percentage(30), Constraint::Percentage(70)])` で左右分割、下段は `Constraint::Length(1)` のフッター。
2. **左ペイン**: `List` ウィジェットで `App.files` を描画。各行は `"{status} {path} +{added}/-{deleted}"`（例: `"M src/auth.rs +12/-3"`）。`DiffContent::Binary` のファイルは `+N/-M` の代わりに `bin` を表示（例: `"M assets/icon.png  bin"`）。選択行は反転色。
3. **右ペイン (テキスト)**: `Paragraph` または `List` で選択ファイルの hunks を描画。各 `DiffLine` を `LineKind` に応じて色分け（added=緑 / deleted=赤 / context=無色）。
4. **右ペイン (バイナリ)**: `DiffContent::Binary` の場合は単一行 `[binary file - diff suppressed]` を中央寄せで表示し、hunks は描画しない。
5. **シンタックスハイライト**: `syntect::easy::HighlightLines` でファイル拡張子から syntax を決定し、hunk 行のコンテンツ部分にハイライトを乗せる。色情報は `ratatui::style::Color::Rgb` に変換。バイナリは対象外。
6. **フッター**: `"[follow] <current_file> +A/-D | session: +ΣA/-ΣD N files"` の形式。フォローモード OFF 時は `[follow]` を `[manual]` に切り替える。`App.last_error` が `Some(msg)` のときはフッタ右端に赤色で `× {msg}` を追記する（長すぎる場合は ellipsis で truncate）。
7. **大規模 diff の truncate**: テキスト diff の合計行数が 2000 を超えるファイルは末尾を切り捨て、最終行に `[+N more lines truncated]` を `Color::DarkGray` で表示する。truncate は `App::visible_diff_lines(file_idx)` のような ヘルパメソッドで行い、`FileDiff` 自体には全行を保持する（v0.1.1 の「展開キー」実装の素地）。
8. **空状態 (No changes)**: `App.files.is_empty()` の場合、左右どちらのペインも個別ウィジェットを描かず、上半分全体に中央揃えで `No changes since baseline (HEAD: <short_sha>)` を 1 行で表示する。`<short_sha>` は `App.baseline_sha[..7]`。フッタは `[follow] -- | session: 0 files`。
9. **パフォーマンス**: 初版は毎フレーム全ファイル再描画でよい。syntect のハイライトは重いので、選択ファイルのみハイライト計算を行う（他のファイルは行数カウントのみ）。
10. **diff スクロール状態**: `App.diff_scroll: usize` を「右ペイン上端の論理行オフセット」として保持。`Ctrl-d` は `+= viewport_height / 2`、`Ctrl-u` は `-= viewport_height / 2` (saturating)、`g` は `0`、`G` は `(visible_lines - viewport_height).max(0)`。follow ON で `recompute_diff` 後に新しい file の最後の hunk の `new_start` 相当の行までスクロールする。`render` は `diff_scroll` を起点に行を切り出して描画する。

**ratatui は毎フレーム再構築が前提なので、`render` はイミュータブルな `&App` から純粋に `Frame` を描画する関数として実装する。**

### Milestone 5: 結線 + 端末復旧 + 手動動作確認

1. `src/main.rs` を `#[tokio::main(flavor = "current_thread")]` で起動するように書き換える。`fn main` を `async fn main` にし、`None => app::run().await` で呼び出す。`Cli::parse` 自体は sync のままでよい。
2. `app::run` の中で `enable_raw_mode` → `execute!(stdout(), EnterAlternateScreen)` → `Terminal::new(CrosstermBackend::new(stdout))` → イベントループ → 終了時に `disable_raw_mode` + `LeaveAlternateScreen` を組む。
3. **panic hook**: `std::panic::set_hook` を `app::run` の最初で登録し、panic 時に `disable_raw_mode` + `LeaveAlternateScreen` を実行してから default hook にチェーンする。これで TUI コードがどこかで panic しても端末が壊れない。
4. `#![allow(dead_code)]`（`main.rs` 1 行目）は不要になり次第削除する。
5. **手動動作確認**: `cargo run` で kizu を起動したまま、別ターミナルから `echo "// test" >> src/main.rs` を実行。kizu の画面で `src/main.rs` の行数が更新されることを目視確認する。`q` で終了し、ターミナルが正常状態に戻ることを確認。
6. **CI と同順のローカル検証**: `cargo fmt --all -- --check` → `cargo clippy --all-targets -- -D warnings` → `cargo test --all-targets` → `cargo build --release` を順に実行し、全て成功することを確認する。

### Milestone 6: tuistory + bun による e2e テスト

`tests/e2e/` ディレクトリを新設し、TypeScript で 5 本の e2e テストを書く。kizu 本体のコードには触らない（black-box テストのため）。

1. **scaffold**:
   - `tests/e2e/package.json` を作成。`tuistory` を依存に追加し、`scripts.test` に `"bun test"` を入れる。バージョンは正確に pin する（pre-1.0 のため）。
   - `tests/e2e/tsconfig.json` を作成。`compilerOptions.target = "ESNext"`, `module = "ESNext"`, `moduleResolution = "Bundler"`, `strict = true`, `types = ["bun-types"]`。
   - `tests/e2e/.gitignore` で `node_modules/` を ignore。
   - `tests/e2e/bun.lockb` は `bun install` で生成（コミットする）。
   - リポルートの `.gitignore` に `tests/e2e/node_modules/` を追加。
2. **ヘルパ実装**: `tests/e2e/helpers.ts` に以下を実装:
   - `createTempRepo()`: `node:fs.mkdtempSync(os.tmpdir() + '/kizu-e2e-')` で一時ディレクトリを作り、`child_process.execSync('git init && git config user.email test@example.com && git config user.name test && touch initial && git add -A && git commit -m initial', { cwd })` で初期コミットを入れた git リポを作る。返り値は `{ path, cleanup }`。
   - `launchKizu({ cwd })`: `tuistory.launchTerminal({ command: process.env.KIZU_BIN ?? '../../target/release/kizu', cwd, cols: 120, rows: 36 })` でセッションを起動。返り値は `tuistory` の `Session` オブジェクトに `cleanup` を加えたもの。
3. **e2e A — smoke** (`tests/e2e/smoke.test.ts`):
   - 一時 git リポを作り kizu を起動 → `waitForText('[follow]')` でフッタを確認 → `press('q')` → `waitForExit` または `text()` が 'No changes' を含むことを確認 → `cleanup`
4. **e2e B — reactive** (`tests/e2e/reactive.test.ts`):
   - 一時 git リポを作り kizu を起動 → 別途 `child_process.writeFileSync(repo + '/foo.rs', 'fn main() {}')` でファイル作成 → `session.waitForText('foo.rs', { timeout: 1000 })` で 1 秒以内に画面に出ることを assert → snapshot
5. **e2e C — navigation** (`tests/e2e/navigation.test.ts`):
   - 2 つのファイルを作り `git add -A && git commit` してから両方を編集 → kizu 起動 → `waitForText('[follow]')` → `press('j')` → `waitForText('[manual]')` を assert → `press('f')` → `waitForText('[follow]')` を assert
6. **e2e D — baseline reset** (`tests/e2e/baseline-reset.test.ts`):
   - 一時 git リポでファイルを変更 → kizu 起動 → `waitForText('foo.rs')` → 別途 `git add -A && git commit -m x` → kizu 画面はまだ baseline=旧 HEAD のため `foo.rs` が表示されている → `press('R')`（Shift+r） → `waitForText('No changes')` で baseline が新 HEAD に更新されたことを assert
7. **e2e E — colors** (`tests/e2e/colors.test.ts`):
   - 既存ファイルを編集 → kizu 起動 → 右ペインの差分行について `session.text({ only: { foreground: '#00ff00' } })` 等で added 行のテキストが取得でき、`{ only: { foreground: '#ff0000' } }` で deleted 行のテキストが取得できることを assert（厳密な hex は syntect の theme 次第で変わる可能性あり、`Color.Green` / `Color.Red` 系の判定にする）
8. **CI 連携**: `.github/workflows/ci.yml` の bun セットアップ + `bun install --frozen-lockfile` + `bun test` ステップが既に追加済み。e2e が失敗するとジョブ全体が fail する (required check)。

## Concrete Steps

全ての作業ディレクトリは `/Users/annenpolka/ghq/github.com/annenpolka/kizu` とする。

### 1. 現状確認

    cargo check
    cargo test --all-targets  # テストが 0 件であることを確認

期待される出力（抜粋）:

    running 0 tests
    test result: ok. 0 passed; 0 failed; ...

### 2. Milestone 1 の TDD サイクル開始（Red）

`src/git.rs` の末尾に `#[cfg(test)] mod tests { ... }` を追加し、最初のテスト `parse_unified_diff_extracts_single_added_line` を書いてから:

    cargo test --all-targets git::tests::parse_unified_diff

期待される出力: コンパイルエラーまたは assertion failed。**Red を目視で確認してから Green に進む**こと。

### 3. Milestone 1 Green → Refactor

`parse_unified_diff` の最小実装を書き、三角測量でテストを追加しながら実装を一般化する。各サイクル終了時:

    cargo test --all-targets
    cargo clippy --all-targets -- -D warnings
    cargo fmt --all

### 4. Milestone 2: watcher の統合テスト

`Cargo.toml` の `[dev-dependencies]` に `tempfile = "3"` を追加し、`src/watcher.rs` 末尾に `#[cfg(test)] mod tests` を追加して tempdir + git init + ファイル書き換え + `recv_timeout` のテストを書く。

    cargo test --all-targets watcher

### 5. Milestone 3: App のキー処理単体テスト

`src/app.rs` に `App::handle_key` の単体テストを追加:

    cargo test --all-targets app

### 6. Milestone 4: UI の手動確認

UI レンダリングは単体テストが難しいため、このマイルストーンで `cargo run` を最初に試す。別ターミナルで kizu のリポジトリ自体を編集し、画面が更新されることを目視で確認する:

    cargo run

### 7. Milestone 6: tuistory e2e セットアップ

    mkdir -p tests/e2e
    cd tests/e2e
    bun init -y
    bun add tuistory@0.0.16

`package.json`, `tsconfig.json`, `bun.lockb` が生成される。`tsconfig.json` に `"types": ["bun-types"]` を入れる。

### 8. e2e テスト 5 本を実装

`helpers.ts` を書いてから `smoke.test.ts` → `reactive.test.ts` → `navigation.test.ts` → `baseline-reset.test.ts` → `colors.test.ts` の順に追加する。各テストごとに:

    cd tests/e2e
    KIZU_BIN=../../target/release/kizu bun test <test-file>

期待される出力:

    bun test v1.x
    smoke.test.ts:
    ✓ launches and exits cleanly with q
    1 pass
    0 fail

### 9. CI 相当の最終検証

    cargo fmt --all -- --check
    cargo clippy --all-targets -- -D warnings
    cargo test --all-targets
    cargo build --release --locked
    cd tests/e2e && bun install --frozen-lockfile && KIZU_BIN=../../target/release/kizu bun test

全て成功を期待する。`release` ビルドは LTO thin + codegen-units=1 のため初回は数十秒〜数分かかる。

## Validation and Acceptance

このマイルストーンの受け入れ基準は以下の全てを同時に満たすことです。

### 自動検証 (Rust)

- `cargo test --all-targets` が通る
  - `git::tests::parse_unified_diff_*` 系のテストが少なくとも 4 ケース（1 ファイル / 複数ファイル / 複数 hunk / バイナリ差分）存在し全て pass
  - `git::tests::head_sha_falls_back_to_empty_tree_in_fresh_repo` が通り、初回コミットがない git リポで `head_sha` が `4b825dc642cb6eb9a060e54bf8d69288fbee4904` を返すこと
  - `git::tests::compute_diff_includes_untracked_files` が通り、untracked ファイルが `FileStatus::Untracked` で合成されること
  - `watcher::tests::*` で tempdir + `#[tokio::test(flavor = "current_thread")]` を使った統合テストが 1 本以上通ること
  - `app::tests::handle_key_*` でキー処理の単体テストが 6 ケース以上（`j` でセレクション前進 / `k` でセレクション後退 / `Ctrl-d` で `diff_scroll` 増 / `g` で `diff_scroll = 0` / `f` でフォロー復帰 / `R` で baseline 再取得を要求）pass
  - `app::tests::recompute_diff_preserves_files_on_error` が通り、`compute_diff` が Err を返したとき `App.last_error` に格納されかつ前回の `files` が保持されること
  - `app::tests::recompute_diff_sorts_by_mtime_desc` が通り、`files` が mtime 降順に並ぶこと
  - `app::tests::recompute_diff_preserves_selection_by_path` が通り、ファイル追加/削除があっても `selected_path` が同じ path を指し続けること
- `cargo fmt --all -- --check` が通る
- `cargo clippy --all-targets -- -D warnings` が警告ゼロで通る
- `cargo build --release --locked` が成功する

### 自動検証 (tuistory e2e)

`tests/e2e` ディレクトリで `KIZU_BIN=../../target/release/kizu bun test` を実行し、以下 5 本が全て pass する:

- `smoke.test.ts`: 起動 → `[follow]` 表示 → `q` → 終了し終了コード 0
- `reactive.test.ts`: ファイル書き換え → 1 秒以内に画面に反映
- `navigation.test.ts`: `j` で `[manual]` / `f` で `[follow]` トグル
- `baseline-reset.test.ts`: 編集 → `git commit` → `R` で baseline 更新後に画面が空になる
- `colors.test.ts`: 右ペインの added 行が緑系 / deleted 行が赤系として描画されている

### 手動最終確認

1. ターミナル A で `cargo run` を実行 → kizu TUI が起動し、リポジトリの現在の変更ファイル一覧が左ペインに表示される
2. ターミナル B で `echo "// scratch" >> src/main.rs` を実行
3. 300ms 以内にターミナル A の画面が更新され、`src/main.rs` の `+1/-0` が表示に反映されること
4. `q` で kizu が正常終了し、ターミナルが normal mode に戻っていること（プロンプトが正常に表示される）

これらは tuistory e2e でも自動化されているため、手動確認は spot-check 程度でよい。

いずれかが満たせなければ Progress にどこで詰まったかを記録して停止する。

## Idempotence and Recovery

- **Red/Green/Refactor サイクルは完全にべき等**: 各サイクルは `git` リポジトリの worktree のみを変更し、`.git` や外部ファイルには触れない。失敗したら `git checkout -- src/` で巻き戻せる。
- **watcher のテスト**: `tempfile::tempdir()` は Drop で自動削除されるため、テストが異常終了してもゴミが残らない。万一 `/tmp/.tmp*` が残った場合は手動で削除可能。
- **TUI 異常終了時の端末復旧**: `std::panic::set_hook` で panic 時に `disable_raw_mode` + `LeaveAlternateScreen` を実行する。それでも端末が壊れた場合は `reset` コマンドで復旧する。
- **ベースライン再取得（`R`）**: 失敗しても副作用なし（state のみ書き換え）。誤爆しても次の `R` で現在の HEAD に再固定するだけ。
- **CI 相当の検証**: 何度実行しても同じ結果になる。`cargo clean` は不要。

## Artifacts and Notes

実装進行中に重要な diff、テスト出力、スクリーンショット相当の情報をここに貼る。簡潔さ重視。

（未記入：実装中に追記）

## Interfaces and Dependencies

### 依存（`Cargo.toml` 反映済み）

- `anyhow = "1.0.102"` — エラーハンドリング
- `clap = { version = "4.6.0", features = ["derive"] }` — CLI パース
- `crossterm = { version = "0.29.0", features = ["event-stream"] }` — 端末入出力・raw mode・`EventStream` (tokio 統合用)
- `notify = "8.2.0"` — ファイルシステム監視バックエンド
- `notify-debouncer-full = "0.7.0"` — notify のデバウンスラッパ
- `ratatui = "0.30.0"` — TUI レンダリング
- `serde = { version = "1", features = ["derive"] }` / `serde_json = "1"` — 現状 v0.1 では未使用だが、将来の設定ファイル / hook JSON 用に保持
- `syntect = "5.3.0"` — シンタックスハイライト
- `tokio = { version = "1.52", features = ["rt", "macros", "sync", "time"] }` — 非同期ランタイム (current_thread flavor)

### dev-dependencies

- `tempfile = "3"` — watcher / git の統合テストで一時ディレクトリを使う

### e2e 依存 (`tests/e2e/package.json`)

- `tuistory` (バージョン正確 pin、初版は 0.0.16) — 実 pty + waitForText + 色フィルタ + inline snapshot
- bun 組み込み: `bun:test` (jest 互換 API)、`bun-types`

### マイルストーン終了時に存在するべきシンボル

`src/git.rs`:

    pub struct FileDiff {
        pub path: PathBuf,
        pub status: FileStatus,
        pub added: usize,
        pub deleted: usize,
        pub content: DiffContent,
    }
    pub enum DiffContent { Text(Vec<Hunk>), Binary }
    pub enum FileStatus { Modified, Added, Deleted, Untracked }  // Renamed は --no-renames で出ない
    pub struct Hunk { pub old_start: usize, pub old_count: usize, pub new_start: usize, pub new_count: usize, pub lines: Vec<DiffLine> }
    pub struct DiffLine { pub kind: LineKind, pub content: String }
    pub enum LineKind { Context, Added, Deleted }

    pub const EMPTY_TREE_SHA: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
    pub fn find_root(start: &Path) -> Result<PathBuf>;
    pub fn git_dir(root: &Path) -> Result<PathBuf>;       // git rev-parse --absolute-git-dir
    pub fn head_sha(root: &Path) -> Result<String>;       // 失敗時は EMPTY_TREE_SHA を返す
    pub fn compute_diff(root: &Path, baseline_sha: &str) -> Result<Vec<FileDiff>>;
    pub(crate) fn parse_unified_diff(raw: &str) -> Vec<FileDiff>;  // 単体テスト用にモジュール内公開

`src/watcher.rs`:

    pub enum WatchEvent { Worktree, GitHead }
    pub struct WatchHandle {
        pub events: tokio::sync::mpsc::UnboundedReceiver<WatchEvent>,
        // debouncer の所有権を保持
    }
    pub fn start(root: &Path, git_dir: &Path) -> Result<WatchHandle>;

`src/app.rs`:

    pub struct App {
        pub root: PathBuf,
        pub git_dir: PathBuf,             // git rev-parse --absolute-git-dir で解決
        pub baseline_sha: String,
        pub files: Vec<FileDiff>,         // mtime 降順ソート済み
        pub selected: usize,              // files 内のインデックス (描画用)
        pub selected_path: Option<PathBuf>, // パス追跡セレクションの真の状態
        pub diff_scroll: usize,           // 右ペイン上端の論理行オフセット
        pub follow_mode: bool,            // false = manual
        pub last_error: Option<String>,
        pub head_dirty: bool,
        pub should_quit: bool,
    }
    // FileDiff には mtime: SystemTime を追加（mtime 取得不能時は UNIX_EPOCH）
    impl App {
        pub fn bootstrap(root: PathBuf) -> Result<Self>;
        pub fn handle_key(&mut self, key: KeyEvent);    // KeyCode + KeyModifiers を見る
        pub fn recompute_diff(&mut self);               // Err は last_error に書き files は保持
        pub fn reset_baseline(&mut self);               // R: head_sha 再取得 + recompute_diff
        pub fn mark_head_dirty(&mut self);
        pub fn visible_diff_lines(&self, file_idx: usize) -> Vec<DiffLineSlice>;  // 2000 行 truncate 適用
    }
    pub async fn run() -> Result<()>;                   // tokio::main から呼ぶ async fn

`src/ui.rs`:

    pub fn render(frame: &mut ratatui::Frame<'_>, app: &App);

`src/main.rs`:

    #[tokio::main(flavor = "current_thread")]
    async fn main() -> Result<()> {
        // Cli::parse は sync
        // None => app::run().await
    }

v0.2 のサブコマンドは `unimplemented!()` を維持する。

### tuistory e2e ヘルパ API (`tests/e2e/helpers.ts`)

    export function createTempRepo(): { path: string; cleanup(): void };
    export function launchKizu(opts: { cwd: string }): Promise<KizuSession>;
    // KizuSession は tuistory の Session に cleanup を加えたもの

### 依存理由

- **notify-debouncer-full を直接 notify ではなく使う理由**: SPEC.md 指定の 300ms / 100ms デバウンスを自前実装するより、公式ラッパの方がバーストイベントをマージする際のエッジケース（rename / atomic replace / 一時ファイル）を正しく扱う。
- **kizu 側に `.gitignore` フィルタを持たない理由**: ignore crate API の正しい使い方が `Gitignore::new()` だけでは不十分で、ネスト `.gitignore` 対応の `dir::Ignore` が `pub(crate)` のため自前実装になる。コストの方が「フィルタを持たないことで `compute_diff` が無駄に呼ばれる」コストより高い。drain coalescing を入れれば 50000 件のバーストも 1 回の `compute_diff` 呼び出しに吸収される。詳細は ADR-0005。
- **`git rev-parse --absolute-git-dir` で git ディレクトリを解決する理由**: `<root>/.git` ハードコードでは linked worktree が動かない。詳細は ADR-0005。
- **drain coalescing を app 層に置く理由**: `unbounded_channel` を 1 件ずつ消費するとバーストでキューが伸び続けキー応答が遅延する。`try_recv` ループで残りを吸い上げて 1 回 `compute_diff` する設計が最小行数で背圧を実装できる。詳細は ADR-0005。
- **tokio の current_thread flavor を選ぶ理由**: TUI に並列実行は不要。`Send` 制約を持ち込まずに済むため `Rc` / `RefCell` / `&mut self` の自由度が増す。
- **syntect を ratatui のスタイリングに変換する理由**: bat / delta と同じエンジンなので言語サポートが広く、追加メンテ不要。
- **git CLI shell out を維持する理由**: CLAUDE.md / SPEC.md の明示方針。`--no-renames` 等を再実装する労力を回避し、git の挙動と必ず一致させる。
- **tuistory を採用する理由**: kizu の価値 (visual diff observation) を直接守れる唯一の e2e 手段。詳細は ADR-0004。
