Compare commits

1 Commits

Author SHA1 Message Date
Ein Anderssono 9cd048d9f3 v0.9.4: add doctor diagnostics
pipeline / test (push) Has been cancelled
pipeline / build (push) Has been cancelled
2026-06-15 02:47:44 +02:00
7 changed files with 138 additions and 8 deletions
+8
View File
@@ -2,6 +2,14 @@
This changelog is maintained from git history plus the published Gitea release series. Future releases should update this file and publish matching release notes on the release page. This changelog is maintained from git history plus the published Gitea release series. Future releases should update this file and publish matching release notes on the release page.
## v0.9.4
Doctor release.
- Add `doctor` to check Photos access and optional backup health.
- Add `doctor --out <dir>` to report backup directory, manifest entries, and failure count.
- Add `doctor --json` for scriptable diagnostics.
## v0.9.3 ## v0.9.3
Cleanup release. Cleanup release.
+1 -1
View File
@@ -1,6 +1,6 @@
BINARY := ./bin/photoscli BINARY := ./bin/photoscli
MODULE := gitea.k3s.k0.nu/tools/photocli MODULE := gitea.k3s.k0.nu/tools/photocli
VERSION := 0.9.3 VERSION := 0.9.4
RELEASE_ZIP := ./bin/photoscli-$(VERSION)-macos-arm64.zip RELEASE_ZIP := ./bin/photoscli-$(VERSION)-macos-arm64.zip
RELEASE_NOTES := RELEASE_NOTES.md RELEASE_NOTES := RELEASE_NOTES.md
BRIDGE_DIR := bridge BRIDGE_DIR := bridge
+1
View File
@@ -120,6 +120,7 @@ Verify a backup later:
./bin/photoscli verify --out ./photos-backup --deep ./bin/photoscli verify --out ./photos-backup --deep
./bin/photoscli manifest repair --out ./photos-backup --checksum sha256 --dry-run ./bin/photoscli manifest repair --out ./photos-backup --checksum sha256 --dry-run
./bin/photoscli cleanup --out ./photos-backup --dry-run ./bin/photoscli cleanup --out ./photos-backup --dry-run
./bin/photoscli doctor --out ./photos-backup
``` ```
## Commands ## Commands
+7 -7
View File
@@ -1,18 +1,18 @@
# v0.9.3 # v0.9.4
This release adds safe cleanup for manifest-backed backups. This release adds `doctor` diagnostics for Photos access and backups.
## Highlights ## Highlights
- Add `cleanup --out <dir>` to remove files not referenced by the manifest. - Add `doctor` to check Photos access.
- Add `cleanup --dry-run` to preview orphaned files before deletion. - Add `doctor --out <dir>` to report backup directory, manifest entries, and failure count.
- Preserve manifest/log/failure files, `.photoscli`, and sidecars for manifest-referenced media. - Add `doctor --json` for scriptable diagnostics.
- Cleanup works with JSONL and SQLite manifests. - `doctor` is read-only and does not modify backup data.
## Assets ## Assets
- `photoscli`: Apple Silicon macOS binary (`darwin/arm64`). - `photoscli`: Apple Silicon macOS binary (`darwin/arm64`).
- `photoscli-0.9.3-macos-arm64.zip`: Apple Silicon binary plus README, USERGUIDE, and CHANGELOG. - `photoscli-0.9.4-macos-arm64.zip`: Apple Silicon binary plus README, USERGUIDE, and CHANGELOG.
- `USERGUIDE.md`: standalone user guide. - `USERGUIDE.md`: standalone user guide.
Intel Macs are not currently a supported release target. Intel Macs are not currently a supported release target.
+10
View File
@@ -856,6 +856,16 @@ Preview and remove files not referenced by the manifest:
Cleanup preserves manifest/log/failure files, `.photoscli`, and sidecars next to manifest-referenced media. Cleanup preserves manifest/log/failure files, `.photoscli`, and sidecars next to manifest-referenced media.
Run read-only diagnostics:
```bash
./bin/photoscli doctor
./bin/photoscli doctor --out ./PhotosBackup
./bin/photoscli doctor --out ./PhotosBackup --json
```
Doctor checks Photos access and, when `--out` is provided, backup directory status, manifest entries, and failure count.
## Safe Operating Practices ## Safe Operating Practices
- Run `--dry-run` before the first large backup. - Run `--dry-run` before the first large backup.
+64
View File
@@ -97,6 +97,8 @@ func run(args []string, stdout, stderr io.Writer, bridge photos.Bridge) int {
return cmdManifest(args[1:], stdout, stderr) return cmdManifest(args[1:], stdout, stderr)
case "cleanup": case "cleanup":
return cmdCleanup(args[1:], stdout, stderr) return cmdCleanup(args[1:], stdout, stderr)
case "doctor":
return cmdDoctor(args[1:], stdout, stderr, bridge)
case "retry-failed": case "retry-failed":
return cmdRetryFailed(args[1:], stdout, stderr, bridge) return cmdRetryFailed(args[1:], stdout, stderr, bridge)
case "failures": case "failures":
@@ -144,6 +146,7 @@ USAGE
photoscli verify --out <dir> [--manifest jsonl|sqlite] photoscli verify --out <dir> [--manifest jsonl|sqlite]
photoscli manifest repair --out <dir> [--manifest jsonl|sqlite] [--checksum sha256] [--dry-run] photoscli manifest repair --out <dir> [--manifest jsonl|sqlite] [--checksum sha256] [--dry-run]
photoscli cleanup --out <dir> [--manifest jsonl|sqlite] [--dry-run] photoscli cleanup --out <dir> [--manifest jsonl|sqlite] [--dry-run]
photoscli doctor [--out <dir>] [--manifest jsonl|sqlite] [--json]
photoscli retry-failed --out <dir> photoscli retry-failed --out <dir>
photoscli retry-failed --out <dir> --clear-on-success photoscli retry-failed --out <dir> --clear-on-success
photoscli failures list --out <dir> photoscli failures list --out <dir>
@@ -195,6 +198,9 @@ COMMANDS
cleanup --out <dir> [--manifest jsonl|sqlite] [--dry-run] cleanup --out <dir> [--manifest jsonl|sqlite] [--dry-run]
Remove files not referenced by the manifest. Use --dry-run first. Remove files not referenced by the manifest. Use --dry-run first.
doctor [--out <dir>] [--manifest jsonl|sqlite] [--json]
Check Photos access and optional backup/manifest health without changing data.
retry-failed --out <dir> retry-failed --out <dir>
Retry assets previously written to failures.jsonl. Retry assets previously written to failures.jsonl.
@@ -2396,6 +2402,64 @@ func verifySidecar(stdout io.Writer, outDir, id, checkPath string, strict bool)
return 0 return 0
} }
func cmdDoctor(args []string, stdout, stderr io.Writer, bridge photos.Bridge) int {
result := map[string]any{"photos_access": "ok"}
problems := 0
if rc := mustAuth(stderr, bridge); rc != exitOK {
result["photos_access"] = "denied"
problems++
}
outDir := flagVal(args, "--out")
if outDir != "" {
result["out"] = outDir
if info, err := statFunc(outDir); err != nil || !info.IsDir() {
result["backup_dir"] = "missing"
problems++
} else {
result["backup_dir"] = "ok"
mf, err := manifest.ParseFormat(flagValWithDefault(args, "--manifest", "jsonl"))
if err != nil {
fmt.Fprintf(stderr, "error: %v\n", err)
return exitErr
}
entries, err := loadManifestEntries(outDir, mf)
if err != nil {
result["manifest"] = "error"
problems++
} else {
result["manifest"] = string(mf)
result["entries"] = len(entries)
}
failures := loadFailures(outDir)
result["failures"] = len(failures)
if len(failures) > 0 {
problems++
}
}
}
if hasFlag(args, "--json") {
result["problems"] = problems
if err := json.NewEncoder(stdout).Encode(result); err != nil {
fmt.Fprintf(stderr, "error: %v\n", err)
return exitErr
}
} else {
keys := make([]string, 0, len(result))
for k := range result {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
fmt.Fprintf(stdout, "%s\t%v\n", k, result[k])
}
fmt.Fprintf(stdout, "problems\t%d\n", problems)
}
if problems > 0 {
return exitPartial
}
return exitOK
}
func cmdCleanup(args []string, stdout, stderr io.Writer) int { func cmdCleanup(args []string, stdout, stderr io.Writer) int {
outDir := flagVal(args, "--out") outDir := flagVal(args, "--out")
if outDir == "" { if outDir == "" {
+47
View File
@@ -4506,6 +4506,53 @@ func TestCleanupErrors(t *testing.T) {
} }
} }
func TestDoctor(t *testing.T) {
dir := t.TempDir()
m := manifest.LoadJSONL(dir)
if err := m.OpenAppend(); err != nil {
t.Fatal(err)
}
m.AddEntry(manifest.Entry{ID: "x1", Filename: "photo.jpg", Path: "photo.jpg", Size: 4, Cloud: "local", Exported: time.Now().Unix()})
m.Close()
out, stderr, rc := runWith([]string{"doctor", "--out", dir}, &mockBridge{})
if rc != exitOK || !strings.Contains(stderr, "access granted") || !strings.Contains(out, "backup_dir\tok") || !strings.Contains(out, "entries\t1") || !strings.Contains(out, "problems\t0") {
t.Fatalf("doctor rc=%d out=%q stderr=%q", rc, out, stderr)
}
out, stderr, rc = runWith([]string{"doctor", "--out", dir, "--json"}, &mockBridge{})
if rc != exitOK || !strings.Contains(out, `"entries":1`) || !strings.Contains(out, `"problems":0`) {
t.Fatalf("doctor json rc=%d out=%q stderr=%q", rc, out, stderr)
}
}
func TestDoctorProblems(t *testing.T) {
dir := t.TempDir()
appendFailure(dir, pendingAsset{asset: photos.Asset{ID: "x1", Filename: "photo.jpg"}}, fmt.Errorf("failed"))
out, stderr, rc := runWith([]string{"doctor", "--out", dir}, &mockBridge{accessErr: fmt.Errorf("denied")})
if rc != exitPartial || !strings.Contains(stderr, "denied") || !strings.Contains(out, "photos_access\tdenied") || !strings.Contains(out, "failures\t1") {
t.Fatalf("doctor problems rc=%d out=%q stderr=%q", rc, out, stderr)
}
_, stderr, rc = runWith([]string{"doctor", "--out", dir, "--manifest", "bad"}, &mockBridge{})
if rc != exitErr || !strings.Contains(stderr, "manifest") {
t.Fatalf("doctor bad manifest rc=%d stderr=%q", rc, stderr)
}
out, stderr, rc = runWith([]string{"doctor", "--out", filepath.Join(dir, "missing")}, &mockBridge{})
if rc != exitPartial || !strings.Contains(out, "backup_dir\tmissing") {
t.Fatalf("doctor missing dir rc=%d out=%q stderr=%q", rc, out, stderr)
}
badDBDir := t.TempDir()
if err := os.WriteFile(manifest.SQLitePath(badDBDir), []byte("not sqlite"), 0644); err != nil {
t.Fatal(err)
}
out, stderr, rc = runWith([]string{"doctor", "--out", badDBDir, "--manifest", "sqlite"}, &mockBridge{})
if rc != exitPartial || !strings.Contains(out, "manifest\terror") {
t.Fatalf("doctor manifest error rc=%d out=%q stderr=%q", rc, out, stderr)
}
stderrBuf := &bytes.Buffer{}
if rc := cmdDoctor([]string{"--json"}, errWriter{}, stderrBuf, &mockBridge{}); rc != exitErr || !strings.Contains(stderrBuf.String(), "error:") {
t.Fatalf("doctor json writer error rc=%d stderr=%q", rc, stderrBuf.String())
}
}
func TestVerifySidecarBranches(t *testing.T) { func TestVerifySidecarBranches(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
subdir := filepath.Join(dir, "sub") subdir := filepath.Join(dir, "sub")