Skip to content

feat(git_exec): to_command と Runner、全 GitOp の argv テスト - #21

Merged
yshrsmz merged 4 commits into
mainfrom
feat/git-exec-runner
Sep 6, 2026
Merged

yshrsmz merged 4 commits into
mainfrom
feat/git-exec-runner

Conversation

@yshrsmz

@yshrsmz yshrsmz commented Sep 6, 2026

Copy link
Copy Markdown
Owner

M2 の 2 本目。GitOp を git の argv と標準入力に変換する純粋関数 to_command と、
それを実行する Runner を実装した。core だけの変更で、Cmd::Exec はランタイムでまだ
not_implemented に落ちる。起動して見えるものは #17 と同じ(ユーザーが触れるものが
増えるのは M2 の 3 本目から)。

設計書 03 を先に直してから実装している。

設計を変えた点

  • StageHunk / UnstageHunk の patch を String → Vec<u8>。git apply --cached に
    戻す入力なので、lossy 変換すると非 UTF-8 のファイルをステージした結果が元と変わる
    (feat(git_read): ワーキングツリーの status と diff のハンク分割 #18 で HunkLine::content を Vec<u8> にしたのと同じ理由)
  • to_argv → to_command。argv と stdin を 1 つの値で返す
  • Discard を tracked 用と DiscardUntracked(git clean)に分けた
  • unstage は restore --staged ではなく reset --quiet(unborn HEAD で前者は落ちる)
  • force push は --force-with-lease=<branch>:<expected>
  • ExecError を NoResult / Failed の enum に分けた
  • エラー分類は stderr だけでなく stdout も見る(git merge は CONFLICT を stdout に出す)

RebaseInteractive は argv だけを組み、Runner は実行せずに拒否する。todo を差し替える
経路が無いまま走らせると GIT_EDITOR=true で全 pick の rebase になり、plan を黙って
捨てた破壊的操作になるため(配線は M5)。

レビューで直したもの

/simplify(4 観点)と /code-review medium の両方から指摘が出た。

  • config.general.git_bin が到達不能になっていた。 設計書 03 から Runner.git_bin を
    「要求が無い」と判断して消したが、01 と 08 は general.git_bin を読む前提のままだった。
    Runner::new の引数に戻した
  • pathspec が空だと -- だけが残り、対象がリポジトリ全体になる。 clean --force -- は
    untracked を全部消す。「0 件を discard します」と確認して全部消える経路だったので、
    ArgvError::EmptyPathspec で拒否する
  • 値の無い --force-with-lease は fetch 一発で無力化する。 「弾かれた → fetch して
    見る → やはり force する」で他人のコミットが消える。GitOp::Push に
    expected: Option<Oid> を足した
  • ExecError の struct → enum、from_utf8_lossy の無駄なコピー、Sandbox::env の副作用、
    テストの重複(push のセットアップ・fake_oid・変種名リスト)

直さなかったもの

  • ssh のパスフレーズで TUI が固まる件は M3 へ。 GIT_TERMINAL_PROMPT=0 は ssh に
    効かない(/dev/tty を直接読む)。GIT_SSH_COMMAND="ssh -o BatchMode=yes" で即失敗
    させられるが、ユーザーの GIT_SSH_COMMAND / core.sshCommand を黙って上書きする。
    fetch / pull / push を作る M3 で決める。それまでこの経路は UI から到達しない。
    設計書 03 に選択肢と、現在の記述が嘘になっている点を書いた
  • GitCommand に lifetime を持たせて patch の clone を避ける案。 公開型に lifetime を
    入れる複雑さの方が、KB スケールの memcpy より高い
  • RebaseInteractive の拒否を to_command 側へ寄せる案。 足りないのは argv ではなく
    Runner が渡す GIT_SEQUENCE_EDITOR なので、門は Runner に置く。argv は M5 でも
    この形のまま

テスト

argv の表(設計書と 1 対 1)を全 28 変種で突き合わせるユニットテストと、実リポジトリに
対する統合テスト 20 本。見張りが本当に効くかは、実装を一時的に戻して確かめた:
-- を落とす / reset を restore --staged に戻す / lease を素に戻す / 空 pathspec の門を
外す / git_bin を直書きに戻す、のいずれでもテストが落ちる。

最初に書いた checkout <target> -- のテストは実装から -- を落としても通ったので書き直した
(git 2.55 は同名のブランチとファイルがあればブランチを優先する。危ないのは「ファイルだけが
存在する名前」で、-- が無いとワークツリーを黙って上書きする方)。

🤖 Generated with Claude Code

https://claude.ai/code/session_01UiPPyG8s6fjBsCSP1TRyks

yshrsmz and others added 4 commits September 6, 2026 19:34
`GitOp` を `git` の argv と標準入力に変換する純粋関数 `to_command` と、それを
実行する `Runner` を実装した。設計書(03)を先に直してから実装している。

設計を変えた点:

- `StageHunk` / `UnstageHunk` の `patch` を `String` から `Vec<u8>` に。
  `git apply --cached` に戻す入力なので、lossy 変換すると非 UTF-8 のファイルを
  ステージした結果が元と変わる(#18 で `HunkLine::content` を `Vec<u8>` にしたのと
  同じ理由)
- `to_argv` を `to_command` に改名し、argv と stdin を 1 つの値で返す。「どの引数で
  何を流すか」は 1 つの判断なので、2 関数に割ると片方だけ直る
- `Discard` を tracked 用と `DiscardUntracked`(`git clean`)に分けた。untracked は
  `git restore` では消せず、純粋関数はファイルシステムを見られない
- unstage は `restore --staged` ではなく `reset --quiet`。コミットが 1 つも無い
  リポジトリで `restore --staged` は `could not resolve 'HEAD'` で落ちる
- force push は `--force-with-lease`
- `ExecErrorKind::NotAttempted` を足した。argv を組めなかったときと
  `RebaseInteractive` を拒否したときは git を起動していないので、`code` も無く
  「実行に失敗しました」も嘘になる
- エラー分類は stderr だけでなく stdout も見る。`git merge`(`pull --no-rebase` の
  経路)はコンフリクトを stdout にしか書かないので、stderr だけだと `Other` に落ちる

`RebaseInteractive` は argv だけを組み、`Runner` は実行せずに拒否する。todo を
差し替える経路が無いまま走らせると `GIT_EDITOR=true` で全 `pick` の rebase になり、
`plan` を黙って捨てた破壊的操作になるため(配線は M5)。

テストは argv の表(設計書と 1 対 1)を全 `GitOp` で突き合わせるユニットテストと、
実リポジトリに対する統合テスト。`--` の有無・`reset` と `restore` の違い・
`--force-with-lease` は、実装を一時的に戻してテストが実際に落ちることを確かめた。

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01UiPPyG8s6fjBsCSP1TRyks
- `Runner` に `git_bin` を戻し、`config` の `general.git_bin` から渡す形にした。
  設計書 01 と 08 は最初から「ランタイムが `general.git_bin` を読む」と書いており、
  設定・既定値・`MEWGIT_GIT_BIN` の上書きとそのテストが実装に到達しないままだった。
  渡し忘れを作らないよう `Runner::new` の引数にしている
- `ExecError` を `NoResult` / `Failed` の enum に割った。1 つの struct に載せると
  「起動していないときは `code` が `None`、`stderr` に git ではない文言、`kind` は
  分類表に載らない値」という約束を読む側が毎回思い出すことになる。手書きの
  `Display` と `ExecErrorKind::NotAttempted` は要らなくなった。終了を待てなかった
  失敗を `Other` にしていた(stderr に mewgit の文言を入れていた)のも直る
- `String::from_utf8_lossy(..).into_owned()` は妥当な UTF-8 でもバッファを複製する。
  `from_utf8` で `Vec<u8>` を転用し、不正なバイトのときだけ複製する
- `Sandbox::env` から日時を外した。「呼ぶと時計が進む getter」だったため、
  スナップショットを取る `runner_env` に「並び順を見るテストでは使うな」という
  規約が生えていた。`Runner` 経由のコミットは実時間になり、`TestRepo` の固定日時
  (2023 年)より必ず後ろに並ぶ
- `BareRepo::git` を足し、bare の ref を無関係な `TestRepo` から `ls-remote` する
  回り道をやめた
- テストの重複を畳んだ: push のリモート準備(`clone_of` / `push`)、`PathBuf` の
  1 行ラッパ、`Oid` の生成(`test_util::fake_oid` に寄せた)、変種名の手書きリスト
  (`VARIANT_COUNT` に)
- `classify` の選択肢は、これまで片側だけが当たる入力のテストが無かった。
  両側それぞれに当たるケースを足した

見張りを 1 つ追加: `git_bin` を直書きに戻すと落ちるテスト。

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01UiPPyG8s6fjBsCSP1TRyks
`/code-review medium` の指摘。どちらも実際に git 2.55 で再現を確認した。

- **pathspec が空だと `--` だけが残り、対象がリポジトリ全体になる。**
  `reset --quiet --` は index 全体を戻し、`clean --force --` は untracked を
  全部消す。破壊的操作は `Modal::Confirm` を通す規約があるので、「0 件を discard
  します」と確認したうえで全部消えることになっていた。`ArgvError::EmptyPathspec`
  で argv を組む側から拒否する。`StashPush { paths: None }` は「全部」を表す
  正当な指定なので `Some(vec![])` と区別する

- **値の無い `--force-with-lease` は fetch 一発で無力化する。** lease は
  remote-tracking ref と比べるだけなので、`git fetch` で `origin/main` が他人の
  位置に進むと検査が素通りする。「push が弾かれた → fetch して見る → やはり
  force する」は UI が普通に通す道なので、素の lease は守りにならなかった。
  `GitOp::Push` に `expected: Option<Oid>` を足し、`--force-with-lease=<branch>:<oid>`
  を出す。`expected` には確認モーダルで見せた位置を渡す

`fetch` を挟んで force push する統合テスト(`the_lease_survives_a_fetch`)を足した。
実装を素の lease に戻すと、他人のコミットを消して実際に落ちることを確認している。

3 件目(ssh のパスフレーズ入力で TUI が固まる。`GIT_TERMINAL_PROMPT=0` は ssh に
効かない)は M3 に送った。`GIT_SSH_COMMAND` を既定で置くとユーザーの
`core.sshCommand` を黙って上書きするため、fetch / pull / push を作るときに決める。
設計書 03 に選択肢と、いまの記述が嘘になっている点を書いた。

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01UiPPyG8s6fjBsCSP1TRyks
`ExecError` の分割と `GitOp::Push { expected }` は #21 のレビュー対応で入ったもので、
03 だけを直していた。同じ話題に触れている設計書を揃える。

- 00: ディレクトリ構成に `git_exec/argv.rs` を足す
- 01: 確認モーダルを組む時点の値を `GitOp` に閉じ込める(`expected` がその例)
- plan: M3 の push タスクに `expected` の受け渡しと、ssh のパスフレーズの扱いを足す

docs のみなので `/simplify` と `/code-review` は省いた(4 観点がコード前提のため)。

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01UiPPyG8s6fjBsCSP1TRyks
@yshrsmz
yshrsmz merged commit 17fd5cf into main Sep 6, 2026
3 checks passed
@yshrsmz
yshrsmz deleted the feat/git-exec-runner branch September 6, 2026 11:10
yshrsmz added a commit that referenced this pull request Sep 7, 2026
* feat(app): ブランチの作成(n)とリネーム(r)

`Modal::Input` の最初の利用者。Branches ペインの `n` は HEAD 起点、Graph ペインの
`n` はカーソルの指すコミット起点(タグのラベルが乗った行を選べばタグから作れる)。
`r` はカーソル行の名前を初期値に入れる。

- `GitOp::Checkout` を `Switch` に改名し、`BranchCreate` の `checkout: bool` も
  `switch: bool` に揃えた(03 がこのタスクへ送っていた判断)
- `InputIntent::BranchCreate` は真偽値を持たない。UI からの作成は常に切り替えるので
  `switch: false` を組む経路が無く、持たせると踏めない腕が増える
- 1 行入力は改行のキーを 1 つも受けない。貼り付け(`Msg::Paste`)は改行を除いて
  1 行につなぐ。腕を足さないと #34 の形(残りがペインのキーになる)に戻る
- `short_oid` を `git_read` へ移した。モーダルのタイトルを組む `app` が 2 人目
- 「Exec が走っていればモーダルを閉じない」を commit と共有(`submit_exec`)

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_012MHJpyKE69BFZ2wdU3mfkN

* refactor: /simplify の指摘を入れる

- 編集キーの共通 7 腕を `edit_key` に切り出す。`commit_message_key` と `input_key`
  で写していて、**どちらのテストも自分のモーダルしか踏まない**ので片方だけ古く
  なっても通る形だった
- `modals::cursor_col` が「ずらしたあとの見える桁」を返すようにし、
  `area.x + col - scroll_x` を呼び出し側から消す。幅の計測は `Line::from`
  (幅を測るためだけに `Vec<Span>` を確保する)から `Span::raw` へ
- 貼り付けの改行除去を `TextBuffer::insert_line` に寄せる。`paste` 側の
  `replace('\n', "")` は改行の扱いを 2 箇所に割り、貼り付け全体のコピーも作っていた
- `short_oid` の確保を 1 回にする。7 バイト確保に 8 桁 push していたので毎回伸長が
  走っていた(`with_capacity(7)` に 8 文字で容量 14。rustc 1.90 で実測)
- `modals::WIDTH` を共有。`Confirm` と `Input` が「同じ」とコメントで主張していた
- `short_oid` を `git_read` へ移した跡に doc コメントが 1 行残り、`header_line` の
  説明を奪っていた
- テストヘルパ: `cursor` を `test_util` へ、`type_text` で 5 箇所を畳む。
  `modals::cursor_col` に直接のユニットテストを足した
- `switch: false` を組む経路が無い理由が 5 箇所にあったので 03 に集約

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_012MHJpyKE69BFZ2wdU3mfkN

* fix(git_exec): branch --move の新しい名前の前に `--` を置く

`to` は `Modal::Input` が受けた文字がそのまま positional に載る唯一の場所。
`git branch` は parse-options なので `-` 始まりをオプションとして拾い、
`git branch --move topic --force` は「`--force` へ改名」ではなく**「HEAD を
`topic` へ強制改名」**になる。元の `topic` は黙って消え、しかも exit 0 で返るので
mewgit には失敗が届かない(git 2.55 で確認)。

**07 に書いた「`--force` は付けないので黙って上書きする経路が無い」は、この経路を
数え落としていた。** 自分で「安全だ」と書いた根拠が外れていた形(#21 と同じ類型)。

- `to_command` に `--` を足し、03 の argv 表と 07 の記述を同じコミットで直す
- `--` を落とすと落ちる統合テストを足した。**成否だけでは見張れない**(git が
  exit 0 で返る)ので、元のブランチが消えていないことを見る
- **`BranchCreate` には置かない。** `switch --create` は次の argv を無条件に名前と
  して食うので `-` 始まりでも fatal になり、`--create --` と書くと `--` 自体が
  名前になって `fatal: invalid reference` で壊れる(git 2.55 で確認)

あわせて `/code-review` の他 2 件:

- `test_util::cursor` を `row_is_reversed` の doc の手前へ移した。挿入したせいで
  doc を奪っていた(CLAUDE.md に書いてある #40 の形の再発)
- `branches_key` の「モーダルごと別の PR にしてある」を消した

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_012MHJpyKE69BFZ2wdU3mfkN

* docs(app-core): TextBuffer::insert_line を 01 の API に載せる

`/simplify` で切り出した公開メソッドが設計書の `impl TextBuffer` の一覧に無く、
「改行の扱いを 1 箇所で決める」という判断がコードにしか無い状態だった。
「1 行入力」の貼り付けの節にも、除くのがどこかを書き足す。

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_012MHJpyKE69BFZ2wdU3mfkN

---------

Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
yshrsmz added a commit that referenced this pull request Sep 8, 2026
* feat(app): 断られた push を憶えて確認モーダル経由の force push にする

`P` が `NonFastForward` で断られたときだけ、次の `P` が確認モーダルを通る
force push になる。専用のキーは作らない(07 のキー表)。

- `git_read::Branch` に `upstream_oid`(追跡 ref の位置)。`tracking` が既に
  引いていた値を返すだけ。`--force-with-lease=<dst>:<lease>` に渡す
- `GitOp::Push` を `{ remote, src, dst, set_upstream, lease }` に変える。
  `force: bool` と `expected: Option<Oid>` に割っていた形は
  `force: true, expected: None`(値の無い `--force-with-lease`。#21 で
  「fetch 一発で無力化」と確認済み)を組めてしまう。1 つにすると型として作れない
- 行き先を `src` / `dst` に割る。refspec は右辺、lease は先頭に同じ名前を要求
  するので、1 本の文字列に畳んだままだと `x` が `refs/heads/y` を追跡している
  形で lease が `x:y:<oid>` に化けていた。force が到達可能になる本 PR で塞ぐ
- 憶えるのは `AppState.push_rejected`(ブランチ名 + そのときのローカルの先端)。
  名前だけだと、`↓n` で断られた直後に `p` で早送りしても force の確認が出る
  (behind だけのブランチも `! [rejected] (non-fast-forward)` で断られる。
  git 2.55 で確認)
- 立てるのは非 force の拒否だけ。lease が古いときの `(stale info)` も
  `NonFastForward` に落ちるので、force の失敗で憶え直すと同じ lease で
  撃ち続ける輪になる
- 追跡 ref が無い(`fetch --prune` のあと)ときは force を断る。lease の値を
  作れないため

pty で確かめた: 分岐したリポジトリで `P` → 断られ → `P` → 確認モーダル →
`y` で force push が通り、グラフと Branches が読み直される。fetch していない
状態では lease が `stale info` で断られ、他人のコミットが残る。

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_012MHJpyKE69BFZ2wdU3mfkN

* refactor: /simplify の指摘を適用する

- `git_read::Branch` の `upstream`(短縮名)と `upstream_oid` を
  `Option<Upstream { name, oid }>` に畳む。**旗と値を割らない**のと同じ話で、
  別々の `Option` にすると「名前はあるが位置は無い」という起こらない形が型で
  組め、揃っていることを散文で 2 箇所に書くことになっていた
- `BranchState::head()` を足す。HEAD の探索が `push_plan` と `push_rejection` の
  2 箇所に増えていた(`selected()` と同じ形)
- `push_plan` の「断るか」と「lease を組むか」を `match` 1 つに畳む。別々だと
  片方だけ緩めたときに気付けない
- リモート側の ref 名(`dst` か `src`)を `git_exec::push_remote_ref` 1 本に。
  確認モーダルの文面と argv が同じ関数を通る
- **確認モーダルの畳み込みインデントの見張りに push の 2 つを通す。** 産出元が
  4 つに増えていたのに 2 つしか回していなかった(force push の本文は `\n` を
  埋めている唯一のもので、混ざっても行数では見えない)。壊して落ちることを確認
- テストヘルパ `finish_exec`(発行して返信まで届ける)を共有し、`reject_push` を
  その 1 呼び出しにする
- `push_refspec` → `push_dst` のリネームで壊れていた doc リンクを直す
- **fetch を挟んでも記憶が生きることのテストを足す。** 拒否 → `f` → `P` 1 回で
  force に上がるのが本命の回復経路なのに、固定するテストが無かった

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_012MHJpyKE69BFZ2wdU3mfkN

* fix(app): force push に上げてよい場面を絞る(/code-review の指摘)

**`↓n` だけのブランチで force が通っていた。** `↓n` だけでも push は非 FF で
断られるので記憶が立ち、そこで案内どおり `f` を踏むと lease がリモートの位置に
追いつく。次の `P` で force が**通り**、出すものが 1 つも無いのに他人のコミットを
消していた(git 2.55 の一時リポジトリで再現。他人の commit が消えることを確認)。
**lease は「リモートが動いていないこと」しか守らない** —— fetch のあとは守りに
ならないので、`ahead == 0` なら force に上げず `p` を勧める。

**hook や保護ブランチの拒否まで force の確認を出していた。** `classify` が
`rejected` を広く拾っていたが、hook が断った push も
`! [remote rejected] main -> main (pre-receive hook declined)` と書く(git 2.55 で
確認)。git が非 FF を表す語は 3 つ(`(non-fast-forward)` / `(fetch first)` /
`(stale info)`)なので、そこまで絞る。

**憶える対象を、押したブランチ(`GitOp::Push` の `src`)にした。** 一覧の
読み直しは別のジョブなので、`Switch` の直後に押した push が返るころには HEAD が
別のブランチになっていることがある。HEAD で憶えると、押していないブランチに
force の腕が立っていた。

**`push_remote_ref` が `push_paths` の doc コメントを奪っていた。** 挿入した
位置が悪く、pathspec に `--` を置く理由が push の関数の下に付いていた
(handover の「関数を既存の関数の直前に挿入すると doc を奪う」を再発させた)。

テストは 3 本追加し、どれも実装を壊して落ちることを確認した。

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_012MHJpyKE69BFZ2wdU3mfkN

* docs(plan): 意図的な巻き戻しの force push を M6 へ送る

`ahead == 0` でまとめて断ったので、`reset --hard` してから force する経路も
落ちる。区別するには別の入口が要るので M6 の行に足した(01 に理由がある)。

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_012MHJpyKE69BFZ2wdU3mfkN

---------

Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
yshrsmz added a commit that referenced this pull request Sep 8, 2026
* feat(app): git の失敗に「次に何を押すか」を添える

`ExecErrorKind` は #21 で入っていたが読む人が誰もおらず、失敗はどれも
`git の実行に失敗しました: git <argv>` としてステータスバーに出ていた。

`app` に `exec_advice` を足し、通知の要約行を案内文に差し替える。argv と stderr は
`detail` に残す(不変ルール 4)。置き場所が `app` なのは、答えるのが「mewgit の
どのキーを押すか」だから(`moves_refs()` を `app` へ移したのと同じ数え方)。

非 FF は kind だけでは文を選べないので `GitOp` まで見る。force push が lease に
断られた形は `f`、`-u` の提案から入った形は force に上げる経路が無いので `r`、
追跡設定に沿った push は `p` かもう一度 `P`。

`classify` に 4 つ目の非 FF の語を足した。`pull --ff-only` が履歴の分かれた
ブランチを断るときの `Not possible to fast-forward` は `rejected` も他の 3 語も
含まず、`Other` に落ちていた(git 2.55 で確認)。広げても force は arm されない
(`push_rejection` が `GitOp::Push` で門番している)ことをテストで見張る。

案内文は 72 セルに収める。ステータスバーは 1 行で `q:quit` と空白 2 が 8 セル取る
ので、超えると末尾から黙って切れて「どうすればよいか」の側が消える。

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_012MHJpyKE69BFZ2wdU3mfkN

* fix(scripts): screen.py が部分再描画で全角の 2 桁目を消していた

ratatui は変わったセルだけを描き直すので、前のフレームに全角があった桁へ新しい
全角が乗ることがある。`put` は「書き込み先が全角の 2 桁目なら左隣を空ける」と
していたが、その左隣が**いま書いたばかりの全角の 2 桁目**のことがある。消すと
1 文字おきに空白が入り、`P を 押 し 直 し て` のように見える。

本 PR の案内文を pty で確かめたときに踏んだ。生の制御列を読むと桁は正しく、
復元側だけがずれていた。**確かめる道具が嘘をつくと、直す先を間違える。**

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_012MHJpyKE69BFZ2wdU3mfkN

* refactor(app): /simplify の指摘を入れる

- 断られた `-u` の push を `push_plan` が読むようにした。`-u` の push も
  `push_rejected` を立てるのに、`propose_upstream` へ抜ける経路が一度も読まず、
  **案内が「force push できません」と言った 1 キーあとに同じ失敗する push を
  もう一度提案していた**。文言は `UNTRACKED_NON_FF` の 1 つを案内と断りで共有する
- テストの `ExecError::Failed` ビルダーが 2 本あったので `failed_with(kind)` に畳み、
  `reject_push` は op を取るのに名前が push に寄っていたので
  `reject_non_fast_forward` に改名した(`pull --ff-only` の拒否も通る)
- ステータスバーの最下行の取り出しが 5 箇所に散っていたので `status_bar` /
  `status_bar_with` に寄せた

`72` セルの予算をレイアウト側(`status_line` がヒント幅を先に確保する)で
解く案は見送った。07 が「取り合うときは通知を切る」の実装を M6 のステータスバーの
タスクに置いており、いまの 2 本の見張りは噛み合っている —— `HINT` が伸びれば
tui 側の `あ×36` が赤くなり、`app` 側の 72 も数え直すことになる。算出に変えると
その噛み合わせが外れる。

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_012MHJpyKE69BFZ2wdU3mfkN

* fix(app): /code-review の指摘を入れる

- **`-u` の拒否を憶えない。** 前のコミットは `push_plan` 側で断るようにしたが、
  断ると `P` が `Push` を出さなくなり、記憶を落とす経路(`exec_finished`)へ
  二度と入れない。リモート側の同名ブランチが外で消えても「force push できません」と
  言い続ける。**憶えるかどうかを「次を force に上げられる経路か」の 1 つの問い
  (`upgradable`)に畳み**、`-u` と force の失敗を同じ場所で落とす
- **案内が `r` を名指ししていた。** `r` が効くのは Branches ペインの中だけで、既定の
  フォーカスである Graph で押しても何も起きない。しかも `r` はカーソル行を改名するので
  押した本人のブランチとは限らない。キーではなくペインの名前を出す
- **`f` のあとの `P` は 2 回。** force の失敗では記憶を落とすので、1 回目の `P` は
  素の push になり、もう一度断られてから force の確認が出る(一時リポジトリで確認)。
  「押し直して」だと 1 回に読め、途中で出る別の案内と食い違う

`p で取り込むか、P をもう一度押してください` が 2 つの選択肢のどちらが効くかを
言っていない件(`ahead` で決まる)は直さない。ここで `ahead` を見ると `push_plan` の
判定が 2 箇所に割れる。どちらを押しても正しい断りか正しい確認に落ちる。

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_012MHJpyKE69BFZ2wdU3mfkN

---------

Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
yshrsmz added a commit that referenced this pull request Sep 9, 2026
4 観点で 20 件。実質的なものが 4 つあった。

**`config` の `general.gh_bin` が黙って死んでいた。** `"gh"` を直書きしていたので、
設定・既定値・`MEWGIT_GH_BIN` の上書きとそのテスト 3 本が実装に到達していなかった。
**しかも根拠に引いた #21 が逆だった** —— 実際のコミット本文は「`Runner` に `git_bin` を
**戻し**、`config` の `general.git_bin` から渡す形にした。設計書 01 と 08 は最初から
『ランタイムが読む』と書いており…」で、#21 はまさに今回と同じ穴を塞いだ PR。
`GhCli::detect(bin)` にして、#21 と同型の見張り(直書きに戻すと落ちるテスト)を足した。
04 と doc コメントの誤った引用も直し、08 の「任意コード実行になる」を 2 つの bin で数えた。

**`state` を gh の層で絞ると、04 が `resolve` へ送った判断の材料が届かない。**
04 は「`Timeout` を `Error` と同じに畳んでよいかは `auth::resolve` で決める」と
書いているのに、`success` だけ採って `GhLogin` に `state` を持たせていなかった。
`LoginState` を足して落とさずに運ぶ。畳むときは `state` が一番良かったものを採り、
**スコープは `Success` なエントリのものを採る** —— 出どころが違えば別のトークンなので
スコープも違いうる(「同じはず」は確かめていない断定だった)。

**`auth_status` が全ホストを検証していた。** `--hostname` を付けないと gh は
config にある全ホストのトークンを検証するので、到達できないホストが 1 つあるだけで
**実測 30.2 秒**(絞れば 0.24 秒)。gh に待ち時間を縮めるフラグは無い。04 の手順 2 が
言う「`target.host` で絞る」を、結果ではなく argv でやる形にした。config に無い
ホストでも exit 0 で `{"hosts":{}}` が返るので「0 件なら `NotLoggedIn`」は変わらない。

**「証」を名乗る型が検査を通さずに作れた。** `Default` + `pub with_env` があったので
`GhCli::default().auth_status()` が通り、テスト自身がその抜け道を使っていた(04 には
「持つ値は無い」と書いてしまっていた)。構築経路を `detect` 1 本に絞り、版判定を
純粋関数 `check_version` に切り出して **`GhTooOld` の腕を初めてテストで踏めるように**した。

`ssh.rs` が「`GhCli` が 3 本目になったら数え直す」と送っていた宿題も片付けた。数えると
3 本ではなく「同形が 2 本 + 別物が 1 本」で、`git_exec::Runner::spawn` は前提が違う
(`process_group`、`kill_on_drop` を敢えて付けない、stdin パイプ、キャンセル)。
`ssh -G` と `gh` を `auth::probe` に寄せた結果、**共有していなかったぶんの実費**も
消えた —— `LC_ALL=C` が無い、起動失敗の `io::Error` を捨てて全部 `GhMissing`
(権限エラーまで「見つかりませんでした」になっていた)、exit code を落としている。

そのほか。

- `AuthError::GhTooOld` から `required` を落とす(常に `REQUIRED` からの導出値)。
  `REQUIRED` を `pub` にして `ui` からも読めるようにした
- `merge_logins` を `BTreeMap` にして、線形探索・末尾の `sort_by`・「順は不定」の
  コメントを 1 つに畳んだ。捨てるための `scopes` パースも走らなくなった
- カンマ区切りスコープの分割を `auth::split_scopes` に寄せた(`github::client` の
  `needed_scopes` が 2 人目)
- stdout のコピーを 1 回に(`from_utf8` で `Vec<u8>` を転用)。`token` も
  `trim_end` で切り詰めて `String` ごと渡し、平のトークンを載せた中間バッファを増やさない
- `parse_version` から「パッチ無しも受ける」を落とした。gh がその形を出す実例を見ていない
- `requirements.md` の degraded 事由に「版が古い」を足した(4 つ目)

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_012MHJpyKE69BFZ2wdU3mfkN
yshrsmz added a commit that referenced this pull request Sep 9, 2026
5 件。

**login が空のエントリを候補に数えていた。** 期限切れの `GH_TOKEN` を export した
ままだと、本物のログインの隣に `{"state":"error","active":true,"login":""}` が並ぶ
(レビューが実測)。`token(host, user)` は login で引くので**取り出す術が無い**のに
候補として数えるので、04 の手順 3「1 件なら `Single`」が誤爆する。落とすようにした。
**`state` の絞り込みとは違う** —— こちらは `resolve` の方針で変わる余地が無い
(どの方針でも使えない)ので、gh の層で落としてよい。

**`scopes.is_empty()` を「まだ埋めていない」の印にしていた。** fine-grained PAT は
本当にスコープが空なので(04 の EMU の節)、1 件目がそれで 2 件目が別のトークンだと、
**持っていないスコープを報告する**。「足りないスコープ」の案内が嘘になる。
`scopes_taken` を別に持って、印と値を割らない形にした。

**`GhMissing` の案内にパスが入っていなかった。** `general.gh_bin` や `MEWGIT_GH_BIN`
の書き間違いでもここに来るので、「gh が見つかりませんでした」だけだと PATH を
探させてしまう。

**04 に古い段落が残っていた。** 「`bin: PathBuf` は持たない … `Command::new("gh")` の
PATH 解決に任せる」が、48 行上の構造体と、同じコミットで足した箇所の両方と矛盾した
まま生き残っていた。しかも**直前のコミットが「誤りだった」と書いた #21 の逆読み**が
そこに残っていたので、次に読む人が `bin` を消して同じ穴を開け直せる状態だった。

**版のぶら下がりで全機能を落としていた。** `2.85.0+dfsg1` や `2.90.0-rc.1` が
`GhTooOld` に落ちる。**`/simplify` の「実例を見ていない一般性は入れない」と逆を
向く指摘だが、実害がある側を採った** —— 厳しくして外すと十分新しい gh で GitHub 機能を
丸ごと失い、緩めて外しても比較に使う 3 つの数は変わらない。3 つ揃っていることは
引き続き求め、**パッチが数字だけで終わっているのに 4 つ目が続く形**(`2.81.0.1`)は
知らない採番なので受けない。

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_012MHJpyKE69BFZ2wdU3mfkN
yshrsmz added a commit that referenced this pull request Sep 9, 2026
* feat(auth): GhCli で gh から認証状態とトークンを取る

detect / auth_status / token の 3 本。実物の `gh` 2.100.0 と cli/cli のソースで
形を確かめてから書いた。

**テキスト出力は解析しないと決めた(設計変更)。** 04 は「JSON とテキスト両対応」と
書いていたが、`gh auth status --json hosts` が入ったのは 2.81.0(2025-10-01
リリース。cli/cli#11544 のマージが 2025-09-25。`gh api` でリリースを引いて確認)で、
`--user` も同じ版に既にある(v2.81.0 の `token.go` を読んで確認)。それ未満は
`AuthError::GhTooOld` で degraded に落とす。

理由は**書式を確かめられないまま書くことになる**から。手元の 2.100.0 のテキストは
`✓ Logged in to github.com account <login> (<source>)` / `- Active account: true` /
スコープはクォート付きだが、古い版は `as <login>` でクォート無し、
`- Active account:` の行自体が無い。再現できない書式にパーサを書くのは
`.claude/rules/verifying-assumptions.md` に反する。`gh` はもともと必須依存なので、
要求版を案内するほうが行動可能。古い gh の利用者が出てきたら、その人の実出力を
フィクスチャにして足すほうが確実。

実物で分かって設計に反映したこと。

- **同じ `login` が複数のエントリで出る。** 実機で `GITHUB_TOKEN`(環境変数)と
  `keyring` が、どちらも同じ login で並んだ。`GhLogin` は `tokenSource` を持たない
  ので**見分けられない重複**になり、`resolve` の「1 件なら `Single`」が誤爆する。
  `(host, login)` で畳んで `active` は OR を取る
- **`scopes` はカンマ区切りの 1 本の文字列**で `omitempty`。fine-grained PAT では
  丸ごと欠ける(04 の EMU の節が「`scopes` が空でも動作させる」と言っている形)
- **`state` は `success` / `timeout` / `error` の 3 値**(v2.100.0 の `status.go`)。
  採るのは `success` だけ。`timeout` を `error` と同じに畳んでよいかは
  `auth::resolve` へ送った
- **`--show-token` は付けない。** 付けると JSON に生のトークンが入る(不変ルール 3)
- `bin: PathBuf` は持たない。差し替える要求が無く、#21 で `Runner.git_bin` を
  同じ理由で消してある

テストは 13 本。版の比較と JSON の畳み込みは純粋関数として、**残り 3 本は実物の
`gh` を叩く**(`ssh` のテストと同じ方針)。`gh` は設定ディレクトリを環境変数でしか
受けないので、`Runner::with_env` と同じ形の `GhCli::with_env` を足し、
`GH_CONFIG_DIR` とトークンの環境変数で実行環境から切り離した。

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_012MHJpyKE69BFZ2wdU3mfkN

* refactor(auth): /simplify の指摘を反映する

4 観点で 20 件。実質的なものが 4 つあった。

**`config` の `general.gh_bin` が黙って死んでいた。** `"gh"` を直書きしていたので、
設定・既定値・`MEWGIT_GH_BIN` の上書きとそのテスト 3 本が実装に到達していなかった。
**しかも根拠に引いた #21 が逆だった** —— 実際のコミット本文は「`Runner` に `git_bin` を
**戻し**、`config` の `general.git_bin` から渡す形にした。設計書 01 と 08 は最初から
『ランタイムが読む』と書いており…」で、#21 はまさに今回と同じ穴を塞いだ PR。
`GhCli::detect(bin)` にして、#21 と同型の見張り(直書きに戻すと落ちるテスト)を足した。
04 と doc コメントの誤った引用も直し、08 の「任意コード実行になる」を 2 つの bin で数えた。

**`state` を gh の層で絞ると、04 が `resolve` へ送った判断の材料が届かない。**
04 は「`Timeout` を `Error` と同じに畳んでよいかは `auth::resolve` で決める」と
書いているのに、`success` だけ採って `GhLogin` に `state` を持たせていなかった。
`LoginState` を足して落とさずに運ぶ。畳むときは `state` が一番良かったものを採り、
**スコープは `Success` なエントリのものを採る** —— 出どころが違えば別のトークンなので
スコープも違いうる(「同じはず」は確かめていない断定だった)。

**`auth_status` が全ホストを検証していた。** `--hostname` を付けないと gh は
config にある全ホストのトークンを検証するので、到達できないホストが 1 つあるだけで
**実測 30.2 秒**(絞れば 0.24 秒)。gh に待ち時間を縮めるフラグは無い。04 の手順 2 が
言う「`target.host` で絞る」を、結果ではなく argv でやる形にした。config に無い
ホストでも exit 0 で `{"hosts":{}}` が返るので「0 件なら `NotLoggedIn`」は変わらない。

**「証」を名乗る型が検査を通さずに作れた。** `Default` + `pub with_env` があったので
`GhCli::default().auth_status()` が通り、テスト自身がその抜け道を使っていた(04 には
「持つ値は無い」と書いてしまっていた)。構築経路を `detect` 1 本に絞り、版判定を
純粋関数 `check_version` に切り出して **`GhTooOld` の腕を初めてテストで踏めるように**した。

`ssh.rs` が「`GhCli` が 3 本目になったら数え直す」と送っていた宿題も片付けた。数えると
3 本ではなく「同形が 2 本 + 別物が 1 本」で、`git_exec::Runner::spawn` は前提が違う
(`process_group`、`kill_on_drop` を敢えて付けない、stdin パイプ、キャンセル)。
`ssh -G` と `gh` を `auth::probe` に寄せた結果、**共有していなかったぶんの実費**も
消えた —— `LC_ALL=C` が無い、起動失敗の `io::Error` を捨てて全部 `GhMissing`
(権限エラーまで「見つかりませんでした」になっていた)、exit code を落としている。

そのほか。

- `AuthError::GhTooOld` から `required` を落とす(常に `REQUIRED` からの導出値)。
  `REQUIRED` を `pub` にして `ui` からも読めるようにした
- `merge_logins` を `BTreeMap` にして、線形探索・末尾の `sort_by`・「順は不定」の
  コメントを 1 つに畳んだ。捨てるための `scopes` パースも走らなくなった
- カンマ区切りスコープの分割を `auth::split_scopes` に寄せた(`github::client` の
  `needed_scopes` が 2 人目)
- stdout のコピーを 1 回に(`from_utf8` で `Vec<u8>` を転用)。`token` も
  `trim_end` で切り詰めて `String` ごと渡し、平のトークンを載せた中間バッファを増やさない
- `parse_version` から「パッチ無しも受ける」を落とした。gh がその形を出す実例を見ていない
- `requirements.md` の degraded 事由に「版が古い」を足した(4 つ目)

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_012MHJpyKE69BFZ2wdU3mfkN

* fix(auth): /code-review の指摘を直す

5 件。

**login が空のエントリを候補に数えていた。** 期限切れの `GH_TOKEN` を export した
ままだと、本物のログインの隣に `{"state":"error","active":true,"login":""}` が並ぶ
(レビューが実測)。`token(host, user)` は login で引くので**取り出す術が無い**のに
候補として数えるので、04 の手順 3「1 件なら `Single`」が誤爆する。落とすようにした。
**`state` の絞り込みとは違う** —— こちらは `resolve` の方針で変わる余地が無い
(どの方針でも使えない)ので、gh の層で落としてよい。

**`scopes.is_empty()` を「まだ埋めていない」の印にしていた。** fine-grained PAT は
本当にスコープが空なので(04 の EMU の節)、1 件目がそれで 2 件目が別のトークンだと、
**持っていないスコープを報告する**。「足りないスコープ」の案内が嘘になる。
`scopes_taken` を別に持って、印と値を割らない形にした。

**`GhMissing` の案内にパスが入っていなかった。** `general.gh_bin` や `MEWGIT_GH_BIN`
の書き間違いでもここに来るので、「gh が見つかりませんでした」だけだと PATH を
探させてしまう。

**04 に古い段落が残っていた。** 「`bin: PathBuf` は持たない … `Command::new("gh")` の
PATH 解決に任せる」が、48 行上の構造体と、同じコミットで足した箇所の両方と矛盾した
まま生き残っていた。しかも**直前のコミットが「誤りだった」と書いた #21 の逆読み**が
そこに残っていたので、次に読む人が `bin` を消して同じ穴を開け直せる状態だった。

**版のぶら下がりで全機能を落としていた。** `2.85.0+dfsg1` や `2.90.0-rc.1` が
`GhTooOld` に落ちる。**`/simplify` の「実例を見ていない一般性は入れない」と逆を
向く指摘だが、実害がある側を採った** —— 厳しくして外すと十分新しい gh で GitHub 機能を
丸ごと失い、緩めて外しても比較に使う 3 つの数は変わらない。3 つ揃っていることは
引き続き求め、**パッチが数字だけで終わっているのに 4 つ目が続く形**(`2.81.0.1`)は
知らない採番なので受けない。

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_012MHJpyKE69BFZ2wdU3mfkN

---------

Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
yshrsmz added a commit that referenced this pull request Sep 9, 2026
M4 の 2 本目(#61、`GhCli`)で出た 2 件。#46 / #48 / #50 / #52 / #55 / #58 / #60 と
同じ通例。

- `CLAUDE.md` 冒頭 —— **教訓として書かれた項目は「踏んだ形」であって結論ではない。**
  `CLAUDE.md` も `handover.local.md` も「こう間違えた」を書いているので、そのまま
  引くと意味が反転する。#61 では handover の「#21 は `Runner.git_bin` を消した」を
  結論と読んで `gh` のパスを直書きしたが、それは拾われた誤りのほうで、#21 は
  `git_bin` を**戻した** PR だった。おかげで `general.gh_bin` が黙って死に、#21 が
  塞いだのと同じ穴を開け直した
- `.claude/rules/asking-questions.md` —— **「どうやるか」の選択肢を出す前に、
  「そもそもやるか」が選択肢になっていないかを数える。** 古い `gh` のテキスト解析で
  「どう書式を確かめるか」を 3 つ並べたが、返ってきたのは「古いやつサポートする
  必要ある?」だった

見送ったもの: 「型が不変条件を主張するなら構築経路を数える」(`GhCli` が `Default` で
検査を通さずに作れ、テスト自身がその抜け道を使っていた件)。既存の「旗と値を割らない」
の近い親戚で、独立した項目にするほどではないと判断した。


Claude-Session: https://claude.ai/code/session_012MHJpyKE69BFZ2wdU3mfkN

Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
@mewgit-bot mewgit-bot Bot mentioned this pull request Oct 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant