From c84c54090ba371130e8e8bb9fd8e7576b9e63132 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 12 Oct 2026 21:00:52 +0900 Subject: [PATCH 1/3] =?UTF-8?q?Docs:=20cli-argument-resolution.md=20?= =?UTF-8?q?=E3=82=92=E7=A2=BA=E5=AE=9A=E4=BB=95=E6=A7=98=E3=81=AB=E3=81=99?= =?UTF-8?q?=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) --- .../specifications/cli-argument-resolution.md | 2 +- issues/issue-455-design.md | 298 ------------------ issues/issue-455-requirements.md | 154 --------- 3 files changed, 1 insertion(+), 453 deletions(-) delete mode 100644 issues/issue-455-design.md delete mode 100644 issues/issue-455-requirements.md diff --git a/docs/specifications/cli-argument-resolution.md b/docs/specifications/cli-argument-resolution.md index 8967912f..bbfaf86a 100644 --- a/docs/specifications/cli-argument-resolution.md +++ b/docs/specifications/cli-argument-resolution.md @@ -765,4 +765,4 @@ zsh を必須にし、何のための値かが `ci.yml` から読めないため - 実装 PR: devbasex/devbase#301(#276・#226・#229・#245・#227) - 実装 PR: devbasex/devbase#428(#214。列挙の正本と一致テスト) - 実装 PR: devbasex/devbase#450(#435。補完の `--context`) -- 課題: devbasex/devbase#455(zsh の補完の実行テスト) +- 実装 PR: devbasex/devbase#460(#455。zsh の補完の実行テスト) diff --git a/issues/issue-455-design.md b/issues/issue-455-design.md deleted file mode 100644 index 24b687ad..00000000 --- a/issues/issue-455-design.md +++ /dev/null @@ -1,298 +0,0 @@ -# zsh の補完: 分岐の文字列がそろっていれば候補が出なくても検査を通る → zsh を起動して全道の実際の候補を比べ、手元と CI で退行を落とす(#455) - -## 目的 - -- **何が壊れているか**: zsh の補完(`etc/_devbase`)の検査は、道の分岐の本体に決まった文字列があるかしか見ない。#449 で `devbase up ` がプロジェクト名を出さなくなった退行は、一致テストをすべて通った -- **誰が困るか**: zsh を使う利用者が、検査を通った補完の退行を自分の端末で初めて踏む。補完の分岐を書き換えた開発者は、候補が出なくなっても気づけない -- **直すと何が成り立つか**: pytest が zsh を起動して `etc/_devbase` を補完として読み込み、`[name]` と `--context` の集合のすべての道で実際の候補を比べる。候補が出なくなると、打った行・期待・実際の候補を挙げて落ちる。CI(Linux)でも同じテストが走り、zsh が無ければ飛ばさずに落ちる - -## 適用範囲 - -- **働く範囲**: このリポジトリのテスト(`tests/cli/`)・CI の pytest のジョブ・確定仕様と開発参加ガイド。`etc/_devbase` と利用者の環境(rc・base イメージ)は変えない。プラグインのリポジトリには及ばない -- **プロジェクトごとに違うもの**: 無し(候補の比べ方はプロジェクトに依らない。テストのプロジェクト名は一時の root の `proj-a`・`proj-b`) -- **当たるモード**: `standard` - -## あるべき姿の根拠 - -| 根拠 | 種類 | 何を示すか | -| --- | --- | --- | -| #450 のリリース後テストの記録(https://github.com/devbasex/devbase/pull/450#issuecomment-6062408599): `zsh/zpty` で対話の zsh を起こし `compadd` を包む形が macOS の zsh 5.9 で動き、`54ed5bf^` では `devbase up ` の候補が `--context` だけになる | 実測 | 方法が動き、#449 の退行を拾えること | -| この設計で同じ形を走査の集合の全 122 行へ広げた試し(下の「実測の記録」): macOS の zsh 5.9 で 0.78 秒、全行が期待どおり。`54ed5bf^` では 27 行が落ちる | 実測 | 全道を回しても所要が 30 秒の上限に遠いこと。全道で回すと退行の広がりが道ごとに見えること | -| 同じ試しを Docker の `ubuntu:26.04`(aarch64、apt の zsh 5.9)で流し、macOS と同じ候補が出た。`54ed5bf^` でも同じ行が落ちた | 実測 | Linux の zsh でも同じ形が動くこと(要求の未決 2) | -| GitHub の `actions/runner-images` の `images/ubuntu/Ubuntu2404-Readme.md`(Image Version 20261004.327.1)に `zsh` の語が 1 つも無い(`grep -i zsh` が 0 行) | 外部の一次情報 | CI の ubuntu ランナーには zsh が入っておらず、走らせるには入れる手順が要ること | - -要求と受け入れ条件は #455 の本文にある(コピーは `issues/issue-455-requirements.md`)。この文書は「どう作るか」だけを扱う。 - -## ドメインモデル - -### コンテキスト - -| コンテキスト | 何の語が 1 つの意味に決まるか | -| --- | --- | -| コマンドの入口(`cli`) | 列挙の正本・写し・一致テスト・補完の例外・補完の実行テスト。`[name]` と `--context` を受け付ける道の集合 | -| 継続的インテグレーション(`ci`) | CI の pytest のジョブが用意する実行環境(zsh の有無) | - -`cli` が顧客、`ci` が供給者の関係(顧客 / 供給者)。補完の実行テストが「zsh が要る」ことを求め、`ci` の pytest のジョブがランナーへ zsh を入れて応える。`ci` は `cli` の語(道・候補)を知らない。 - -### 集約 - -| 集約 | 持ち主(書き換えてよいもの) | 根 | エンティティ | 値オブジェクト | -| --- | --- | --- | --- | --- | -| 補完の実行テスト(新規) | `tests/cli/test_zsh_completion_run.py` と `tests/cli/zsh_completion_harness.py` | 補完の要求の一覧(打つ行と期待の組を 1 か所で作る関数) | zsh のセッション(1 回のテストの実行で 1 つ) | 打った行・期待(含む語の集合か 0 件)・実際の候補の集合・欠けたもの(zsh / `zsh/zpty`) | -| 列挙の正本 | `lib/devbase/cli.py`(この変更では書き換えない) | `_create_parser()` の木 | サブコマンドの parser | 道・`[name]` の集合・`--context` の集合 | -| zsh の補完の写し | `etc/_devbase`(この変更では書き換えない) | `_devbase` | 道の分岐 | — | -| CI の pytest のジョブ | `.github/workflows/ci.yml` の `pytest` | ジョブ | 手順 | zsh を入れる手順・zsh を必須とする印(環境変数) | - -補完の実行テストは、道の集合を一致テスト(`tests/cli/test_name_context_consistency.py`)の走査の結果から読むだけで、列挙の正本と zsh の補完の写しを書き換えない。CI の pytest のジョブは補完の実行テストを名前で知らず、印の環境変数だけでつながる。 - -### 不変条件 - -| # | 集約 | 条件 | 破れたときの扱い | -| --- | --- | --- | --- | -| I1 | 補完の実行テスト | `[name]` の集合から補完の例外を引いたすべての道で、`devbase <道> ` の候補に `proj-a` と `proj-b` が入る | 落ちた行ごとに、打った行・期待・実際の候補を挙げて落ちる | -| I2 | 補完の実行テスト | `devbase login ` の候補に `1` と `2` が入る | 同上 | -| I3 | 補完の実行テスト | `--context` の集合のすべての道(`container` の道は `ct` で始めても)で、`devbase <道> -` の候補に `--context` が入る | 同上 | -| I4 | 補完の実行テスト | `[name]` の集合から補完の例外を引いた道のうち `--context` の集合にもある道で、`devbase <道> proj-a -` の候補に `--context` が入る | 同上 | -| I5 | 補完の実行テスト | `--context` の集合のすべての道(`ct` で始めても)で、`devbase <道> --context ` の候補が 0 件である | 同上(実際の候補を挙げる) | -| I6 | 補完の実行テスト | 1 回の補完の要求に 10 秒以内の応答が無ければ打ち切って落ち、テストの後に起動した zsh のプロセスが残らない | 打ち切った行と、それより後で流さなかった行を挙げて落ちる | -| I7 | 補完の実行テスト | zsh が無い・`zsh/zpty` を読み込めない環境では、zsh を必須とする印が無ければ飛ばし、印があれば落ちる。どちらも欠けたものの名前を `pytest tests/ -q` の既定の出力に出す。ほかのテストは止めない | — | -| I8 | 補完の実行テスト | 起動する zsh は利用者の rc を読まず、`DEVBASE_ROOT` は一時の root を指し、`HOME` も一時のディレクトリを指す | — | -| I9 | CI の pytest のジョブ | pytest を走らせる前に zsh を入れ、zsh を必須とする印を立てる | `tests/ci/` のテストが、欠けた手順か印を挙げて落ちる | - -### ドメインイベント - -| # | イベント | 発生元 | 受け手 | -| --- | --- | --- | --- | -| E1 | zsh と `zsh/zpty` を使えるかを確かめた | 補完の実行テスト(テストを走らせる前) | 補完の実行テスト(欠けていれば I7) | -| E2 | 一時の root にプロジェクトを置き、`DEVBASE_ROOT` を向けた | 補完の実行テストのフィクスチャ | zsh のセッション | -| E3 | zsh を起こし、`etc/_devbase` を補完として読み込んだ | zsh のセッション | 補完の実行テスト(読み込めなければ端末の出力を挙げて落ちる) | -| E4 | 行を打って補完を求めた | zsh のセッション | `etc/_devbase` の `_devbase` | -| E5 | 補完が候補を書き出した | 包んだ `compadd` | zsh のセッション(10 秒で来なければ I6) | -| E6 | 書き出した候補を期待と比べた | 補完の実行テスト | 開発者(手元の pytest か CI の出力) | -| E7 | zsh を終えた | 補完の実行テストのフィクスチャ(正常の後も失敗の後も) | — (プロセスが残らないこと。I6) | - -### 用語 - -| 用語 | 意味 | 用語集への反映 | -| --- | --- | --- | -| 補完の実行テスト | zsh を起動して補完のファイルを読み込み、行を打って補完が実際に出した候補(`compadd` が加えた語)を期待と比べるテスト。一致テストが内容を読んで比べるのに対し、候補そのものを見る | 追加(`cli`) | -| 一致テスト | 列挙の正本を走査して得た集合と写しを比べるテスト。zsh の写しは今までどおり内容を読んで比べる | 変えない | -| 補完の例外 | 補完がプロジェクト名を出さないと決めた道と理由の組。補完の実行テストも I1・I4 で同じ一覧を引く | 変えない | - -## 機能一覧 - -| # | 機能 | 誰が使うか | -| --- | --- | --- | -| F1 | zsh の補完が `[name]`・`--context` の集合のすべての道で出す候補を、zsh を起動して比べる | devbase の開発者(手元の pytest) | -| F2 | 候補が期待と違うと、打った行・期待・実際の候補を並べて落ちる | 同上 | -| F3 | zsh か `zsh/zpty` が無い手元では、欠けたものを出して飛ばす | 同上 | -| F4 | CI の pytest のジョブが zsh を入れて F1 を走らせ、zsh が無ければ落ちる | Pull Request を出す開発者とレビューする人 | - -## 構成要素 - -| 要素 | 責務 | 変え方 | -| --- | --- | --- | -| zsh の補完のハーネス `tests/cli/zsh_completion_harness.py`(新規) | 外側の zsh から `zsh/zpty` で対話の zsh(`zsh -f -i`)を 1 つ起こし、`etc/` を `fpath` に入れて `compinit` する。`compadd` を包んで加えた語を行ごとのファイルへ書き、受け取った行を順に打って補完を求める。行ごとの候補・時間切れ・端末の出力を返す。zsh と `zsh/zpty` の有無を確かめる関数も持つ | 新しく作る。`tests/containers/tmux_harness.py` と同じく、テストから使う部品として置く | -| 補完の実行テスト `tests/cli/test_zsh_completion_run.py`(新規) | 補完の要求の一覧を 1 か所で作り(I1〜I5)、ハーネスで 1 回流して、不変条件ごとのテストで比べる。欠けた環境の扱い(I7)を持つ | 新しく作る。道の集合と補完の例外は一致テストのモジュールの `NAME_PATHS`・`CONTEXT_PATHS`・`COMPLETION_NAME_EXCEPTIONS` を読む(書き写さない) | -| `tests/cli/test_completion.py` | 補完の個々の振る舞いを固定する | モジュールの説明の「zsh 補完はランナー非依存にするため静的内容チェックのみ」を、候補の確認は補完の実行テストが持つ旨に直す。テストは変えない | -| `.github/workflows/ci.yml` の `pytest` のジョブ | pytest を走らせる | `uv sync` の前に zsh を apt で入れて版をログへ出す手順を足し、ジョブの `env` に zsh を必須とする印 `DEVBASE_TEST_REQUIRE_ZSH: "1"` を置く | -| `tests/ci/test_ci_workflow.py` | `ci.yml` の形を固定する | I9 を見るテストを足す | -| `docs/specifications/cli-argument-resolution.md` | 確定仕様 | 下の「確定仕様の改め」 | -| `docs/specifications/ci-checks.md` | CI の確定仕様 | 「検査ジョブ」の表の `pytest` の行に、zsh を入れる手順と印を足す。「テスト観点」に I9 の行を足す | -| `docs/developer/contributing.md` | 開発参加ガイド | 「CI が実行するもの」に、pytest の前に zsh を入れること、手元で zsh が無ければ補完の実行テストが飛ぶことを足す | -| `docs/glossary/glossary.json`・`docs/glossary.md` | 用語集 | 「補完の実行テスト」を `cli` に足し、`glossary.py render` で作り直す | - -`etc/_devbase`・`etc/devbase-completion.bash`・`lib/`・`bin/`・`containers/` は変えない。`CHANGELOG.md` は書かない(テストと開発者向けの文書だけの変更)。 - -```mermaid -graph LR - subgraph 正本と写し - P[列挙の正本
cli.py] - Z[zsh の補完
etc/_devbase] - end - subgraph テスト - T[一致テスト] - R[補完の実行テスト] - H[zsh の補完の
ハーネス] - end - subgraph CI - J[pytest のジョブ
zsh と印] - K[ci.yml の形のテスト] - end - T -->|走査| P - T -->|内容を読む| Z - R -->|道の集合を読む| T - R -->|行を渡す| H - H -->|起動して補完| Z - J -->|印の環境変数| R - K -->|形を読む| J -``` - -文書(確定仕様・開発参加ガイド・用語集)と `tests/cli/test_completion.py` の説明の直しは、テストの実行時の関係を持たないため図に含めない。 - -### 確定仕様の改め - -`docs/specifications/cli-argument-resolution.md` から、zsh を起動するテストが無いことを前提にした 3 か所を消し、補完の実行テストを書く。 - -| 場所 | 今 | 改めた後 | -| --- | --- | --- | -| 「一致テストが比べる写し」の表の `etc/_devbase` の行 | zsh を起動せず内容を読む | そのまま残す(前提 1)。表の下に「zsh の候補そのものは補完の実行テストが見る」の 1 文と、次の小節への参照を足す | -| 新しい小節「zsh の補完の実行テスト」(`#### 補完が --context を出す位置` の後) | 無し | 何を確かめるか(I1〜I5 の行の作り方と期待)、どの環境で走るか(手元の macOS は zsh があれば走り、無ければ飛ぶ。CI は zsh を入れ、無ければ落ちる)、時間切れ(10 秒)、利用者の rc を通した見た目はリリース後テストで見ること | -| 「テスト観点」の最後の行(「zsh を起動するテストが無いため手元の zsh で確かめる」) | 手元の zsh で確かめる | 補完の実行テストが確かめる I1〜I7 の行に置き換える(`tests/cli/test_zsh_completion_run.py`) | -| 決定 9 の最後の文(「zsh を起動する形は、CI のランナーに zsh があることを前提にするため採らない」) | 起動する形を採らない | 「候補そのものは補完の実行テストが zsh を起動して見る。一致テストが内容を読むのは、zsh の無い環境でも分岐の記述を確かめるためである」に改める | -| 決定 13 の「zsh を起動する検査が無く番号の誤りを拾えないため」 | 検査が無いことを理由にする | 「番号を分岐ごとに数える形は、数える場所が分岐の数だけ増えるため」に改める。番号の誤りは補完の実行テストが道ごとに拾うことを添える | -| 「決定の記録」の冒頭の文と「関連リンク」 | #214・#435 の決定 | 補完の実行テスト(#455)の決定として、この文書の決定 1〜4 を決定 14〜17 に写して足し、実装 PR のリンクを足す | - -## 処理の流れ - -補完の実行テストの 1 回の実行(`pytest` のワーカーの 1 つで、要求の一覧のすべての行を 1 つの zsh のセッションで流す)。 - -```mermaid -sequenceDiagram - participant T as 補完の実行テスト - participant H as ハーネス(外側の zsh) - participant S as 対話の zsh(zpty) - participant C as etc/_devbase - T->>T: zsh と zsh/zpty を確かめる(E1) - alt 欠けている - T-->>T: 印が無ければ飛ばす・あれば落ちる(I7) - end - T->>T: 一時の root に proj-a・proj-b(E2) - T->>H: 要求の一覧の行 - H->>S: zsh -f -i を起こし compinit(E3) - loop 行ごと - H->>S: 行と Tab(E4) - S->>C: _devbase - C->>S: compadd(包んだ関数が語をファイルへ)(E5) - alt 10 秒で応答が無い - H-->>T: 時間切れの行と流さなかった行 - end - H->>S: 行を消す - end - H->>S: 終える(E7) - H-->>T: 行ごとの候補・端末の出力 - T->>T: 不変条件ごとに期待と比べる(E6) -``` - -- **E3 で読み込めない**(`etc/_devbase` の構文の誤りなど)とき、包んだ `compadd` はどの行でも呼ばれない(実測の記録)。テストは比べる前に、どの行でも包んだ `compadd` が 1 回も呼ばれていなければ、端末の出力の末尾を挙げて落ちる -- **時間切れ**のとき、ハーネスはその行で流すのをやめ、外側の zsh とその子をプロセスのグループごと終える。時間切れの行とそれより後の行は、候補ではなく「時間切れ」「流していない」として返す -- **E7 は正常の後も失敗の後も通る。** フィクスチャの後始末で、起動したプロセスのグループが残っていれば強制的に終える - -## 非機能の実現方式 - -| 大項目 | 要求の条件 | 実現方式 | 確かめ方 | -| --- | --- | --- | --- | -| 性能・拡張性 | 足したテストの全体で、手元(macOS)の `pytest tests/cli -q` の所要の増分が 30 秒以内。CI の pytest のジョブの `timeout-minutes: 15` を越えない | すべての行を 1 つの zsh のセッションで流し、`compinit` を 1 回にする。テストを 1 つの `xdist_group` に寄せ、セッションを 1 ワーカーで 1 回だけ起こす。時間切れを確かめるテストは短い待ち時間をハーネスへ渡す | 手元で `pytest tests/cli -q` を足す前と後で測り、実装 PR に載せる。CI のジョブの所要を実装 PR で見る | -| 運用・保守性 | 確かめる道と期待する候補を、テストの中の 1 か所の一覧で持つ。道を足すときはその一覧に 1 行足せば済む | 要求の一覧を作る関数を 1 つ置く。走査の集合から作る行と、走査に無い行(`login` の `1 2`)を同じ関数で並べる。走査の集合に入る道は、サブコマンドを足せば一覧を直さずに入る | テストのファイルを読んで、行と期待を作る場所が 1 つであることをレビューで見る | -| システム環境 | macOS の zsh 5.9 で走る。CI(ubuntu ランナー)で走るかは未決 1 による | CI でも走らせる(決定 3)。ハーネスは macOS と Linux で振る舞いが分かれるオプションを使わない | 手元の macOS の実行と、実装 PR の CI の Pytest(3.10・3.13)の両方で、補完の実行テストが飛ばされずに通ったことをログで見る | - -## 決定の記録 - -### 決定 1: 利用者の端末と同じ経路で候補を取るため、zpty で対話の zsh を起こし `compadd` を包む - -`compadd` に渡った語を取るには、行を打って補完を求める経路(zle の `complete-word`)を通す必要がある。`zsh/zpty` は zsh 自身のモジュールで、macOS と Linux の zsh 5.9 で同じスクリプトが同じ候補を出した(下の「実測の記録」)。包んだ `compadd` は `builtin compadd -O` で加わる語を受け取ってファイルへ書き、元の `compadd` も呼ぶため、補完の振る舞いを変えない。 - -Python の `pty` で zsh を操作する形は、端末の制御の文字を読み分ける処理をテストの側に持つことになり、zpty より部品が増えるため採らない。`script` コマンドで端末を作る形は、macOS と Linux で引数の形が違うため採らない。 - -根拠: Mission(MVV 版 1) - -### 決定 2: 分岐ごとの誤りを道ごとに見つけるため、走査の集合のすべての道を zsh で回す - -`_devbase_arguments` へ渡す深さの誤りのように、退行は分岐ごとに起き得る。`54ed5bf^` では、代表の 10 行が落とすのは 3 行だけで、`container`・`ct`・`env token` の直後の `-` で `--context` が出ない 13 行は、全道を回したときにだけ見えた(実測の記録)。全道(実測で 122 行)を回しても macOS で 0.78 秒で、30 秒の上限に遠い。行は一致テストの走査の集合(`NAME_PATHS`・`CONTEXT_PATHS`・`COMPLETION_NAME_EXCEPTIONS`)から作り、期待の集合を書き写さない(確定仕様の決定 2 と同じ理由)。走査に無い `login` の `1 2` だけを名指しで足す。 - -代表の道(受け入れ条件 2〜5 の 10 行)に留める形は、時間の節約が 1 秒に満たず、拾える退行が狭まるため採らない。 - -根拠: Mission(MVV 版 1) - -### 決定 3: Pull Request の検査で退行を落とすため、CI の pytest のジョブに zsh を入れ、CI では zsh が無ければ落とす - -#449 の退行はマージ前の検査をすり抜けた。手元だけで走らせると、zsh の無い環境や手元で流し忘れた Pull Request で同じことが起きる。ubuntu ランナーには zsh が無い(あるべき姿の根拠)ため、pytest のジョブの手順で apt から入れて `zsh --version` をログへ出す。ジョブの `env` に印 `DEVBASE_TEST_REQUIRE_ZSH=1` を置き、印があるのに zsh か `zsh/zpty` が欠けていればテストを落とす。印が無い手元では飛ばし、欠けたものを既定の出力(`-q` でも出る警告の要約)に出す。手順と印が消えていないことは `tests/ci/` が見る。 - -手元だけで走らせて CI では飛ばす形は、検査をすり抜ける経路が残るため採らない。GitHub が立てる `CI` の環境変数で落とすかを決める形は、手元で `CI` を立てた開発者まで zsh を必須にし、何のための値かが `ci.yml` から読めないため採らない。zsh の版を公式の配布物で固定する形(ShellCheck の扱い)は、zsh の公式の配布がソースで建てる手順が増える一方、比べるのは候補の有無で、ShellCheck の指摘の数のように版で揺れるものではないため採らない。 - -根拠: Mission / Vision(MVV 版 1) - -### 決定 4: zsh が無い環境で静的な検査まで飛ばないよう、補完の実行テストを別のファイルに置く - -zsh が無いときの飛ばしは、ファイルの単位で掛けるのが漏れにくい。一致テストのファイルに足すと、同じファイルの静的な zsh の検査(zsh が無い環境で唯一の検査。前提 1)と飛ばす範囲の境目を関数ごとに持つことになる。ハーネスは `tests/containers/tmux_harness.py` と同じく部品のファイルに分け、テストのファイルには要求の一覧と比べ方だけを置く。 - -一致テストのファイルに足す形は、上の理由で採らない。 - -根拠: 根拠なし(MVV 版 1) - -## テスト設計 - -| 受け入れ条件・不変条件 | どの振る舞いで縛るか | どう壊したら落ちるべきか | -| --- | --- | --- | -| 受け入れ条件 1 | 補完の実行テストが zsh を起動し、`compadd` が加えた語を候補として比べる | `etc/_devbase` の `_devbase_project_names` を空の関数にすると I1 が落ちる(内容を読む一致テストは通る) | -| 受け入れ条件 2・I1 | `[name]` の集合から補完の例外を引いた 14 の道で `devbase <道> ` の候補に `proj-a`・`proj-b` | `_devbase_arguments` が `words` を詰めないように戻す(`54ed5bf^` の形)と、14 の道すべてが落ち、`devbase up ` の実際の候補が `--context` だけと出る | -| 受け入れ条件 2・I2 | `devbase login ` の候補に `1`・`2` | `login` の分岐の位置引数の番号をずらすと落ちる | -| 受け入れ条件 3・I3 | `--context` の集合の 35 の道と `ct` で始めた 12 の道で `devbase <道> -` の候補に `--context` | 道 1 つの分岐から `--context` の指定を外すと、その道(`ct` の道を含む)だけを挙げて落ちる | -| 受け入れ条件 4・I4 | `[name]` と `--context` の両方を取る道で `devbase <道> proj-a -` の候補に `--context` | `up` の分岐の `--context` の指定を外すと、`devbase up proj-a -` を挙げて落ちる | -| 受け入れ条件 5・I5 | `--context` の集合のすべての道で `devbase <道> --context ` の候補が 0 件 | `--context` の指定に `:_devbase_project_names` を足すと、その道を挙げ、実際の候補に `proj-a`・`proj-b` が出て落ちる | -| 受け入れ条件 6 | 落ちたときの出力に、行ごとに打った行・期待・実際の候補が並ぶ | マージ前の手動確認: `etc/_devbase` を `git show 54ed5bf^:etc/_devbase` に一時に差し替えて流し、I1 が落ちて `devbase up ` の実際の候補が `--context` だけと出ることを実装 PR に貼る | -| 受け入れ条件 7・I7 | 有無の確かめが「zsh が無い」「`zsh/zpty` が無い」を返したとき、印が無ければ欠けたものの名前を理由に飛ばし、印があれば同じ名前を挙げて落ちる。ほかのテストは走る | 印を読まずに常に飛ばすように壊すと、印がある側のテストが落ちる。欠けたものの名前を理由から落とすと、名前を見る行が落ちる。マージ前の手動確認: zsh を `PATH` から外して流した `-q` の出力を実装 PR に貼る | -| 受け入れ条件 8・I6 | 応答しない補完(`fpath` の先頭に置いた、眠り続ける `_devbase`)で、ハーネスが待ち時間で打ち切って時間切れを返し、終わった後に起動した zsh のプロセスが残らない。待ち時間の既定が 10 秒 | 時間切れの打ち切りを外すと、テストが待ち続けて pytest の外側の時間で落ちる。後始末の強制の終了を外すと、プロセスが残ることを確かめる行が落ちる | -| 受け入れ条件 9 | 確定仕様の 3 か所が改まり、補完の実行テストの小節がある | 文書の変更。承認ゲート 2 で人が見る | -| 受け入れ条件 10 | 既存の `test_name_context_consistency.py`・`test_completion.py` のテストが残り、全体のテストが手元と CI(3.10・3.13)で通る | 既存のテスト関数の数が減ると、実装 PR の差分で見える。全体のテストの結果を実装 PR に載せる | -| 受け入れ条件 11・I8 | 一時の `HOME` に印を書き出す `.zshrc` を置いて流しても、印が書かれない。I1 の候補が一時の root の `proj-a`・`proj-b` だけである | zsh を起こすときの `-f` を外すと、印が書かれて落ちる。`HOME` か `DEVBASE_ROOT` を継いだ値のまま渡すと、ハーネスに渡した環境を見る行が落ちる | -| I9 | CI の pytest のジョブに zsh を入れる手順があり、印 `DEVBASE_TEST_REQUIRE_ZSH` が `"1"` である | 手順か印を `ci.yml` から消すと `tests/ci/` のテストが落ちる | -| E3(読み込めない) | どの行でも包んだ `compadd` が呼ばれていなければ、端末の出力の末尾を挙げて落ちる | 一時の `fpath` に構文の誤りを入れた `_devbase` を置くと、「補完を読み込めない」と端末の出力を挙げて落ちる(比べる前に止まり、行ごとの違いを並べない) | - -## 設計の結果 - -| 課題 | 扱い | 取り込み先 | 触るファイル | -| --- | --- | --- | --- | -| #455 | 実装する | — | `tests/cli/zsh_completion_harness.py`、`tests/cli/test_zsh_completion_run.py`、`tests/cli/test_completion.py`、`tests/ci/test_ci_workflow.py`、`.github/workflows/ci.yml`、`docs/specifications/cli-argument-resolution.md`、`docs/specifications/ci-checks.md`、`docs/developer/contributing.md`、`docs/glossary/glossary.json`、`docs/glossary.md` | - -## 未確認のまま残ること - -| 項目 | 内容 | -| --- | --- | -| CI のランナー(ubuntu-24.04・x86_64)の zsh | Linux での実測は Docker の `ubuntu:26.04`(aarch64)だけである。24.04 の apt の zsh の版と、zpty の振る舞いが同じかは、実装 PR の最初の CI で確かめる | -| CI で zsh を入れる時間 | apt の取得の時間は測っていない。実装 PR の CI のジョブの所要で見る。15 分の上限には余裕がある見込み | -| `pytest-xdist` の下での起動 | 試しは pytest の外で流した。1 つの `xdist_group` に寄せたときの所要と、ワーカーの終了でプロセスが残らないことは実装で走らせて確かめる | -| 利用者の rc を通した見た目 | 対象外(要求の前提 3)。リリース後テストで利用者の端末で確かめる | - -## 実測の記録 - -決定 1〜3 の根拠に使った試しである。2026-10-12 に、macOS の zsh 5.9(arm64)と、Docker の `ubuntu:26.04` に apt で入れた zsh 5.9(aarch64)で流した。一時の root に `projects/proj-a`・`projects/proj-b` を置き、`env -i` で `PATH`・`HOME`(一時)・`TERM=xterm`・`DEVBASE_ROOT`(一時)だけを渡した。引数は「行ごとの出力の置き場・`fpath` に入れる場所・打つ行…」である。 - -```zsh -zmodload zsh/zpty || { print -r -- "zsh/zpty を読み込めない" >&2; exit 3; } -outdir=$1 fp=$2; shift 2 -zpty z zsh -f -i -local buf; integer k=0 -cmd() { (( k++ )); zpty -w z "$1; print __S\"\"${k}__"; zpty -r z buf "*__S${k}__*"; zpty -r z buf '*

*'; } -cmd "PS1='<''P> '; unsetopt auto_list list_ambiguous beep" -cmd "fpath=($fp \$fpath); autoload -Uz compinit; compinit -u -D -d /dev/null" -cmd "compadd() { local -a __m; builtin compadd -O __m \"\$@\"; print -rl -- \$__m >> \$PROBE_OUT; builtin compadd \"\$@\"; }" -cmd "_probe() { zle complete-word; print -r -- __DONE__ >> \$PROBE_OUT; }; zle -N _probe; bindkey '^I' _probe" -integer n=0 -for line in "$@"; do - (( n++ )) - : > $outdir/$n - cmd "PROBE_OUT=$outdir/$n" - zpty -w -n z "$line"$'\t' - integer i; for ((i=0;i<200;i++)); do grep -q __DONE__ $outdir/$n && break; sleep 0.05; done - zpty -w -n z $'\x15' - cmd ":" -done -zpty -d z -``` - -| 試し | 結果 | -| --- | --- | -| 受け入れ条件 2〜5 の 10 行、今の `etc/_devbase`(macOS) | 0.44 秒。`devbase up `・`devbase project up ` は `proj-a proj-b`、`devbase login ` は `1 2`、`-` で終わる 5 行は `--context` を含む(`env exec -` は `-` も出る)、`--context ` で終わる 2 行は 0 件 | -| 同じ 10 行、`54ed5bf^` の `etc/_devbase`(macOS) | `devbase up `・`devbase project up `・`devbase login ` の候補が `--context` だけ。ほかの 7 行は今と同じ | -| 同じ 10 行、今と `54ed5bf^`(Linux) | macOS と同じ候補 | -| 走査の集合から作った 122 行(`[name]` 14・`--context` の道の直後 47・名前の後 14・`--context` の値 47)、今の `etc/_devbase`(macOS) | 0.78 秒。すべて期待どおり | -| 同じ 122 行、`54ed5bf^`(macOS) | 0.84 秒。27 行が落ちる。`[name]` の 14 行すべて(`devbase up ` などの候補が `--context` と旗だけ)と、`container`・`ct` の 12 の道と `env token` の道の直後の `-` の 13 行(候補が 0 件) | - -試しの途中で分かったこと(実装の手がかり。上のスクリプトはこれを踏まえた形): - -- 包んだ `compadd` が加える語が無いとき `print -rl --` は空の行を 1 つ書く。候補の集合からは空の文字列を除いて数える -- `TERM=dumb` では zle が動かず、候補が書かれなかった。`TERM=xterm` で動いた -- 前のコマンドの後のプロンプトを待たずに行と Tab を送ると、候補が書かれずに待ちが尽きた。コマンドごとに印とプロンプトの両方を待つと 122 行が通った -- `etc/_devbase` の末尾に構文の誤りを足すと、`devbase up `・`devbase up -` のどちらでも包んだ `compadd` が 1 回も呼ばれなかった(ファイルの補完などへ落ちて別の候補が出ることは無かった) -- `auto_list` が有効なままだと、曖昧な候補の一覧を描き直したプロンプトが次の待ちの印と取り違えられ、2 行目の後で止まった。`unsetopt auto_list list_ambiguous` で止まらなくなった diff --git a/issues/issue-455-requirements.md b/issues/issue-455-requirements.md deleted file mode 100644 index 7e6abdd5..00000000 --- a/issues/issue-455-requirements.md +++ /dev/null @@ -1,154 +0,0 @@ -# #455: zsh の補完の実際の候補を確かめるテストが無く、分岐の文字列はそろっていても候補が出ない退行を拾えない - -正は課題の本文(#455)で、この文書はその写しである。`spec-copy.py write` で作り直す。手で直さない。 - -## 依頼(原文) - -> ## 何を見つけたか -> -> zsh の補完が実際に出す候補を確かめるテストが無い。`tests/cli/test_name_context_consistency.py` と `tests/cli/test_completion.py` は `etc/_devbase` を起動せずに内容を読む(道の分岐の本体に `_devbase_project_names` や `'--context[...]:context:'` の文字列があるか)。このため、文字列はそろっていても候補が出ない退行を拾えない。 -> -> 実例: #449 で zsh の分岐を `_arguments` へ揃えたとき、`'1:name:_devbase_project_names'` の `1` がサブコマンドの語自身を指し、`devbase up ` がプロジェクト名を出さなくなった。一致テストはすべて通り、#450 のクロスレビューが major として指摘し、`54ed5bf` で直した。 -> -> スプリント m10b のリリース後テストで、`zsh/zpty` で対話の zsh を起こし `compadd` を包んで候補を書き出す方法が macOS の zsh 5.9 で動いた(#435 の設計の時点では zpty で端末の入出力を取れず断念していた)。この方法で `54ed5bf^` の `etc/_devbase` を読むと `devbase up ` の候補が `--context` だけになり、退行を機械で拾えることを確かめた。記録: https://github.com/devbasex/devbase/pull/450#issuecomment-6062408599 -> -> ## どこで見つけたか -> -> スプリント m10b の振り返りで、#450 のレビューの major(zsh の位置引数の退行)がマージ前の検査をすり抜けた理由を見たとき。 -> -> ## なぜこの変更の範囲外なのか -> -> #435 の要求の前提 7 は、一致テストの zsh の比べ方を「zsh を起動せずに内容を読む」と決めている(CI のランナーに zsh があることを前提にしないため。確定仕様の決定 9)。zsh を起動するテストを足すことは #435・#444 の受け入れ条件に含まれない。根拠: Mission(MVV 版 1) -> -> ## 直さないと何が起きるか -> -> zsh の補完の分岐を書き換えるたびに、候補が出なくなる退行が検査を通り、利用者の端末で初めて見つかる。CI(Linux)の ubuntu ランナーに zsh を入れるか、`skipif` で手元だけで走らせるかは設計で決める。 -> -> ## 由来 -> -> PR #450 - -(書き手: 起票者(スプリント m10b の振り返りで課題を書いた AI)、日付: 2026-10-08。利用者の指示の原文は無い。利用者はこの課題をマイルストーン「04 CLI 引数と文書」の順位 1 に置いた(2026-10-11、マイルストーンの説明)が、受け入れの条件は書いていない) - -## 起票者の推定 - -題・本文の条件はすべて起票者の書いたもので、利用者の指示の原文に無い。承認ゲート 1 で利用者が確かめるまで、利用者の決定として扱わない。 - -| # | 条件 | 推した出所 | この要求での扱い | -| --- | --- | --- | --- | -| S1 | 内容を読むだけの検査では、文字列がそろっていても候補が出ない退行を拾えない | #450 のクロスレビューの major(`54ed5bf` で直した) | 目的 | -| S2 | zsh を実際に起動して出る候補を確かめるテストを足す | 本文の「何を見つけたか」 | 受け入れ条件 1〜5 | -| S3 | 確かめる方法は `zsh/zpty` で対話の zsh を起こし、`compadd` を包んで候補を書き出す形 | #450 のリリース後テストの記録(macOS の zsh 5.9 で動いた) | 前提 2。方法は設計で決め、受け入れ条件は方法に依らない形で書く | -| S4 | `54ed5bf^` の `etc/_devbase` で退行を拾えること | 同じ記録 | 受け入れ条件 6 | -| S5 | CI のランナーに zsh を入れるか、`skipif` で手元だけで走らせるかは設計で決める | 本文の「直さないと何が起きるか」 | 未決 1(設計で決める)。どちらでも満たす条件を受け入れ条件 7・8 に書く | -| S6 | 確定仕様の決定 9(zsh を起動する形は採らない)を見直す | 本文の「なぜこの変更の範囲外なのか」 | 受け入れ条件 9 | - -## 目的 - -- zsh の補完(`etc/_devbase`)を書き換えたとき、候補が出なくなる退行を、利用者の端末に届く前に検査で落とせるようにする -- 今は「分岐の本体に決まった文字列があるか」しか見ておらず、#449 の退行(`devbase up ` がプロジェクト名を出さない)は一致テストをすべて通った - -## 前提 - -- 前提 1: 既存の静的な検査(`tests/cli/test_name_context_consistency.py` の zsh の比べ方、`tests/cli/test_completion.py` の zsh の内容の検査)は消さずに残す。zsh を起動できない環境で、それが唯一の zsh の検査になるため -- 前提 2: zsh を起動して候補を取る方法は、`zsh/zpty` で対話の zsh を起こし `compadd` を包んで候補を書き出す形が macOS の zsh 5.9 で動くことを #450 のリリース後テストで確かめてある。Linux の zsh で同じ形が動くかはまだ確かめておらず、設計で確かめる -- 前提 3: テストが読む zsh の設定は `zsh -f` と同じく利用者の rc を読まない形とする。利用者の rc(oh-my-zsh などの zstyle・補完の表示)を通した確認は、引き続きリリース後テストで利用者の端末で行う -- 前提 4: テストのプロジェクト名は一時の `DEVBASE_ROOT`(`projects/proj-a`・`projects/proj-b` のような中立の名前だけを持つ)から出す。実の `DEVBASE_ROOT` と `projects/*` に触れない -- 前提 5: 確かめる道は、#450 のリリース後テストで確かめた道を基にした代表の道とする(下の受け入れ条件 2〜5)。`--context` の集合や `[name]` の集合のすべての道を zsh で起動して回すかは設計で決める(回すと時間がかかるため) -- 前提 6: テストだけの変更であり、利用者に見える振る舞いは変えない。`CHANGELOG.md` は書かない(`AGENTS.md` の「Pull Request」) - -## 対象範囲 - -含む: -- zsh を起動して `etc/_devbase` を補完として読み込み、打った行に対して実際に出る候補を集めて比べる pytest のテスト -- zsh が無い・`zsh/zpty` を読めない環境での扱い(受け入れ条件 7) -- CI でこのテストを走らせるための手当て(ランナーへ zsh を入れるなど。設計で走らせると決めた場合だけ) -- 確定仕様 `docs/specifications/cli-argument-resolution.md` の、zsh を起動する検査が無いことを前提にした記述(決定 9・決定 13・検証の節の「手元の zsh で確かめる」の行)の改め - -含まない: -- `etc/_devbase`・`etc/devbase-completion.bash` の振る舞いの変更(テストが退行を見つけた場合は別の課題にする) -- bash の補完のテスト(既に bash を実行して `COMPREPLY` を読む形で確かめている) -- 利用者の rc・プラグイン管理・zstyle を通した見た目の確認 -- 一致テストが補完の `--context` の余分を比べないこと(#453)、`env` の補完の一覧に `token` と `backend` が無いこと(#454) - -## ドメインイベント - -| # | イベント | 引き金 | 失敗したとき | 順序の前提 | -| --- | --- | --- | --- | --- | -| E1 | zsh と `zsh/zpty` を使えるかを確かめた | pytest がテストを集めた・走らせた | 使えない → 受け入れ条件 7 の扱い(飛ばす、または CI では落とす。未決 1) | — | -| E2 | 一時の root にプロジェクトを置き、`DEVBASE_ROOT` を向けた | テストの準備 | 作れない → テストの失敗 | — | -| E3 | zsh を起こし、`etc/_devbase` を補完として読み込んだ | テストの本体 | 読み込めない(構文の誤りなど)→ 失敗し、zsh の出したエラーを出力に挙げる | E1・E2 | -| E4 | 行を打って補完を求めた | テストの本体 | — | E3 | -| E5 | 補完が候補を書き出した | E4 | 決めた時間内に応答が無い → 打ち切って失敗する(受け入れ条件 8) | E4 | -| E6 | 書き出した候補を期待と比べた | E5 | 違う → 失敗し、打った行・期待した候補・実際の候補を出力に挙げる | E5 | -| E7 | zsh を終えた | E6 の後、または途中の失敗 | 終わらない → 強制的に終える。テストの後に zsh のプロセスを残さない(受け入れ条件 8) | E3 | - -## 用語 - -| 用語 | 意味 | -| --- | --- | -| 一致テスト | 用語集のとおり | -| 補完の例外 | 用語集のとおり | - -## 受け入れ条件 - -- [ ] 受け入れ条件 1: zsh を起動して `etc/_devbase` を補完として読み込み、打った行に対して補完が出した候補(`compadd` に渡った語)を集めて期待と比べるテストが `tests/` にある。ファイルの内容を読むだけの検査ではない -- [ ] 受け入れ条件 2: 一時の root に `proj-a`・`proj-b` を置いたとき、`devbase up `・`devbase project up ` の後で補完を求めると、候補に `proj-a` と `proj-b` が出ることをテストが確かめる。`devbase login ` では `1` と `2` が出ることを確かめる -- [ ] 受け入れ条件 3: `devbase up -`・`devbase env exec -`・`devbase ct profile up -`・`devbase project post-start -` の後で補完を求めると、候補に `--context` が出ることをテストが確かめる -- [ ] 受け入れ条件 4: `devbase up proj-a -` の後で補完を求めると、候補に `--context` が出ることをテストが確かめる -- [ ] 受け入れ条件 5: `devbase up --context `・`devbase env exec --context ` の後で補完を求めると、候補が 0 件であることをテストが確かめる -- [ ] 受け入れ条件 6: テストが落ちたとき、出力に「打った行・期待した候補・実際の候補」が並ぶ。`etc/_devbase` を `54ed5bf^`(#450 の修正前)の内容に差し替えて流すと、受け入れ条件 2 のテストが落ち、`devbase up ` の実際の候補が `--context` だけであることが出力に出る -- [ ] 受け入れ条件 7: zsh が無い環境、または `zsh/zpty` を読み込めない環境では、テストは他のテストを止めずに終わり、何が欠けていたか(zsh が無い/`zsh/zpty` が無い)を pytest の出力に残す。この環境で飛ばすか落とすかは未決 1 の決定に従う -- [ ] 受け入れ条件 8: 補完が応答しないとき、テストは決めた時間(1 回の補完の要求につき 10 秒)で打ち切って失敗し、テストが終わった後に起動した zsh のプロセスが残らない -- [ ] 受け入れ条件 9: 確定仕様 `docs/specifications/cli-argument-resolution.md` から、「zsh を起動するテストが無い」ことを前提にした記述(決定 9 の「zsh を起動する形は採らない」、決定 13 の「zsh を起動する検査が無く」、検証の節の「手元の zsh で確かめる」の行)が無くなり、新しいテストが何を確かめ、どの環境で走るかが書かれている -- [ ] 受け入れ条件 10: 退行しない: 既存の `tests/cli/test_name_context_consistency.py`・`tests/cli/test_completion.py` の検査が残り、`env -u DEVBASE_ROOT uv run --locked pytest tests/ -q` が手元(macOS)と CI(Linux の Python 3.10・3.13)の両方で通る -- [ ] 受け入れ条件 11: 退行しない: テストは利用者の rc(`~/.zshrc` など)と実の `DEVBASE_ROOT`・`projects/*` を読まず、書かない - -## 非機能の条件 - -| 大項目 | 条件 | -| --- | --- | -| 性能・拡張性 | 足したテストの全体で、手元(macOS)の `pytest tests/cli -q` の所要の増分が 30 秒以内。CI の pytest のジョブの `timeout-minutes: 15` を越えない | -| 運用・保守性 | 確かめる道と期待する候補を、テストの中の 1 か所の一覧で持つ。道を足すときはその一覧に 1 行足せば済む | -| システム環境 | macOS の zsh 5.9 で走る。CI(ubuntu ランナー)で走るかは未決 1 による | - -## 影響 - -| 対象 | 影響 | -| --- | --- | -| 公開インタフェース | 変わらない | -| データ | 無し | -| 既存の振る舞い | 変わらない(テストと確定仕様と、設計で決めた場合の CI の設定だけ) | - -## 検証手段 - -| 項目 | 手段 | -| --- | --- | -| テスト | `env -u DEVBASE_ROOT uv run --locked pytest tests/ -q`(手元)、CI の Pytest | -| 静的解析・型検査 | `uvx ruff check --select=E9,F63,F7,F82 lib`、`python3 .github/scripts/proper_term_check.py` | -| 手動確認(マージ前) | 受け入れ条件 6: `etc/_devbase` を `git show 54ed5bf^:etc/_devbase` の内容に一時に差し替えてテストを流し、落ちることと出力を確かめて戻す。結果を実装の PR に貼る | -| 手動確認(マージ前) | 受け入れ条件 7: zsh を `PATH` から外した環境(または `zsh/zpty` を読めない形)でテストを流し、出力を実装の PR に貼る | - -## 前提とする取り決め - -| 項目 | 参照先 / 決めたこと | -| --- | --- | -| プロジェクト構造 | テストは `tests/cli/` に置く(`AGENTS.md` の「このリポジトリ」)。`etc/` の補完のファイルは変えない | -| コーディング規約 | 周りのテスト(`tests/cli/test_completion.py`)の書き方に合わせる。日本語で書く(`AGENTS.md` の「書き方」) | -| テスト戦略 | zsh の補完の候補は、zsh を起動するテスト(この課題)で確かめる。道の集合の一致(`[name]`・`--context` の集合と補完の写し)は既存の一致テストが静的に確かめる。利用者の rc を通した見た目はリリース後テストで利用者の端末で確かめる | - -## 境界 - -| 区分 | 内容 | -| --- | --- | -| 常に行う | `env -u DEVBASE_ROOT` での全体のテスト、CI と同じ lint、固有の語の検査。テストの中の名前は中立の名前(`proj-a` など)で書く | -| 確認してから行う | CI のワークフローの変更(ランナーへ zsh を入れるなど)。承認ゲート 2 で人が見る | -| 行わない | `etc/_devbase` の振る舞いの変更、既存の静的な検査の削除、利用者の rc や実の `DEVBASE_ROOT` に触れること、base イメージへの zsh の追加(P1) | - -## 未決 - -| 項目 | 誰が決めるか | 期限 | -| --- | --- | --- | -| 1. CI(ubuntu ランナー)でこのテストを走らせるか(ランナーに zsh を入れる/入っている zsh を使う)、zsh が無い環境では飛ばして手元だけで走らせるか。走らせる場合、CI で zsh が無いときは飛ばさずに落とすか | 設計(承認ゲート 1 で人が見る) | 設計 PR | -| 2. Linux の zsh で、前提 2 の方法(`zsh/zpty` と `compadd` を包む形)が macOS と同じに動くか | 設計で確かめる | 設計 PR | -| 3. `--context` の集合・`[name]` の集合のすべての道を zsh で回すか、代表の道に留めるか(前提 5) | 設計 | 設計 PR | From b0a967edab95953b35a321b78e1a75bb87151599 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 12 Oct 2026 21:00:56 +0900 Subject: [PATCH 2/3] =?UTF-8?q?Docs:=20snapshot-exclude.md=20=E3=82=92?= =?UTF-8?q?=E7=A2=BA=E5=AE=9A=E4=BB=95=E6=A7=98=E3=81=AB=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/glossary.md | 8 +- docs/glossary/glossary.json | 12 +- docs/specifications/snapshot-exclude.md | 314 ++++++++++++++++++ issues/issue-368-design.md | 421 ------------------------ issues/issue-368-requirements.md | 194 ----------- 5 files changed, 322 insertions(+), 627 deletions(-) create mode 100644 docs/specifications/snapshot-exclude.md delete mode 100644 issues/issue-368-design.md delete mode 100644 issues/issue-368-requirements.md diff --git a/docs/glossary.md b/docs/glossary.md index 524fa09d..ebb11d2b 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -107,10 +107,10 @@ up の後に VS Code で dev コンテナを開く(docs/specifications/editor- | 系列の最新の世代 | — | 系列の中で created_at が最も新しい世代 | — | — | `docs/specifications/snapshot-series.md` | | 全体の上限 | — | 系列をまたいで数えた世代の数の上限(max_total) | — | — | `docs/specifications/snapshot-series.md` | | 復元前バックアップ | — | restore が書き戻す前に自動で作るフルの世代 pre-restore-<時刻>。復元する世代の対象ボリュームの組を控える | — | — | `docs/specifications/snapshot-series.md` | -| 除外の一覧 | — | スナップショットのアーカイブに入れないパスの一覧。既定の除外と利用者の除外を合わせたもの | — | — | — | -| 既定の除外 | — | devbase の本体が持つ除外の一覧。作り直せる一時ファイル・ログ・取り直せる取得物を挙げる | — | — | — | -| 利用者の除外 | — | 利用者が backups/exclude.yml で既定の除外に足すパス | — | — | — | -| 除外のパターン | — | 除外の一覧の 1 項目。ボリュームの中の相対のパスで、* は 1 つの要素の中の任意の文字列に当たる。当たったパスとその下を控えない | — | — | — | +| 除外の一覧 | — | スナップショットのアーカイブに入れないパスの一覧。既定の除外と利用者の除外を合わせたもの | — | — | `docs/specifications/snapshot-exclude.md` | +| 既定の除外 | — | devbase の本体が持つ除外の一覧。作り直せる一時ファイル・ログ・取り直せる取得物を挙げる | — | — | `docs/specifications/snapshot-exclude.md` | +| 利用者の除外 | — | 利用者が backups/exclude.yml で既定の除外に足すパス | — | — | `docs/specifications/snapshot-exclude.md` | +| 除外のパターン | — | 除外の一覧の 1 項目。ボリュームの中の相対のパスで、* は 1 つの要素の中の任意の文字列に当たる。当たったパスとその下を控えない | — | — | `docs/specifications/snapshot-exclude.md` | ## base イメージの描画(`base-image`) diff --git a/docs/glossary/glossary.json b/docs/glossary/glossary.json index 5c7389e7..dd3c93c7 100644 --- a/docs/glossary/glossary.json +++ b/docs/glossary/glossary.json @@ -347,29 +347,25 @@ "term": "除外の一覧", "context": "snapshot", "meaning": "スナップショットのアーカイブに入れないパスの一覧。既定の除外と利用者の除外を合わせたもの", - "source": "", - "pending_source": "issues/issue-368-requirements.md" + "source": "docs/specifications/snapshot-exclude.md" }, { "term": "既定の除外", "context": "snapshot", "meaning": "devbase の本体が持つ除外の一覧。作り直せる一時ファイル・ログ・取り直せる取得物を挙げる", - "source": "", - "pending_source": "issues/issue-368-requirements.md" + "source": "docs/specifications/snapshot-exclude.md" }, { "term": "利用者の除外", "context": "snapshot", "meaning": "利用者が backups/exclude.yml で既定の除外に足すパス", - "source": "", - "pending_source": "issues/issue-368-design.md" + "source": "docs/specifications/snapshot-exclude.md" }, { "term": "除外のパターン", "context": "snapshot", "meaning": "除外の一覧の 1 項目。ボリュームの中の相対のパスで、* は 1 つの要素の中の任意の文字列に当たる。当たったパスとその下を控えない", - "source": "", - "pending_source": "issues/issue-368-design.md" + "source": "docs/specifications/snapshot-exclude.md" }, { "term": "総称ファミリ", diff --git a/docs/specifications/snapshot-exclude.md b/docs/specifications/snapshot-exclude.md new file mode 100644 index 00000000..d7f60b6a --- /dev/null +++ b/docs/specifications/snapshot-exclude.md @@ -0,0 +1,314 @@ +# スナップショットの除外の一覧(既定の除外と利用者の除外) + +## 概要 + +スナップショットの控え(フルの世代・差分・復元前バックアップ)は、対象のボリュームから**除外の一覧**に +当たるパスとその下を外して作る。除外の一覧は、devbase の本体が持つ**既定の除外**と、利用者が +`backups/exclude.yml` に書いた**利用者の除外**を合わせたものである。 + +- 既定の除外は、作り直せる一時ファイル・ログ・取り直せる取得物(Codex の動作ログ・プラグインの取得物・ + Serena のログなど)だけを挙げる。会話の記録とプラグインの状態のファイルは控える +- 利用者の除外は既定の除外に足すだけで、既定の除外を外せない +- 世代は full を作ったときの除外の一覧を `meta.yml` の `exclude` に残す。1 つの世代のアーカイブは、すべて + 同じ除外の一覧で作る。今と違う除外の一覧の世代へは差分を積まず、自動スナップショットは新しい世代を作る +- 復元は除外の一覧を読まない。除外を持たない世代も持つ世代も同じ手順で戻す + +世代の系列と差分の積み先の規則は [スナップショットの世代の系列](snapshot-series.md) にある。利用者向けの +読み方は [スナップショットガイド: 控えから外すもの](../user/snapshot-guide.md#控えから外すもの) にある。 + +## 用語 + +除外の一覧・既定の除外・利用者の除外・除外のパターンは [用語集](../glossary.md) のとおり。 + +## 背景 + +除外を持たない控えは、使う間ずっと書き換わる `.codex/logs_2.sqlite`(測った端末で 602MB)を差分のたびに +丸ごと入れ、作り直せるもの(同じ端末で合計約 1.9GB)を毎回控えていた。`.gemini/tmp/<プロジェクト>/` は +`chats/` に会話の記録を持つため、丸ごとは外さず `logs.json`・`logs/` だけを外す。 + +## 対象範囲 + +- フルの世代・差分・復元前バックアップのアーカイブから除外の一覧のパスを外すこと +- `backups/exclude.yml` の読み込みと、使えない値・読めない設定の扱い +- 世代の `meta.yml` の `exclude` と、除外の一覧の違いによる差分の止め方 + +含まない: + +- 復元・`copy`・`list`・`rotate`(除外の一覧を読まない。`copy` は `meta.yml` ごと写すため `exclude` も写る) +- CLI の引数(除外を指定する引数は持たない) +- プロジェクトごと・ボリュームごとの除外(除外の一覧は devbase のルートに 1 つ) + +## 構成要素 + +| 要素 | 置き場所 | 責務 | +| --- | --- | --- | +| 除外の一覧の組み立て | `lib/devbase/snapshot/exclude.py` | 既定の除外 `DEFAULT_EXCLUDES` を持つ。`backups/exclude.yml` を読み(`_load_user_excludes`)、パターンを正規化・検査し(`normalize`)、既定の除外と合わせて並べ替える(`resolve_excludes`・`canonical`)。除外の一覧から tar の引数を作る(`tar_exclude_args`)。Docker も `meta.yml` も使わない | +| 今の除外の一覧 | `lib/devbase/snapshot/manager.py` の `SnapshotManager.excludes`・`exclude_config_readable` | 初回の参照で 1 回だけ `resolve_excludes` を呼んで持つ。同じ実行で警告を 2 度出さない | +| 世代の記録 | 同 `_recorded_excludes` | 世代の `meta.yml` の `exclude` を読む。無い・リストでない・文字列でない要素を含むなら `None` | +| 比較 | 同 `_excludes_differ`・`_incremental_block_reason` | 世代の記録が今の一覧と違うかを決める。差分を積めない理由(対象ボリューム → 控えの起点 → 除外の一覧の順)の最後に当てる | +| 控えに使う一覧 | 同 `_excludes_for`・`_latest_recorded_excludes` | 設定を読めたら今の一覧、読めなければ記録の一覧を返す(下の「読めない設定の間」) | +| tar のコマンド | 同 `backup_command(archive, volumes, excludes)` | 除外の引数を `-C` とメンバーより前に置く | +| 作成 | 同 `_create_full`・`_create_incremental` | `_excludes_for` の一覧で控える。full は `meta.yml` に `exclude` を書き、差分は書き換えない | + +世代に関わる判断(記録・比較・積み先)は `manager.py` に置き、`exclude.py` は世代の状態に依らない処理だけを +持つ。世代の規則を読む人が検査の細部を読まずに済み、テストが `SnapshotManager` を組まずに検査を縛れるためである。 + +```mermaid +classDiagram + class SnapshotManager { + +excludes: tuple~str~ + +exclude_config_readable: bool + +backup_command(archive, volumes, excludes)$ str + +auto_snapshot_target(max_incrementals) str|None + -_exclude_resolution: tuple + -_create_full(name, snap_dir, volumes) + -_create_incremental(name, snap_dir) + -_incremental_block_reason(snap_dir) tuple + -_recorded_excludes(snap_dir) tuple~str~|None + -_excludes_differ(snap_dir) bool + -_excludes_for(snap_dir, volumes) tuple~str~ + -_latest_recorded_excludes(volumes) tuple~str~|None + } + class exclude { + +DEFAULT_EXCLUDES: tuple~str~ + +USER_CONFIG: str + +normalize(value) str + +canonical(patterns) tuple~str~ + +resolve_excludes(backups_dir) tuple~tuple~str~, bool~ + +tar_exclude_args(excludes, members) str + -_load_user_excludes(backups_dir) tuple + -_read_exclude_values(path) list + } + class 世代のmeta { + +volumes + +archive_root + +exclude: list~str~ + } + SnapshotManager ..> exclude : 一覧を組み立て tar の引数にする + SnapshotManager ..> 世代のmeta : exclude を書く・読む +``` + +## 仕様 + +### 常に成り立つ条件 + +| # | 条件 | 破れたときの扱い | +| --- | --- | --- | +| I1 | 1 つの世代のアーカイブ(full と各差分)は、すべて同じ除外の一覧で作る | 自動スナップショットは差分を積まず新しい世代を作る(INFO 1 行)。名前を指定した差分は `SnapshotError`(終了コード 1)で止め、アーカイブ・`snapshot.snar`・`meta.yml` を変えない | +| I2 | `meta.yml` の `exclude` は、その世代の full を作ったときの除外の一覧に等しい。差分を積んでも書き換えない | — (full を作るときにだけ書く) | +| I3 | `meta.yml` に `exclude` が無い世代(除外を持たない世代)は、今の除外の一覧と違うものとして扱う | I1 と同じ | +| I4 | 除外のパターンはボリュームの中だけを指す(絶対パス・`..` の要素・空・`*` 以外のワイルドカードの文字を含まない) | その値を採らず WARNING 1 行。残りで控えを作る | +| I5 | 除外のパターンは各ボリュームの頭(`ai/`・`group/`・旧レイアウトの `./`)からだけ当たり、`*` は 1 つの要素の中だけに当たる | — (tar へ渡す形で守る) | +| I6 | 既定の除外は常に効く。利用者の除外は足すだけで、既定の除外を外せない | — (設定に「外す」書き方を持たない) | +| I7 | 既定の除外は、会話の記録・プラグインの状態のファイル・`.serena/language_servers` に当たらない | — (既定の一覧を変えたときにテストで落ちる) | +| I8 | 復元は除外の一覧を読まない。除外を持たない世代も持つ世代も同じ手順で戻す | — | +| I9 | 利用者の除外の設定が読めない間は、除外の一覧の違いを理由に新しい世代を作らず、今の世代の記録の一覧で控える | — (下の「読めない設定の間」) | + +### 既定の除外 + +| パターン | 中身 | +| --- | --- | +| `.gemini/tmp/*/logs.json` | Gemini CLI のログ | +| `.gemini/tmp/*/logs` | 同上 | +| `.codex/logs_2.sqlite` | Codex の動作ログ | +| `.codex/logs_2.sqlite-wal` | 同じ SQLite の WAL | +| `.codex/logs_2.sqlite-shm` | 同じ SQLite の共有メモリのファイル | +| `.codex/.tmp` | Codex の一時ファイル・マーケットプレイスの写し | +| `.codex/cache` | Codex のキャッシュ | +| `.claude/plugins/cache` | Claude Code のプラグインの取得物 | +| `.claude/plugins/marketplaces` | Claude Code のマーケットプレイスの取得物 | +| `.serena/logs` | Serena のログ | + +- 共通ボリュームとグループのボリュームの両方に当てる。同じパスがどちらにあっても作り直せ、ボリュームごとに + 一覧を分けると利用者の除外にも置き場の書き分けが要るためである +- SQLite の本体だけを外すと、復元後に本体が無く古い WAL だけが残る。WAL と共有メモリのファイルも作り直せる + ログで、使う間ずっと書き換わるため、本体と一緒に外す +- 控えるもの: `.claude/projects`・`.codex/sessions`・`.codex/thread_history_1.sqlite`・`.codex/state_5.sqlite`・ + `.gemini/tmp/<プロジェクト>/chats/` の下(`chats/logs.json` を含む)・`.claude/plugins/installed_plugins.json`・ + `.claude/plugins/known_marketplaces.json`・`.claude/plugins/data/`・`.serena/language_servers` +- 除外の単位は「パスとその下」である。ディレクトリの中の一部だけを残すときは、外すものを個別に挙げる + (`.gemini/tmp/*/` は `logs.json` と `logs` を挙げて `chats/` を残す)。GNU tar の `--exclude` は否定を + 持たないため、「外さないもの」を挙げて残りを外す形は採らない + +### 除外のパターンの正規化と検査(`normalize`) + +上から順に当て、最初に当たった行で決まる。 + +| 順 | 入力の形 | 扱い | +| --- | --- | --- | +| 1 | 文字列でない | 採らない(文字列ではありません) | +| 2 | 前後の空白を落として空 | 採らない(空です) | +| 3 | `/` で始まる | 採らない(絶対パスです) | +| 4 | `?`・`[`・`]`・`\` を含む | 採らない(使えない文字を含みます。ワイルドカードは `*` だけ) | +| 5 | `/` で分けた要素に `..` がある | 採らない(ボリュームの外を指します) | +| 6 | 空の要素と `.` の要素を落とす(`./a//b/` → `a/b`) | 落とした後に要素が無ければ採らない(空です) | +| 7 | それ以外 | 要素を `/` でつないだ文字列 | + +### tar へ渡す形 + +`tar_exclude_args(excludes, members)` は次の形を返す。`members` は `backup_command` のメンバー名(`ai`・`group`、 +旧レイアウトは `.`)で、一覧が空なら空文字を返す。 + +```text +--anchored --no-wildcards-match-slash --exclude=<メンバー>/<パターン> ...(メンバー × パターン) +``` + +- 各 `--exclude=…` は `shlex.quote` し、`*` をシェルに展開させない +- `backup_command` はこれを `tar --listed-incremental=…` の直後、`-cf - -C /source <メンバー>` より前に置く。 + GNU tar 1.35 では、メンバーの後に置いた `--anchored --exclude` は効かず、終了コードが 0 でなくなる +- `--anchored` が無いと `--exclude=.codex/cache` が `ai/work/.codex/cache` のようなボリュームの下の深い場所の + 同じ名前にも当たり、利用者のプロジェクトの中のファイルを黙って外す。`--no-wildcards-match-slash` が無いと + `*` が `/` に当たり、`.gemini/tmp/*/logs.json` が `chats/` の下の `logs.json` に当たりうる +- 端末に無いパスを除外しても、tar は終了コード 0 で警告を出さない + +### 世代の記録と差分の止め方 + +- `_create_full` は控えに使った一覧を並べ替えて `meta.yml` の `exclude` に書く。差分の上限で作る新しい世代・ + `snapshot.snar` 無しのフォールバック・復元前バックアップもこの経路を通る +- `_create_incremental` は `exclude` を書き換えない(I2) +- 比べるのは正規化した一覧そのもので、集合として比べる(並びに依らない)。既定の除外を変えても、利用者が + 設定を変えても、同じ比較で新しい世代へ倒れる +- 自動スナップショットの積み先の判定は、[系列の規則の順 4a](snapshot-series.md#差分の積み先) で、 + 記録が無い(I3)か、設定を読めて今の一覧と違う世代を `None`(新しい世代)にし、INFO 1 行を出す +- 名前を指定した差分は、`_incremental_block_reason` が対象ボリューム → 控えの起点(`archive_root`)→ + 除外の一覧の順に調べ、除外の一覧で止まるときは `snapshot.snar` に触る前に `SnapshotError` を投げる。 + `snapshot.snar` が無いときのフルへの切り替えはこの比較より後に置き、記録の違う世代へ名前で積もうとしたら + snar の有無に関わらず止める + +違う除外の一覧の差分を積まないのは、GNU tar 1.35 で、除外なしの full の後に除外つきの差分を積んで順に展開すると、 +外したパスが full の時点の中身で残るためである(消えも更新もされない)。復元の結果が除外の一覧とも、控えた +時点の中身とも合わなくなる。 + +### 読めない設定の間 + +`backups/exclude.yml` を読めない間(`exclude_config_readable` が偽)は、除外の一覧の違いを理由に新しい世代を作らない。 + +| 場面 | 控えに使う一覧 | +| --- | --- | +| 記録を持つ世代へ積む差分(自動・名前の指定) | 比べずに、その世代の記録の一覧 | +| 新しいフル(差分の上限・snar 無しのフォールバック・復元前バックアップ) | `_excludes_for` が、作り直す世代の記録 → 系列の最新の世代の記録の順に最初にある記録の一覧を返す。新しい世代の `meta.yml` の `exclude` も同じ一覧になる | +| 系列のどの世代も記録を持たない | I3 のとおり新しい世代になり、既定の除外だけで控える | +| 記録の無い世代へ名前で積む | I3 のとおり `SnapshotError` で止める | + +読めない設定を「既定の除外だけ」として比べると、除外の一覧が変わったと判定して新しい世代ができ、 +`_auto_snapshot` の直後の `rotate()`(系列ごとに 3 世代)が最も古い世代を消す。設定を直した次の `up` でも +一覧が戻ってもう 1 世代消え、編集の誤り 1 回で復元点を 2 つ失う。記録の一覧を引き継げば、直した設定が +記録と同じなら同じ世代へ積み続ける。読めない設定で控えを止める形は、控えの無い期間を作るほうが控えが +一時的に大きくなるより戻す費用が高いため採らない。 + +## データ・設定 + +### 世代の `meta.yml` の `exclude` + +| 列 | 型 | 空を許すか | 意味 | +| --- | --- | --- | --- | +| `exclude` | 文字列のリスト(並べ替え済み・重複なし) | 無いことを許す | full を作ったときの除外の一覧(正規化したパターン。メンバー名の頭は付けない)。**無いのは除外を持たない世代**で、「何も外していない」ではない。空のリストは「何も外していない」 | + +一覧のハッシュではなく一覧そのものを残すのは、世代ごとの除外の一覧を後から読めるようにするためである。 +`backups/snapshot.yml` は変えない(系列の判定は `snapshot.yml` だけで行い、除外の比較は積む直前に +`meta.yml` で行う。`volumes` の照合と同じ置き方)。除外を持たない devbase は `exclude` を読み捨てる。 + +| 操作 | `meta.yml` の `exclude` | `snapshot.snar` | `backups/exclude.yml` | +| --- | --- | --- | --- | +| フルを作る | 書く | 作る | 読む | +| 差分を積む | 読む | 更新する | 読む | +| 積み先の判定 | 読む | — | 読む | +| 復元 | — | — | — | + +### 利用者の除外の設定 `backups/exclude.yml` + +```yaml +# スナップショットの控えから外すパス(ボリュームの中の相対のパス) +exclude: + - .cache/pip + - work/*/node_modules +``` + +| 項目 | 規則 | +| --- | --- | +| 置き場 | `$DEVBASE_ROOT/backups/exclude.yml`(`USER_CONFIG`)。devbase は作らず書かない | +| 形 | 最上位が辞書で、`exclude` が文字列のリスト | +| ファイルが無い | 既定の除外だけ。警告を出さない。設定を読めたものとして扱う | +| 中身が空・`exclude` のキーが無い・`null`・空のリスト | 既定の除外だけ。警告を出さない | +| 読めない(`OSError`・UTF-8 で読めない・YAML として読めない・最上位が辞書でない・`exclude` がリストでない) | WARNING 1 行。設定を読めなかったものとして扱い、控えは続ける(「読めない設定の間」) | +| 要素が文字列でない・空・使えない値 | 要素ごとに WARNING 1 行で採らない。残りで続ける | + +利用者の除外を devbase のルートの 1 つのファイルで受けるのは、系列ごとの除外の一覧を起動のたびに揺らさない +ためである。`bin/devbase` は実行したディレクトリのプロジェクトの `env` を読むため、環境変数で受けると、同じ +グループのボリュームを使うプロジェクトのどれから `up` したかで一覧が変わり、そのたびに新しい世代ができる。 +`backups/` はスナップショットの置き場で、プロジェクトの境界をまたがない。 + +## エラー処理 + +| 場面 | 扱い | +| --- | --- | +| `backups/exclude.yml` を読めない | WARNING `backups/exclude.yml を読めないため、今の世代の除外の一覧のまま控えます(記録の無い世代だけなら既定の除外だけで作ります): {理由}`。終了コードは変えない | +| 使えない値 | WARNING `backups/exclude.yml の除外 {値!r} を採りません: {理由}` を要素ごとに 1 行。終了コードは変えない | +| 自動スナップショットの積み先の世代の除外の一覧が違う・記録が無い | INFO `世代 {名前} は今と違う除外の一覧で作られているため、新しい世代を作成します` | +| `devbase snapshot create --name <除外の一覧が違う世代>`(`--full` なし) | `SnapshotError` `スナップショット '{名前}' は今と違う除外の一覧で作られています。新しい世代を作成してください (devbase snapshot create)` → 終了コード 1。アーカイブ・`snapshot.snar`・`meta.yml` は変わらない | + +同じ `SnapshotManager` で積み先の判定と作成を呼んでも、設定の WARNING は 1 回だけ出す。 + +## 運用 + +- 除外を持たない世代は書き換えない。更新後の最初の自動スナップショットで、系列ごとに新しいフルの世代が + できる(I3)。そのまま一覧・復元でき、復元の結果は除外を持たない devbase で復元したときと同じである +- `backups/exclude.yml` で除外の一覧を変えるたびに、次の自動スナップショットが新しい世代を作り、系列ごとの + 保持数(3)を超えた最も古い世代がローテーションで消える。I1 から外せないため、利用者向けの文書に書いてある +- 切り戻し(除外を持たない devbase へ戻す)では `exclude` が読み捨てられる。除外を持つ世代を復元すると、 + 除外したパスは無い +- 既定の除外のパスを消したホームで、Claude Code・Codex・Gemini CLI・Serena はエラーで止まらずに起動する。 + Claude Code は最初のセッションが始まるまで `claude plugin list` がプラグインを `cache-miss` と出し、最初の + セッションでマーケットプレイスとプラグインを取り直して `installed_plugins.json` の一覧のまま読み込む。 + Codex は消した `logs_2.sqlite` と `cache` を作り直す +- 既存の世代の名前へ `--full` を打つと full が変更分だけになる不具合は #458 にある。除外の一覧が違う世代への + 案内は「新しい世代を作成してください (devbase snapshot create)」とし、`--name` に既存の名前を勧めない + +## テスト観点 + +正規化・設定の読み込み・tar の引数(`tests/snapshot/test_exclude.py`): + +- 既定の除外が上の 10 個で、正規化済みであること。会話の記録・プラグインの状態のファイル・ + `.serena/language_servers` に当たらないこと(I7) +- 相対パスを正規化すること(`./a//b/` → `a/b`)。絶対パス・`..` を含む値・空・`null`・数・`?[]\` を含む値を採らないこと +- 設定が無い・空・`exclude` が無い・空のリストで、既定の除外だけで警告が出ないこと +- 足した値が既定の除外に加わること。使えない値は値ごとに WARNING 1 行で、残りの値は効くこと +- 読めない設定(壊れた YAML・最上位がリスト・`exclude` が文字列)で、WARNING 1 行、一覧は既定の除外だけ、 + 設定を読めたかが偽であること +- tar の引数が各パターンに各メンバーを頭に付けること。旧レイアウトは `./` で始まること。一覧が空なら空文字 + +世代の記録と積み先(`tests/snapshot/test_exclude_generations.py`。`_run_docker_tar` を差し替える): + +- full の `meta.yml` の `exclude` が並べ替えた一覧で、差分を積んでも変わらないこと(I2) +- 利用者の除外が既定の除外と一緒に tar のコマンドに入ること(I6) +- 復元前バックアップの tar のコマンドに除外の引数があり、`meta.yml` に `exclude` があること。旧レイアウトの + 復元前バックアップでパターンの頭が `./` であること +- 同じ実行で WARNING が 1 回であること +- 同じ除外の世代には積み続け、違う・記録の無い世代では `auto_snapshot_target` が `None` と INFO 1 行(I1・I3) +- 名前を指定した差分で記録が違う・無いと `SnapshotError` で、snar の有無に関わらず tar を呼ばず、 + `snapshot.snar` と `meta.yml` のバイト列が変わらないこと。`cmd_snapshot` は終了コード 1 +- 積めない理由が重なるとき、最初の 1 件(対象ボリューム → 控えの起点 → 除外の一覧)だけを報告すること +- 読めない設定で、記録を持つ世代へ記録の一覧で積み続け、記録を変えないこと。記録の無い世代を引き継がないこと。 + 差分の上限の後の新しいフル・snar 無しのフォールバック・復元前バックアップが記録の一覧で作られること。 + どの世代も記録を持たなければ既定の除外だけで作ること(I9) +- 除外を持たない世代の復元のコマンドが変わらないこと(I8) + +実の GNU tar(`tests/snapshot/test_backup_real_tar.py`。GNU tar と zstd が無い端末では飛ぶ。CI の Linux で走る): + +- 既定の除外のパスを `ai`・`group` に置いた full に、どのパスもその下も無く、控えるもの(`chats/logs.json`・ + `work/.codex/cache` を含む)があること(I5・I7) +- full の後に `.codex/logs_2.sqlite` とほかのファイルを書き換えた差分に、`logs_2.sqlite` が無く、ほかのファイルがあること +- 除外つきの full と差分を順に展開すると、除外の外のパスは差分の時点の中身で、除外したパスが無いこと +- 既定の除外のパスが 1 つも無い木を控えて、終了コード 0 で警告が無いこと + +除外を持たない世代の復元のテスト(`tests/snapshot/test_restore_incremental.py`・`test_manager_volumes.py`)が通ること。 + +実機で確かめる観点: 利用者の端末で `devbase snapshot create --full` の大きさを除外を持たない世代と比べ、 +端末とボリュームの大きさを記録に残す。 + +## 関連リンク + +- [スナップショットの世代の系列](snapshot-series.md) +- [スナップショットガイド: 控えから外すもの](../user/snapshot-guide.md#控えから外すもの) +- [CLI リファレンス: snapshot (ss) グループ](../user/cli-reference/05-snapshot.md) +- 実装 PR: devbasex/devbase#461(#368) diff --git a/issues/issue-368-design.md b/issues/issue-368-design.md deleted file mode 100644 index 00367c2f..00000000 --- a/issues/issue-368-design.md +++ /dev/null @@ -1,421 +0,0 @@ -# スナップショット: 作り直せるキャッシュ・一時ファイル・ログまで毎回控え、フルの世代と差分が大きくなる → 本体が持つ既定の除外と利用者が足した除外を控えから外し、除外が違う世代へは差分を積まない(#368) - -## 目的 - -- **何が壊れているか**: スナップショットは対象のボリュームを丸ごと控え、除外の仕組みを持たない。Codex の動作ログ・プラグインの取得物・Serena のログなど、作り直せるもの(手元の測定で合計約 1.9GB)を毎回控える -- **誰が困るか**: `devbase up` のたびに自動スナップショットを待つ利用者。使う間ずっと書き換わる `.codex/logs_2.sqlite`(602MB)が、差分のたびに丸ごと入る -- **直すと何が成り立つか**: 既定の除外と利用者の除外がフル・差分・復元前バックアップのアーカイブから外れる。会話の記録・入れたプラグインの一覧はこれまでどおり控えて復元で戻る。除外が途中で変わった世代へ差分を積まず、復元の結果が除外の一覧と食い違わない - -## 適用範囲 - -- **働く範囲**: devbase の本体(`lib/devbase/snapshot/`)。配布先の利用者の端末で、`devbase snapshot create`・`devbase up` の自動スナップショット・`restore` の復元前バックアップに働く -- **プロジェクトごとに違うもの**: 無し。利用者の除外は devbase のルートに 1 つだけ置く設定ファイル(`backups/exclude.yml`)で受け、プロジェクトの設定や環境変数では受けない(決定 3) -- **当たるモード**: `standard` - -## あるべき姿の根拠 - -| 根拠 | 種類 | 何を示すか | -| --- | --- | --- | -| 利用者の決定(#368 のコメント、2026-10-11)「除外の一覧は本体に既定の一覧を持ち、利用者が設定で足せる形にする。既定の一覧は本文の『控えなくてよいもの』の表から作る(`.gemini/tmp/<プロジェクト>/chats/` は外さない)」 | 利用者の指示の原文 | 既定の除外を本体に持つこと・利用者は足すだけであること・既定の除外の中身 | -| 課題の本文の測定(2026-10-02): `.codex/logs_2.sqlite` 602MB、`.claude/plugins/cache` 約 607MB、`.gemini/tmp` 約 1.3GB のうち `chats/` が約 984MB | 実測 | 既定の除外の大きさと、`.gemini/tmp` を丸ごと外してはいけないこと | -| `devbase-snapshot:latest` の GNU tar 1.35 で測った除外の効き方(下の「測った tar の振る舞い」) | 実測 | 除外のオプションの組み合わせと置く位置(決定 2)、世代の途中で除外を変えると復元が除外の一覧と食い違うこと(決定 5) | -| 手元の `~/.codex` に `logs_2.sqlite-wal`・`logs_2.sqlite-shm` がある(2026-10-12) | 実測 | 本体だけを外すと WAL が残ること(決定 6) | - -要求と受け入れ条件は #368 の本文にある(コピーは `issues/issue-368-requirements.md`)。この文書は「どう作るか」だけを扱う。 - -### 測った tar の振る舞い - -作業用のディレクトリだけを使い、利用者のボリュームには触れていない(2026-10-12、`devbase-snapshot:latest`、`tar (GNU tar) 1.35`)。`ai/` と `group/` の下に同じ木を置き、`-C /source ai group` で控えた。 - -| 試したこと | 結果 | -| --- | --- | -| `--anchored --no-wildcards-match-slash --exclude=ai/.gemini/tmp/*/logs.json --exclude=ai/.codex/cache …`(`group/` も同じ)をメンバーより前に置く | 外したパスとその下が一覧に無い。`ai/.gemini/tmp/p1/chats/logs.json`・`ai/.gemini/tmp/p1/chats/logs/x`・`ai/work/.codex/cache/keep` は残る | -| 既定のまま(アンカーなし)`--exclude=.codex/cache` | `ai/work/.codex/cache` まで外れる(ボリュームの下の深い場所の同じ名前に当たる) | -| `--anchored --exclude=…` をメンバーの後に置く | `tar: --exclude 'ai/.codex/cache' has no effect` と出て除外が効かず、終了コードが 0 でない | -| 端末に無いパスを除外する(`--exclude=ai/nope`) | 終了コード 0、警告なし | -| 除外なしで full を作り、同じ snar へ `ai/.codex/cache` を除外して差分を積み、空の先へ順に展開する | `ai/.codex/cache/c` が full の時点の中身で残る(消えも更新もされない) | -| 同じ除外で full と差分を作り、順に展開する | 除外したパスは無く、ほかのファイルは差分の時点の中身 | - -課題の本文は、世代の途中で除外を足すと差分の復元で「そのパスが消える」と推していた。測った結果は「full の時点の中身で残る」であり、どちらにせよ復元の結果が除外の一覧と合わない(受け入れ条件 8)。 - -## ドメインモデル - -### コンテキスト - -| コンテキスト | 何の語が 1 つの意味に決まるか | -| --- | --- | -| スナップショット(`snapshot`) | 世代・系列・除外の一覧・既定の除外・利用者の除外・除外のパターン | - -1 つのコンテキストで閉じる。ボリュームの名前(`volume` の語)は既存どおり `snapshot` が順応者として受け取り、この変更で関係を変えない。 - -### 集約 - -| 集約 | 持ち主(書き換えてよいもの) | 根 | エンティティ | 値オブジェクト | -| --- | --- | --- | --- | --- | -| 世代 | `SnapshotManager`(`lib/devbase/snapshot/manager.py`) | 世代のディレクトリ(`backups/<名前>/` と `meta.yml`) | アーカイブ(`full.tar.zst`・`incr-NNN.tar.zst`)・`snapshot.snar` | 対象ボリュームの組・**除外の一覧**(この変更で足す) | -| 利用者の除外の設定 | 利用者(devbase は読むだけで書かない) | `backups/exclude.yml` | — | 除外のパターン | - -除外の一覧は値オブジェクトで、`exclude.py`(新しく作る)が既定の除外と利用者の除外の設定から組み立てる。世代はそれを受け取って `meta.yml` に写し、比べるだけで、中身を変えない。 - -### 不変条件 - -| # | 集約 | 条件 | 破れたときの扱い | -| --- | --- | --- | --- | -| I1 | 世代 | 1 つの世代のアーカイブ(full と各差分)は、すべて同じ除外の一覧で作る | 自動スナップショットは差分を積まず新しい世代を作る(理由の INFO 1 行)。名前を指定した差分は `SnapshotError`(終了コード 1)で止め、アーカイブ・`snapshot.snar`・`meta.yml` を変えない | -| I2 | 世代 | `meta.yml` の `exclude` は、その世代の full を作ったときの除外の一覧に等しい。差分を積んでも書き換えない | — (full を作るときにだけ書く) | -| I3 | 世代 | `meta.yml` に `exclude` が無い世代(この変更より前に作った世代)は、今の除外の一覧と違うものとして扱う | I1 と同じ | -| I4 | 利用者の除外の設定 | 除外のパターンはボリュームの中だけを指す(絶対パス・`..` の要素・空・`*` 以外のワイルドカードの文字を含まない) | その値を採らず WARNING 1 行。残りで控えを作る | -| I5 | 世代 | 除外のパターンは各ボリュームの頭(`ai/`・`group/`・旧レイアウトの `./`)からだけ当たり、`*` は 1 つの要素の中だけに当たる | — (tar へ渡す形で守る。破るとボリュームの下の深い場所や `chats/` の下を外す) | -| I6 | 世代 | 既定の除外は常に効く。利用者の除外の設定は足すだけで、既定の除外を外せない | — (設定に「外す」書き方を持たない) | -| I7 | 世代 | 既定の除外は、会話の記録・プラグインの状態のファイル・`.serena/language_servers` に当たらない | — (既定の一覧を変えたときにテストで落ちる) | -| I8 | 世代 | 復元は除外の一覧を読まない。除外を持たない世代も持つ世代も同じ手順で戻す | — | -| I9 | 世代 | 利用者の除外の設定が読めない間は、除外の一覧の違いを理由に新しい世代を作らず、控えを今の世代の記録の一覧で作る。`exclude` の記録を持つ世代へはその記録の一覧で差分を積み、新しい世代のフル(差分の上限に達したとき・snar 無しのフォールバック・復元前バックアップ)も、作り直す世代か系列の最新の世代が記録を持てばその記録の一覧で作る(決定 4) | — (読めない設定で I1 の比較を飛ばし、記録の一覧を使う。系列のどの世代も記録を持たないとき(この変更より前の世代だけ)は I3 のとおり新しい世代になり、既定の除外だけで控える) | - -### ドメインイベント - -| # | イベント | 発生元 | 受け手 | -| --- | --- | --- | --- | -| E1 | 除外の一覧を決めた | `exclude.resolve_excludes`(`SnapshotManager.excludes` の初回の参照) | `SnapshotManager`(作成・積み先の判定) | -| E2 | 積み先の世代の除外の一覧と今の除外の一覧を比べた | `auto_snapshot_target`・`_create_incremental` | `_auto_snapshot`(`None` なら新しい世代)・CLI(`SnapshotError` なら終了コード 1) | -| E3 | 新しいフルの世代を作った(除外を適用した) | `_create_full` | E5 | -| E4 | 差分を積んだ(同じ除外を適用した) | `_create_incremental` | E5(`exclude` は書き換えない) | -| E5 | 世代に除外の一覧を記録した | `_create_full`(`meta.yml` の `exclude`) | 次の E2 | -| E6 | 世代を復元した | `restore` | 利用者(除外したパスは復元後に無い) | -| E7 | 復元後に CLI が要るものを作り直した | 利用者の CLI の起動(devbase の外) | 受け入れ条件 9 の確認(前提 4) | - -### 用語 - -| 用語 | 意味 | 用語集への反映 | -| --- | --- | --- | -| 除外のパターン | 除外の一覧の 1 項目。ボリュームの中の相対のパスで、`*` は 1 つの要素の中の任意の文字列に当たる。当たったパスとその下を控えない | 追加(`snapshot`) | -| 除外の一覧 | スナップショットのアーカイブに入れないパスの一覧。既定の除外と利用者の除外を合わせたもの | 変えない(要求で足した語) | -| 既定の除外 | devbase の本体が持つ除外の一覧 | 変えない(要求で足した語) | -| 利用者の除外 | 利用者が `backups/exclude.yml` で既定の除外に足すパス | 意味の変更(置き場を書き足す) | - -## 機能一覧 - -| # | 機能 | 誰が使うか | -| --- | --- | --- | -| F1 | 控え(フル・差分・復元前バックアップ)から既定の除外のパスを外す | `devbase up`・`snapshot create`・`snapshot restore` を使う利用者 | -| F2 | 利用者が `backups/exclude.yml` に書いたパスを控えから外す。使えない値は警告して外す | 控えを小さくしたい利用者 | -| F3 | 世代ごとに、作ったときの除外の一覧を `meta.yml` に残す | 世代の中身を確かめたい利用者・保守する開発者 | -| F4 | 除外の一覧が違う世代へ差分を積まない(自動は新しい世代を作り、名前の指定は止める) | `devbase up`・`snapshot create --name` を使う利用者 | - -## 構成要素 - -| 要素 | 責務 | -| --- | --- | -| `lib/devbase/snapshot/exclude.py`(新しく作る) | 既定の除外(`DEFAULT_EXCLUDES`)を持つ。利用者の除外の設定を読み、パターンを正規化・検査し、使えない値と読めない設定を WARNING にし、除外の一覧と設定を読めたかを返す(`resolve_excludes`)。除外の一覧から tar の引数を組み立てる(`tar_exclude_args`)。Docker を呼ばない | -| `SnapshotManager.excludes`(`manager.py`、足す) | 除外の一覧と設定を読めたか(`exclude_config_readable`)を初回の参照で 1 回だけ組み立てて持つ。同じ実行で警告を 2 度出さない | -| `SnapshotManager.backup_command`(変える) | 引数に除外の一覧を足し、`tar_exclude_args` の結果を `-C` とメンバーより前に置く | -| `SnapshotManager._excludes_for`(`manager.py`、足す) | 控えに使う除外の一覧を決める。設定を読めたら `excludes`。読めなければ、作り直す世代(`snap_dir`)の記録 → `volumes` の系列の最新の世代の記録の順に最初にある記録の一覧、どちらも無ければ `excludes`(既定の除外だけ)(I9) | -| `SnapshotManager._create_full`(変える) | `_excludes_for` の一覧を適用して控え、`meta.yml` の `exclude` にその一覧を書く。差分の上限で作る新しい世代・snar 無しのフォールバック・復元前バックアップもこの経路を通る | -| `SnapshotManager._create_incremental`(変える) | `snapshot.snar` を触る前に、世代の `exclude` と今の一覧を比べ、違えば `SnapshotError`。設定が読めず世代に記録があれば、比べずに記録の一覧で差分を積む。snar 無しのフォールバックは `_create_full` へ渡し、同じ記録の一覧でフルを作る(I9) | -| `SnapshotManager.auto_snapshot_target`(変える) | 順 4 の後に除外の一覧の比較を足し、違えば理由の INFO 1 行と `None`。設定が読めず世代に記録があれば比べない(I9) | -| `docs/user/snapshot-guide.md`・`docs/user/cli-reference/05-snapshot.md`・`CHANGELOG.md` | 既定の除外の一覧・`backups/exclude.yml` の書き方・更新後の最初の自動スナップショットが新しい世代を作ること。`snapshot-guide.md` には、`backups/exclude.yml` で除外の一覧を変えるたびに次の自動スナップショットが新しい世代を作り、系列ごとの保持数(3)を超えた最も古い世代がローテーションで消えること、読めない設定の間は今の世代の除外の一覧のまま控える(差分も、差分の上限で作る新しい世代も)ことを書く | - -変えないもの: `restore`・`_apply_incrementals`・`restore_command`・`clear_command`(I8)、`copy`(`meta.yml` ごと写すため `exclude` も写る)、`list`・`rotate`・系列の規則、`commands/snapshot.py` と CLI の引数、`devbase-snapshot` イメージ(GNU tar 1.35 の既存の機能だけを使う)。 - -```mermaid -graph TB - subgraph host["ホストの devbase(Python)"] - CMD["commands/container.py _auto_snapshot
commands/snapshot.py cmd_snapshot"] - MGR["snapshot/manager.py SnapshotManager"] - EXC["snapshot/exclude.py(新)"] - end - subgraph files["backups/"] - CFG["exclude.yml(利用者が書く)"] - META["<世代>/meta.yml"] - end - TAR["devbase-snapshot コンテナの GNU tar"] - CMD --> MGR - MGR -->|excludes| EXC - EXC -->|読む| CFG - MGR -->|exclude を書く・読む| META - MGR -->|"--exclude を含むコマンド"| TAR -``` - -### パッケージ・モジュール構成 - -```text -lib/devbase/snapshot/ -├── __init__.py -├── exclude.py # 新: 既定の除外・利用者の除外の設定の読み込み・tar の引数 -└── manager.py # 変: excludes・backup_command・_create_full・_create_incremental・auto_snapshot_target -tests/snapshot/ -├── test_exclude.py # 新: 正規化・検査・設定の読み込み・tar の引数 -├── test_exclude_generations.py # 新: meta.yml の記録・積み先の判定・名前を指定した差分 -└── test_backup_real_tar.py # 変: 実の GNU tar で除外の効き目と復元 -``` - -## 構造 - -```mermaid -classDiagram - class SnapshotManager { - +excludes: tuple~str~ - +exclude_config_readable: bool - +backup_command(archive, volumes, excludes)$ str - +auto_snapshot_target(max_incrementals) str|None - -_create_full(name, snap_dir, volumes) - -_create_incremental(name, snap_dir) - -_recorded_excludes(snap_dir) tuple~str~|None - -_excludes_for(snap_dir, volumes) tuple~str~ - } - class exclude { - +DEFAULT_EXCLUDES: tuple~str~ - +USER_CONFIG: str - +normalize(value) str - +resolve_excludes(backups_dir) tuple~tuple~str~, bool~ - +tar_exclude_args(excludes, members) str - } - class 世代のmeta { - +volumes - +archive_root - +exclude: list~str~ - } - SnapshotManager ..> exclude : 一覧を組み立て tar の引数にする - SnapshotManager ..> 世代のmeta : exclude を書く・読む -``` - -- `normalize(value)` は使えない値で `ValueError`(理由つき)を投げる純粋な関数。WARNING は `resolve_excludes` が出す -- `resolve_excludes` は既定の除外と利用者の除外を合わせ、重複を除いて並べ替えたタプルと、設定を読めたか(ファイルが無いときは `True`、下の表の「読めない」の行に当たるときだけ `False`)を返す。比較は並びに依らない(I1) -- `_recorded_excludes` は `meta.yml` の `exclude` を読み、無ければ `None`(I3)。リストでない・文字列でない要素を含むときも `None`(違うものとして扱う) -- `_excludes_for(snap_dir, volumes)` は `exclude_config_readable` が真なら `excludes` を返す。偽なら `_recorded_excludes(snap_dir)`、続けて `series_latest(volumes)` の世代のディレクトリの `_recorded_excludes` を見て最初の記録を返し、どちらも `None` なら `excludes`(既定の除外だけ)を返す。新しい世代は `_update_global_metadata` より前に呼ぶため、`series_latest` は 1 つ前の世代を指す - -## データ構造 - -### 世代の `meta.yml` - -| 列 | 型 | 空を許すか | 意味 | -| --- | --- | --- | --- | -| `exclude`(足す) | 文字列のリスト(並べ替え済み・重複なし) | 無いことを許す | full を作ったときの除外の一覧(正規化したパターン。マウント名の頭は付けない)。**無いのは「この変更より前に作った世代(除外を持たない)」**で、「何も外していない」ではない。空のリストは「何も外していない」 | - -既存の列(`name`・`created_at`・`type`・`volumes`・`files`・`incremental_count`・`archive_root`)は変えない。`backups/snapshot.yml` も変えない(系列の判定は `snapshot.yml` だけで行い、除外の比較は積む直前に `meta.yml` で行う。`volumes` の照合と同じ置き方)。 - -| 機能 | `meta.yml` の `exclude` | `snapshot.snar` | `backups/exclude.yml` | -| --- | --- | --- | --- | -| F1・F2 フルを作る | C | C | R | -| F1・F2 差分を積む | R | U | R | -| F3 記録を読む | R | — | — | -| F4 積み先の判定 | R | — | R | -| 復元(変えない) | — | — | — | - -- **時系列の扱い**: 世代ごとに作ったときの一覧を残し、上書きしない(差分は `exclude` を書かない)。一覧が変われば新しい世代になり、前の世代の記録はそのまま残る -- **移行**: 既存の世代は書き換えない。`exclude` が無い世代は I3 により、更新後の最初の自動スナップショットで系列ごとに新しい世代へ倒れる(前提 6) -- **切り戻し**: 変更前の devbase は `exclude` を読まずに無視する(`yaml.safe_load` の未知のキー)。変更後の世代を変更前の devbase で復元すると、除外したパスは無い(非機能の移行性の条件のとおり) - -### 利用者の除外の設定 `backups/exclude.yml` - -```yaml -# スナップショットの控えから外すパス(ボリュームの中の相対のパス) -exclude: - - .cache/pip - - work/*/node_modules -``` - -| 項目 | 規則 | -| --- | --- | -| 置き場 | `$DEVBASE_ROOT/backups/exclude.yml`。devbase は作らず書かない | -| 形 | 最上位が辞書で、`exclude` が文字列のリスト | -| ファイルが無い | 既定の除外だけ。警告を出さない(受け入れ条件 12) | -| YAML として読めない・最上位が辞書でない・`exclude` がリストでない | WARNING `backups/exclude.yml を読めないため、今の世代の除外の一覧のまま控えます(記録の無い世代だけなら既定の除外だけで作ります): {理由}` を 1 行。設定を読めなかったことを返し、控えは続ける。記録を持つ世代へ積む差分も、差分の上限・snar 無しのフォールバック・復元前バックアップで作る新しい世代のフルも、`_excludes_for` が返す記録の一覧で作る(I9) | -| `exclude` のキーが無い・`null`・空のリスト | 既定の除外だけ。警告を出さない | -| 要素が文字列でない・空・使えない値 | WARNING `backups/exclude.yml の除外 {値!r} を採りません: {理由}` を要素ごとに 1 行。残りで続ける(受け入れ条件 11) | - -### 除外のパターンの正規化と検査(`normalize`) - -| 順 | 入力の形 | 扱い | -| --- | --- | --- | -| 1 | 文字列でない | 採らない(理由: 文字列でない) | -| 2 | 前後の空白を落として空 | 採らない(理由: 空) | -| 3 | `/` で始まる | 採らない(理由: 絶対パス) | -| 4 | `?`・`[`・`]`・`\` を含む | 採らない(理由: 使えない文字。`*` 以外のワイルドカードは持たない) | -| 5 | `/` で分けた要素に `..` がある | 採らない(理由: ボリュームの外を指す) | -| 6 | 空の要素と `.` の要素を落とす(`./a//b/` → `a/b`) | 落とした後に要素が無ければ採らない(理由: 空) | -| 7 | それ以外 | 要素を `/` でつないだ文字列を返す | - -### 既定の除外(`DEFAULT_EXCLUDES`) - -| パターン | 出所 | -| --- | --- | -| `.gemini/tmp/*/logs.json` | 課題の表(S1) | -| `.gemini/tmp/*/logs` | 課題の表(S1) | -| `.codex/logs_2.sqlite` | 課題の表(S2) | -| `.codex/logs_2.sqlite-wal` | **課題の表に無い**(決定 6) | -| `.codex/logs_2.sqlite-shm` | **課題の表に無い**(決定 6) | -| `.codex/.tmp` | 課題の表(S3) | -| `.codex/cache` | 課題の表(S3) | -| `.claude/plugins/cache` | 課題の表(S4) | -| `.claude/plugins/marketplaces` | 課題の表(S4) | -| `.serena/logs` | 課題の表(S5) | - -10 個。共通ボリュームとグループのボリュームの両方に当てる(課題の表は `.gemini/tmp` を両方、ほかを共通と書くが、グループのボリュームに同じパスがあれば同じく作り直せるため、分けない)。 - -## 入出力の契約 - -CLI の引数・終了コードの体系は変えない。変わる約束は次の 3 つである。 - -| 名前 | 入力 | 出力 | 失敗の形 | 互換性 | -| --- | --- | --- | --- | --- | -| `backups/exclude.yml` | 上の「利用者の除外の設定」の形 | 次に作る控えから外れる | 読めない・使えない値は WARNING で、控えは続ける(終了コードを変えない)。読めない間は除外の違いで新しい世代を作らない(I9) | 新しい設定。無ければこれまでと同じく既定の除外だけ | -| `devbase snapshot create --name <除外の一覧が違う世代>`(`--full` なし) | 既存の世代の名前 | — | `SnapshotError` `スナップショット '{名前}' は今と違う除外の一覧で作られています。新しい世代を作成してください (devbase snapshot create)` → 終了コード 1。アーカイブ・`snapshot.snar`・`meta.yml` は変わらない | 変更前は積めた。この変更より前の世代へ名前で積むと止まるようになる | -| `devbase up` の自動スナップショットのログ | — | INFO `世代 {名前} は今と違う除外の一覧で作られているため、新しい世代を作成します`(`exclude` の無い世代も同じ文) | — | 更新後の最初の 1 回は系列ごとにこの行が出る | - -`tar_exclude_args(excludes, members)` が返す形(`members` は `backup_command` が使うメンバー名 `ai`・`group`、旧レイアウトは `.`): - -```text ---anchored --no-wildcards-match-slash --exclude=<メンバー>/<パターン> ...(メンバー × パターン) -``` - -各 `--exclude=…` は `shlex.quote` する(`*` をシェルに展開させない)。`backup_command` はこれを `tar --listed-incremental=… ` の直後、`-cf - -C /source <メンバー>` より前に置く(測った tar の振る舞いの 3 行目)。 - -## 処理の流れ - -### 自動スナップショットの積み先の判定 - -既存の順 1〜5(`docs/specifications/snapshot-series.md` の「差分の積み先」)のうち、順 4 と順 5 の間に 1 つ足す。 - -| 順 | 条件 | 結果 | ログ | -| --- | --- | --- | --- | -| 1〜4 | 既存どおり | 既存どおり | 既存どおり | -| 4a(足す) | 最新の世代のディレクトリがあり、`_recorded_excludes` が `None` か、`exclude_config_readable` が真で `set(self.excludes)` と違う | `None` | INFO `世代 {名前} は今と違う除外の一覧で作られているため、新しい世代を作成します` | -| 5〜6 | 既存どおり | 既存どおり | 既存どおり | - -順 4 の後に置くのは、`archive_root` の古い世代は除外の記録も持たず、既存の理由の行を変えないためである。設定が読めないとき(`exclude_config_readable` が偽)は、記録を持つ世代を違うと判定せず、順 5 以降へ進む(I9)。順 5(差分の上限)で新しい世代へ倒すときも、そのフルは `_excludes_for` で系列の最新の世代の記録の一覧になり、新しい世代の `meta.yml` の `exclude` も同じ一覧になる。設定を直した次の `up` では、直した一覧が記録と同じなら同じ世代へ積み続け、順 4a でもう 1 世代を作らない。 - -```mermaid -sequenceDiagram - participant C as _auto_snapshot / cmd_snapshot - participant M as SnapshotManager - participant X as exclude.py - participant T as devbase-snapshot の tar - C->>M: auto_snapshot_target() / create(name, full) - M->>X: resolve_excludes(backups_dir)(初回だけ) - X-->>M: 除外の一覧(使えない値は WARNING 済み) - alt 新しい世代・full - M->>T: tar --listed-incremental … 除外の引数 … -C /source ai group - T-->>M: 成功 / 失敗(既存の SnapshotCommandError) - M->>M: meta.yml に exclude を書く(成功のときだけ) - else 既存の世代へ差分 - M->>M: _recorded_excludes と比べる - alt 違う・記録なし - M-->>C: SnapshotError(snar・アーカイブは触らない) - else 同じ - M->>T: cp snar snar.bak && tar … 除外の引数 … - T-->>M: 成功 / 失敗(既存の扱い) - M->>M: meta.yml の files・incremental_count だけ更新 - end - end -``` - -- 比べる順は `volumes` の照合 → `archive_root` → 除外の一覧で、`_create_incremental` も同じ順に止める。snar が無いときのフルへの切り替えは、除外の比較より後に置く(記録の違う世代へ名前で積もうとしたら、snar の有無に関わらず止める) -- 設定が読めないときは、`_create_incremental` は記録を持つ世代と比べずに、その記録の一覧を tar の除外の引数に使う(I1 と I9 を同時に守る)。記録の無い世代へ名前で積むときは、読めない設定でも I3 のとおり `SnapshotError` で止める。snar 無しのフォールバックは `_create_full(name, snap_dir)` へ渡し、`_excludes_for` が同じ世代の記録を返すため、作り直したフルの `exclude` も記録のまま変わらない -- 復元前バックアップは `_backup_before_restore` → `_create_full(name, snap_dir, volumes)` を通るため、同じ除外が効き `exclude` も書かれる。旧レイアウトの世代の復元前バックアップはメンバーが `.` なので、パターンの頭は `./` になる - -## 非機能の実現方式 - -| 大項目 | 要求の条件 | 実現方式 | 確かめ方 | -| --- | --- | --- | --- | -| 性能・拡張性 | 課題の本文の測定(2026-10-02)と同じ構成で、フルの世代の展開前の控えの対象から、既定の除外のパスの合計(約 1.9GB)が減る。測った端末とボリュームの大きさを記録に残す | tar の `--exclude` で既定の除外を辿らない。差分では `logs_2.sqlite` とその WAL が入らない | リリース後テストで、利用者の端末の `devbase snapshot create --full` の大きさを変更前の世代と比べ、端末とボリュームの大きさを記録に残す | -| 運用・保守性 | 除外の一覧が前の控えと違うために新しい世代を作るときは、理由をログの 1 行で示す(受け入れ条件 6)。世代ごとの除外の一覧が後から読める(受け入れ条件 14) | 積み先の判定の順 4a の INFO 1 行。`meta.yml` の `exclude` | 単体テストでログの行と `meta.yml` を見る | -| 移行性 | 既存の世代は書き換えずにそのまま一覧・復元できる(受け入れ条件 10)。更新後の最初の自動スナップショットで、系列ごとに新しいフルの世代を作る(前提 6)。切り戻し(変更前の devbase へ戻す)で、変更後に作った世代を復元すると除外したパスは無い | 復元・一覧の経路を変えない(I8)。`exclude` の無い世代を違うものとして扱う(I3)。変更前の devbase は未知のキーを読み捨てる | 既存の復元のテストを変えずに通す。`exclude` の無い世代で順 4a が `None` を返すテスト | - -## 決定の記録 - -### 決定 1: 世代の規則と除外の規則を分けて読めるよう、除外の一覧の組み立てを `exclude.py` に置き、`manager.py` は受け取って使うだけにする - -既定の除外・設定の読み込み・パターンの検査・tar の引数の組み立ては、世代の状態に依らない処理で、Docker も `meta.yml` も要らない(副作用は設定ファイルを読むことと WARNING を出すことだけ)。`manager.py` は 1,226 行あり、世代の規則(積み先・保持・場所の検証)を持つ。除外の規則を足すと、世代の規則を読む人が検査の細部まで読むことになる。世代に関わる判断(記録・比較・積み先)は確定仕様のとおり `manager.py` に残す。 - -`manager.py` の中に関数として足す形は採らない。テストが `SnapshotManager` を組まずに検査だけを縛れなくなる。 - -根拠: Value 1(MVV 版 1) - -### 決定 2: ボリュームの下の深い場所や `chats/` の下を外さないよう、パターンにメンバー名を頭に付け、`--anchored --no-wildcards-match-slash` でメンバーより前に渡す - -GNU tar の除外は既定でアンカーが無く、`--exclude=.codex/cache` は `ai/work/.codex/cache` にも当たる(測った)。利用者のプロジェクトの中の同じ名前を黙って外すと、会話の記録や作業のファイルを失う。`*` が `/` に当たると `.gemini/tmp/*/logs.json` が `chats/` の下の `logs.json` に当たりうる。オプションはメンバーの後では効かず tar が失敗するため、`-C` より前に置く。除外の単位は「パスとその下」で、ディレクトリの中の一部だけを残すには、外したいものを個別に挙げる(`.gemini/tmp/*/logs.json` と `.gemini/tmp/*/logs`、`.claude/plugins/cache` と `.claude/plugins/marketplaces`)。 - -「外さないもの」を挙げて残りを外す形(否定のパターン)は採らない。GNU tar の `--exclude` は否定を持たず、`chats/` 以外に何があるかを列挙する前提が要る。 - -根拠: Value 2 / Value 3(MVV 版 1) - -### 決定 3: 系列ごとの除外の一覧が起動のたびに揺れないよう、利用者の除外を devbase のルートに 1 つの設定ファイル `backups/exclude.yml` で受ける - -`bin/devbase` は実行したディレクトリのプロジェクトの `env` を読み込む。環境変数で受けると、同じグループのボリュームを使うプロジェクトのどれから `up` したかで除外の一覧が変わり、そのたびに I1 で新しいフルの世代ができる。`backups/` はスナップショットの置き場で、devbase のルートに 1 つしかなく、プロジェクトの境界をまたがない。YAML のリストにするのは、空の値・文字列でない値を値として見分けて警告できる(受け入れ条件 11)ためで、`secrets/host-import.yml` と同じ書き方である。 - -環境変数(`DEVBASE_SNAPSHOT_EXCLUDE`)は上の揺れのため採らない。`project.yml` は 1 つのプロジェクトの設定で、グループのボリュームと共通ボリュームを控える除外を持つ場所ではない。 - -根拠: Value 2 / Value 3(MVV 版 1) - -### 決定 4: 使えない値や読めない設定で控えが止まらず古い世代も消えないよう、警告して続け、読めない間は今の世代の除外の一覧のまま差分を積む - -使えない値は、その値を採らずに残りで続ける。値ごとに警告するのは、どの行を直せばよいかを示すためである。止めると `devbase up` の自動スナップショットが無くなり、利用者が設定を直すまで控えが取れない。 - -読めない設定を「既定の除外だけ」として比べると、除外の一覧が変わったと判定して I1 で新しいフルの世代ができ、`_auto_snapshot` の直後の `rotate()`(系列ごとに 3 世代)が最も古い世代を消す。設定を直した次の `up` でも一覧が戻ってもう 1 世代消え、編集の誤り 1 回で復元点を 2 つ失う。そこで読めない間は除外の違いを理由に新しい世代を作らず、記録を持つ世代へはその記録の一覧で差分を積む(I9)。読めない間に差分の上限・snar 無しのフォールバック・復元前バックアップで新しいフルを作るときも、既定の除外だけで作ると設定を直した次の `up` で順 4a が違うと判定してもう 1 世代でき、同じく復元点を 2 つ失う。そのため新しいフルも、作り直す世代か系列の最新の世代の記録の一覧で作る(`_excludes_for`)。I1(1 つの世代は同じ除外の一覧)はそのまま守られ、直した設定が前と同じなら同じ世代へ積み続ける。系列のどの世代も記録を持たない(この変更より前の世代だけの)ときは読めるかどうかによらず I3 で新しい世代になり、引き継ぐ記録が無いため、そのフルは既定の除外だけで作る。 - -読めない設定で止める形は採らない。控えの無い期間を作るほうが、控えが一時的に大きくなるより戻す費用が高い。読めない設定を「既定の除外だけ」として比べる形も上の理由で採らない。利用者が読める設定で除外の一覧を変えたときに新しい世代ができ古い世代がローテーションで消えるのは、I1 から外せないため、`snapshot-guide.md` に書いて利用者が変える前に知れるようにする。 - -根拠: Value 2(MVV 版 1) - -### 決定 5: 復元の結果が除外の一覧と食い違わないよう、世代の `meta.yml` に full を作ったときの一覧を残し、違う世代へは差分を積まない - -測った結果、除外なしの full の後に除外つきの差分を積むと、外したパスは full の時点の中身で復元される。復元の結果が「除外したパスは無い」(受け入れ条件 8)とも「控えた時点の中身」とも合わない。比べるのは正規化した一覧そのもので、版の番号ではない。既定の除外を将来変えても、利用者が設定を変えても、同じ比較で新しい世代へ倒れる。並びに依らないよう並べ替えて残し、集合として比べる。 - -一覧のハッシュだけを残す形は採らない。受け入れ条件 14(後から読める)を満たさない。除外を変えたら既存の世代を作り直す形は、前提 6 と対象範囲(既存の世代を書き換えない)に反する。 - -根拠: Value 2(MVV 版 1) - -### 決定 6: 外した SQLite の本体に古い WAL が残らないよう、既定の除外に `.codex/logs_2.sqlite-wal` と `-shm` を足す - -SQLite は WAL モードで `<名前>-wal`・`<名前>-shm` を本体の横に置く(手元の `~/.codex` にもある)。本体だけを外すと、復元後に本体が無く WAL だけが残り、Codex が新しく作った本体へ古い WAL を当てようとする形になる。どちらも本体と同じく作り直せるログで、控える理由が無い。課題の表に無いパスを既定の除外に足すため、要求の「確認してから行う」に当たり、承認ゲート 1 で人が見る。 - -本体だけを外す形は採らない。WAL は使う間ずっと書き換わり、差分のたびに入る点も本体と同じである。 - -根拠: Value 2(MVV 版 1) - -### 決定 7: 既定の除外を共通ボリュームとグループのボリュームの両方に当てる - -課題の表は `.gemini/tmp` だけを両方とし、ほかを共通ボリュームと書くが、これは測った端末でそこにあったことを示すだけである。同じパスがグループのボリュームにあれば、同じく作り直せる。ボリュームごとに一覧を分けると、利用者の除外の設定にも置き場を書き分ける形が要り、間違える場所が増える。 - -根拠: Value 1(MVV 版 1) - -## テスト設計 - -| 受け入れ条件・不変条件 | どの振る舞いで縛るか | どう壊したら落ちるべきか | -| --- | --- | --- | -| 受け入れ条件 1・I5 | 実の GNU tar(`test_backup_real_tar.py`、無ければ飛ぶ)で既定の除外のパスを `ai`・`group` に置いて full を作ると、中身の一覧にどのパスもその下も無い | `backup_command` が除外の引数を渡さない・メンバーの後に置く・メンバー名を頭に付けない | -| 受け入れ条件 2・I5・I7 | 同じ full に `.gemini/tmp/

/chats/` の下(`chats/logs.json` を含む)・`.claude/plugins/installed_plugins.json`・`known_marketplaces.json`・`data/`・`.claude/projects`・`.codex/sessions`・`thread_history_1.sqlite`・`state_5.sqlite`・`.serena/language_servers`・`work/.codex/cache` がある | `--anchored` か `--no-wildcards-match-slash` を外す。既定の除外に `.gemini/tmp` や `.claude/plugins` を丸ごと入れる | -| 受け入れ条件 3 | 実の tar で full の後に `.codex/logs_2.sqlite` とほかのファイルを書き換えて差分を作ると、差分の一覧に `logs_2.sqlite` が無く、ほかのファイルはある | 差分のコマンドに除外を渡さない | -| 受け入れ条件 4 | 復元前バックアップの tar のコマンドに除外の引数があり、`meta.yml` に `exclude` がある | `_backup_before_restore` が除外を通らない経路で控える | -| 受け入れ条件 5・I6 | `backups/exclude.yml` に 1 つ足すと、次の full のコマンドにそのパターンと既定の除外の両方が `ai/`・`group/` を頭にして入る | 利用者の除外で既定の除外を置き換える。足した値を落とす | -| 受け入れ条件 6・I1・I3 | 最新の世代の `meta.yml` に `exclude` が無い・違うとき `auto_snapshot_target` が `None` と INFO 1 行。同じなら名前を返す | 順 4a を外す。リストの並びの違いを違うと判定する。記録の無い世代を同じとみなす | -| 受け入れ条件 7・I1 | 名前を指定した差分で記録が違う・無いと `SnapshotError`、`cmd_snapshot` は終了コード 1。tar を呼ばず、`snapshot.snar` と `meta.yml` のバイト列が変わらない。snar が無い世代でも止まる | 比較を `cp snar snar.bak` の後に置く。snar が無いときのフルへの切り替えを比較より前に置く | -| 受け入れ条件 8・I8 | 実の tar で除外つきの full と差分を順に展開すると、除外の外のパスは差分の時点の中身で、除外したパスは無い | 復元のコマンドを変える。差分で除外を落とす | -| 受け入れ条件 9 | マージ前の手動確認(下の「未確認のまま残ること」) | — | -| 受け入れ条件 10・I8 | 既存の復元のテスト(`test_restore_incremental.py`・`test_manager_volumes.py`)を変えずに通る。`exclude` の無い世代の復元のコマンドが変更前と同じ | 復元で `exclude` を読む・無いと止める | -| 受け入れ条件 11・I4 | 絶対パス・`..` を含む値・空・`null`・数・`?[]\` を含む値をそれぞれ書くと、値ごとに WARNING 1 行で採らず、残りの値は効く。`./a//b/` は `a/b` になる | 検査を外す。1 つの不正な値で設定全体を捨てる | -| 受け入れ条件 11(読めない設定) | YAML として壊れた設定・最上位がリスト・`exclude` が文字列で、WARNING 1 行、`resolve_excludes` の一覧は既定の除外だけで、設定を読めたかが偽 | 例外で控えを止める。読めない設定を「読めた」と返す | -| 受け入れ条件 11(読めない設定)・I9・I1 | 最新の世代の `meta.yml` に既定の除外と違う `exclude` があり設定が読めないとき、`auto_snapshot_target` は INFO を出さずにその世代の名前を返し、`_create_incremental` の tar の除外の引数は記録の一覧で、`meta.yml` の `exclude` は変わらない。直した設定(記録と同じ一覧)の次の判定も同じ名前を返す。記録の無い世代では読めない設定でも `None` | 順 4a で読めない設定を既定の除外だけとして比べる。差分の tar に既定の除外だけを渡す。記録の無い世代を引き継ぐ | -| 受け入れ条件 11(読めない設定)・I9・I1(新しいフル) | 系列の最新の世代の `meta.yml` に既定の除外と違う `exclude` があり設定が読めないとき、(a) `incremental_count` が上限に達して `auto_snapshot_target` が `None` を返した後の新しい世代のフル、(b) snar を消した世代への差分(フォールバックのフル)、(c) 復元前バックアップで、tar の除外の引数と `meta.yml` の `exclude` が記録の一覧である。(a) の後に直した設定(記録と同じ一覧)で判定すると新しい世代の名前を返す。系列のどの世代も記録を持たないときは既定の除外だけ | 新しいフルを既定の除外だけで作る。直した設定の次の判定で `None` を返す | -| 受け入れ条件 12 | `backups/exclude.yml` が無い・`exclude` が無い・空のリストで、既定の除外だけで警告が出ない | 無いときに警告を出す | -| 受け入れ条件 13 | 実の tar で既定の除外のパスが 1 つも無い木を控えて、終了コード 0 で警告が無い | 除外の対象の有無を事前に確かめて止める | -| 受け入れ条件 14・I2 | full の `meta.yml` の `exclude` が並べ替えた一覧で、差分を積んでも変わらない | 差分で `exclude` を上書きする。記録を書かない | -| I5(旧レイアウト) | 旧レイアウト(`{'': …}`)の復元前バックアップのコマンドでは、パターンの頭が `./` | メンバー名をいつも `ai`・`group` にする | -| 同じ実行で警告を 2 度出さない | `auto_snapshot_target` と `create` を同じ `SnapshotManager` で呼んでも、使えない値の WARNING は 1 回 | `excludes` を参照のたびに組み立てる | - -## 設計の結果 - -| 課題 | 扱い | 取り込み先 | 触るファイル | -| --- | --- | --- | --- | -| #368 | 実装する | — | `lib/devbase/snapshot/`、`tests/snapshot/`、`docs/user/snapshot-guide.md`、`docs/user/cli-reference/05-snapshot.md`、`CHANGELOG.md` | - -## 未確認のまま残ること - -| 項目 | 内容 | -| --- | --- | -| 除外したパスが無い状態での各 CLI の起動(受け入れ条件 9・前提 4) | 実装の担当がマージ前に、使い捨てのホームのボリュームから既定の除外のパスを消して dev コンテナを起動し、Claude Code・Codex・Gemini CLI・Serena を起動して確かめる。起動できない CLI があれば、そのパスを既定の除外から外し、実装の PR に決定の番号と理由を書く | -| `.claude/plugins/marketplaces` が無いときにプラグインを使えるか | `known_marketplaces.json` は取得先の場所を指す。Claude Code が取り直すかは受け入れ条件 9 で確かめる | -| `.codex/.tmp` の中の取得物 | Codex は `.codex/.tmp/marketplaces/` にマーケットプレイスの写しを置く(NDF の `scripts` の探し方の候補 4 がそこを見る)。復元後に Codex が取り直すまで写しが無い。受け入れ条件 9 で Codex のプラグインが使えるかを見る | -| `.gemini/tmp/<プロジェクト>/` の `chats/`・`logs.json`・`logs/` 以外(前提 3) | 外さない(既定の除外に入れない)。`logs.json`・`logs/` を外して Gemini CLI が会話を再開できるかは受け入れ条件 9 で見る | -| 性能の条件 | 利用者の端末でのリリース後テスト(非機能の実現方式) | -| 既存の世代の名前で `--full` を打つと full が変更分だけになる | 範囲外として #458 に起票した。除外の一覧が違う世代へ名前で `--full` を打つ利用者がこの不具合に当たりうる。案内は「新しい世代を作成してください (devbase snapshot create)」とし、`--name` に既存の名前を勧めない | diff --git a/issues/issue-368-requirements.md b/issues/issue-368-requirements.md deleted file mode 100644 index 2bb77a07..00000000 --- a/issues/issue-368-requirements.md +++ /dev/null @@ -1,194 +0,0 @@ -# #368: スナップショットが作り直せるキャッシュ・一時ファイル・ログまで控える(除外の仕組みが無い) - -正は課題の本文(#368)で、この文書はその写しである。`spec-copy.py write` で作り直す。手で直さない。 - -## 何を見つけたか - -スナップショット(`devbase snapshot`)は、対象のボリューム(共通 `devbase_home_ubuntu` とグループのボリューム)を丸ごと控え、除外の仕組みを持たない(`lib/devbase/snapshot/manager.py` の `backup_command` は `tar --listed-incremental=... -cf - -C /source <各マウント先>` だけで、除外の指定が無い)。作り直せるキャッシュ・一時ファイル・ログも毎回控えている。 - -手元のボリュームで測った内訳のうち、控えなくてよいもの: - -| パス(ボリューム内) | 大きさ | 中身 | 外してよい理由 | -| --- | --- | --- | --- | -| `.gemini/tmp/<プロジェクト>/` の `chats/` **以外**(`logs.json`・`logs/` など。共通・グループの両方) | 共通側で約 325MB(2026-10-02) | Gemini の作業用のログ・一時ファイル | 一時ファイル・ログ。ただし `chats/` 以外に復元で要るものが無いかは未確認 | -| `.codex/logs_2.sqlite`(共通) | 602MB | Codex の動作ログ(`logs` テーブルだけ。直近約 9.5 日分で古い行から消える) | 会話の履歴は `sessions/`・`thread_history_1.sqlite`・`state_5.sqlite` にある。使う間ずっと書き換わるため、差分のたびにファイル全体が入る | -| `.codex/.tmp`・`.codex/cache`(共通) | 約 200MB | 一時ファイル・キャッシュ | 作り直せる | -| `.claude/plugins/cache`・`.claude/plugins/marketplaces`(共通) | 約 607MB・約 70MB | プラグインとマーケットプレイスの取得物 | 配布元から取り直せる | -| `.serena/logs`(共通) | 158MB | 日付ごとのログ(消されず溜まる) | 復元で要らない | - -外さないもの: - -- 会話の記録(`.claude/projects`・`.codex/sessions`・`thread_history_1.sqlite`・`state_5.sqlite`・**`.gemini/tmp/<プロジェクト>/chats/`**)。`.gemini/tmp` は名前に反して Gemini の会話の記録(`chats/session-*.jsonl`)を持つ。共通側の `.gemini/tmp` 約 1.3GB のうち約 984MB が `chats/` である(2026-10-02)。`.gemini/tmp` を丸ごと外すと会話の記録を失う -- `.claude/plugins` の状態のファイル(`installed_plugins.json`・`known_marketplaces.json`・`data/`)。入れたプラグインとマーケットプレイスの一覧と、プラグインが持つデータで、取り直せない -- `.serena/language_servers` — 取り直しは TLS を傍受するプロキシの下で失敗しやすく、手で差し替えた版(`*.bak-*`)がある。変わらないため差分への負担は小さい - -## 決めること - -- 除外の一覧を持つ場所(既定の一覧を本体に持つか、利用者が足せるか) -- 除外の単位。`.gemini/tmp` と `.claude/plugins` は、ディレクトリ丸ごとではなく中の一部だけを外す必要がある -- 復元したときに除外したパスが無い状態で、各 CLI が起動できること(`logs_2.sqlite` は `_sqlx_migrations` を持つので作り直すと見ているが未確認。`.claude/plugins/cache` が無いときにプラグインを取り直すか) -- 世代の途中で除外を変えたときの扱い。差分は GNU tar の `--listed-incremental` で作っており、前の世代に入っていたパスを次の差分で除外すると、そのパスは消えたものとして記録される。差分を順に展開する復元(`--listed-incremental=/dev/null`)では、そのパスが消える。除外を足すときは新しいフルから始めるなどの扱いが要る - -## 関連 - -差分バックアップが毎回全体を控えていた不具合(tar の起点の修正、PR #369 でマージ済み)とは別。そちらの修正で変わらないファイルは差分に入らないため、この課題の効果は主に「頻繁に書き換わるもの」とフルバックアップの大きさに出る。 - -## 由来 - -利用者からの依頼(2026-09-30、差分バックアップが遅い件の調査) - -## 依頼(原文) - -> 利用者からの依頼(2026-09-30、差分バックアップが遅い件の調査) - -(書き手: 起票者(課題の本文の「由来」の節)、日付: 2026-09-30。依頼の原文の文は課題に残っていない。下の「何を見つけたか」以降の表と条件は、起票者が調査で書いたものである) - -> 除外の一覧は **本体に既定の一覧を持ち、利用者が設定で足せる** 形にする。既定の一覧は本文の「控えなくてよいもの」の表から作る(`.gemini/tmp/<プロジェクト>/chats/` は外さない)。 -> -> 残りの「決めること」(除外の単位、除外したパスが無いときの各 CLI の起動、世代の途中で除外を変えたときの扱い)は設計の中で決める。これで判断待ちは解けた。 - -(書き手: 利用者(課題のコメント「決定(2026-10-11 利用者)」)、日付: 2026-10-11) - -> #368。人へ問わずに進め、決められない点は前提か未決として課題の本文へ書く。 - -(書き手: AI の起動指示、日付: 2026-10-12。利用者の指示の原文として扱わない) - -## 起票者の推定 - -題・本文のうち、利用者の指示の原文に無く起票者が推して足した条件。承認ゲート 1 で利用者が確かめるまで、利用者の決定として扱わない。 - -| # | 条件 | 推した出所 | この要求での扱い | -| --- | --- | --- | --- | -| S1 | `.gemini/tmp/<プロジェクト>/` のうち `chats/` 以外(`logs.json`・`logs/` など)を外す。共通・グループの両方 | 課題の本文の「控えなくてよいもの」の表(2026-10-02 の手元の測定)。`chats/` 以外に復元で要るものが無いかは未確認と本文が書いている | 受け入れ条件 1・2。未確認の点は前提 3 | -| S2 | `.codex/logs_2.sqlite`(共通)を外す | 同じ表。会話の履歴は `sessions/`・`thread_history_1.sqlite`・`state_5.sqlite` にあると起票者が調べた | 受け入れ条件 1 | -| S3 | `.codex/.tmp`・`.codex/cache`(共通)を外す | 同じ表 | 受け入れ条件 1 | -| S4 | `.claude/plugins/cache`・`.claude/plugins/marketplaces`(共通)を外す | 同じ表。配布元から取り直せると起票者が推した | 受け入れ条件 1。取り直すかは前提 4 と受け入れ条件 9 | -| S5 | `.serena/logs`(共通)を外す | 同じ表 | 受け入れ条件 1 | -| S6 | 会話の記録(`.claude/projects`・`.codex/sessions`・`thread_history_1.sqlite`・`state_5.sqlite`・`.gemini/tmp/<プロジェクト>/chats/`)は外さない | 本文の「外さないもの」 | 受け入れ条件 2 | -| S7 | `.claude/plugins` の状態のファイル(`installed_plugins.json`・`known_marketplaces.json`・`data/`)は外さない | 本文の「外さないもの」 | 受け入れ条件 2 | -| S8 | `.serena/language_servers` は外さない | 本文の「外さないもの」。TLS を傍受するプロキシの下で取り直しが失敗しやすく、手で差し替えた版がある | 受け入れ条件 2 | -| S9 | 世代の途中で除外を変えると、差分を順に展開する復元でそのパスが消える。新しいフルから始めるなどの扱いが要る | 本文の「決めること」の 4 つ目(GNU tar の `--listed-incremental` の振る舞いからの推定) | 受け入れ条件 6・7。扱いの形は設計で決める | -| S10 | `logs_2.sqlite` は `_sqlx_migrations` を持つので、無ければ作り直す | 本文の「決めること」の 3 つ目(未確認と本文が書いている) | 前提 4 と受け入れ条件 9 で確かめる | - -## 目的 - -- スナップショットが、作り直せるもの(一時ファイル・ログ・取り直せる取得物)を控えずに済むようにし、フルの世代と頻繁に書き換わるファイルの差分の大きさを減らす -- 会話の記録・入れたプラグインの一覧・利用者が手で整えたものは、これまでどおり控え、復元で戻る - -## 前提 - -- 前提 1: 対象は devbase のスナップショット(`devbase snapshot create`・`devbase up` の自動スナップショット・`restore` の前に作る復元前バックアップ)が作るアーカイブである。`snapshot copy` は既存の世代のアーカイブを写すだけで、除外を新たに適用しない -- 前提 2: 既定の除外の一覧は、課題の本文の「控えなくてよいもの」の表(S1〜S5)から作る。利用者の決定(2026-10-11)に従う。表のパスが端末に無いときは、そのパスを除外しても何も起きない -- 前提 3: `.gemini/tmp/<プロジェクト>/` の `chats/` 以外に、Gemini の復元で要るものは無い。誤っていたら、そのパスを既定の除外の一覧から外す形で直せる(局所的に直せる) -- 前提 4: 除外したパスが無い状態から、Claude Code・Codex・Gemini CLI・Serena は起動でき、要るものを作り直すか取り直す。成否はマージの前に受け入れ条件 9 で確かめ、起動できない CLI があれば、そのパスを既定の除外の一覧から外す -- 前提 5: 利用者が足す除外は、ボリュームの中の相対のパス(とその下)で書く。書き方(パターンの記法・置き場)は設計で決める -- 前提 6: 除外の一覧が変わったことで、更新後の最初の自動スナップショットが系列ごとに新しいフルの世代を作ってよい(1 系列につき 1 回のフルの取り直し)。既存の世代のアーカイブは書き換えない - -## 対象範囲 - -含む: - -- 既定の除外の一覧を本体に持ち、作成(フル・差分)と復元前バックアップのアーカイブへ適用すること -- 利用者が設定で除外を足せること -- 除外の一覧が前の控えと違うときに、復元で除外の外のデータを失わない扱い -- 除外したパスが無い状態で復元した後に、各 CLI が起動できることの確認 -- 利用者向けの文書(スナップショットガイド・CLI リファレンス・設定の文書)と `CHANGELOG.md` の `[Unreleased]` - -含まない: - -- 既定の除外の一覧から利用者が項目を外すこと(利用者の決定は「足せる」だけである) -- 既にある世代のアーカイブから除外のパスを取り除くこと(作り直さない) -- 復元の対象・順序・失敗時の案内の変更(除外したパスが復元後に無いことを除く) -- 世代の保持(ローテーション)・系列・最小間隔の規則の変更 -- 各 CLI のログやキャッシュの置き場・保存の期間を変えること(CLI 側の設定) -- `snapshot copy` で除外を適用し直すこと -- base イメージに入れる道具の変更(P1 に当たる変更はしない) - -## ドメインイベント - -| # | イベント | 引き金 | 失敗したとき | 順序の前提 | -| --- | --- | --- | --- | --- | -| E1 | 除外の一覧を決めた(既定の一覧と利用者が足した分を合わせた) | `create`・自動スナップショット・復元前バックアップの開始 | 利用者の設定が読めない・不正な項目がある → 受け入れ条件 11・12 | — | -| E2 | 積み先の世代の除外の一覧と、今の除外の一覧を比べた | 自動スナップショットの積み先の判定・名前を指定した差分 | 前の世代が除外の一覧を記録していない(この変更より前の世代) → 違うものとして扱う(受け入れ条件 6) | E1 | -| E3 | 新しいフルの世代を作った(除外を適用した) | E2 で違う・系列に世代が無い・差分の上限 | tar が失敗 → 既存の `create` の失敗の扱い(変えない) | E1・E2 | -| E4 | 差分を積んだ(同じ除外を適用した) | E2 で同じ | 同上 | E1・E2 | -| E5 | 世代に、作ったときの除外の一覧を記録した | E3・E4 | 書けない → 既存の `meta.yml` の書き込みの失敗の扱い | E3 か E4 | -| E6 | 世代を復元した(ボリュームを空にしてから展開した) | `devbase snapshot restore` | 既存の扱い(変えない) | E5 | -| E7 | 復元後に CLI を起動し、除外したパスが無い状態から CLI が要るものを作り直した | 利用者の CLI の起動 | 起動できない → 前提 4 に従い既定の除外から外す | E6 | - -E2 は E1 の順序を仮定しているため、受け入れ条件 6・7 へ移した。E7 は引き金が devbase の外(利用者の CLI の起動)にあるため、前提 4 と受け入れ条件 9 へ移した。 - -## 用語 - -| 用語 | 意味 | -| --- | --- | -| 除外の一覧 | スナップショットのアーカイブに入れないパスの一覧。既定の除外と利用者の除外を合わせたもの | -| 既定の除外 | devbase の本体が持つ除外の一覧。作り直せる一時ファイル・ログ・取り直せる取得物を挙げる | -| 利用者の除外 | 利用者が設定で既定の除外に足すパス | - -## 受け入れ条件 - -- [ ] 1. 前提: 共通ボリュームとグループのボリュームに、既定の除外のパス(`.gemini/tmp/<プロジェクト>/logs.json`・`.gemini/tmp/<プロジェクト>/logs/`・`.codex/logs_2.sqlite`・`.codex/.tmp`・`.codex/cache`・`.claude/plugins/cache`・`.claude/plugins/marketplaces`・`.serena/logs`)がある。操作: `devbase snapshot create --full` で世代を作る。結果: `full.tar.zst` の中身の一覧(`tar -t`)に、どのパスもその下のファイルも無い -- [ ] 2. 同じ世代の `full.tar.zst` の中身の一覧に、`.claude/projects`・`.codex/sessions`・`.codex/thread_history_1.sqlite`・`.codex/state_5.sqlite`・`.gemini/tmp/<プロジェクト>/chats/` の下のファイル・`.claude/plugins/installed_plugins.json`・`.claude/plugins/known_marketplaces.json`・`.claude/plugins/data/`・`.serena/language_servers` がある(置いたパスのとき) -- [ ] 3. 差分(`incr-NNN.tar.zst`)でも同じ除外が効き、前の控えから書き換えた `.codex/logs_2.sqlite` が差分の中身の一覧に無い -- [ ] 4. 復元前バックアップ(`pre-restore-*`)のアーカイブにも同じ除外が効く -- [ ] 5. 利用者が設定で除外を 1 つ足すと、次に作る世代のアーカイブの中身の一覧にそのパスとその下が無く、既定の除外も引き続き効いている -- [ ] 6. 前提: 系列の最新の世代が、今と違う除外の一覧で作られている(この変更より前に作った、除外を持たない世代を含む)。操作: `devbase up` の自動スナップショット。結果: その世代へ差分を積まず、系列に新しい世代を作り、その理由をログの 1 行で示す -- [ ] 7. 前提: 名前を指定して、今と違う除外の一覧で作った世代へ差分を作ろうとする。結果: 差分を作らず、理由を示して止まる(終了コード 1)。その世代のアーカイブと `snapshot.snar` は変わらない -- [ ] 8. 除外を適用した世代を復元すると、除外の外のパスは控えた時点の中身に戻り、除外したパスは復元後のボリュームに無い(復元の前にボリュームを空にするため) -- [ ] 9. 前提: 既定の除外のパスを消したホームで dev コンテナを起動する。操作: Claude Code・Codex・Gemini CLI・Serena をそれぞれ起動する。結果: どれもエラーで止まらずに起動し、Claude Code は入れたプラグインの一覧(`installed_plugins.json`)のプラグインを使える状態になる -- [ ] 10. この変更より前に作った世代(除外を持たない世代)を、変更後の devbase で一覧・復元でき、復元の結果は変更前の devbase で復元したときと同じである -- [ ] 11. 利用者の除外に、ボリュームの外を指す値(絶対パス・`..` の要素を含む)や空の値を書くと、その値を採らずに警告を 1 行出し、残りの除外の一覧で控えを作る -- [ ] 12. 利用者の除外を書いていないときは、既定の除外だけで控えを作り、警告を出さない -- [ ] 13. 除外のパスが端末に無いとき(その CLI を使っていない)、控えの作成は失敗せず、警告も出さない -- [ ] 14. 世代ごとに、作ったときの除外の一覧が後から読める(世代のメタ情報に残る) - -## 非機能の条件 - -| 大項目 | 条件 | -| --- | --- | -| 性能・拡張性 | 課題の本文の測定(2026-10-02)と同じ構成で、フルの世代の展開前の控えの対象から、既定の除外のパスの合計(約 1.9GB)が減る。測った端末とボリュームの大きさを記録に残す | -| 運用・保守性 | 除外の一覧が前の控えと違うために新しい世代を作るときは、理由をログの 1 行で示す(受け入れ条件 6)。世代ごとの除外の一覧が後から読める(受け入れ条件 14) | -| 移行性 | 既存の世代は書き換えずにそのまま一覧・復元できる(受け入れ条件 10)。更新後の最初の自動スナップショットで、系列ごとに新しいフルの世代を作る(前提 6)。切り戻し(変更前の devbase へ戻す)で、変更後に作った世代を復元すると除外したパスは無い | - -## 影響 - -| 対象 | 影響 | -| --- | --- | -| 公開インタフェース | 変わる。利用者の除外を足す設定が増える(互換を壊さない追加)。CLI の引数は変えない | -| データ | 世代のメタ情報に除外の一覧が増える。既存の世代は書き換えない。スナップショットに入らなくなるパスがあり、復元後にそのパスは無い | -| 既存の振る舞い | 控えの中身から既定の除外のパスが消える。更新後の最初の自動スナップショットが系列ごとに新しい世代を作る。違う除外の一覧の世代へ名前を指定して差分を作ると止まる | - -## 検証手段 - -| 項目 | 手段 | -| --- | --- | -| テスト | `env -u DEVBASE_ROOT uv run --locked pytest tests/ -q` | -| 静的解析 | `uvx ruff check --select=E9,F63,F7,F82 lib`・`python3 .github/scripts/proper_term_check.py` | -| 手動確認(マージ前) | 受け入れ条件 1〜4・8: 作業用の名前のボリューム(利用者のボリュームではない)に既定の除外のパスと外さないパスを置き、実の `devbase-snapshot` イメージで作成・差分・復元を行い、アーカイブの中身の一覧と復元後のボリュームを見る。受け入れ条件 9: 使い捨てのホームのボリュームから既定の除外のパスを消して dev コンテナを起動し、4 つの CLI を起動する | -| 手動確認 | 非機能の条件の性能: 利用者の端末でリリース後に `devbase snapshot create --full` の大きさを変更前の世代と比べる(リリース後テスト) | - -## 前提とする取り決め - -| 項目 | 参照先 / 決めたこと | -| --- | --- | -| プロジェクト構造 | `AGENTS.md` の「このリポジトリ」。除外の規則は `lib/devbase/snapshot/` に置く(確定仕様 `docs/specifications/snapshot-series.md` の「世代の規則は `manager.py` に置く」) | -| コーディング規約 | `AGENTS.md` の「書き方」と「検査」 | -| テスト戦略 | 単体テストは `DEVBASE_ROOT` を `tmp_path` へ向け、`_run_docker_tar` を差し替えて Docker を起動しない(確定仕様の「テスト観点」)。tar に渡す除外の形と積み先の判定はここで担保する。tar の除外の実際の効き目と復元の結果はマージ前の手動確認で担保する | - -## 境界 - -| 区分 | 内容 | -| --- | --- | -| 常に行う | 既存のスナップショットのテストを通す。利用者に見える変更として `CHANGELOG.md` の `[Unreleased]` を書く。実機の記録は中立の呼び名で書く | -| 確認してから行う | 既定の除外の一覧に、課題の本文の表に無いパスを足すこと(承認ゲートで人が見る)。利用者の設定の置き場を新しく作ること | -| 行わない | 利用者のボリュームでの作成・復元の確認。既存の世代のアーカイブの書き換え。base イメージの道具の変更 | - -## 未決 - -| 項目 | 誰が決めるか | 期限 | -| --- | --- | --- | -| 除外の単位と書き方(`.gemini/tmp/<プロジェクト>/` のうち `chats/` だけを残す表し方、パターンの記法) | 設計(利用者の決定 2026-10-11 で設計に任された) | 設計の承認ゲート | -| 利用者の除外の置き場(`devbase env` の変数か、設定ファイルか) | 設計 | 設計の承認ゲート | -| 除外の一覧が違うと判定する方法(世代のメタ情報の何と比べるか) | 設計(利用者の決定 2026-10-11 で設計に任された) | 設計の承認ゲート | -| 前提 3・4 が外れたときに既定の除外から外すパス | 実装の担当(受け入れ条件 9 の結果による) | 実装の PR | From 7977270a2096737b4587f3b6eb4c23d8b9d86baa Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 12 Oct 2026 21:01:00 +0900 Subject: [PATCH 3/3] =?UTF-8?q?Docs:=20=E7=B3=BB=E5=88=97=E3=81=AE?= =?UTF-8?q?=E7=A2=BA=E5=AE=9A=E4=BB=95=E6=A7=98=E3=81=8B=E3=82=89=E9=99=A4?= =?UTF-8?q?=E5=A4=96=E3=81=AE=E4=B8=80=E8=A6=A7=E3=81=AE=E7=A2=BA=E5=AE=9A?= =?UTF-8?q?=E4=BB=95=E6=A7=98=E3=81=B8=E3=81=A4=E3=81=AA=E3=81=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01An6x89UFiiBZS373jKdShZ --- docs/specifications/snapshot-series.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/specifications/snapshot-series.md b/docs/specifications/snapshot-series.md index 5b184d49..0b9eb5fa 100644 --- a/docs/specifications/snapshot-series.md +++ b/docs/specifications/snapshot-series.md @@ -152,7 +152,7 @@ classDiagram 止める。旧レイアウト(ルートへ直接マウント)の `full` は起点が `.` のままなので `archive_root` を書かない - 名前を明示して組の違う世代へ差分を作ろうとすると、`_create_incremental` が理由を示して `SnapshotError` で止める(旧レイアウトの世代へは差分を積まない) -- 順 4a は除外の一覧(既定の除外と `backups/exclude.yml`。#368)の違いを見る。1 つの世代は full を +- 順 4a は除外の一覧(既定の除外と `backups/exclude.yml`。[スナップショットの除外の一覧](snapshot-exclude.md))の違いを見る。1 つの世代は full を 作ったときの一覧で差分も積む。違う一覧の差分を積むと、外したパスが full の時点の中身で復元されるためである。 `backups/exclude.yml` を読めない間は、記録を持つ世代を違うと判定せず順 5 へ進み、その記録の一覧で積む (読めない設定で新しい世代ができ、ローテーションで古い世代が消えるのを防ぐ)。名前を明示して除外の一覧の @@ -380,3 +380,4 @@ CLI と TUI: - [コンテナ運用ガイド](../user/container-operations.md) - [別ホストの Docker への dev コンテナ起動(docker context)](remote-docker-context.md) - [位置引数の解決(プロジェクト名・イメージ名)](cli-argument-resolution.md)(スナップショットの名前の規則) +- [スナップショットの除外の一覧](snapshot-exclude.md)