Uniquify automatic wrapper snapshot names
This commit is contained in:
4 files changed
+83
-5
No files matched your search
+3
-2
@@ -316,7 +316,7 @@ The wrapper snapshot flow is:
|
|||||||
wrapper_cmd
|
wrapper_cmd
|
||||||
-> _extract_paths_from_command()
|
-> _extract_paths_from_command()
|
||||||
-> _common_snapshot_root()
|
-> _common_snapshot_root()
|
||||||
-> create auto-YYYYMMDD-HHMMSS state
|
-> create a unique auto-YYYYMMDD-HHMMSS[-N] state
|
||||||
-> _iter_entries_for_target() for each path
|
-> _iter_entries_for_target() for each path
|
||||||
-> insert each relative path once
|
-> insert each relative path once
|
||||||
-> run subprocess.run(wrapper_cmd)
|
-> run subprocess.run(wrapper_cmd)
|
||||||
@@ -798,7 +798,8 @@ The list of `PlannedChange` objects produced before any mutation occurs.
|
|||||||
The `chguard -- chmod|chown|chgrp ...` mode that saves an automatic pre-command snapshot before running the command.
|
The `chguard -- chmod|chown|chgrp ...` mode that saves an automatic pre-command snapshot before running the command.
|
||||||
|
|
||||||
**Auto snapshot**
|
**Auto snapshot**
|
||||||
A wrapper-created state named like `auto-YYYYMMDD-HHMMSS`.
|
A wrapper-created state named like `auto-YYYYMMDD-HHMMSS`, with a numeric
|
||||||
|
suffix when needed to keep same-second snapshots unique.
|
||||||
|
|
||||||
**Owner restore**
|
**Owner restore**
|
||||||
Restoring numeric uid/gid with `os.chown(..., follow_symlinks=False)`.
|
Restoring numeric uid/gid with `os.chown(..., follow_symlinks=False)`.
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ auto-20251230-161301
|
|||||||
```
|
```
|
||||||
|
|
||||||
Auto-generated snapshots are visually distinguished in the output so they are easy to identify.
|
Auto-generated snapshots are visually distinguished in the output so they are easy to identify.
|
||||||
|
Snapshots created during the same second use a numeric suffix to remain unique,
|
||||||
|
for example `auto-20251230-161301-1`.
|
||||||
|
|
||||||
### Scope control
|
### Scope control
|
||||||
Restore:
|
Restore:
|
||||||
|
|||||||
+25
-3
@@ -6,6 +6,7 @@ import grp
|
|||||||
import importlib.metadata
|
import importlib.metadata
|
||||||
import os
|
import os
|
||||||
import pwd
|
import pwd
|
||||||
|
import sqlite3
|
||||||
import stat
|
import stat
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
@@ -166,6 +167,28 @@ def _common_snapshot_root(paths: list[Path]) -> Path:
|
|||||||
return Path(os.path.commonpath([str(p.resolve()) for p in paths]))
|
return Path(os.path.commonpath([str(p.resolve()) for p in paths]))
|
||||||
|
|
||||||
|
|
||||||
|
def _create_auto_state(
|
||||||
|
conn, root_path: Path, created_by_uid: int
|
||||||
|
) -> tuple[int, str]:
|
||||||
|
base_name = f"auto-{datetime.now().strftime('%Y%m%d-%H%M%S')}"
|
||||||
|
suffix = 0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
auto_name = base_name if suffix == 0 else f"{base_name}-{suffix}"
|
||||||
|
try:
|
||||||
|
state_id = create_state(
|
||||||
|
conn,
|
||||||
|
auto_name,
|
||||||
|
str(root_path),
|
||||||
|
created_by_uid,
|
||||||
|
commit=False,
|
||||||
|
)
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
suffix += 1
|
||||||
|
else:
|
||||||
|
return state_id, auto_name
|
||||||
|
|
||||||
|
|
||||||
def _type_for_mode(mode: int) -> str | None:
|
def _type_for_mode(mode: int) -> str | None:
|
||||||
if stat.S_ISDIR(mode):
|
if stat.S_ISDIR(mode):
|
||||||
return "dir"
|
return "dir"
|
||||||
@@ -451,12 +474,11 @@ def main() -> None:
|
|||||||
paths = _extract_paths_from_command(wrapper_cmd)
|
paths = _extract_paths_from_command(wrapper_cmd)
|
||||||
|
|
||||||
if paths:
|
if paths:
|
||||||
auto_name = f"auto-{datetime.now().strftime('%Y%m%d-%H%M%S')}"
|
|
||||||
root_path = _common_snapshot_root(paths)
|
root_path = _common_snapshot_root(paths)
|
||||||
|
|
||||||
with conn:
|
with conn:
|
||||||
state_id = create_state(
|
state_id, auto_name = _create_auto_state(
|
||||||
conn, auto_name, str(root_path), os.getuid(), commit=False
|
conn, root_path, os.getuid()
|
||||||
)
|
)
|
||||||
|
|
||||||
seen_paths: set[str] = set()
|
seen_paths: set[str] = set()
|
||||||
|
|||||||
@@ -2,9 +2,13 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import chguard.cli as cli
|
||||||
from chguard.cli import _restore_preview_rows, main
|
from chguard.cli import _restore_preview_rows, main
|
||||||
from chguard.db import connect, create_state, init_db
|
from chguard.db import connect, create_state, init_db
|
||||||
from chguard.restore import PlannedChange
|
from chguard.restore import PlannedChange
|
||||||
@@ -109,3 +113,52 @@ def test_restore_with_only_skipped_items_does_not_prompt(
|
|||||||
output = capsys.readouterr().out
|
output = capsys.readouterr().out
|
||||||
assert "missing path" in output
|
assert "missing path" in output
|
||||||
assert "No applicable changes" in output
|
assert "No applicable changes" in output
|
||||||
|
|
||||||
|
|
||||||
|
def test_wrapper_uses_unique_auto_names_within_same_second(
|
||||||
|
tmp_path: Path, monkeypatch
|
||||||
|
) -> None:
|
||||||
|
db_path = tmp_path / "states.db"
|
||||||
|
target = tmp_path / "target"
|
||||||
|
target.write_text("data", encoding="utf-8")
|
||||||
|
|
||||||
|
class FixedDatetime:
|
||||||
|
@classmethod
|
||||||
|
def now(cls) -> datetime:
|
||||||
|
return datetime(2026, 10, 10, 0, 10, 48)
|
||||||
|
|
||||||
|
monkeypatch.setattr(cli, "datetime", FixedDatetime)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
cli.subprocess,
|
||||||
|
"run",
|
||||||
|
lambda command: SimpleNamespace(returncode=0),
|
||||||
|
)
|
||||||
|
|
||||||
|
for _ in range(2):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
sys,
|
||||||
|
"argv",
|
||||||
|
[
|
||||||
|
"chguard",
|
||||||
|
"--db",
|
||||||
|
str(db_path),
|
||||||
|
"--",
|
||||||
|
"chmod",
|
||||||
|
"600",
|
||||||
|
str(target),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(SystemExit) as exc_info:
|
||||||
|
main()
|
||||||
|
|
||||||
|
assert exc_info.value.code == 0
|
||||||
|
|
||||||
|
conn = connect(db_path)
|
||||||
|
rows = conn.execute("SELECT name FROM states ORDER BY id").fetchall()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
assert rows == [
|
||||||
|
("auto-20261010-001048",),
|
||||||
|
("auto-20261010-001048-1",),
|
||||||
|
]
|
||||||
Reference in new issue
Block a user