diff --git a/client.go b/client.go index 3a73784..be41d8d 100644 --- a/client.go +++ b/client.go @@ -313,12 +313,13 @@ type ProfileAuth = engine.ProfileAuth type BackupProfile = engine.BackupProfile var ( - WithVerbose = engine.WithVerbose - WithBackupDryRun = engine.WithBackupDryRun - WithTags = engine.WithTags - WithGenerator = engine.WithGenerator - WithMeta = engine.WithMeta - WithExcludeHash = engine.WithExcludeHash + WithVerbose = engine.WithVerbose + WithBackupDryRun = engine.WithBackupDryRun + WithIgnoreEmptySnapshot = engine.WithIgnoreEmptySnapshot + WithTags = engine.WithTags + WithGenerator = engine.WithGenerator + WithMeta = engine.WithMeta + WithExcludeHash = engine.WithExcludeHash ) func (c *Client) Backup(ctx context.Context, src source.Source, opts ...BackupOption) (*BackupResult, error) { diff --git a/cmd/cloudstic/cmd_backup.go b/cmd/cloudstic/cmd_backup.go index 5a7d760..ded0f53 100644 --- a/cmd/cloudstic/cmd_backup.go +++ b/cmd/cloudstic/cmd_backup.go @@ -27,6 +27,7 @@ type backupArgs struct { authRef string profilesFile string dryRun bool + ignoreEmpty bool excludeFile string skipNativeFiles bool volumeUUID string @@ -55,6 +56,7 @@ func parseBackupArgs() *backupArgs { allProfiles := fs.Bool("all-profiles", false, "Run backup for all enabled profiles from profiles.yaml") authRef := fs.String("auth-ref", "", "Use named auth entry from profiles.yaml for cloud source credentials") dryRun := fs.Bool("dry-run", false, "Scan source and report changes without writing to the store") + ignoreEmpty := fs.Bool("ignore-empty-snapshot", false, "Skip creating a new snapshot when nothing changed") skipNativeFiles := fs.Bool("skip-native-files", false, "Exclude Google-native files (Docs, Sheets, Slides, etc.) from the backup") excludeFile := fs.String("exclude-file", "", "Path to file with exclude patterns (one per line, gitignore syntax)") volumeUUID := fs.String("volume-uuid", envDefault("CLOUDSTIC_VOLUME_UUID", ""), "Override volume UUID for local source (enables cross-machine incremental backup)") @@ -79,6 +81,7 @@ func parseBackupArgs() *backupArgs { a.authRef = *authRef a.profilesFile = *a.g.profilesFile a.dryRun = *dryRun + a.ignoreEmpty = *ignoreEmpty a.skipNativeFiles = *skipNativeFiles a.excludeFile = *excludeFile a.volumeUUID = *volumeUUID @@ -375,6 +378,9 @@ func mergeProfileBackupArgs(base *backupArgs, profileName string, p cloudstic.Ba if !a.flagsSet["exclude-file"] && p.ExcludeFile != "" { a.excludeFile = p.ExcludeFile } + if !a.flagsSet["ignore-empty-snapshot"] { + a.ignoreEmpty = p.IgnoreEmpty + } if p.Store != "" { storeCfg, ok := cfg.Stores[p.Store] @@ -616,6 +622,9 @@ func buildBackupOpts(a *backupArgs, excludePatterns []string) []cloudstic.Backup if a.dryRun { opts = append(opts, engine.WithBackupDryRun()) } + if a.ignoreEmpty { + opts = append(opts, cloudstic.WithIgnoreEmptySnapshot()) + } if len(a.tags) > 0 { opts = append(opts, cloudstic.WithTags(a.tags...)) } @@ -631,6 +640,8 @@ func (r *runner) printBackupSummary(res *engine.RunResult) { res.DirsNew + res.DirsChanged + res.DirsUnmodified if res.DryRun { _, _ = fmt.Fprintf(r.out, "\nBackup dry run complete.\n") + } else if res.EmptySnapshotIgnored { + _, _ = fmt.Fprintf(r.out, "\nBackup complete. No new snapshot created; nothing changed. Root: %s\n", res.Root) } else { _, _ = fmt.Fprintf(r.out, "\nBackup complete. Snapshot: %s, Root: %s\n", res.SnapshotRef, res.Root) } @@ -638,13 +649,13 @@ func (r *runner) printBackupSummary(res *engine.RunResult) { res.FilesNew, res.FilesChanged, res.FilesUnmodified, res.FilesRemoved) _, _ = fmt.Fprintf(r.out, "Dirs: %d new, %d changed, %d unmodified, %d removed\n", res.DirsNew, res.DirsChanged, res.DirsUnmodified, res.DirsRemoved) - if !res.DryRun { + if !res.DryRun && !res.EmptySnapshotIgnored { _, _ = fmt.Fprintf(r.out, "Added to the repository: %s (%s compressed)\n", formatBytes(res.BytesAddedRaw), formatBytes(res.BytesAddedStored)) } _, _ = fmt.Fprintf(r.out, "Processed %d entries in %s\n", total, res.Duration.Round(time.Second)) - if !res.DryRun { + if !res.DryRun && !res.EmptySnapshotIgnored { _, _ = fmt.Fprintf(r.out, "Snapshot %s saved\n", res.SnapshotHash) } } diff --git a/cmd/cloudstic/cmd_backup_profile_test.go b/cmd/cloudstic/cmd_backup_profile_test.go index 952e4f1..9bfab68 100644 --- a/cmd/cloudstic/cmd_backup_profile_test.go +++ b/cmd/cloudstic/cmd_backup_profile_test.go @@ -27,10 +27,11 @@ func TestMergeProfileBackupArgs_AppliesProfileAndStore(t *testing.T) { }, } p := cloudstic.BackupProfile{ - Source: "local:/data", - Store: "s", - Tags: []string{"daily"}, - Excludes: []string{"*.tmp"}, + Source: "local:/data", + Store: "s", + Tags: []string{"daily"}, + Excludes: []string{"*.tmp"}, + IgnoreEmpty: true, } eff, err := mergeProfileBackupArgs(base, "p", p, cfg) @@ -52,6 +53,9 @@ func TestMergeProfileBackupArgs_AppliesProfileAndStore(t *testing.T) { if len(eff.excludes) != 1 || eff.excludes[0] != "*.tmp" { t.Fatalf("excludes=%v want [*.tmp]", eff.excludes) } + if !eff.ignoreEmpty { + t.Fatal("expected ignoreEmpty to be true") + } } func TestMergeProfileBackupArgs_CLIFlagsWin(t *testing.T) { diff --git a/cmd/cloudstic/cmd_backup_test.go b/cmd/cloudstic/cmd_backup_test.go index 083d23f..ea3570b 100644 --- a/cmd/cloudstic/cmd_backup_test.go +++ b/cmd/cloudstic/cmd_backup_test.go @@ -4,6 +4,8 @@ import ( "context" "strings" "testing" + + "github.com/cloudstic/cli/internal/engine" ) func TestInitSource_Local_ExtendedOptions(t *testing.T) { @@ -106,3 +108,31 @@ func TestPrintUsage_Smoke(t *testing.T) { // Verify printUsage doesn't panic. printUsage() } + +func TestBuildBackupOpts_IgnoreEmptySnapshot(t *testing.T) { + a := &backupArgs{ignoreEmpty: true, g: newTestGlobalFlags()} + opts := buildBackupOpts(a, nil) + if len(opts) != 1 { + t.Fatalf("len(opts)=%d want 1", len(opts)) + } +} + +func TestPrintBackupSummary_EmptySnapshotIgnored(t *testing.T) { + var out strings.Builder + r := &runner{out: &out} + + r.printBackupSummary(&engine.RunResult{ + Root: "node/abc", + FilesUnmodified: 1, + Duration: 2, + EmptySnapshotIgnored: true, + }) + + got := out.String() + if !strings.Contains(got, "No new snapshot created; nothing changed") { + t.Fatalf("missing empty snapshot message:\n%s", got) + } + if strings.Contains(got, "saved") { + t.Fatalf("unexpected snapshot saved line:\n%s", got) + } +} diff --git a/cmd/cloudstic/cmd_profile.go b/cmd/cloudstic/cmd_profile.go index abcae03..8d1e1d3 100644 --- a/cmd/cloudstic/cmd_profile.go +++ b/cmd/cloudstic/cmd_profile.go @@ -155,6 +155,7 @@ type profileNewArgs struct { tags stringArrayFlags excludes stringArrayFlags excludeFile string + ignoreEmpty bool skipNativeFiles bool volumeUUID string googleCreds string @@ -182,6 +183,7 @@ func parseProfileNewArgs() *profileNewArgs { store := fs.String("store", "", "Store URI to create/update under -store-ref") authRef := fs.String("auth-ref", "", "Auth reference name from top-level auth map") excludeFile := fs.String("exclude-file", "", "Path to file with exclude patterns") + ignoreEmpty := fs.Bool("ignore-empty-snapshot", false, "Skip creating a new snapshot when nothing changed") skipNativeFiles := fs.Bool("skip-native-files", false, "Exclude Google-native files (Docs, Sheets, Slides, etc.)") volumeUUID := fs.String("volume-uuid", "", "Override volume UUID for local source") googleCreds := fs.String("google-credentials", "", "Path to Google service account credentials JSON file") @@ -206,6 +208,7 @@ func parseProfileNewArgs() *profileNewArgs { a.store = *store a.authRef = *authRef a.excludeFile = *excludeFile + a.ignoreEmpty = *ignoreEmpty a.skipNativeFiles = *skipNativeFiles a.volumeUUID = *volumeUUID a.googleCreds = *googleCreds @@ -383,6 +386,7 @@ func (r *runner) runProfileNew(ctx context.Context) int { Tags: []string(a.tags), Excludes: []string(a.excludes), ExcludeFile: a.excludeFile, + IgnoreEmpty: a.ignoreEmpty, SkipNativeFiles: a.skipNativeFiles, VolumeUUID: a.volumeUUID, GoogleCreds: a.googleCreds, diff --git a/cmd/cloudstic/completion.go b/cmd/cloudstic/completion.go index 78e8c33..4a9abfc 100644 --- a/cmd/cloudstic/completion.go +++ b/cmd/cloudstic/completion.go @@ -75,7 +75,7 @@ _cloudstic() { init) cmd_flags="-add-recovery-key -no-encryption -adopt-slots" ;; backup) - cmd_flags="-source -profile -all-profiles -auth-ref -profiles-file -skip-native-files -google-credentials -google-credentials-ref -google-credentials-json -google-token-file -google-token-ref -onedrive-client-id -onedrive-token-file -onedrive-token-ref -tag -dry-run -skip-mode -skip-flags -skip-xattrs -xattr-namespaces" ;; + cmd_flags="-source -profile -all-profiles -auth-ref -profiles-file -skip-native-files -google-credentials -google-credentials-ref -google-credentials-json -google-token-file -google-token-ref -onedrive-client-id -onedrive-token-file -onedrive-token-ref -tag -ignore-empty-snapshot -dry-run -skip-mode -skip-flags -skip-xattrs -xattr-namespaces" ;; restore) cmd_flags="-output -format -path -dry-run" ;; prune) @@ -129,7 +129,7 @@ _cloudstic() { show) cmd_flags="-profiles-file" ;; new) - cmd_flags="-profiles-file -name -source -store-ref -store -auth-ref -tag -exclude -exclude-file -skip-native-files -volume-uuid -google-credentials -google-credentials-ref -google-credentials-json -google-token-file -google-token-ref -onedrive-client-id -onedrive-token-file -onedrive-token-ref" ;; + cmd_flags="-profiles-file -name -source -store-ref -store -auth-ref -tag -exclude -exclude-file -ignore-empty-snapshot -skip-native-files -volume-uuid -google-credentials -google-credentials-ref -google-credentials-json -google-token-file -google-token-ref -onedrive-client-id -onedrive-token-file -onedrive-token-ref" ;; *) cmd_flags="" ;; esac @@ -329,6 +329,7 @@ _cloudstic() { '-onedrive-token-file[OneDrive OAuth token file]:path:_files' \ '-onedrive-token-ref[Secret reference to OneDrive OAuth token]:ref:' \ '*-tag[Tag for the snapshot]:tag:' \ + '-ignore-empty-snapshot[Skip creating a new snapshot when nothing changed]' \ '-dry-run[Scan without writing]' \ '-skip-mode[Skip POSIX mode/uid/gid/btime/flags]' \ '-skip-flags[Skip file flags collection]' \ @@ -373,6 +374,7 @@ _cloudstic() { '*-tag[Tag for snapshots]:tag:' \ '*-exclude[Exclude pattern]:pattern:' \ '-exclude-file[Path to exclude file]:path:_files' \ + '-ignore-empty-snapshot[Skip creating a new snapshot when nothing changed]' \ '-skip-native-files[Exclude Google-native files]' \ '-volume-uuid[Volume UUID override]:uuid:' \ '-google-credentials[Google service account credentials JSON]:path:_files' \ @@ -670,6 +672,7 @@ complete -c cloudstic -n '__fish_seen_subcommand_from backup' -l onedrive-client complete -c cloudstic -n '__fish_seen_subcommand_from backup' -l onedrive-token-file -r -F -d 'OneDrive OAuth token file' complete -c cloudstic -n '__fish_seen_subcommand_from backup' -l onedrive-token-ref -x -d 'Secret reference to OneDrive OAuth token' complete -c cloudstic -n '__fish_seen_subcommand_from backup' -l tag -x -d 'Tag for the snapshot' +complete -c cloudstic -n '__fish_seen_subcommand_from backup' -l ignore-empty-snapshot -d 'Skip creating a new snapshot when nothing changed' complete -c cloudstic -n '__fish_seen_subcommand_from backup' -l dry-run -d 'Scan without writing' complete -c cloudstic -n '__fish_seen_subcommand_from backup' -l skip-mode -d 'Skip POSIX mode/uid/gid/btime/flags' complete -c cloudstic -n '__fish_seen_subcommand_from backup' -l skip-flags -d 'Skip file flags collection' @@ -691,6 +694,7 @@ complete -c cloudstic -n '__fish_seen_subcommand_from profile; and __fish_seen_s complete -c cloudstic -n '__fish_seen_subcommand_from profile; and __fish_seen_subcommand_from new' -l tag -x -d 'Tag for snapshots' complete -c cloudstic -n '__fish_seen_subcommand_from profile; and __fish_seen_subcommand_from new' -l exclude -x -d 'Exclude pattern' complete -c cloudstic -n '__fish_seen_subcommand_from profile; and __fish_seen_subcommand_from new' -l exclude-file -r -F -d 'Path to exclude file' +complete -c cloudstic -n '__fish_seen_subcommand_from profile; and __fish_seen_subcommand_from new' -l ignore-empty-snapshot -d 'Skip creating a new snapshot when nothing changed' complete -c cloudstic -n '__fish_seen_subcommand_from profile; and __fish_seen_subcommand_from new' -l skip-native-files -d 'Exclude Google-native files' complete -c cloudstic -n '__fish_seen_subcommand_from profile; and __fish_seen_subcommand_from new' -l volume-uuid -x -d 'Volume UUID override' complete -c cloudstic -n '__fish_seen_subcommand_from profile; and __fish_seen_subcommand_from new' -l google-credentials -r -F -d 'Google service account credentials JSON' diff --git a/cmd/cloudstic/completion_test.go b/cmd/cloudstic/completion_test.go index 2a58735..979620c 100644 --- a/cmd/cloudstic/completion_test.go +++ b/cmd/cloudstic/completion_test.go @@ -31,6 +31,7 @@ func TestCompletionBash(t *testing.T) { "-profiles-file", "-profile", "-all-profiles", "-auth-ref", + "-ignore-empty-snapshot", // Value completions "local: s3: b2: sftp://", "gdrive", "onedrive", @@ -81,6 +82,7 @@ func TestCompletionZsh(t *testing.T) { "-profile[Backup profile name]", "-all-profiles[Run all enabled backup profiles]", "-auth-ref[Use named auth entry from profiles.yaml]", + "-ignore-empty-snapshot[Skip creating a new snapshot when nothing changed]", // Value completions (source type list still present) "(local: sftp:// gdrive gdrive-changes onedrive onedrive-changes)", "(bash zsh fish)", @@ -128,6 +130,7 @@ func TestCompletionFish(t *testing.T) { "-l profile", "-l all-profiles", "-l auth-ref", + "-l ignore-empty-snapshot", "-a show -d 'Show one profile and resolved refs'", "-a new -d 'Create or update backup profile'", "-a login -d 'Run OAuth login flow for auth entry'", diff --git a/cmd/cloudstic/config_tables.go b/cmd/cloudstic/config_tables.go index 806a645..b422daf 100644 --- a/cmd/cloudstic/config_tables.go +++ b/cmd/cloudstic/config_tables.go @@ -458,6 +458,7 @@ func (r *runner) renderProfileShow(cfg *cloudstic.ProfilesConfig, name string, p {"Tags", joinOrDash(p.Tags)}, {"Excludes", fmt.Sprintf("%d pattern(s)", len(p.Excludes))}, {"Exclude File", dashIfEmpty(p.ExcludeFile)}, + {"Ignore Empty Snapshot", boolLabel(p.IgnoreEmpty)}, {"Skip Native Files", boolLabel(p.SkipNativeFiles)}, } if p.VolumeUUID != "" { diff --git a/cmd/cloudstic/usage.go b/cmd/cloudstic/usage.go index 5ec383f..c05d5b2 100644 --- a/cmd/cloudstic/usage.go +++ b/cmd/cloudstic/usage.go @@ -145,6 +145,7 @@ func printUsage() { {"-tag ", "Tag to apply to the snapshot (repeatable)"}, {"-exclude ", "Exclude pattern, gitignore syntax (repeatable)"}, {"-exclude-file ", "Load exclude patterns from file (one per line, gitignore syntax)"}, + {"-ignore-empty-snapshot", "Skip creating a new snapshot when nothing changed"}, {"-dry-run", "Scan source and report changes without writing to the store"}, {"-skip-mode", "Skip POSIX mode, uid, gid, btime, and flags collection"}, {"-skip-flags", "Skip file flags collection"}, @@ -240,6 +241,7 @@ func printUsage() { {"-tag ", "Tag for snapshots (repeatable)"}, {"-exclude ", "Exclude pattern (repeatable)"}, {"-exclude-file ", "Path to exclude file"}, + {"-ignore-empty-snapshot", "Skip creating a new snapshot when nothing changed"}, {"-skip-native-files", "Exclude Google-native files"}, {"-volume-uuid ", "Override local source volume UUID"}, {"-google-credentials ", "Path to Google service account credentials JSON"}, diff --git a/docs/user-guide.md b/docs/user-guide.md index cd1d58e..3db8fdf 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -316,6 +316,7 @@ cloudstic backup -source local:~/Documents -dry-run | `-tag` | | Tag to apply to the snapshot (repeatable) | | `-exclude` | | Exclude pattern using gitignore syntax (repeatable) | | `-exclude-file` | | Path to file containing exclude patterns, one per line | +| `-ignore-empty-snapshot` | `false` | Skip creating a new snapshot when the resulting tree is identical to the previous one | | `-volume-uuid` | | Override volume UUID for local source (enables cross-machine incremental backup for portable drives) | | `-skip-mode` | | Skip POSIX metadata collection (mode, uid, gid, btime, flags) | | `-skip-flags` | | Skip file flags collection | @@ -336,6 +337,8 @@ provider default auth entry in `profiles.yaml` so it is discoverable later: The `gdrive-changes` and `onedrive-changes` source types use their respective change/delta APIs for faster incremental backups after the first full backup. +When `-ignore-empty-snapshot` is enabled, Cloudstic still scans the source and reports stats, but it does not write a new snapshot if the resulting tree is unchanged. For changes-based cloud sources, this also means an unchanged run does not persist a fresh change token, so the next run may revisit the same empty delta window. + Cloudstic tracks source lineage using stable source identities internally (container identity + root location identity), not just display labels. For cloud sources, this uses stable drive/folder IDs so incremental continuity is preserved across folder renames or moves. > **Locking:** `backup` acquires a **shared lock** on the repository at the start of the run (skipped for `-dry-run`). Multiple backups can run concurrently. The lock is released when the command exits. If the repository is exclusively locked by a `prune` run, `backup` will fail immediately with an error message. Use `break-lock` if a lock is stale. diff --git a/internal/engine/backup.go b/internal/engine/backup.go index 6fc5fa0..4dec364 100644 --- a/internal/engine/backup.go +++ b/internal/engine/backup.go @@ -37,12 +37,13 @@ type backupStats struct { type BackupOption func(*backupConfig) type backupConfig struct { - verbose bool - dryRun bool - tags []string - generator string - meta map[string]string - excludeHash string + verbose bool + dryRun bool + ignoreEmptySnapshot bool + tags []string + generator string + meta map[string]string + excludeHash string } // WithBackupDryRun scans the source and reports what would change without writing to the store. @@ -50,6 +51,12 @@ func WithBackupDryRun() BackupOption { return func(cfg *backupConfig) { cfg.dryRun = true } } +// WithIgnoreEmptySnapshot skips persisting a new snapshot when the resulting +// tree is identical to the previous snapshot for the same source lineage. +func WithIgnoreEmptySnapshot() BackupOption { + return func(cfg *backupConfig) { cfg.ignoreEmptySnapshot = true } +} + // WithVerbose enables verbose output during backup. func WithVerbose() BackupOption { return func(cfg *backupConfig) { cfg.verbose = true } @@ -130,21 +137,22 @@ func NewBackupManager(src source.Source, dest store.ObjectStore, reporter ui.Rep // RunResult holds the outcome of a successful backup run. type RunResult struct { - SnapshotHash string - SnapshotRef string - Root string - FilesNew int64 - FilesChanged int64 - FilesUnmodified int64 - FilesRemoved int64 - DirsNew int64 - DirsChanged int64 - DirsUnmodified int64 - DirsRemoved int64 - BytesAddedRaw int64 - BytesAddedStored int64 - Duration time.Duration - DryRun bool + SnapshotHash string + SnapshotRef string + Root string + FilesNew int64 + FilesChanged int64 + FilesUnmodified int64 + FilesRemoved int64 + DirsNew int64 + DirsChanged int64 + DirsUnmodified int64 + DirsRemoved int64 + BytesAddedRaw int64 + BytesAddedStored int64 + Duration time.Duration + DryRun bool + EmptySnapshotIgnored bool } // Run executes a full backup: scan the source for changes, upload new/modified @@ -246,6 +254,13 @@ func (bm *BackupManager) Run(ctx context.Context) (*RunResult, error) { } } + if bm.cfg.ignoreEmptySnapshot && prevSnap != nil && newRoot == oldRoot { + r := bm.buildResult() + r.Root = newRoot + r.EmptySnapshotIgnored = true + return r, nil + } + snapRef, snapHash, snap, err := bm.saveSnapshot(ctx, newRoot, seq+1, newToken) if err != nil { return nil, err diff --git a/internal/engine/backup_test.go b/internal/engine/backup_test.go index b8824a6..3c82de6 100644 --- a/internal/engine/backup_test.go +++ b/internal/engine/backup_test.go @@ -3,6 +3,7 @@ package engine import ( "context" "encoding/json" + "strings" "testing" "github.com/cloudstic/cli/internal/core" @@ -222,6 +223,83 @@ func TestBackupManager_Run(t *testing.T) { } } +func TestBackupManager_IgnoreEmptySnapshot(t *testing.T) { + ctx := context.Background() + src := NewMockSource() + dest := NewMockStore() + + src.AddFile("file1.txt", "id1", []byte("hello world")) + + first := NewBackupManager(src, dest, ui.NewNoOpReporter(), nil) + firstResult, err := first.Run(ctx) + if err != nil { + t.Fatalf("first backup failed: %v", err) + } + + readStore := store.NewCompressedStore(dest) + idxData, err := readStore.Get(ctx, "index/latest") + if err != nil { + t.Fatalf("read index/latest after first backup: %v", err) + } + var idx core.Index + if err := json.Unmarshal(idxData, &idx); err != nil { + t.Fatalf("unmarshal index: %v", err) + } + firstLatest := idx.LatestSnapshot + + second := NewBackupManager(src, dest, ui.NewNoOpReporter(), nil, WithIgnoreEmptySnapshot()) + result, err := second.Run(ctx) + if err != nil { + t.Fatalf("second backup failed: %v", err) + } + if !result.EmptySnapshotIgnored { + t.Fatal("expected EmptySnapshotIgnored to be true") + } + if result.SnapshotRef != "" { + t.Fatalf("SnapshotRef=%q want empty", result.SnapshotRef) + } + if result.SnapshotHash != "" { + t.Fatalf("SnapshotHash=%q want empty", result.SnapshotHash) + } + if result.Root != firstResult.Root { + t.Fatalf("Root=%q want %q", result.Root, firstResult.Root) + } + if result.FilesChanged != 0 || result.FilesNew != 0 || result.FilesRemoved != 0 { + t.Fatalf("unexpected file changes in result: %+v", result) + } + if result.FilesUnmodified != 1 { + t.Fatalf("FilesUnmodified=%d want 1", result.FilesUnmodified) + } + + idxData2, err := readStore.Get(ctx, "index/latest") + if err != nil { + t.Fatalf("read index/latest after second backup: %v", err) + } + var idx2 core.Index + if err := json.Unmarshal(idxData2, &idx2); err != nil { + t.Fatalf("unmarshal second index: %v", err) + } + if idx2.Seq != 1 { + t.Fatalf("seq=%d want 1", idx2.Seq) + } + if idx2.LatestSnapshot != firstLatest { + t.Fatalf("latest=%q want %q", idx2.LatestSnapshot, firstLatest) + } + + snapshotKeys, err := readStore.List(ctx, "snapshot/") + if err != nil { + t.Fatalf("list snapshots: %v", err) + } + if len(snapshotKeys) != 1 { + t.Fatalf("snapshot count=%d want 1 (%v)", len(snapshotKeys), snapshotKeys) + } + for _, key := range snapshotKeys { + if !strings.HasPrefix(key, "snapshot/") { + t.Fatalf("unexpected snapshot key %q", key) + } + } +} + // TestFindPreviousSnapshot_Identity verifies that findPreviousSnapshot // uses Identity for matching when present, enabling cross-machine // incremental backup for portable drives. diff --git a/internal/engine/profiles.go b/internal/engine/profiles.go index 0d253c2..cdf9ebf 100644 --- a/internal/engine/profiles.go +++ b/internal/engine/profiles.go @@ -49,6 +49,7 @@ type BackupProfile struct { Tags []string `yaml:"tags,omitempty"` Excludes []string `yaml:"excludes,omitempty"` ExcludeFile string `yaml:"exclude_file,omitempty"` + IgnoreEmpty bool `yaml:"ignore_empty,omitempty"` SkipNativeFiles bool `yaml:"skip_native_files,omitempty"` VolumeUUID string `yaml:"volume_uuid,omitempty"` GoogleCreds string `yaml:"google_credentials,omitempty"`