コンテンツにスキップ

版と更新

ccs が「自分はどの版か」をどう名乗り、どう配布・更新されるか。

なぜ導出するのか — 手書きの番号は実際に腐った

CCS_VERSION は長らく bin/ccs に手で書かれていた。履歴はこうなっている。

コミット 版
骨格を置いた 0.1.0
hub と設定層 0.2.0
ADR-0003 を書いた(docs だけの PR) 0.0.1 ← 後退している
ccs gc の 4 分類 0.0.3
restore --last ほか 3 つの機能 PR 0.0.3 のまま

2 つ壊れている。番号が後退したこと(docs だけの PR で、誰も気づかなかった)と、 機能が入っても動かなかったこと。

後者の実害が 2026-08-29 に出た。PATH の ccs が古く ccs restore --last が 「知らないオプション」で落ちたが、ccs version は古い方も新しい方も 0.0.3 と答えた。

「前」と「後」を区別できない番号は、版として機能していない。 だから ccs の版は、人が打つ数字ではなく コミットから導出する。

何を名乗るか

$ ccs version
0.0.3+3fed500

$ ccs version --short
0.0.3

解決の順は 3 段。上から順に、最初に答えられたものを使う。

段 いつ効くか 例
1. 焼き込み (CCS_BUILD) インストールされた実体。install が埋める 0.0.3+3fed500
2. git から導出 repo / worktree の bin/ccs を直に叩いたとき 0.0.3+3fed500 (dev)
3. どちらも無い git の外に置かれ、焼き込みも無い 0.0.3+unknown
  • (dev) が付いていたら、それはインストールされた実体ではない。 手元の worktree を直に叩いている。一致しないものを見比べているとき、 真っ先に知りたいのがこれ。
  • unknown は「分からない」と言っているだけで、嘘はついていない。 黙って錨の番号だけを答えると、また「前と後が同じ」に戻る。

tag を打つとどうなるか

tag があればそちらが勝つ。

状態 ccs version --short
tag が無い 0.0.3+3fed500 0.0.3
v1.2.3 ちょうど 1.2.3 1.2.3
v1.2.3 から 3 コミット先 1.2.3-3-g3fed500 1.2.3
追跡されている変更がある 末尾に -dirty 同上

CCS_VERSION(bin/ccs の中の 0.0.3)は tag がまだ 1 つも無いときの錨。 tag を打ち始めれば出番は無くなる。

種別は綴りから当てていない

短い sha は数字で始まることがある(実測: 3f5673a)ので、 「数字で始まれば tag 由来」は誤判定する。--always を付けずに git describe させ、成否そのもので分けている ── tag が 1 つも 無ければ describe は失敗するので、そこで sha に落とす。

--short は以前の契約そのまま

以前の ccs version は 0.0.3 のように X.Y.Z だけを返していた。 その形は --short に移した(機械可読の入口を消さないため)。 既定の出力は情報を足したので 1 行だが X.Y.Z ではない。

CHANGELOG は作らない

ROADMAP の完了ログが 既に日付つきの変更記録になっている。CHANGELOG を足すと同じことを 2 箇所に書くことになり、 片方が必ず腐る。GitHub Release を出すときは、Conventional Commits を守っているので auto-generated notes で足りる。

検査 — 版が嘘をつかないことを固定する

make lint が 2 つ見張る。

  • コミットされた bin/ccs の CCS_BUILD は必ず空。 焼き込み済みのファイルをコミットすると、そのファイルはどのコミットに 置かれても同じ版を名乗り続ける ── 手書きの番号が 0.0.3 で止まったのと まったく同じ壊れ方を、自動化した経路で再現することになる。
  • CCS_VERSION は X.Y.Z の形。 --short の対外的な契約がここから出る。

test/unit/version.bats が 14 件で挙動を固定する。tag・dirty・symlink 経由・ git の外、といった状態は使い捨ての git リポジトリを作って試している (本物のリポジトリに tag を打つのは副作用なので、テストからは触らない)。


配布と更新

なぜ ghq のチェックアウトを指してはいけないのか

以前の入れ方は symlink 1 本だった。

ln -sf ~/ghq/github.com/ken-ty/ccs/bin/ccs ~/.local/bin/ccs   # もうしない

これだと 走るコードは「main checkout の HEAD がその瞬間に何であるか」そのものになる。

2026-08-29 に事故になった。PR は worktree のセッションから GitHub 上で self-merge される ── main checkout には何も起きない。誰かが手で git pull するまで、PATH の ccs は 取り残される。5 コミット遅れていて、ccs restore --last を知らなかった。

「古い」よりも「可変」であることのほうが問題だった。

性質 何が起きるか
可変 main checkout で branch を切っただけで、インストール操作なしに PATH の ccs が別物になる
非原子的 git pull は bin/ccs をその場で書き換える。hub は 300 秒ごとにそれを起動している
戻せない 直前の既知の良品がどこにも残らない

同じマシンの claude / cursor-agent と同じ形にした (~/.local/bin/claude -> ~/.local/share/claude/versions/2.1.251)。 ccs は単一ファイルの自己完結スクリプトなので、この形にきれいに嵌まる。

~/.local/share/ccs/versions/0.0.3+3fed500   実行可能な単一ファイル
~/.local/share/ccs/meta/0.0.3+3fed500       commit / installed / source
~/.local/share/ccs/current                  いま指している版
~/.local/share/ccs/source                   install 元のチェックアウト
~/.local/share/ccs/bin/ccs-install          インストーラの複製
~/.local/share/ccs/install.log              切り替えの記録(JSONL)
~/.local/bin/ccs -> ~/.local/share/ccs/versions/0.0.3+3fed500

得られるもの:

  • 原子的 ── symlink は rename で差し替える。「消えている瞬間」が無く、 走っているプロセスは自分が開いたファイルを持ち続ける
  • 不変 ── 入った実体を git は二度と触らない。worktree の切り替えも dirty も届かない
  • 巻き戻せる ── 前の版がそこにある。symlink を 1 回戻すだけ
  • launchd を変えなくてよい ── plist は ~/.local/bin/ccs を指しているので、 symlink を差し替えれば次の起動から新しい実体を掴む

インストーラは自分の複製を ~/.local/share/ccs/bin/ に置く

ghq のチェックアウトが壊れていても・消えていても・別ブランチでも巻き戻せるように。 巻き戻しがチェックアウトに依存していたら、「別 path で管理する」が徹底できない。

入れる

make install                    # いまの作業ツリーを入れて切り替える
ccs doctor                      # 何が入っていて、最新かを見る

ccs 自身に upgrade サブコマンドは作らない。作ると ccs が GitHub とネットワークを 知ることになり、4 つの責務の 5 つ目になる。

ccs-install                     いまの作業ツリーを入れて切り替える
ccs-install --auto              origin/main が新しければ入れて切り替える
ccs-install --check [--force]   新しい版があるか見る(切り替えない)
ccs-install --list              入っている版
ccs-install --switch <版>       その版へ切り替える(巻き戻し)
ccs-install --where             いま何がどこに入っているか

更新 — 作業ツリーに触らずに取り出す

--auto は git show origin/main:bin/ccs でオブジェクトから直接読む。

これが「ghq は実装用、動作は別 path」を成立させている核心。 pull も branch の 切り替えも要らないので、他のセッションが使っている作業ツリーに一切手を出さない (fetch は ref を更新するだけで作業ツリーを触らない)。

手で make install したときは逆に、作業ツリーの bin/ccs を入れる ── 手元のビルドを 実機で試すための入口で、dirty なら版に -dirty が付く。並べて置かれるので、 試したあと 1 手で戻せる。

古いまま気づかないことを防ぐ

4 層。上ほど安く、下ほど確実。

層 何が起きるか
1. 見える ccs version がコミットまで答える。ccs doctor が PATH の指す先まで出す
2. 検知 インストーラが origin/main と突き合わせる(終了コード 10 / 11)
3. 自分で鳴る ccs hub up の鼓動に相乗りして、古ければ切り替える
4. 発生源 マージ後に make install(規律。漏れた分を 3 が拾う)

3 が要点 ── 新しいデーモンは作らない

ccs hub up は launchd / systemd から 5 分ごとに冪等に走っている。 検知はそこに相乗りする ── 既に在るものが気づくだけで、監視の仕組みは増えていない (design.md §2.1 の「自前のデーモンやポーリングを作らない」に反しない)。

ネットワークを使うのは CCS_UPDATE_INTERVAL(既定 1 時間)に 1 回だけ。

検知したら自動で切り替える。 無人で入れ替わる以上、次を満たしている。

  • 原子的に差し替える(rename)。走っている hub は自分が開いたファイルを持ち続ける
  • 切り替える前に、その版が version を答えられるか試す。 人が見ていないので、起動もできないものを掴ませると hub ごと止まる
  • 記録が残る ── install.log に「いつ / どの版から どの版へ / きっかけ」、 hub.log に updated。無人で変わる以上、記録が無いと「なぜ挙動が変わったか」を追えない
  • 1 手で戻せる ── 前の版は残る。巻き戻し方は切り替えのたびに出る
  • 止められる ── CCS_AUTO_UPDATE=off にすると、検知して報告するだけになる

切り替えを実行しているのは、切り替えられる当人(古い版)

launchd が起動した古い ccs が、自分を指している symlink を差し替える。 動作は安全(symlink の差し替えは開かれているファイルに影響しない)だが、 記録に自分の版を書くと「何から何へ」を取り違える。記録はインストーラが 「切り替え後の版」で書く。

何を根拠に「新しい」と言っているか

origin/main の HEAD。 tag ではない(tag は節目にしか打たないので遅れる)。

CI が緑であることは条件にしていない。 理由は 2 つ。launchd の経路から gh の認証を 要求することになるのが 1 つ、そして main は ruleset で required check が付いているので 赤いものはそもそもマージできない ── origin/main は構造上すでに CI 緑になる。

確認できないときは、そう言う

検知は fetch できて初めて意味を持つ。オフラインで ref を読めても、それが今のリモートと 同じとは限らない。そこで「最新です」と言うと、見ていないのに大丈夫だと言うことになる。

$ ccs doctor
更新
  状態:       **確認できていません**
  理由:       fetch できませんでした(オフライン?)。確認できていません

hub up の側では騒がない ── 5 分ごとに「分からない」と言われても人にできることは無く、 肝心の変化が埋もれる。見せるのは人が訊いたとき。

install 元のチェックアウトが消えたら

「分からない」と言って、切り替えない。

$ ccs doctor
更新
  状態:       **確認できていません**
  理由:       install 元のチェックアウトがありません: ~/ghq/github.com/ken-ty/ccs(消えた/移動した?)

  入れ直す:   チェックアウトで make install
  巻き戻しは、チェックアウトが無くてもできます。

代わりのリポジトリを勝手に探さない

実装中に一度踏んだ。控えが辿れないときに「インストーラ自身が居るリポジトリ」へ 落ちる作りにしていたので、install 元を消したあと ccs の作業ツリーから --check を 打つと、そちらの origin/main を「新しい版」として報告した。 検知の根拠が黙って入れ替わるのは、古さを見逃すより悪い。 いまは控えてある install 元だけを見て、消えていれば場所を名指しして止まる (test/unit/install.bats が回帰を見張る)。

巻き戻しはチェックアウトが無くてもできる。 インストーラの複製も過去の版も ~/.local/share/ccs/ に残っているため ── 複製を置いているのはまさにこのためで、 チェックアウトを要るのは入れ直すほうだけ。

設定

キー 既定 何を変えるか
CCS_INSTALL_ROOT ~/.local/share/ccs 版の置き場所
CCS_BIN_DIR ~/.local/bin symlink を置く場所(PATH が通っているところ)
CCS_AUTO_UPDATE on off にすると自動では切り替えず、検知と報告だけ
CCS_UPDATE_INTERVAL 3600 検知でネットワークを使う間隔(秒)
CCS_KEEP_VERSIONS 5 残す版の数(2 未満にはならない ── 巻き戻し先が消えては困る)

テスト

test/unit/install.bats が 28 件、test/integration/hub-update.bats が 11 件。 上流(bare リポジトリ)と、そこから引いたチェックアウトをテストごとに作って回す。

とくに次の 3 つは、わざと壊して落ちることを確かめてある(空振りしていないことの確認):

  • --auto が作業ツリーに触らない(別ブランチ・未コミットのままでも)
  • 起動できない版に切り替えない(smoke test を殺すと落ちる)
  • CCS_AUTO_UPDATE=off が効く(分岐を殺すと落ちる)
  • install 元が消えたときに別のリポジトリへすり替わらない(fallback を戻すと落ちる)