From 202e2cc044ef9f61e22332ef88ec9a2a9511e3f7 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 12 Oct 2026 18:03:49 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat(snapshot):=20=E6=97=A2=E5=AE=9A?= =?UTF-8?q?=E3=81=AE=E9=99=A4=E5=A4=96=E3=81=A8=20backups/exclude.yml=20?= =?UTF-8?q?=E3=81=AE=E9=99=A4=E5=A4=96=E3=82=92=E6=8E=A7=E3=81=88=E3=81=8B?= =?UTF-8?q?=E3=82=89=E5=A4=96=E3=81=99=20(#368)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 除外の一覧を exclude.py で組み立て、フル・差分・復元前バックアップの tar に --anchored --no-wildcards-match-slash でメンバーより前に渡す。世代の meta.yml に 作ったときの除外の一覧を残し、違う一覧の世代へは差分を積まない(自動は新しい世代、 名前の指定は止める)。設定が読めない間は世代の記録の一覧で控える。 Co-Authored-By: Claude Opus 5.5 --- lib/devbase/snapshot/exclude.py | 124 +++++++++++++++++++++ lib/devbase/snapshot/manager.py | 107 +++++++++++++++++- tests/snapshot/test_archive_layout.py | 5 +- tests/snapshot/test_manager_series.py | 2 + tests/snapshot/test_restore_incremental.py | 12 +- 5 files changed, 239 insertions(+), 11 deletions(-) create mode 100644 lib/devbase/snapshot/exclude.py diff --git a/lib/devbase/snapshot/exclude.py b/lib/devbase/snapshot/exclude.py new file mode 100644 index 00000000..9762a6c5 --- /dev/null +++ b/lib/devbase/snapshot/exclude.py @@ -0,0 +1,124 @@ +"""スナップショットの控えから外すパス (除外の一覧) の組み立て (#368) + +既定の除外 (``DEFAULT_EXCLUDES``) と、利用者が ``backups/exclude.yml`` に書いた除外を +合わせて除外の一覧にし、GNU tar の引数へ変える。世代の状態に依らず、Docker も +``meta.yml`` も使わない。世代に関わる判断 (記録・比較・積み先) は ``manager.py`` が持つ。 + +除外のパターンはボリュームの中の相対のパスで、``*`` は 1 つの要素の中の任意の文字列に +当たる。当たったパスとその下を控えない。各ボリュームの頭 (``ai/``・``group/``・旧レイアウトの +``./``) からだけ当たり、ボリュームの下の深い場所の同じ名前には当たらない。 +""" + +from __future__ import annotations + +import shlex +from pathlib import Path + +import yaml + +from devbase.log import get_logger + +logger = get_logger(__name__) + +# 利用者の除外の設定。devbase のルートの ``backups/`` に 1 つだけ置き、devbase は読むだけ。 +USER_CONFIG = 'exclude.yml' + +# 既定の除外。作り直せる一時ファイル・ログ・取り直せる取得物だけを挙げる。 +# 会話の記録 (``.claude/projects``・``.codex/sessions``・``.gemini/tmp/*/chats`` など)・ +# プラグインの状態のファイル (``.claude/plugins/installed_plugins.json`` など)・ +# ``.serena/language_servers`` には当てない。 +DEFAULT_EXCLUDES: tuple = ( + '.gemini/tmp/*/logs.json', + '.gemini/tmp/*/logs', + '.codex/logs_2.sqlite', + '.codex/logs_2.sqlite-wal', + '.codex/logs_2.sqlite-shm', + '.codex/.tmp', + '.codex/cache', + '.claude/plugins/cache', + '.claude/plugins/marketplaces', + '.serena/logs', +) + +# ``*`` 以外のワイルドカードとエスケープの文字。除外のパターンは持たない。 +_FORBIDDEN_CHARS = ('?', '[', ']', '\\') + + +def normalize(value) -> str: + """除外のパターンを正規化して返す。使えない値は理由つきの ``ValueError``。""" + if not isinstance(value, str): + raise ValueError('文字列ではありません') + text = value.strip() + if not text: + raise ValueError('空です') + if text.startswith('/'): + raise ValueError('絶対パスです') + if any(ch in text for ch in _FORBIDDEN_CHARS): + raise ValueError('使えない文字を含みます (ワイルドカードは * だけ)') + parts = text.split('/') + if '..' in parts: + raise ValueError('ボリュームの外を指します (.. を含みます)') + parts = [p for p in parts if p not in ('', '.')] + if not parts: + raise ValueError('空です') + return '/'.join(parts) + + +def _load_user_excludes(backups_dir: Path) -> tuple: + """利用者の除外を読む。``(パターンのリスト, 設定を読めたか)`` を返す。""" + path = backups_dir / USER_CONFIG + if not path.exists(): + return [], True + try: + with open(path, encoding='utf-8') as f: + data = yaml.safe_load(f) + if data is None: + data = {} + if not isinstance(data, dict): + raise ValueError('最上位が辞書ではありません') + values = data.get('exclude') + if values is None: + values = [] + if not isinstance(values, list): + raise ValueError('exclude がリストではありません') + except (OSError, UnicodeDecodeError, yaml.YAMLError, ValueError) as e: + logger.warning( + "backups/%s を読めないため、今の世代の除外の一覧のまま控えます" + "(記録の無い世代だけなら既定の除外だけで作ります): %s", USER_CONFIG, e) + return [], False + + patterns = [] + for value in values: + try: + patterns.append(normalize(value)) + except ValueError as e: + logger.warning("backups/%s の除外 %r を採りません: %s", USER_CONFIG, value, e) + return patterns, True + + +def resolve_excludes(backups_dir: Path) -> tuple: + """既定の除外と利用者の除外を合わせる。 + + Returns: + ``(除外の一覧, 設定を読めたか)``。一覧は重複を除いて並べ替えたタプル。 + 設定ファイルが無いときは読めたとみなす。 + """ + user, readable = _load_user_excludes(backups_dir) + return tuple(sorted(set(DEFAULT_EXCLUDES) | set(user))), readable + + +def tar_exclude_args(excludes, members) -> str: + """除外の一覧から GNU tar の引数を組み立てる。 + + ``members`` は控えるメンバー名 (``ai``・``group``。旧レイアウトは ``.``)。各パターンに + メンバー名を頭に付け、``--anchored --no-wildcards-match-slash`` で頭からだけ・1 つの要素の + 中だけに当てる。これらのオプションはメンバーより前に置かないと効かない。 + 一覧が空なら空文字を返す。 + """ + if not excludes: + return '' + args = ['--anchored', '--no-wildcards-match-slash'] + for member in members: + for pattern in excludes: + args.append(shlex.quote(f'--exclude={member}/{pattern}')) + return ' '.join(args) diff --git a/lib/devbase/snapshot/manager.py b/lib/devbase/snapshot/manager.py index ce550937..ead31a0d 100644 --- a/lib/devbase/snapshot/manager.py +++ b/lib/devbase/snapshot/manager.py @@ -12,6 +12,7 @@ from devbase.errors import DevbaseError, SnapshotCommandError, SnapshotError from devbase.log import get_logger +from devbase.snapshot.exclude import resolve_excludes, tar_exclude_args from devbase.utils.names import is_single_segment_name from devbase.volume.manager import ( HOME_UBUNTU_VOLUME, @@ -214,6 +215,76 @@ def __init__(self, devbase_root: Path, group: Optional[str] = None): self._metadata_path = self.backups_dir / METADATA_FILE self._group = group self._volumes: Optional[dict] = None + self._excludes: Optional[tuple] = None + self._exclude_config_readable = True + + def _resolve_excludes(self) -> None: + # 同じ実行で警告を 2 度出さないよう、初回の参照で 1 回だけ組み立てる + if self._excludes is None: + self._excludes, self._exclude_config_readable = resolve_excludes( + self.backups_dir) + + @property + def excludes(self) -> tuple: + """今の除外の一覧 (既定の除外と ``backups/exclude.yml`` の除外。並べ替え済み)。""" + self._resolve_excludes() + return self._excludes + + @property + def exclude_config_readable(self) -> bool: + """``backups/exclude.yml`` を読めたか (無いときは真)。""" + self._resolve_excludes() + return self._exclude_config_readable + + def _recorded_excludes(self, snap_dir: Path) -> Optional[tuple]: + """世代の ``meta.yml`` の ``exclude`` を返す。 + + 無い (この変更より前の世代)・リストでない・文字列でない要素を含むときは ``None`` + で、今の除外の一覧と違うものとして扱う。 + """ + try: + value = self._load_snap_meta(snap_dir).get('exclude') + except (OSError, yaml.YAMLError, AttributeError): + return None + if not isinstance(value, list) or not all(isinstance(v, str) for v in value): + return None + return tuple(sorted(set(value))) + + def _excludes_differ(self, snap_dir: Path) -> bool: + """世代の除外の記録が今の一覧と違うか。 + + 記録が無ければ違う。設定を読めない間は、記録を持つ世代を違うとみなさない。 + """ + recorded = self._recorded_excludes(snap_dir) + if recorded is None: + return True + if not self.exclude_config_readable: + return False + return set(recorded) != set(self.excludes) + + def _excludes_for(self, snap_dir: Path, volumes: dict) -> tuple: + """控えに使う除外の一覧を決める。 + + 設定を読めたら今の一覧。読めなければ、作り直す世代の記録 → 系列の最新の世代の + 記録の順に最初にある記録を使い、どちらも無ければ今の一覧 (既定の除外だけ)。 + 読めない設定のために除外の一覧が変わって新しい世代ができ、古い世代が消えるのを防ぐ。 + """ + if self.exclude_config_readable: + return self.excludes + recorded = self._recorded_excludes(snap_dir) + if recorded is not None: + return recorded + latest = self.series_latest(volumes) + if latest is not None: + try: + latest_dir = self._safe_snap_dir(latest['name']) + except SnapshotError: + latest_dir = None + if latest_dir is not None and latest_dir.is_dir(): + recorded = self._recorded_excludes(latest_dir) + if recorded is not None: + return recorded + return self.excludes @property def volumes(self) -> dict: @@ -860,6 +931,13 @@ def auto_snapshot_target( "世代 %s は控えの起点が古い形 (/source) のため、新しい世代を作成します", name) return None + if self._excludes_differ(snap_dir): + # 1 つの世代は同じ除外の一覧で作る。違う一覧の差分を積むと、外したパスが + # full の時点の中身で復元され、除外の一覧と食い違う (#368) + logger.info( + "世代 %s は今と違う除外の一覧で作られているため、新しい世代を作成します", + name) + return None if latest.get('incremental_count', 0) >= max_incrementals: logger.info( @@ -877,7 +955,7 @@ def _ensure_snapshot_image(self) -> str: return ensure_snapshot_image(self.devbase_root) @staticmethod - def backup_command(archive: str, volumes: dict) -> str: + def backup_command(archive: str, volumes: dict, excludes=()) -> str: """対象を 1 本のアーカイブへ書き出すコマンドを組み立てる。 full も差分も同じ形で、``snapshot.snar`` に前回の状態を積み上げる。 @@ -886,10 +964,16 @@ def backup_command(archive: str, volumes: dict) -> str: 起点は各ボリュームのマウント先 (``ai`` / ``group``) で、``/source`` そのものではない。 ``/source`` はコンテナを起動するたびに作られ inode が変わるため、GNU tar が毎回 新しいディレクトリとみなし、差分がボリューム全体になる。 + + ``excludes`` (除外の一覧) の引数は ``-C`` とメンバーより前に置く。後ろに置くと + GNU tar は除外を効かせず失敗する。 """ - members = ' '.join(sub or '.' for sub in volumes) + member_list = [sub or '.' for sub in volumes] + members = ' '.join(member_list) + exclude_args = tar_exclude_args(excludes, member_list) + exclude_part = f"{exclude_args} " if exclude_args else '' return ( - f"tar --listed-incremental={ARCHIVE_MOUNT}/{SNAR_FILE} " + f"tar --listed-incremental={ARCHIVE_MOUNT}/{SNAR_FILE} {exclude_part}" f"-cf - -C {BACKUP_ROOT} {members} | zstd -1 -T0 -o {ARCHIVE_MOUNT}/{archive}" ) @@ -992,9 +1076,10 @@ def _create_full(self, name: str, snap_dir: Path, """ if volumes is None: volumes = self.volumes + excludes = self._excludes_for(snap_dir, volumes) logger.info("フルバックアップを作成中: %s", name) self._run_docker_tar( - snap_dir, 'backup', self.backup_command(FULL_ARCHIVE, volumes), volumes, + snap_dir, 'backup', self.backup_command(FULL_ARCHIVE, volumes, excludes), volumes, ) # meta.yml を作成 @@ -1005,6 +1090,8 @@ def _create_full(self, name: str, snap_dir: Path, 'volumes': dict(volumes), 'files': [FULL_ARCHIVE], 'incremental_count': 0, + # 作ったときの除外の一覧。差分では書き換えない (#368) + 'exclude': sorted(excludes), } if '' not in volumes: # 旧レイアウト (ルートへ直接マウント) の起点は ``.`` のままなので書かない @@ -1033,6 +1120,16 @@ def _create_incremental(self, name: str, snap_dir: Path) -> None: "新しい世代を作成してください (devbase snapshot create)" ) + if self._excludes_differ(snap_dir): + # snar に触る前に止める。違う除外の一覧の差分を積むと、外したパスが full の + # 時点の中身で復元され、除外の一覧と食い違う (#368) + raise SnapshotError( + f"スナップショット '{name}' は今と違う除外の一覧で作られています。" + "新しい世代を作成してください (devbase snapshot create)" + ) + # 設定を読めない間は記録の一覧で積む (記録と同じなら今の一覧と同じ) + excludes = self._excludes_for(snap_dir, self.volumes) + snar_file = snap_dir / SNAR_FILE if not snar_file.exists(): # snarファイルがなければフルバックアップにフォールバック @@ -1051,7 +1148,7 @@ def _create_incremental(self, name: str, snap_dir: Path) -> None: self._run_docker_tar( snap_dir, 'backup', f"cp {ARCHIVE_MOUNT}/{SNAR_FILE} {ARCHIVE_MOUNT}/{SNAR_FILE}.bak && " - + self.backup_command(incr_name, self.volumes) + + self.backup_command(incr_name, self.volumes, excludes) ) # meta.yml を更新 diff --git a/tests/snapshot/test_archive_layout.py b/tests/snapshot/test_archive_layout.py index 7dada065..69e9287d 100644 --- a/tests/snapshot/test_archive_layout.py +++ b/tests/snapshot/test_archive_layout.py @@ -151,9 +151,10 @@ def test_create_and_restore_go_through_the_helpers(tmp_path: Path): mgr.create(name="snap1") mgr.create(name="snap1") # 差分 backup_cmds = [c["command"] for c in mgr.calls if c["mode"] == "backup"] - assert backup_cmds[0] == SnapshotManager.backup_command(FULL_ARCHIVE, mgr.volumes) + assert backup_cmds[0] == SnapshotManager.backup_command( + FULL_ARCHIVE, mgr.volumes, mgr.excludes) assert backup_cmds[1].endswith( - SnapshotManager.backup_command(incr_archive_name(1), mgr.volumes)) + SnapshotManager.backup_command(incr_archive_name(1), mgr.volumes, mgr.excludes)) assert backup_cmds[1].startswith( f"cp {ARCHIVE_MOUNT}/{SNAR_FILE} {ARCHIVE_MOUNT}/{SNAR_FILE}.bak && ") diff --git a/tests/snapshot/test_manager_series.py b/tests/snapshot/test_manager_series.py index ab19ed85..6fda54b3 100644 --- a/tests/snapshot/test_manager_series.py +++ b/tests/snapshot/test_manager_series.py @@ -20,6 +20,7 @@ from devbase.commands.snapshot import cmd_snapshot from devbase.errors import SnapshotError +from devbase.snapshot.exclude import DEFAULT_EXCLUDES from devbase.snapshot.manager import RotateResult, SnapshotManager @@ -69,6 +70,7 @@ def write_state(root: Path, entries: list) -> Path: else: meta["volumes"] = vols(group) meta["archive_root"] = "members" + meta["exclude"] = sorted(DEFAULT_EXCLUDES) (snap_dir / "meta.yml").write_text(yaml.safe_dump(meta)) entry = {"name": name, "created_at": created, "updated_at": created, "incremental_count": incr} diff --git a/tests/snapshot/test_restore_incremental.py b/tests/snapshot/test_restore_incremental.py index 58ea30ff..6ade88e9 100644 --- a/tests/snapshot/test_restore_incremental.py +++ b/tests/snapshot/test_restore_incremental.py @@ -457,13 +457,17 @@ def _in_volume(volume: str, script: str) -> subprocess.CompletedProcess: def _swap_directories(volume: str, generation: str) -> None: - """ディレクトリを総入れ替えして inode を再利用させる。""" + """ディレクトリを総入れ替えして inode を再利用させる。 + + 入れ替える場所は既定の除外 (``.claude/plugins/cache`` など) の外に置く。除外の中だと + 控えに入らず、入れ替えた中身の復元を確かめられない (#368)。 + """ _in_volume( volume, - 'rm -rf /work/.claude/plugins/cache; ' - 'mkdir -p /work/.claude/plugins/cache; ' + 'rm -rf /work/data/cache; ' + 'mkdir -p /work/data/cache; ' f'for i in $(seq 1 40); do ' - f' d=/work/.claude/plugins/cache/{generation}$i/unknown/commands; ' + f' d=/work/data/cache/{generation}$i/unknown/commands; ' f' mkdir -p $d; echo {generation}$i > $d/file.txt; ' f'done; ' f'echo {generation} > /work/marker.txt') From ef256707e186ece653fca08a1cb476ea01750873 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 12 Oct 2026 18:05:56 +0900 Subject: [PATCH 2/4] =?UTF-8?q?test(snapshot):=20=E9=99=A4=E5=A4=96?= =?UTF-8?q?=E3=81=AE=E4=B8=80=E8=A6=A7=E3=83=BB=E4=B8=96=E4=BB=A3=E3=81=AE?= =?UTF-8?q?=E8=A8=98=E9=8C=B2=E3=83=BB=E5=AE=9F=E3=81=AE=20tar=20=E3=81=A7?= =?UTF-8?q?=E3=81=AE=E9=99=A4=E5=A4=96=E3=81=AE=E5=8A=B9=E3=81=8D=E7=9B=AE?= =?UTF-8?q?=E3=82=92=E7=B8=9B=E3=82=8B=20(#368)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- tests/snapshot/test_backup_real_tar.py | 114 ++++++++ tests/snapshot/test_exclude.py | 171 ++++++++++++ tests/snapshot/test_exclude_generations.py | 291 +++++++++++++++++++++ 3 files changed, 576 insertions(+) create mode 100644 tests/snapshot/test_exclude.py create mode 100644 tests/snapshot/test_exclude_generations.py diff --git a/tests/snapshot/test_backup_real_tar.py b/tests/snapshot/test_backup_real_tar.py index 51606027..047f93d4 100644 --- a/tests/snapshot/test_backup_real_tar.py +++ b/tests/snapshot/test_backup_real_tar.py @@ -13,6 +13,7 @@ import pytest +from devbase.snapshot.exclude import DEFAULT_EXCLUDES from devbase.snapshot.manager import ( ARCHIVE_MOUNT, BACKUP_ROOT, @@ -85,3 +86,116 @@ def test_incremental_from_a_fresh_source_root_holds_only_changes(tmp_path): assert not (target / "ai" / "f0.txt").exists() assert (target / "ai" / "new.txt").read_text() == "new" assert len(list((target / "group").iterdir())) == 20 + + +# --------------------------------------------------------------------------- +# 除外 (#368) +# --------------------------------------------------------------------------- + +EXCLUDED = [ + ".gemini/tmp/p1/logs.json", ".gemini/tmp/p1/logs/x.log", + ".codex/logs_2.sqlite", ".codex/logs_2.sqlite-wal", ".codex/logs_2.sqlite-shm", + ".codex/.tmp/marketplaces/m.json", ".codex/cache/c", + ".claude/plugins/cache/p/file", ".claude/plugins/marketplaces/m/file", + ".serena/logs/2026-10-12/log.txt", +] +KEPT = [ + ".claude/projects/p/s.jsonl", ".codex/sessions/s.jsonl", + ".codex/thread_history_1.sqlite", ".codex/state_5.sqlite", + ".gemini/tmp/p1/chats/session-1.jsonl", ".gemini/tmp/p1/chats/logs.json", + ".gemini/tmp/p1/chats/logs/x", ".claude/plugins/installed_plugins.json", + ".claude/plugins/known_marketplaces.json", ".claude/plugins/data/d/state", + ".serena/language_servers/ls/bin", "work/.codex/cache/keep", +] + + +def _excludes() -> tuple: + return tuple(sorted(DEFAULT_EXCLUDES)) + + +def _backup_excluded(tmp_path: Path, archive: str, run: int) -> None: + source = tmp_path / f"source-{run}" + source.mkdir() + for sub in VOLUMES: + prev = next(tmp_path.glob(f"source-*/{sub}"), None) or tmp_path / "init" / sub + prev.rename(source / sub) + _run(SnapshotManager.backup_command(archive, VOLUMES, _excludes()), source, + tmp_path / "archive", tmp_path / "unused") + + +def _populate(root: Path, paths: list, text: str = "v1") -> None: + for rel in paths: + path = root / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text) + + +def _init_volumes(tmp_path: Path, paths: list) -> None: + (tmp_path / "archive").mkdir() + for sub in VOLUMES: + _populate(tmp_path / "init" / sub, paths) + + +def test_full_leaves_out_default_excludes_and_keeps_the_records(tmp_path): + """受け入れ条件 1・2・I5・I7""" + _init_volumes(tmp_path, EXCLUDED + KEPT) + _backup_excluded(tmp_path, FULL_ARCHIVE, 1) + + members = set(_members(tmp_path / "archive" / FULL_ARCHIVE)) + for sub in VOLUMES: + for rel in EXCLUDED: + assert f"{sub}/{rel}" not in members + for rel in KEPT: + assert f"{sub}/{rel}" in members + assert not any("/logs_2.sqlite" in m or "/plugins/cache/" in m for m in members) + + +def test_incremental_leaves_out_rewritten_excluded_files(tmp_path): + """受け入れ条件 3""" + _init_volumes(tmp_path, EXCLUDED + KEPT) + _backup_excluded(tmp_path, FULL_ARCHIVE, 1) + + ai = tmp_path / "source-1" / "ai" + (ai / ".codex/logs_2.sqlite").write_text("v2 longer") + (ai / ".codex/state_5.sqlite").write_text("v2 longer") + _backup_excluded(tmp_path, incr_archive_name(1), 2) + + members = _members(tmp_path / "archive" / incr_archive_name(1)) + assert "ai/.codex/state_5.sqlite" in members + assert "ai/.codex/logs_2.sqlite" not in members + + +def test_restore_has_no_excluded_paths_and_latest_contents(tmp_path): + """受け入れ条件 8: 同じ除外の full と差分を順に展開する。""" + _init_volumes(tmp_path, EXCLUDED + KEPT) + _backup_excluded(tmp_path, FULL_ARCHIVE, 1) + (tmp_path / "source-1" / "ai" / ".codex/state_5.sqlite").write_text("v2 longer") + _backup_excluded(tmp_path, incr_archive_name(1), 2) + + target = tmp_path / "target" + for sub in VOLUMES: + (target / sub).mkdir(parents=True) + for archive in (FULL_ARCHIVE, incr_archive_name(1)): + _run(SnapshotManager.restore_command(archive), tmp_path / "unused", + tmp_path / "archive", target) + assert (target / "ai/.codex/state_5.sqlite").read_text() == "v2 longer" + for sub in VOLUMES: + for rel in EXCLUDED: + assert not (target / sub / rel).exists(), rel + for rel in KEPT: + assert (target / sub / rel).exists(), rel + + +def test_missing_excluded_paths_do_not_fail_or_warn(tmp_path): + """受け入れ条件 13: 除外のパスが 1 つも無くても終了コード 0 で警告が無い。""" + _init_volumes(tmp_path, ["only.txt"]) + source = tmp_path / "source-1" + source.mkdir() + for sub in VOLUMES: + (tmp_path / "init" / sub).rename(source / sub) + command = (SnapshotManager.backup_command(FULL_ARCHIVE, VOLUMES, _excludes()) + .replace(ARCHIVE_MOUNT, str(tmp_path / "archive")) + .replace(BACKUP_ROOT, str(source))) + result = subprocess.run(["bash", "-c", command], capture_output=True, text=True) + assert result.returncode == 0 + assert "tar:" not in result.stderr diff --git a/tests/snapshot/test_exclude.py b/tests/snapshot/test_exclude.py new file mode 100644 index 00000000..d562dd72 --- /dev/null +++ b/tests/snapshot/test_exclude.py @@ -0,0 +1,171 @@ +"""除外の一覧の組み立て (#368) + +既定の除外・``backups/exclude.yml`` の読み込み・パターンの検査・tar の引数を縛る。 +Docker も ``SnapshotManager`` も使わない。 +""" + +from __future__ import annotations + +import logging +import shlex + +import pytest + +from devbase.snapshot.exclude import ( + DEFAULT_EXCLUDES, + USER_CONFIG, + normalize, + resolve_excludes, + tar_exclude_args, +) + + +def _write(backups, text: str) -> None: + backups.mkdir(parents=True, exist_ok=True) + (backups / USER_CONFIG).write_text(text, encoding="utf-8") + + +def _warnings(caplog) -> list: + return [r.getMessage() for r in caplog.records if r.levelno == logging.WARNING] + + +# --------------------------------------------------------------------------- +# 既定の除外 +# --------------------------------------------------------------------------- + +def test_default_excludes_are_the_listed_paths(): + assert set(DEFAULT_EXCLUDES) == { + ".gemini/tmp/*/logs.json", ".gemini/tmp/*/logs", + ".codex/logs_2.sqlite", ".codex/logs_2.sqlite-wal", ".codex/logs_2.sqlite-shm", + ".codex/.tmp", ".codex/cache", + ".claude/plugins/cache", ".claude/plugins/marketplaces", + ".serena/logs", + } + + +@pytest.mark.parametrize("kept", [ + ".claude/projects", ".codex/sessions", ".codex/thread_history_1.sqlite", + ".codex/state_5.sqlite", ".gemini/tmp", ".gemini/tmp/*/chats", ".claude/plugins", + ".claude/plugins/installed_plugins.json", ".claude/plugins/known_marketplaces.json", + ".claude/plugins/data", ".serena/language_servers", ".serena", +]) +def test_default_excludes_never_cover_records_to_keep(kept): + """I7: 会話の記録・プラグインの状態・language_servers と、その親を外さない。""" + for pattern in DEFAULT_EXCLUDES: + assert pattern != kept + assert not kept.startswith(pattern + "/"), (pattern, kept) + + +def test_default_excludes_are_already_normalized(): + assert all(normalize(p) == p for p in DEFAULT_EXCLUDES) + + +# --------------------------------------------------------------------------- +# normalize +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("value,expected", [ + ("./a//b/", "a/b"), + (" .cache/pip ", ".cache/pip"), + ("work/*/node_modules", "work/*/node_modules"), +]) +def test_normalize_accepts_relative_paths(value, expected): + assert normalize(value) == expected + + +@pytest.mark.parametrize("value", [ + None, 3, ["a"], "", " ", "/etc", "a/../b", "..", "a?", "a[b]", "a\\b", "./", ".//.", +]) +def test_normalize_rejects_unusable_values(value): + with pytest.raises(ValueError): + normalize(value) + + +# --------------------------------------------------------------------------- +# resolve_excludes +# --------------------------------------------------------------------------- + +def test_missing_config_uses_defaults_without_warning(tmp_path, caplog): + """受け入れ条件 12""" + with caplog.at_level(logging.WARNING, logger="devbase"): + excludes, readable = resolve_excludes(tmp_path) + assert excludes == tuple(sorted(DEFAULT_EXCLUDES)) + assert readable is True + assert _warnings(caplog) == [] + + +@pytest.mark.parametrize("text", ["", "exclude:\n", "exclude: []\n", "other: 1\n"]) +def test_empty_config_uses_defaults_without_warning(tmp_path, caplog, text): + """受け入れ条件 12: ``exclude`` が無い・null・空のリスト""" + _write(tmp_path, text) + with caplog.at_level(logging.WARNING, logger="devbase"): + excludes, readable = resolve_excludes(tmp_path) + assert excludes == tuple(sorted(DEFAULT_EXCLUDES)) + assert readable is True + assert _warnings(caplog) == [] + + +def test_user_excludes_are_added_to_defaults(tmp_path, caplog): + """受け入れ条件 5・I6: 足した値と既定の除外の両方が効く。並べ替え・重複なし。""" + _write(tmp_path, "exclude:\n - ./.cache//pip/\n - .codex/cache\n") + with caplog.at_level(logging.WARNING, logger="devbase"): + excludes, readable = resolve_excludes(tmp_path) + assert readable is True + assert ".cache/pip" in excludes + assert set(DEFAULT_EXCLUDES) <= set(excludes) + assert list(excludes) == sorted(set(excludes)) + assert len(excludes) == len(DEFAULT_EXCLUDES) + 1 + assert _warnings(caplog) == [] + + +def test_unusable_values_are_dropped_one_warning_each(tmp_path, caplog): + """受け入れ条件 11: 値ごとに WARNING 1 行。残りの値は効く。""" + _write(tmp_path, "exclude:\n - /abs\n - a/../b\n - ''\n - null\n - 3\n" + " - 'a?b'\n - keep/me\n") + with caplog.at_level(logging.WARNING, logger="devbase"): + excludes, readable = resolve_excludes(tmp_path) + assert readable is True + assert "keep/me" in excludes + assert len(excludes) == len(DEFAULT_EXCLUDES) + 1 + warnings = _warnings(caplog) + assert len(warnings) == 6 + assert all("を採りません" in w for w in warnings) + + +@pytest.mark.parametrize("text", ["exclude: [a\n", "- a\n", "exclude: a\n"]) +def test_unreadable_config_warns_once_and_reports_unreadable(tmp_path, caplog, text): + """受け入れ条件 11 (読めない設定): WARNING 1 行、既定の除外だけ、読めたかは偽。""" + _write(tmp_path, text) + with caplog.at_level(logging.WARNING, logger="devbase"): + excludes, readable = resolve_excludes(tmp_path) + assert excludes == tuple(sorted(DEFAULT_EXCLUDES)) + assert readable is False + warnings = _warnings(caplog) + assert len(warnings) == 1 + assert "を読めないため" in warnings[0] + + +# --------------------------------------------------------------------------- +# tar_exclude_args +# --------------------------------------------------------------------------- + +def test_tar_args_anchor_each_pattern_to_each_member(): + """I5: メンバー名を頭に付け、頭から・1 要素の中だけに当てる。``*`` は引用する。""" + args = shlex.split(tar_exclude_args((".codex/cache", ".gemini/tmp/*/logs"), ["ai", "group"])) + assert args[:2] == ["--anchored", "--no-wildcards-match-slash"] + assert args[2:] == [ + "--exclude=ai/.codex/cache", "--exclude=ai/.gemini/tmp/*/logs", + "--exclude=group/.codex/cache", "--exclude=group/.gemini/tmp/*/logs", + ] + assert "'--exclude=ai/.gemini/tmp/*/logs'" in tar_exclude_args( + (".gemini/tmp/*/logs",), ["ai"]) + + +def test_tar_args_for_legacy_layout_start_with_dot(): + """I5 (旧レイアウト): メンバーが ``.`` なら頭は ``./``。""" + args = shlex.split(tar_exclude_args((".codex/cache",), ["."])) + assert args[-1] == "--exclude=./.codex/cache" + + +def test_tar_args_are_empty_without_excludes(): + assert tar_exclude_args((), ["ai"]) == "" diff --git a/tests/snapshot/test_exclude_generations.py b/tests/snapshot/test_exclude_generations.py new file mode 100644 index 00000000..b38734ca --- /dev/null +++ b/tests/snapshot/test_exclude_generations.py @@ -0,0 +1,291 @@ +"""世代と除外の一覧 (#368) + +``meta.yml`` の ``exclude`` の記録・自動スナップショットの積み先・名前を指定した差分・ +読めない設定の扱いを縛る。``DEVBASE_ROOT`` を使わず ``tmp_path`` に閉じ、 +``_run_docker_tar`` を差し替えて Docker を起動しない。 +""" + +from __future__ import annotations + +import logging +import re +import shlex +import types + +import pytest +import yaml + +from devbase.commands.snapshot import cmd_snapshot +from devbase.errors import SnapshotError +from devbase.snapshot.exclude import DEFAULT_EXCLUDES, USER_CONFIG +from devbase.snapshot.manager import SNAR_FILE, SnapshotManager + +from .test_manager_series import write_state + +DEFAULTS = sorted(DEFAULT_EXCLUDES) +OTHER = sorted(set(DEFAULT_EXCLUDES) | {"recorded/only"}) + + +@pytest.fixture(autouse=True) +def _group_env(monkeypatch): + monkeypatch.setenv("DEVBASE_ACCOUNT_GROUP", "acme") + + +class RecordingManager(SnapshotManager): + """``docker run`` を実行せず、渡されたコマンドだけを記録する。""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.calls: list = [] + + def _run_docker_tar(self, snap_dir, mode, command, volumes=None): + self.calls.append({"mode": mode, "command": command}) + if mode == "backup": + archive = re.search(r"-o /backup/(\S+)", command).group(1) + (snap_dir / archive).write_text("archive") + (snap_dir / SNAR_FILE).write_text("snar") + + def backup_excludes(self) -> list: + """記録した控えのコマンドごとに、``--exclude=`` の値の並びを返す。""" + result = [] + for call in self.calls: + if call["mode"] != "backup": + continue + result.append([a.split("=", 1)[1] for a in shlex.split(call["command"]) + if a.startswith("--exclude=")]) + return result + + +def _expected_args(patterns, members=("ai", "group")) -> list: + return [f"{m}/{p}" for m in members for p in sorted(patterns)] + + +def _meta(backups, name) -> dict: + return yaml.safe_load((backups / name / "meta.yml").read_text()) + + +def _set_exclude(backups, name, value) -> None: + path = backups / name / "meta.yml" + meta = yaml.safe_load(path.read_text()) + if value is None: + meta.pop("exclude", None) + else: + meta["exclude"] = value + path.write_text(yaml.safe_dump(meta)) + + +def _broken_config(backups) -> None: + (backups / USER_CONFIG).write_text("exclude: [a\n") + + +def _info(caplog) -> list: + return [r.getMessage() for r in caplog.records if r.levelno == logging.INFO] + + +# --------------------------------------------------------------------------- +# 記録 (受け入れ条件 14・I2) と tar への受け渡し (受け入れ条件 1・3・4・5) +# --------------------------------------------------------------------------- + +def test_full_records_sorted_excludes_and_incremental_keeps_them(tmp_path): + mgr = RecordingManager(tmp_path) + mgr.create(name="G") + assert _meta(tmp_path / "backups", "G")["exclude"] == DEFAULTS + mgr.create(name="G") + meta = _meta(tmp_path / "backups", "G") + assert meta["exclude"] == DEFAULTS + assert meta["incremental_count"] == 1 + assert mgr.backup_excludes() == [_expected_args(DEFAULTS)] * 2 + + +def test_user_exclude_reaches_tar_with_defaults(tmp_path): + """受け入れ条件 5・I6""" + (tmp_path / "backups").mkdir() + (tmp_path / "backups" / USER_CONFIG).write_text("exclude:\n - .cache/pip\n") + mgr = RecordingManager(tmp_path) + mgr.create(name="G") + expected = sorted(set(DEFAULTS) | {".cache/pip"}) + assert mgr.backup_excludes() == [_expected_args(expected)] + assert _meta(tmp_path / "backups", "G")["exclude"] == expected + + +def test_pre_restore_backup_applies_the_excludes(tmp_path): + """受け入れ条件 4""" + mgr = RecordingManager(tmp_path) + mgr.create(name="G") + mgr.calls.clear() + mgr.restore("G") + pre = [n for n in (tmp_path / "backups").iterdir() if n.name.startswith("pre-restore-")] + assert len(pre) == 1 + assert mgr.backup_excludes() == [_expected_args(DEFAULTS)] + assert _meta(tmp_path / "backups", pre[0].name)["exclude"] == DEFAULTS + + +def test_legacy_layout_pre_restore_backup_uses_dot_prefix(tmp_path): + """I5 (旧レイアウト): 旧レイアウトの組では頭が ``./``。""" + mgr = RecordingManager(tmp_path) + (tmp_path / "backups" / "pre").mkdir() + mgr._create_full("pre", tmp_path / "backups" / "pre", {"": "devbase_home_ubuntu"}) + assert mgr.backup_excludes() == [_expected_args(DEFAULTS, members=(".",))] + + +def test_warnings_are_emitted_once_per_manager(tmp_path, caplog): + (tmp_path / "backups").mkdir() + (tmp_path / "backups" / USER_CONFIG).write_text("exclude:\n - /abs\n") + mgr = RecordingManager(tmp_path) + with caplog.at_level(logging.WARNING, logger="devbase"): + mgr.auto_snapshot_target() + mgr.create(name="G") + mgr.create(name="G") + assert sum("を採りません" in r.getMessage() for r in caplog.records) == 1 + + +# --------------------------------------------------------------------------- +# 自動スナップショットの積み先 (受け入れ条件 6・I1・I3) +# --------------------------------------------------------------------------- + +def test_same_excludes_keep_appending(tmp_path, caplog): + backups = write_state(tmp_path, [("A", "acme", 0)]) + _set_exclude(backups, "A", list(reversed(DEFAULTS))) # 並びに依らない + with caplog.at_level(logging.INFO, logger="devbase"): + assert RecordingManager(tmp_path).auto_snapshot_target() == "A" + assert not any("除外の一覧" in m for m in _info(caplog)) + + +@pytest.mark.parametrize("recorded", [None, OTHER, [], "x", [1]]) +def test_different_or_missing_excludes_start_a_new_generation(tmp_path, caplog, recorded): + backups = write_state(tmp_path, [("A", "acme", 0)]) + _set_exclude(backups, "A", recorded) + with caplog.at_level(logging.INFO, logger="devbase"): + assert RecordingManager(tmp_path).auto_snapshot_target() is None + lines = [m for m in _info(caplog) if "除外の一覧" in m] + assert lines == ["世代 A は今と違う除外の一覧で作られているため、新しい世代を作成します"] + + +# --------------------------------------------------------------------------- +# 名前を指定した差分 (受け入れ条件 7) +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("recorded", [None, OTHER]) +@pytest.mark.parametrize("with_snar", [True, False]) +def test_named_incremental_into_different_excludes_stops(tmp_path, recorded, with_snar): + backups = write_state(tmp_path, [("A", "acme", 0)]) + _set_exclude(backups, "A", recorded) + if not with_snar: + (backups / "A" / SNAR_FILE).unlink() + before = {p.name: p.read_bytes() for p in (backups / "A").iterdir()} + mgr = RecordingManager(tmp_path) + + with pytest.raises(SnapshotError, match="今と違う除外の一覧"): + mgr.create(name="A") + + assert mgr.calls == [] + assert {p.name: p.read_bytes() for p in (backups / "A").iterdir()} == before + + +def test_cli_named_incremental_into_different_excludes_exits_1(tmp_path, caplog): + backups = write_state(tmp_path, [("A", "acme", 0)]) + _set_exclude(backups, "A", OTHER) + ns = types.SimpleNamespace(subcommand="create", name="A", full=False, group="acme") + with caplog.at_level(logging.ERROR, logger="devbase"): + assert cmd_snapshot(tmp_path, ns) == 1 + assert any("今と違う除外の一覧" in r.getMessage() for r in caplog.records) + + +# --------------------------------------------------------------------------- +# 読めない設定 (受け入れ条件 11・I9) +# --------------------------------------------------------------------------- + +def test_unreadable_config_keeps_appending_with_the_record(tmp_path, caplog): + backups = write_state(tmp_path, [("A", "acme", 0)]) + _set_exclude(backups, "A", OTHER) + _broken_config(backups) + + mgr = RecordingManager(tmp_path) + with caplog.at_level(logging.INFO, logger="devbase"): + assert mgr.auto_snapshot_target() == "A" + assert not any("除外の一覧で作られている" in m for m in _info(caplog)) + + mgr.create(name="A") + assert mgr.backup_excludes() == [_expected_args(OTHER)] + assert _meta(backups, "A")["exclude"] == OTHER + + # 直した設定 (記録と同じ一覧) の次の判定も同じ世代 + (backups / USER_CONFIG).write_text("exclude:\n - recorded/only\n") + assert RecordingManager(tmp_path).auto_snapshot_target() == "A" + + +def test_unreadable_config_does_not_inherit_a_missing_record(tmp_path): + backups = write_state(tmp_path, [("A", "acme", 0)]) + _set_exclude(backups, "A", None) + _broken_config(backups) + assert RecordingManager(tmp_path).auto_snapshot_target() is None + + mgr = RecordingManager(tmp_path) + with pytest.raises(SnapshotError, match="今と違う除外の一覧"): + mgr.create(name="A") + + +def test_unreadable_config_new_full_after_limit_inherits_the_record(tmp_path): + """(a) 差分の上限で作る新しい世代のフル。直した設定の次の判定はその世代を返す。""" + backups = write_state(tmp_path, [("A", "acme", 10)]) + _set_exclude(backups, "A", OTHER) + _broken_config(backups) + + mgr = RecordingManager(tmp_path) + assert mgr.auto_snapshot_target() is None + mgr.create(name="B") + assert mgr.backup_excludes() == [_expected_args(OTHER)] + assert _meta(backups, "B")["exclude"] == OTHER + + (backups / USER_CONFIG).write_text("exclude:\n - recorded/only\n") + assert RecordingManager(tmp_path).auto_snapshot_target() == "B" + + +def test_unreadable_config_snar_fallback_keeps_the_record(tmp_path): + """(b) snar の無い世代への差分はフルへ切り替わり、記録の一覧のまま。""" + backups = write_state(tmp_path, [("A", "acme", 0)]) + _set_exclude(backups, "A", OTHER) + (backups / "A" / SNAR_FILE).unlink() + _broken_config(backups) + + mgr = RecordingManager(tmp_path) + mgr.create(name="A") + assert mgr.backup_excludes() == [_expected_args(OTHER)] + assert _meta(backups, "A")["exclude"] == OTHER + + +def test_unreadable_config_pre_restore_backup_inherits_the_record(tmp_path): + """(c) 復元前バックアップ""" + backups = write_state(tmp_path, [("A", "acme", 0)]) + _set_exclude(backups, "A", OTHER) + _broken_config(backups) + + mgr = RecordingManager(tmp_path) + mgr.restore("A") + pre = [n.name for n in backups.iterdir() if n.name.startswith("pre-restore-")] + assert mgr.backup_excludes() == [_expected_args(OTHER)] + assert _meta(backups, pre[0])["exclude"] == OTHER + + +def test_unreadable_config_without_any_record_uses_defaults(tmp_path): + backups = write_state(tmp_path, [("A", "acme", 0)]) + _set_exclude(backups, "A", None) + _broken_config(backups) + + mgr = RecordingManager(tmp_path) + mgr.create(name="B") + assert mgr.backup_excludes() == [_expected_args(DEFAULTS)] + assert _meta(backups, "B")["exclude"] == DEFAULTS + + +# --------------------------------------------------------------------------- +# 除外を持たない世代の復元 (受け入れ条件 10・I8) +# --------------------------------------------------------------------------- + +def test_restore_of_a_generation_without_excludes_is_unchanged(tmp_path): + backups = write_state(tmp_path, [("A", "acme", 0)]) + _set_exclude(backups, "A", None) + mgr = RecordingManager(tmp_path) + mgr.restore("A") + restores = [c["command"] for c in mgr.calls if c["mode"] == "restore"] + assert restores and all("--exclude" not in c for c in restores) From d52d6fde5b2f68035382ed31d470c5e4e7ee8b9d Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 12 Oct 2026 18:08:58 +0900 Subject: [PATCH 3/4] =?UTF-8?q?docs(snapshot):=20=E6=8E=A7=E3=81=88?= =?UTF-8?q?=E3=81=8B=E3=82=89=E5=A4=96=E3=81=99=E3=82=82=E3=81=AE=E3=83=BB?= =?UTF-8?q?backups/exclude.yml=E3=83=BB=E9=99=A4=E5=A4=96=E3=81=8C?= =?UTF-8?q?=E9=81=95=E3=81=86=E4=B8=96=E4=BB=A3=E3=81=AE=E6=89=B1=E3=81=84?= =?UTF-8?q?=E3=82=92=E6=9B=B8=E3=81=8F=20(#368)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 10 ++++ docs/user/cli-reference/05-snapshot.md | 7 +++ docs/user/snapshot-guide.md | 68 ++++++++++++++++++++++++-- 3 files changed, 82 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f23474b7..25d540df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,16 @@ `project` / `container` の配下(`logs`・`profile up|down|list` を含む)、`env exec`・`env token` で、`-` を打って補完すると `--context` が候補に出ます(`devbase up web -` のようにプロジェクト名の後でも出ます)。値(docker context の名前)は補完しません。 `-` で始まらない語の候補は変わりません。 +- **スナップショットが、作り直せる一時ファイル・ログ・取り直せる取得物を控えないようにしました(#368)。** + 既定で `.codex/logs_2.sqlite`(`-wal`・`-shm` を含む)・`.codex/.tmp`・`.codex/cache`・`.claude/plugins/cache`・ + `.claude/plugins/marketplaces`・`.serena/logs`・`.gemini/tmp/*/logs.json`・`.gemini/tmp/*/logs` を、フル・差分・ + 復元前バックアップから外します。会話の記録(`.gemini/tmp/<プロジェクト>/chats/` を含む)・入れたプラグインの一覧と + データ・`.serena/language_servers` はこれまでどおり控えます。外したパスは復元後のボリュームに無く、各 CLI が作り直します。 + `backups/exclude.yml` の `exclude` にボリュームの中の相対のパスを並べると、除外を足せます。 + 世代の `meta.yml` に作ったときの除外の一覧(`exclude`)を残し、今と違う除外の一覧の世代へは差分を積みません。 + **更新後の最初の `devbase up` の自動スナップショットは、系列ごとに新しい世代(フル)を作ります。** + 除外の一覧が違う世代を `devbase snapshot create --name` で指定すると、差分を作らずに終了コード 1 で止まります。 + 既存の世代はそのまま一覧・復元できます。 ### Fixed - **プロジェクトの `env` に `DEV_SERVICE_NAME=` と空で書いたとき、開発サービス名を `dev` として扱うようにしました(#425)。** diff --git a/docs/user/cli-reference/05-snapshot.md b/docs/user/cli-reference/05-snapshot.md index d99c61d2..47c125e4 100644 --- a/docs/user/cli-reference/05-snapshot.md +++ b/docs/user/cli-reference/05-snapshot.md @@ -22,6 +22,13 @@ devbase snapshot create [--name NAME] [--full] [--group NAME] で止まります。`--group default` も終了コード 2 です(旧既定のボリュームの系列には新しい世代を 作りません。一覧・復元・コピー・削除・ローテーションはできます)。 +既にある世代の名前を `--full` なしで指定すると、その世代へ差分を積みます。その世代が今と違う除外の一覧で +作られている(除外の一覧を記録していない世代を含む)ときは、差分を作らずに終了コード 1 で止まります。 + +控えには、既定の除外(作り直せる一時ファイル・ログ・取り直せる取得物)と `backups/exclude.yml` に +書いた除外が入りません。一覧と書き方は +[スナップショットガイドの「控えから外すもの」](../snapshot-guide.md#控えから外すもの) にあります。 + ```bash # 自動命名でフルスナップショット(差分を積むのは devbase up の自動スナップショット) devbase snapshot create diff --git a/docs/user/snapshot-guide.md b/docs/user/snapshot-guide.md index 0d1a9f24..7c195c7e 100644 --- a/docs/user/snapshot-guide.md +++ b/docs/user/snapshot-guide.md @@ -35,7 +35,7 @@ graph LR style D fill:#e8f4e8 ``` -- **フルバックアップ**: 対象ボリューム 2 本の全体をアーカイブ(アーカイブ内では `ai/` と `group/` に分かれます) +- **フルバックアップ**: 対象ボリューム 2 本の全体([控えから外すもの](#控えから外すもの)を除く)をアーカイブ(アーカイブ内では `ai/` と `group/` に分かれます) - **差分バックアップ**: 前回からの変更分のみをアーカイブ - **圧縮**: zstd `-1 -T0`(圧縮レベル 1、全 CPU コア使用)で高速圧縮 @@ -52,6 +52,61 @@ graph LR プロジェクトのコンテナを起動せずにバックアップ・復元を実行できるため、ダウンタイムが発生しません。 +## 控えから外すもの + +作り直せる一時ファイル・ログ・取り直せる取得物は、スナップショットに入れません。フルバックアップ・ +差分バックアップ・復元前バックアップのどれにも同じ除外が効きます。外したパスは復元後のボリュームに無く、 +各 CLI が必要なときに作り直すか取り直します。 + +### 既定の除外 + +devbase が持つ除外の一覧です。共通ボリュームとグループのボリュームの両方に当てます。端末に無いパスは +何も起きません(警告も出ません)。 + +| パス(ボリューム内) | 中身 | +|---|---| +| `.gemini/tmp/*/logs.json`・`.gemini/tmp/*/logs` | Gemini CLI の作業用のログ | +| `.codex/logs_2.sqlite`(`-wal`・`-shm` を含む) | Codex の動作ログ | +| `.codex/.tmp`・`.codex/cache` | Codex の一時ファイル・キャッシュ | +| `.claude/plugins/cache`・`.claude/plugins/marketplaces` | Claude Code のプラグインとマーケットプレイスの取得物 | +| `.serena/logs` | Serena のログ | + +次のものは外さず、これまでどおり控えて復元で戻します。 + +- 会話の記録(`.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` + +### 除外を足す + +`${DEVBASE_ROOT}/backups/exclude.yml` に、ボリュームの中の相対のパスを並べると、既定の除外に足せます。 +devbase はこのファイルを作らず、書き換えません。既定の除外を外すことはできません。 + +```yaml +# スナップショットの控えから外すパス(ボリュームの中の相対のパス) +exclude: + - .cache/pip + - work/*/node_modules +``` + +- 書いたパスとその下を外します。パスはボリュームの頭(共通ボリュームとグループのボリュームのそれぞれ) + からだけ当たり、ボリュームの下の深い場所にある同じ名前には当たりません +- `*` は 1 つの要素(`/` で区切った 1 段)の中の任意の文字列に当たります。`?`・`[`・`]`・`\` は使えません +- 絶対パス・`..` を含むパス・空の値・文字列でない値は採らず、値ごとに警告を 1 行出して、残りで控えます +- ファイルが無い・`exclude` が無い・空のリストのときは、既定の除外だけで控えます + +**除外の一覧を変えると、次の自動スナップショットは差分を積まずに新しい世代を作ります。** 1 つの世代は +同じ除外の一覧で控えるためです。系列ごとの保持数(3)を超えると、最も古い世代がローテーションで消えます。 +既定の除外を持つ版へ更新した後の最初の自動スナップショットも、系列ごとに新しい世代を作ります。 + +`exclude.yml` を YAML として読めない・最上位が辞書でない・`exclude` がリストでないときは、警告を 1 行出し、 +今の世代の除外の一覧のまま控えます(差分も、差分の上限で作る新しい世代も同じ一覧です)。読めない設定を +理由に新しい世代は作りません。除外の一覧を記録していない世代しか無いときは、既定の除外だけで控えます。 + +世代ごとに、作ったときの除外の一覧が `meta.yml` の `exclude` に残ります。 + ## 世代管理 ### 設定パラメータ @@ -128,7 +183,8 @@ flowchart TD ``` 起動時の出力には、扱った系列のグループ名が出ます。新しい世代を作るときは、その理由 -(系列に世代が無い / 差分が上限に達した)を 1 行出します。 +(系列に世代が無い / 差分が上限に達した / 系列の最新の世代が今と違う除外の一覧で作られている)を 1 行出します。 +除外の一覧が違う世代へは、差分の数に関わらず差分を積みません([控えから外すもの](#控えから外すもの))。 ```text [0/6] スナップショットを差分更新中: 20260920-212546 (グループ initech) @@ -157,6 +213,7 @@ flowchart TD ``` backups/ ├── snapshot.yml # スナップショット全体のメタデータ +├── exclude.yml # 利用者が足す除外(任意。利用者が書く) ├── 20260220-103000/ # タイムスタンプ名の世代 │ ├── meta.yml # 世代のメタデータ │ ├── full.tar.zst # フルバックアップ @@ -172,7 +229,8 @@ backups/ | ファイル | 内容 | |---------|------| | `snapshot.yml` | 全世代のインデックス情報(対象ボリューム名を含む)| -| `meta.yml` | 世代ごとの作成日時、バックアップポイント数、サイズ、**対象ボリューム**等 | +| `meta.yml` | 世代ごとの作成日時、バックアップポイント数、サイズ、**対象ボリューム**、**除外の一覧**(`exclude`)等 | +| `exclude.yml` | 利用者が既定の除外に足すパス([除外を足す](#除外を足す)) | | `full.tar.zst` | フルバックアップアーカイブ | | `incr-NNN.tar.zst` | 差分バックアップアーカイブ(NNN は連番) | @@ -204,6 +262,10 @@ devbase snapshot create --name before-upgrade 指定した名前でスナップショットを作成します。重要な変更の前に手動で作成する場合に便利です。 +既にある世代の名前を指定すると、その世代へ差分を積みます。その世代が今と違う除外の一覧で作られて +いる(除外の一覧を記録していない世代を含む)ときは、差分を作らずに理由を示して終了コード 1 で止まります。 +世代のアーカイブと `snapshot.snar` は変わりません。新しい世代を作成してください(`devbase snapshot create`)。 + #### フルバックアップの強制作成 ```bash From 32b2074598fb0bb7de4c08246f30ff258ff08ea7 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 12 Oct 2026 18:12:37 +0900 Subject: [PATCH 4/4] =?UTF-8?q?docs(snapshot):=20=E7=B3=BB=E5=88=97?= =?UTF-8?q?=E3=81=AE=E7=A2=BA=E5=AE=9A=E4=BB=95=E6=A7=98=E3=81=AB=E9=99=A4?= =?UTF-8?q?=E5=A4=96=E3=81=AE=E4=B8=80=E8=A6=A7=E3=81=AE=E9=81=95=E3=81=84?= =?UTF-8?q?=E3=81=A7=E6=96=B0=E3=81=97=E3=81=84=E4=B8=96=E4=BB=A3=E3=82=92?= =?UTF-8?q?=E4=BD=9C=E3=82=8B=E9=A0=86=204a=20=E3=82=92=E6=9B=B8=E3=81=8F?= =?UTF-8?q?=20(#368)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/specifications/snapshot-series.md | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/docs/specifications/snapshot-series.md b/docs/specifications/snapshot-series.md index 8b76ef02..5b184d49 100644 --- a/docs/specifications/snapshot-series.md +++ b/docs/specifications/snapshot-series.md @@ -135,6 +135,7 @@ classDiagram | 2 | 系列の最新の世代が `_safe_snap_dir` を通らない(不正な名前・シンボリックリンク・`backups/` の外) | `None` | WARNING `{系列} の最新の世代 '{名前}' は扱えないため、新しい世代を作成します: {理由}` | | 3 | 最新の世代のディレクトリがあり、その `meta.yml` の組が `self.volumes` と違う | `None` | INFO `世代 {名前} の meta.yml の対象ボリューム ({組}) が {系列} と一致しないため、新しい世代を作成します` | | 4 | 最新の世代のディレクトリがあり、その `meta.yml` の `archive_root` が `members` でない | `None` | INFO `世代 {名前} は控えの起点が古い形 (/source) のため、新しい世代を作成します` | +| 4a | 最新の世代のディレクトリがあり、その `meta.yml` の `exclude` が無い(除外を持たない世代)か、`backups/exclude.yml` を読めて今の除外の一覧と集合として違う | `None` | INFO `世代 {名前} は今と違う除外の一覧で作られているため、新しい世代を作成します` | | 5 | 最新の世代の `incremental_count` が上限以上 | `None` | INFO `世代 {名前}({系列})の差分が上限 ({上限}) に達したため、新しい世代を作成します` | | 6 | それ以外 | 最新の世代の名前 | 出さない | @@ -151,6 +152,12 @@ classDiagram 止める。旧レイアウト(ルートへ直接マウント)の `full` は起点が `.` のままなので `archive_root` を書かない - 名前を明示して組の違う世代へ差分を作ろうとすると、`_create_incremental` が理由を示して `SnapshotError` で止める(旧レイアウトの世代へは差分を積まない) +- 順 4a は除外の一覧(既定の除外と `backups/exclude.yml`。#368)の違いを見る。1 つの世代は full を + 作ったときの一覧で差分も積む。違う一覧の差分を積むと、外したパスが full の時点の中身で復元されるためである。 + `backups/exclude.yml` を読めない間は、記録を持つ世代を違うと判定せず順 5 へ進み、その記録の一覧で積む + (読めない設定で新しい世代ができ、ローテーションで古い世代が消えるのを防ぐ)。名前を明示して除外の一覧の + 違う世代(記録の無い世代を含む)へ差分を作ろうとすると、`_create_incremental` が snar に触る前に + `SnapshotError` で止める。比べる順は組 → `archive_root` → 除外の一覧で、snar 無しのフォールバックより前に置く **間に別グループの起動を挟んでも、系列の最新の世代へ積む。** `snapshot.snar` は世代ごとにあり、 系列が同じならアーカイブのレイアウト(`/source/ai` と `/source/group`)もマウントするボリュームも @@ -244,7 +251,8 @@ classDiagram ## データ・設定 -**形は変えていない。** 移行は無く、変更前の devbase も同じファイルを読める。 +**形は変えていない。** 移行は無く、変更前の devbase も同じファイルを読める。各世代の `meta.yml` に +足した `exclude`(#368)は、変更前の devbase が読み捨てる。 | 保存先 | 系列の規則での扱い | | --- | --- | @@ -253,6 +261,7 @@ classDiagram | 同 `snapshots[].incremental_count` | 差分の上限の判定 | | 同 `max_generations` | `rotate` が消したときに `keep` を書く。読む側は無い | | 各世代の `meta.yml` の `volumes` | 系列の判定には使わない。積む直前の検証(`auto_snapshot_target` の順 3 と `_create_incremental`)でだけ読む | +| 各世代の `meta.yml` の `exclude` | full を作ったときの除外の一覧(並べ替え済み)。差分では書き換えない。積む直前の検証(順 4a と `_create_incremental`)でだけ読む | | アーカイブの mtime | 最小間隔の判定 | | 設定 | 値 | @@ -357,7 +366,8 @@ CLI と TUI: - `cmd_down` の `rotate()` の失敗で終了コード 0(`tests/commands/test_container_down_snapshot.py`) 復元のテスト(`tests/snapshot/test_restore_incremental.py`、`test_manager_volumes.py` の復元)は -変えずに通ること。 +変えずに通ること。ただし `test_restore_incremental.py` の実の Docker で入れ替える場所は、既定の除外 +(#368)に入らない `data/cache` に置く。 実機で確かめる観点: 2 つのグループのプロジェクトを `DEVBASE_SNAPSHOT_MIN_INTERVAL_MINUTES=0` で 交互に `devbase up` し、それぞれの系列の最新の世代に `incr-001` が積まれ、世代の数が変わらないことを