Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
15 changes: 13 additions & 2 deletions cmd/cloudstic/cmd_backup.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ type backupArgs struct {
authRef string
profilesFile string
dryRun bool
ignoreEmpty bool
excludeFile string
skipNativeFiles bool
volumeUUID string
Expand Down Expand Up @@ -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)")
Expand All @@ -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
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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...))
}
Expand All @@ -631,20 +640,22 @@ 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)
}
_, _ = fmt.Fprintf(r.out, "Files: %d new, %d changed, %d unmodified, %d removed\n",
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)
}
}
Expand Down
12 changes: 8 additions & 4 deletions cmd/cloudstic/cmd_backup_profile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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) {
Expand Down
30 changes: 30 additions & 0 deletions cmd/cloudstic/cmd_backup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"context"
"strings"
"testing"

"github.com/cloudstic/cli/internal/engine"
)

func TestInitSource_Local_ExtendedOptions(t *testing.T) {
Expand Down Expand Up @@ -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)
}
}
4 changes: 4 additions & 0 deletions cmd/cloudstic/cmd_profile.go
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ type profileNewArgs struct {
tags stringArrayFlags
excludes stringArrayFlags
excludeFile string
ignoreEmpty bool
skipNativeFiles bool
volumeUUID string
googleCreds string
Expand Down Expand Up @@ -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")
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down
8 changes: 6 additions & 2 deletions cmd/cloudstic/completion.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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]' \
Expand Down Expand Up @@ -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' \
Expand Down Expand Up @@ -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'
Expand All @@ -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'
Expand Down
3 changes: 3 additions & 0 deletions cmd/cloudstic/completion_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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)",
Expand Down Expand Up @@ -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'",
Expand Down
1 change: 1 addition & 0 deletions cmd/cloudstic/config_tables.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 != "" {
Expand Down
2 changes: 2 additions & 0 deletions cmd/cloudstic/usage.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ func printUsage() {
{"-tag <tag>", "Tag to apply to the snapshot (repeatable)"},
{"-exclude <pattern>", "Exclude pattern, gitignore syntax (repeatable)"},
{"-exclude-file <path>", "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"},
Expand Down Expand Up @@ -240,6 +241,7 @@ func printUsage() {
{"-tag <tag>", "Tag for snapshots (repeatable)"},
{"-exclude <pattern>", "Exclude pattern (repeatable)"},
{"-exclude-file <path>", "Path to exclude file"},
{"-ignore-empty-snapshot", "Skip creating a new snapshot when nothing changed"},
{"-skip-native-files", "Exclude Google-native files"},
{"-volume-uuid <uuid>", "Override local source volume UUID"},
{"-google-credentials <path>", "Path to Google service account credentials JSON"},
Expand Down
3 changes: 3 additions & 0 deletions docs/user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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.
Expand Down
57 changes: 36 additions & 21 deletions internal/engine/backup.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,19 +37,26 @@ 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.
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 }
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Comment on lines +257 to +262
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ignore-empty-snapshot fast-path runs after flushPendingMetas, PreloadKeys, and upload. On an unchanged backup pending is typically empty and newRoot == oldRoot, so PreloadKeys will still list all chunk/, content/, and node/ keys even though no upload is needed. Consider moving the ignore-empty check earlier (right after scanSource, gated on prevSnap != nil && len(pending)==0 && newRoot==oldRoot and optionally len(bm.pendingMetas)==0) so unchanged runs can return without the expensive key preloading step.

Copilot uses AI. Check for mistakes.

snapRef, snapHash, snap, err := bm.saveSnapshot(ctx, newRoot, seq+1, newToken)
if err != nil {
return nil, err
Expand Down
Loading
Loading