refactor(restic_backup): extract helpers from run() to reduce complexity (S3776) #83

Open
Claude wants to merge 1 commit from Claude/backup-script:fix/issue-8-S3776-restic-backup-complexity into master
First-time contributor

Closes #8

ResticBackupStep.run() had cognitive complexity 29 (SonarQube ceiling: 15).

Refactor

  • _failed_result() — single shared FAILED StepResult builder. Collapses three near-identical 6-line blocks (init wrong-password, init other-error, backup-loop wrong-password) into one call site each. Same duration_s = time.monotonic() - started, same [-1024:] stderr_tail.
  • _init_repo_or_fail() — wraps the idempotent restic.init call. Returns None on success (repo created or already exists) or a FAILED StepResult on either ResticWrongPassword or ResticError.
  • _dry_run_result() — the dry-run short-circuit, including the BACKUP_SUCCESS_SCRATCH_KEY = "dry-run" sentinel that lets a downstream ResticForgetStep exercise its own dry-run path instead of being gated out by the no-snapshot guard.
  • _run_backup_with_retry() — drives the network_retrying() loop around restic.backup, raising TimeoutError on result.timed_out and ResticRepoLocked on returncode == 11 to trigger retry. Surfaces the wrong-password fast path via a sentinel third return (early_exit) so the caller short-circuits without ever reaching classification — wrong password is never retryable.
  • _classify_result() — maps a non-None ResticBackupResult to SUCCESS / PARTIAL / FAILED, populates extras (repo, returncode, file_errors, and when summary is present: snapshot_id, bytes_added, files_new, files_changed), and gates the BACKUP_SUCCESS_SCRATCH_KEY flag.

run() itself is now ~20 lines: init → dry-run? → retry → no-result guard → classify.

Behavior

Identical. Key invariants preserved:

  • Exit-code mapping unchanged: 0 → SUCCESS, 3 → PARTIAL, 11 → retry via ResticRepoLocked, 12 (ResticWrongPassword) → FAILED with no retry, other non-zero → FAILED.
  • CLAUDE.md data-safety gate unchanged: BACKUP_SUCCESS_SCRATCH_KEY is set only when outcome in (SUCCESS, PARTIAL) and result.summary is not None and result.summary.snapshot_id. If restic exits 0 but the JSON summary never arrived (truncated stream), the flag is still withheld so ResticForgetStep cannot silently prune the last good snapshot. Pinned by test_backup_does_not_set_scratch_flag_when_summary_missing and ..._when_snapshot_id_empty.
  • extras payload unchanged: same keys, same values, same conditional inclusion when result.summary is not None.
  • Retry boundary unchanged: network_retrying() wraps only restic.backup, never restic.init. Init failures fail the step immediately.
  • Exception narrowing unchanged: still except Exception (not BaseException) so SystemExit / KeyboardInterrupt / the streamer's kill-signal propagate untouched.
  • stderr_tail / exception fields on the final return unchanged: same [-1024:] slice on result.stderr only when outcome is not SUCCESS and result.stderr, same repr(last_exc) only when last_exc and outcome is FAILED.
  • CLAUDE.md hard rule #2 (idempotency / safe rollback) unchanged: no new state on self, rollback() is still the inherited base-class no-op. Pinned by test_restic_backup_rollback_is_safe_noop.

Verification

  • pytest -q tests/unit/ — passes (207/207). Includes tests/unit/test_restic_forget_guard.py which directly exercises 8 of the behavioral cases (scratch flag set on SUCCESS-with-id, set on PARTIAL-with-id, not set on FAILED, not set when summary missing, not set when snapshot_id empty, dry-run sets sentinel, end-to-end backup-then-forget shares scratch, failed backup blocks forget).
  • mypy --strict src/gardien/core src/gardien/config src/gardien/engine src/gardien/steps — passes (Success: no issues found in 26 source files).
  • SonarQube analyze_code_snippet on the refactored file — issueCount: 0.
Closes #8 `ResticBackupStep.run()` had cognitive complexity 29 (SonarQube ceiling: 15). ### Refactor - `_failed_result()` — single shared FAILED `StepResult` builder. Collapses three near-identical 6-line blocks (init wrong-password, init other-error, backup-loop wrong-password) into one call site each. Same `duration_s = time.monotonic() - started`, same `[-1024:]` `stderr_tail`. - `_init_repo_or_fail()` — wraps the idempotent `restic.init` call. Returns `None` on success (repo created or already exists) or a FAILED `StepResult` on either `ResticWrongPassword` or `ResticError`. - `_dry_run_result()` — the dry-run short-circuit, including the `BACKUP_SUCCESS_SCRATCH_KEY = "dry-run"` sentinel that lets a downstream `ResticForgetStep` exercise its own dry-run path instead of being gated out by the no-snapshot guard. - `_run_backup_with_retry()` — drives the `network_retrying()` loop around `restic.backup`, raising `TimeoutError` on `result.timed_out` and `ResticRepoLocked` on `returncode == 11` to trigger retry. Surfaces the wrong-password fast path via a sentinel third return (`early_exit`) so the caller short-circuits without ever reaching classification — wrong password is never retryable. - `_classify_result()` — maps a non-`None` `ResticBackupResult` to SUCCESS / PARTIAL / FAILED, populates `extras` (`repo`, `returncode`, `file_errors`, and when `summary` is present: `snapshot_id`, `bytes_added`, `files_new`, `files_changed`), and gates the `BACKUP_SUCCESS_SCRATCH_KEY` flag. `run()` itself is now ~20 lines: init → dry-run? → retry → no-result guard → classify. ### Behavior Identical. Key invariants preserved: - **Exit-code mapping** unchanged: 0 → SUCCESS, 3 → PARTIAL, 11 → retry via `ResticRepoLocked`, 12 (`ResticWrongPassword`) → FAILED with no retry, other non-zero → FAILED. - **CLAUDE.md data-safety gate** unchanged: `BACKUP_SUCCESS_SCRATCH_KEY` is set only when `outcome in (SUCCESS, PARTIAL) and result.summary is not None and result.summary.snapshot_id`. If restic exits 0 but the JSON summary never arrived (truncated stream), the flag is still withheld so `ResticForgetStep` cannot silently prune the last good snapshot. Pinned by `test_backup_does_not_set_scratch_flag_when_summary_missing` and `..._when_snapshot_id_empty`. - **`extras` payload** unchanged: same keys, same values, same conditional inclusion when `result.summary is not None`. - **Retry boundary** unchanged: `network_retrying()` wraps only `restic.backup`, never `restic.init`. Init failures fail the step immediately. - **Exception narrowing** unchanged: still `except Exception` (not `BaseException`) so `SystemExit` / `KeyboardInterrupt` / the streamer's kill-signal propagate untouched. - **`stderr_tail` / `exception` fields on the final return** unchanged: same `[-1024:]` slice on `result.stderr` only when `outcome is not SUCCESS and result.stderr`, same `repr(last_exc)` only when `last_exc and outcome is FAILED`. - **CLAUDE.md hard rule #2 (idempotency / safe rollback)** unchanged: no new state on `self`, `rollback()` is still the inherited base-class no-op. Pinned by `test_restic_backup_rollback_is_safe_noop`. ### Verification - `pytest -q tests/unit/` — passes (207/207). Includes `tests/unit/test_restic_forget_guard.py` which directly exercises 8 of the behavioral cases (scratch flag set on SUCCESS-with-id, set on PARTIAL-with-id, not set on FAILED, not set when summary missing, not set when snapshot_id empty, dry-run sets sentinel, end-to-end backup-then-forget shares scratch, failed backup blocks forget). - `mypy --strict src/gardien/core src/gardien/config src/gardien/engine src/gardien/steps` — passes (`Success: no issues found in 26 source files`). - SonarQube `analyze_code_snippet` on the refactored file — `issueCount: 0`.
Split ResticBackupStep.run() into focused, single-purpose helpers:

- _failed_result(): shared FAILED StepResult builder for the three
  identical error-shaping sites (init wrong-password, init other,
  backup-loop wrong-password).
- _init_repo_or_fail(): wraps the idempotent restic.init call and
  returns None on success or a FAILED StepResult on either restic error.
- _dry_run_result(): the dry-run short-circuit, including the scratch
  sentinel that lets a downstream ResticForgetStep exercise its own
  dry-run path.
- _run_backup_with_retry(): drives the network_retrying() loop around
  restic.backup, raising on timeout / exit-11 to trigger retry, and
  surfacing the wrong-password fast path via a sentinel third return.
- _classify_result(): maps the final ResticBackupResult to
  SUCCESS / PARTIAL / FAILED, populates extras, and gates the
  BACKUP_SUCCESS_SCRATCH_KEY flag.

Behavior is identical:
  - Same exit-code → Outcome mapping (0 SUCCESS, 3 PARTIAL, 11 retry,
    12 FAILED, other FAILED).
  - Same data-safety gate: BACKUP_SUCCESS_SCRATCH_KEY only set when a
    real snapshot_id was observed (or "dry-run" sentinel in dry-run).
  - Same extras payload (repo, returncode, file_errors, snapshot_id,
    bytes_added, files_new, files_changed).
  - Same retry semantics (network_retrying around backup, never around init).
  - rollback() is still the inherited base-class no-op, preserving the
    CLAUDE.md idempotency invariant.

Closes #8
This pull request can be merged automatically.
You are not authorized to merge this pull request.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u fix/issue-8-S3776-restic-backup-complexity:Claude-fix/issue-8-S3776-restic-backup-complexity
git switch Claude-fix/issue-8-S3776-restic-backup-complexity

Merge

Merge the changes and update on Forgejo.

Warning: The "Autodetect manual merge" setting is not enabled for this repository, you will have to mark this pull request as manually merged afterwards.

git switch master
git merge --no-ff Claude-fix/issue-8-S3776-restic-backup-complexity
git switch Claude-fix/issue-8-S3776-restic-backup-complexity
git rebase master
git switch master
git merge --ff-only Claude-fix/issue-8-S3776-restic-backup-complexity
git switch Claude-fix/issue-8-S3776-restic-backup-complexity
git rebase master
git switch master
git merge --no-ff Claude-fix/issue-8-S3776-restic-backup-complexity
git switch master
git merge --squash Claude-fix/issue-8-S3776-restic-backup-complexity
git switch master
git merge --ff-only Claude-fix/issue-8-S3776-restic-backup-complexity
git switch master
git merge Claude-fix/issue-8-S3776-restic-backup-complexity
git push origin master
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
bc1bb/backup-script!83
No description provided.