3 Commits

Author SHA1 Message Date
7f6ef11371 Simplify sgardd Dockerfile: remove baked-in user/args, defer config to orchestration.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 09:54:59 -07:00
7713d071c2 Make add idempotent: skip already-tracked files instead of erroring.
Enables glob workflows like `sgard add ~/.config/mcp/services/*` to
pick up new files without failing on ones already tracked.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 09:52:37 -07:00
de5759ac77 Add file exclusion support (sgard exclude/include).
Paths added to the manifest's exclude list are skipped during Add,
MirrorUp, and MirrorDown directory walks. Excluding a directory
excludes everything under it. Already-tracked entries matching a
new exclusion are removed from the manifest.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 00:25:42 -07:00
16 changed files with 588 additions and 23 deletions

View File

@@ -129,6 +129,8 @@ All commands operate on a repository directory (default: `~/.sgard`, override wi
| `sgard prune` | Remove orphaned blobs not referenced by the manifest | | `sgard prune` | Remove orphaned blobs not referenced by the manifest |
| `sgard mirror up <path>...` | Sync filesystem → manifest (add new, remove deleted, rehash) | | `sgard mirror up <path>...` | Sync filesystem → manifest (add new, remove deleted, rehash) |
| `sgard mirror down <path>... [--force]` | Sync manifest → filesystem (restore + delete untracked) | | `sgard mirror down <path>... [--force]` | Sync manifest → filesystem (restore + delete untracked) |
| `sgard exclude <path>... [--list]` | Exclude paths from tracking; `--list` shows current exclusions |
| `sgard include <path>...` | Remove paths from the exclusion list |
**Workflow example:** **Workflow example:**
@@ -674,6 +676,7 @@ sgard/
cmd/sgard/ # CLI entry point — one file per command cmd/sgard/ # CLI entry point — one file per command
main.go # cobra root command, --repo/--remote/--ssh-key/--tls/--tls-ca flags main.go # cobra root command, --repo/--remote/--ssh-key/--tls/--tls-ca flags
encrypt.go # sgard encrypt init/add-fido2/remove-slot/list-slots/change-passphrase encrypt.go # sgard encrypt init/add-fido2/remove-slot/list-slots/change-passphrase
exclude.go # sgard exclude/include
push.go pull.go prune.go mirror.go push.go pull.go prune.go mirror.go
init.go add.go remove.go checkpoint.go init.go add.go remove.go checkpoint.go
restore.go status.go verify.go list.go info.go diff.go version.go restore.go status.go verify.go list.go info.go diff.go version.go
@@ -687,6 +690,7 @@ sgard/
encrypt_fido2.go # FIDO2Device interface, AddFIDO2Slot, unlock resolution encrypt_fido2.go # FIDO2Device interface, AddFIDO2Slot, unlock resolution
fido2_hardware.go # Real FIDO2 via go-libfido2 (//go:build fido2) fido2_hardware.go # Real FIDO2 via go-libfido2 (//go:build fido2)
fido2_nohardware.go # Stub returning nil (//go:build !fido2) fido2_nohardware.go # Stub returning nil (//go:build !fido2)
exclude.go # Exclude/Include methods
restore.go mirror.go prune.go remove.go verify.go list.go info.go diff.go restore.go mirror.go prune.go remove.go verify.go list.go info.go diff.go
hasher.go # SHA-256 file hashing hasher.go # SHA-256 file hashing
@@ -743,6 +747,8 @@ func (g *Garden) MirrorUp(paths []string) error
func (g *Garden) MirrorDown(paths []string, force bool, confirm func(string) bool) error func (g *Garden) MirrorDown(paths []string, force bool, confirm func(string) bool) error
func (g *Garden) Lock(paths []string) error func (g *Garden) Lock(paths []string) error
func (g *Garden) Unlock(paths []string) error func (g *Garden) Unlock(paths []string) error
func (g *Garden) Exclude(paths []string) error
func (g *Garden) Include(paths []string) error
// Encryption // Encryption
func (g *Garden) EncryptInit(passphrase string) error func (g *Garden) EncryptInit(passphrase string) error
@@ -776,7 +782,15 @@ different usernames.
**Adding a directory recurses.** `Add` walks directories and adds each **Adding a directory recurses.** `Add` walks directories and adds each
file/symlink individually. Directories are not tracked as entries — only file/symlink individually. Directories are not tracked as entries — only
leaf files and symlinks. leaf files and symlinks. Excluded paths (see below) are skipped during walks.
**File exclusion.** The manifest stores an `exclude` list of tilde-form
paths that should never be tracked. Excluding a directory excludes
everything under it. Exclusions are checked during `Add` directory walks,
`MirrorUp` walks, and `MirrorDown` cleanup (excluded files are left alone
on disk). `sgard exclude` adds paths; `sgard include` removes them. When a
path is excluded, any already-tracked entries matching it are removed from
the manifest.
**No history.** Only the latest checkpoint is stored. For versioning, place **No history.** Only the latest checkpoint is stored. For versioning, place
the repo under git — `sgard init` creates a `.gitignore` that excludes the repo under git — `sgard init` creates a `.gitignore` that excludes

View File

@@ -7,9 +7,9 @@ ARCHITECTURE.md for design details.
## Current Status ## Current Status
**Phase:** Phase 5 complete. All 5 steps done (2832). **Phase:** Phase 5 complete. File exclusion feature added. Add is now idempotent.
**Last updated:** 2026-03-24 **Last updated:** 2026-03-30
## Completed Steps ## Completed Steps
@@ -113,3 +113,5 @@ Phase 6: Manifest Signing (to be planned).
| 2026-03-25 | — | `sgard remote set/show`: persistent remote config in `<repo>/remote.yaml` (addr, tls, tls_ca). | | 2026-03-25 | — | `sgard remote set/show`: persistent remote config in `<repo>/remote.yaml` (addr, tls, tls_ca). |
| 2026-03-26 | — | `sgard list` remote support: uses `resolveRemoteConfig()` to list server manifest via `PullManifest` RPC. Client `List()` method added. | | 2026-03-26 | — | `sgard list` remote support: uses `resolveRemoteConfig()` to list server manifest via `PullManifest` RPC. Client `List()` method added. |
| 2026-03-26 | — | Version derived from git tags via `VERSION` file. flake.nix reads `VERSION`; Makefile `version` target syncs from latest tag, `build` injects via ldflags. | | 2026-03-26 | — | Version derived from git tags via `VERSION` file. flake.nix reads `VERSION`; Makefile `version` target syncs from latest tag, `build` injects via ldflags. |
| 2026-03-27 | — | File exclusion: `sgard exclude`/`include` commands, `Manifest.Exclude` field, Add/MirrorUp/MirrorDown respect exclusions, directory exclusion support. 8 tests. |
| 2026-03-30 | — | Idempotent add: `sgard add` silently skips already-tracked files/directories instead of erroring, enabling glob-based workflows. |

View File

@@ -316,6 +316,18 @@ Depends on Steps 17, 18.
- [x] E2e test: push/pull with targeting labels, restore respects targeting - [x] E2e test: push/pull with targeting labels, restore respects targeting
- [x] Verify: all tests pass, lint clean, both binaries compile - [x] Verify: all tests pass, lint clean, both binaries compile
## Standalone: File Exclusion
- [x] `manifest/manifest.go`: `Exclude []string` field on Manifest, `IsExcluded(tildePath)` method (exact match + directory prefix)
- [x] `garden/exclude.go`: `Exclude(paths)` and `Include(paths)` methods; Exclude removes already-tracked matching entries
- [x] `garden/garden.go`: Add's WalkDir checks `IsExcluded`, returns `filepath.SkipDir` for excluded directories
- [x] `garden/mirror.go`: MirrorUp skips excluded paths; MirrorDown leaves excluded files on disk
- [x] `cmd/sgard/exclude.go`: `sgard exclude <path>... [--list]`, `sgard include <path>...`
- [x] `proto/sgard/v1/sgard.proto`: `repeated string exclude = 7` on Manifest; regenerated
- [x] `server/convert.go`: round-trip Exclude field
- [x] `garden/exclude_test.go`: 8 tests (add/dedup/remove-tracked/include, Add skips file/dir, MirrorUp skips, MirrorDown leaves alone, IsExcluded prefix matching)
- [x] Update ARCHITECTURE.md, PROJECT_PLAN.md, PROGRESS.md
## Phase 6: Manifest Signing ## Phase 6: Manifest Signing
(To be planned — requires trust model design) (To be planned — requires trust model design)

View File

@@ -1 +1 @@
3.1.7 3.2.2

65
cmd/sgard/exclude.go Normal file
View File

@@ -0,0 +1,65 @@
package main
import (
"fmt"
"github.com/kisom/sgard/garden"
"github.com/spf13/cobra"
)
var listExclude bool
var excludeCmd = &cobra.Command{
Use: "exclude [path...]",
Short: "Exclude paths from tracking",
Long: "Add paths to the exclusion list. Excluded paths are skipped during add and mirror operations. Use --list to show current exclusions.",
RunE: func(cmd *cobra.Command, args []string) error {
g, err := garden.Open(repoFlag)
if err != nil {
return err
}
if listExclude {
for _, p := range g.GetManifest().Exclude {
fmt.Println(p)
}
return nil
}
if len(args) == 0 {
return fmt.Errorf("provide paths to exclude, or use --list")
}
if err := g.Exclude(args); err != nil {
return err
}
fmt.Printf("Excluded %d path(s)\n", len(args))
return nil
},
}
var includeCmd = &cobra.Command{
Use: "include <path>...",
Short: "Remove paths from the exclusion list",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
g, err := garden.Open(repoFlag)
if err != nil {
return err
}
if err := g.Include(args); err != nil {
return err
}
fmt.Printf("Included %d path(s)\n", len(args))
return nil
},
}
func init() {
excludeCmd.Flags().BoolVarP(&listExclude, "list", "l", false, "list current exclusions")
rootCmd.AddCommand(excludeCmd)
rootCmd.AddCommand(includeCmd)
}

View File

@@ -13,18 +13,11 @@ RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /sgardd ./cmd/sgardd
# Runtime stage # Runtime stage
FROM alpine:3.21 FROM alpine:3.21
RUN apk add --no-cache ca-certificates tzdata \ RUN apk add --no-cache ca-certificates tzdata
&& adduser -D -h /srv/sgard sgard
COPY --from=builder /sgardd /usr/local/bin/sgardd COPY --from=builder /sgardd /usr/local/bin/sgardd
VOLUME /srv/sgard WORKDIR /srv/sgard
EXPOSE 9473 EXPOSE 9473
USER sgard ENTRYPOINT ["sgardd"]
ENTRYPOINT ["sgardd", \
"--repo", "/srv/sgard", \
"--authorized-keys", "/srv/sgard/authorized_keys", \
"--tls-cert", "/srv/sgard/certs/sgard.pem", \
"--tls-key", "/srv/sgard/certs/sgard.key"]

80
garden/exclude.go Normal file
View File

@@ -0,0 +1,80 @@
package garden
import (
"fmt"
"path/filepath"
)
// Exclude adds the given paths to the manifest's exclusion list. Excluded
// paths are skipped during Add and MirrorUp directory walks. If any of the
// paths are already tracked, they are removed from the manifest.
func (g *Garden) Exclude(paths []string) error {
existing := make(map[string]bool, len(g.manifest.Exclude))
for _, e := range g.manifest.Exclude {
existing[e] = true
}
for _, p := range paths {
abs, err := filepath.Abs(p)
if err != nil {
return fmt.Errorf("resolving path %s: %w", p, err)
}
tilded := toTildePath(abs)
if existing[tilded] {
continue
}
g.manifest.Exclude = append(g.manifest.Exclude, tilded)
existing[tilded] = true
// Remove any already-tracked entries that match this exclusion.
g.removeExcludedEntries(tilded)
}
if err := g.manifest.Save(g.manifestPath); err != nil {
return fmt.Errorf("saving manifest: %w", err)
}
return nil
}
// Include removes the given paths from the manifest's exclusion list,
// allowing them to be tracked again.
func (g *Garden) Include(paths []string) error {
remove := make(map[string]bool, len(paths))
for _, p := range paths {
abs, err := filepath.Abs(p)
if err != nil {
return fmt.Errorf("resolving path %s: %w", p, err)
}
remove[toTildePath(abs)] = true
}
filtered := g.manifest.Exclude[:0]
for _, e := range g.manifest.Exclude {
if !remove[e] {
filtered = append(filtered, e)
}
}
g.manifest.Exclude = filtered
if err := g.manifest.Save(g.manifestPath); err != nil {
return fmt.Errorf("saving manifest: %w", err)
}
return nil
}
// removeExcludedEntries drops manifest entries that match the given
// exclusion path (exact match or under an excluded directory).
func (g *Garden) removeExcludedEntries(tildePath string) {
kept := g.manifest.Files[:0]
for _, e := range g.manifest.Files {
if !g.manifest.IsExcluded(e.Path) {
kept = append(kept, e)
}
}
g.manifest.Files = kept
}

331
garden/exclude_test.go Normal file
View File

@@ -0,0 +1,331 @@
package garden
import (
"os"
"path/filepath"
"testing"
)
func TestExcludeAddsToManifest(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
g, err := Init(repoDir)
if err != nil {
t.Fatalf("Init: %v", err)
}
secretFile := filepath.Join(root, "secret.key")
if err := os.WriteFile(secretFile, []byte("secret"), 0o600); err != nil {
t.Fatalf("writing secret: %v", err)
}
if err := g.Exclude([]string{secretFile}); err != nil {
t.Fatalf("Exclude: %v", err)
}
if len(g.manifest.Exclude) != 1 {
t.Fatalf("expected 1 exclusion, got %d", len(g.manifest.Exclude))
}
expected := toTildePath(secretFile)
if g.manifest.Exclude[0] != expected {
t.Errorf("exclude[0] = %q, want %q", g.manifest.Exclude[0], expected)
}
// Verify persistence.
g2, err := Open(repoDir)
if err != nil {
t.Fatalf("re-Open: %v", err)
}
if len(g2.manifest.Exclude) != 1 {
t.Errorf("persisted excludes = %d, want 1", len(g2.manifest.Exclude))
}
}
func TestExcludeDeduplicates(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
g, err := Init(repoDir)
if err != nil {
t.Fatalf("Init: %v", err)
}
secretFile := filepath.Join(root, "secret.key")
if err := os.WriteFile(secretFile, []byte("secret"), 0o600); err != nil {
t.Fatalf("writing secret: %v", err)
}
if err := g.Exclude([]string{secretFile}); err != nil {
t.Fatalf("first Exclude: %v", err)
}
if err := g.Exclude([]string{secretFile}); err != nil {
t.Fatalf("second Exclude: %v", err)
}
if len(g.manifest.Exclude) != 1 {
t.Errorf("expected 1 exclusion after dedup, got %d", len(g.manifest.Exclude))
}
}
func TestExcludeRemovesTrackedEntry(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
g, err := Init(repoDir)
if err != nil {
t.Fatalf("Init: %v", err)
}
secretFile := filepath.Join(root, "secret.key")
if err := os.WriteFile(secretFile, []byte("secret"), 0o600); err != nil {
t.Fatalf("writing secret: %v", err)
}
// Add the file first.
if err := g.Add([]string{secretFile}); err != nil {
t.Fatalf("Add: %v", err)
}
if len(g.manifest.Files) != 1 {
t.Fatalf("expected 1 file, got %d", len(g.manifest.Files))
}
// Now exclude it — should remove from tracked files.
if err := g.Exclude([]string{secretFile}); err != nil {
t.Fatalf("Exclude: %v", err)
}
if len(g.manifest.Files) != 0 {
t.Errorf("expected 0 files after exclude, got %d", len(g.manifest.Files))
}
}
func TestIncludeRemovesFromExcludeList(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
g, err := Init(repoDir)
if err != nil {
t.Fatalf("Init: %v", err)
}
secretFile := filepath.Join(root, "secret.key")
if err := os.WriteFile(secretFile, []byte("secret"), 0o600); err != nil {
t.Fatalf("writing secret: %v", err)
}
if err := g.Exclude([]string{secretFile}); err != nil {
t.Fatalf("Exclude: %v", err)
}
if len(g.manifest.Exclude) != 1 {
t.Fatalf("expected 1 exclusion, got %d", len(g.manifest.Exclude))
}
if err := g.Include([]string{secretFile}); err != nil {
t.Fatalf("Include: %v", err)
}
if len(g.manifest.Exclude) != 0 {
t.Errorf("expected 0 exclusions after include, got %d", len(g.manifest.Exclude))
}
}
func TestAddSkipsExcludedFile(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
g, err := Init(repoDir)
if err != nil {
t.Fatalf("Init: %v", err)
}
testDir := filepath.Join(root, "config")
if err := os.MkdirAll(testDir, 0o755); err != nil {
t.Fatalf("creating dir: %v", err)
}
normalFile := filepath.Join(testDir, "settings.yaml")
secretFile := filepath.Join(testDir, "credentials.key")
if err := os.WriteFile(normalFile, []byte("settings"), 0o644); err != nil {
t.Fatalf("writing normal file: %v", err)
}
if err := os.WriteFile(secretFile, []byte("secret"), 0o600); err != nil {
t.Fatalf("writing secret file: %v", err)
}
// Exclude the secret file before adding the directory.
if err := g.Exclude([]string{secretFile}); err != nil {
t.Fatalf("Exclude: %v", err)
}
if err := g.Add([]string{testDir}); err != nil {
t.Fatalf("Add: %v", err)
}
if len(g.manifest.Files) != 1 {
t.Fatalf("expected 1 file, got %d", len(g.manifest.Files))
}
expectedPath := toTildePath(normalFile)
if g.manifest.Files[0].Path != expectedPath {
t.Errorf("tracked file = %q, want %q", g.manifest.Files[0].Path, expectedPath)
}
}
func TestAddSkipsExcludedDirectory(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
g, err := Init(repoDir)
if err != nil {
t.Fatalf("Init: %v", err)
}
testDir := filepath.Join(root, "config")
subDir := filepath.Join(testDir, "secrets")
if err := os.MkdirAll(subDir, 0o755); err != nil {
t.Fatalf("creating dirs: %v", err)
}
normalFile := filepath.Join(testDir, "settings.yaml")
secretFile := filepath.Join(subDir, "token.key")
if err := os.WriteFile(normalFile, []byte("settings"), 0o644); err != nil {
t.Fatalf("writing normal file: %v", err)
}
if err := os.WriteFile(secretFile, []byte("token"), 0o600); err != nil {
t.Fatalf("writing secret file: %v", err)
}
// Exclude the entire secrets subdirectory.
if err := g.Exclude([]string{subDir}); err != nil {
t.Fatalf("Exclude: %v", err)
}
if err := g.Add([]string{testDir}); err != nil {
t.Fatalf("Add: %v", err)
}
if len(g.manifest.Files) != 1 {
t.Fatalf("expected 1 file, got %d", len(g.manifest.Files))
}
expectedPath := toTildePath(normalFile)
if g.manifest.Files[0].Path != expectedPath {
t.Errorf("tracked file = %q, want %q", g.manifest.Files[0].Path, expectedPath)
}
}
func TestMirrorUpSkipsExcluded(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
g, err := Init(repoDir)
if err != nil {
t.Fatalf("Init: %v", err)
}
testDir := filepath.Join(root, "config")
if err := os.MkdirAll(testDir, 0o755); err != nil {
t.Fatalf("creating dir: %v", err)
}
normalFile := filepath.Join(testDir, "settings.yaml")
secretFile := filepath.Join(testDir, "credentials.key")
if err := os.WriteFile(normalFile, []byte("settings"), 0o644); err != nil {
t.Fatalf("writing normal file: %v", err)
}
if err := os.WriteFile(secretFile, []byte("secret"), 0o600); err != nil {
t.Fatalf("writing secret file: %v", err)
}
// Exclude the secret file.
if err := g.Exclude([]string{secretFile}); err != nil {
t.Fatalf("Exclude: %v", err)
}
if err := g.MirrorUp([]string{testDir}); err != nil {
t.Fatalf("MirrorUp: %v", err)
}
// Only the normal file should be tracked.
if len(g.manifest.Files) != 1 {
t.Fatalf("expected 1 file, got %d", len(g.manifest.Files))
}
expectedPath := toTildePath(normalFile)
if g.manifest.Files[0].Path != expectedPath {
t.Errorf("tracked file = %q, want %q", g.manifest.Files[0].Path, expectedPath)
}
}
func TestMirrorDownLeavesExcludedAlone(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
g, err := Init(repoDir)
if err != nil {
t.Fatalf("Init: %v", err)
}
testDir := filepath.Join(root, "config")
if err := os.MkdirAll(testDir, 0o755); err != nil {
t.Fatalf("creating dir: %v", err)
}
normalFile := filepath.Join(testDir, "settings.yaml")
secretFile := filepath.Join(testDir, "credentials.key")
if err := os.WriteFile(normalFile, []byte("settings"), 0o644); err != nil {
t.Fatalf("writing normal file: %v", err)
}
if err := os.WriteFile(secretFile, []byte("secret"), 0o600); err != nil {
t.Fatalf("writing secret file: %v", err)
}
// Add only the normal file.
if err := g.Add([]string{normalFile}); err != nil {
t.Fatalf("Add: %v", err)
}
// Exclude the secret file.
if err := g.Exclude([]string{secretFile}); err != nil {
t.Fatalf("Exclude: %v", err)
}
// MirrorDown with force — excluded file should NOT be deleted.
if err := g.MirrorDown([]string{testDir}, true, nil); err != nil {
t.Fatalf("MirrorDown: %v", err)
}
if _, err := os.Stat(secretFile); err != nil {
t.Error("excluded file should not have been deleted by MirrorDown")
}
}
func TestIsExcludedDirectoryPrefix(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
g, err := Init(repoDir)
if err != nil {
t.Fatalf("Init: %v", err)
}
// Exclude a directory.
g.manifest.Exclude = []string{"~/config/secrets"}
if !g.manifest.IsExcluded("~/config/secrets") {
t.Error("exact match should be excluded")
}
if !g.manifest.IsExcluded("~/config/secrets/token.key") {
t.Error("file under excluded dir should be excluded")
}
if !g.manifest.IsExcluded("~/config/secrets/nested/deep.key") {
t.Error("deeply nested file under excluded dir should be excluded")
}
if g.manifest.IsExcluded("~/config/secrets-backup/file.key") {
t.Error("path with similar prefix but different dir should not be excluded")
}
if g.manifest.IsExcluded("~/config/other.yaml") {
t.Error("unrelated path should not be excluded")
}
}

View File

@@ -239,7 +239,7 @@ func (g *Garden) Add(paths []string, opts ...AddOptions) error {
// Track the directory itself as a structural entry. // Track the directory itself as a structural entry.
tilded := toTildePath(abs) tilded := toTildePath(abs)
if g.findEntry(tilded) != nil { if g.findEntry(tilded) != nil {
return fmt.Errorf("already tracking %s", tilded) continue
} }
entry := manifest.Entry{ entry := manifest.Entry{
Path: tilded, Path: tilded,
@@ -256,6 +256,13 @@ func (g *Garden) Add(paths []string, opts ...AddOptions) error {
if err != nil { if err != nil {
return err return err
} }
tilded := toTildePath(path)
if g.manifest.IsExcluded(tilded) {
if d.IsDir() {
return filepath.SkipDir
}
return nil
}
if d.IsDir() { if d.IsDir() {
return nil return nil
} }
@@ -270,7 +277,7 @@ func (g *Garden) Add(paths []string, opts ...AddOptions) error {
} }
} }
} else { } else {
if err := g.addEntry(abs, info, now, false, o); err != nil { if err := g.addEntry(abs, info, now, true, o); err != nil {
return err return err
} }
} }

View File

@@ -200,7 +200,7 @@ func TestAddSymlink(t *testing.T) {
} }
} }
func TestAddDuplicateRejected(t *testing.T) { func TestAddDuplicateIsIdempotent(t *testing.T) {
root := t.TempDir() root := t.TempDir()
repoDir := filepath.Join(root, "repo") repoDir := filepath.Join(root, "repo")
@@ -218,8 +218,19 @@ func TestAddDuplicateRejected(t *testing.T) {
t.Fatalf("first Add: %v", err) t.Fatalf("first Add: %v", err)
} }
if err := g.Add([]string{testFile}); err == nil { if err := g.Add([]string{testFile}); err != nil {
t.Fatal("second Add of same path should fail") t.Fatalf("second Add of same path should be idempotent: %v", err)
}
entries := g.List()
count := 0
for _, e := range entries {
if e.Path == toTildePath(testFile) {
count++
}
}
if count != 1 {
t.Fatalf("expected 1 entry, got %d", count)
} }
} }

View File

@@ -30,6 +30,13 @@ func (g *Garden) MirrorUp(paths []string) error {
if walkErr != nil { if walkErr != nil {
return walkErr return walkErr
} }
tilded := toTildePath(path)
if g.manifest.IsExcluded(tilded) {
if d.IsDir() {
return filepath.SkipDir
}
return nil
}
if d.IsDir() { if d.IsDir() {
return nil return nil
} }
@@ -154,6 +161,9 @@ func (g *Garden) MirrorDown(paths []string, force bool, confirm func(string) boo
return walkErr return walkErr
} }
if d.IsDir() { if d.IsDir() {
if g.manifest.IsExcluded(toTildePath(path)) {
return filepath.SkipDir
}
// Collect directories for potential cleanup (post-order). // Collect directories for potential cleanup (post-order).
if path != abs { if path != abs {
emptyDirs = append(emptyDirs, path) emptyDirs = append(emptyDirs, path)
@@ -163,6 +173,10 @@ func (g *Garden) MirrorDown(paths []string, force bool, confirm func(string) boo
if tracked[path] { if tracked[path] {
return nil return nil
} }
// Excluded paths are left alone on disk.
if g.manifest.IsExcluded(toTildePath(path)) {
return nil
}
// Untracked file/symlink on disk. // Untracked file/symlink on disk.
if !force { if !force {
if confirm == nil || !confirm(path) { if confirm == nil || !confirm(path) {

View File

@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"time" "time"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
@@ -48,9 +49,32 @@ type Manifest struct {
Updated time.Time `yaml:"updated"` Updated time.Time `yaml:"updated"`
Message string `yaml:"message,omitempty"` Message string `yaml:"message,omitempty"`
Files []Entry `yaml:"files"` Files []Entry `yaml:"files"`
Exclude []string `yaml:"exclude,omitempty"`
Encryption *Encryption `yaml:"encryption,omitempty"` Encryption *Encryption `yaml:"encryption,omitempty"`
} }
// IsExcluded reports whether the given tilde path should be excluded from
// tracking. A path is excluded if it matches an exclude entry exactly, or
// if it falls under an excluded directory (an exclude entry that is a prefix
// followed by a path separator).
func (m *Manifest) IsExcluded(tildePath string) bool {
for _, ex := range m.Exclude {
if tildePath == ex {
return true
}
// Directory exclusion: if the exclude entry is a prefix of the
// path with a separator boundary, the path is under that directory.
prefix := ex
if !strings.HasSuffix(prefix, "/") {
prefix += "/"
}
if strings.HasPrefix(tildePath, prefix) {
return true
}
}
return false
}
// New creates a new empty manifest with Version 1 and timestamps set to now. // New creates a new empty manifest with Version 1 and timestamps set to now.
func New() *Manifest { func New() *Manifest {
return NewWithTime(time.Now().UTC()) return NewWithTime(time.Now().UTC())

View File

@@ -46,6 +46,7 @@ message Manifest {
string message = 4; string message = 4;
repeated ManifestEntry files = 5; repeated ManifestEntry files = 5;
Encryption encryption = 6; Encryption encryption = 6;
repeated string exclude = 7;
} }
// BlobChunk is one piece of a streamed blob. The first chunk for a given // BlobChunk is one piece of a streamed blob. The first chunk for a given

View File

@@ -18,6 +18,7 @@ func ManifestToProto(m *manifest.Manifest) *sgardpb.Manifest {
Updated: timestamppb.New(m.Updated), Updated: timestamppb.New(m.Updated),
Message: m.Message, Message: m.Message,
Files: files, Files: files,
Exclude: m.Exclude,
} }
if m.Encryption != nil { if m.Encryption != nil {
pb.Encryption = EncryptionToProto(m.Encryption) pb.Encryption = EncryptionToProto(m.Encryption)
@@ -38,6 +39,7 @@ func ProtoToManifest(p *sgardpb.Manifest) *manifest.Manifest {
Updated: p.GetUpdated().AsTime(), Updated: p.GetUpdated().AsTime(),
Message: p.GetMessage(), Message: p.GetMessage(),
Files: files, Files: files,
Exclude: p.GetExclude(),
} }
if p.GetEncryption() != nil { if p.GetEncryption() != nil {
m.Encryption = ProtoToEncryption(p.GetEncryption()) m.Encryption = ProtoToEncryption(p.GetEncryption())

View File

@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT. // Code generated by protoc-gen-go. DO NOT EDIT.
// versions: // versions:
// protoc-gen-go v1.36.11 // protoc-gen-go v1.36.11
// protoc v7.34.0 // protoc v6.32.1
// source: sgard/v1/sgard.proto // source: sgard/v1/sgard.proto
package sgardpb package sgardpb
@@ -354,6 +354,7 @@ type Manifest struct {
Message string `protobuf:"bytes,4,opt,name=message,proto3" json:"message,omitempty"` Message string `protobuf:"bytes,4,opt,name=message,proto3" json:"message,omitempty"`
Files []*ManifestEntry `protobuf:"bytes,5,rep,name=files,proto3" json:"files,omitempty"` Files []*ManifestEntry `protobuf:"bytes,5,rep,name=files,proto3" json:"files,omitempty"`
Encryption *Encryption `protobuf:"bytes,6,opt,name=encryption,proto3" json:"encryption,omitempty"` Encryption *Encryption `protobuf:"bytes,6,opt,name=encryption,proto3" json:"encryption,omitempty"`
Exclude []string `protobuf:"bytes,7,rep,name=exclude,proto3" json:"exclude,omitempty"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@@ -430,6 +431,13 @@ func (x *Manifest) GetEncryption() *Encryption {
return nil return nil
} }
func (x *Manifest) GetExclude() []string {
if x != nil {
return x.Exclude
}
return nil
}
// BlobChunk is one piece of a streamed blob. The first chunk for a given // BlobChunk is one piece of a streamed blob. The first chunk for a given
// hash carries the hash field; subsequent chunks omit it. // hash carries the hash field; subsequent chunks omit it.
type BlobChunk struct { type BlobChunk struct {
@@ -1125,7 +1133,7 @@ const file_sgard_v1_sgard_proto_rawDesc = "" +
"\tkek_slots\x18\x02 \x03(\v2\".sgard.v1.Encryption.KekSlotsEntryR\bkekSlots\x1aN\n" + "\tkek_slots\x18\x02 \x03(\v2\".sgard.v1.Encryption.KekSlotsEntryR\bkekSlots\x1aN\n" +
"\rKekSlotsEntry\x12\x10\n" + "\rKekSlotsEntry\x12\x10\n" +
"\x03key\x18\x01 \x01(\tR\x03key\x12'\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12'\n" +
"\x05value\x18\x02 \x01(\v2\x11.sgard.v1.KekSlotR\x05value:\x028\x01\"\x8f\x02\n" + "\x05value\x18\x02 \x01(\v2\x11.sgard.v1.KekSlotR\x05value:\x028\x01\"\xa9\x02\n" +
"\bManifest\x12\x18\n" + "\bManifest\x12\x18\n" +
"\aversion\x18\x01 \x01(\x05R\aversion\x124\n" + "\aversion\x18\x01 \x01(\x05R\aversion\x124\n" +
"\acreated\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\acreated\x124\n" + "\acreated\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\acreated\x124\n" +
@@ -1134,7 +1142,8 @@ const file_sgard_v1_sgard_proto_rawDesc = "" +
"\x05files\x18\x05 \x03(\v2\x17.sgard.v1.ManifestEntryR\x05files\x124\n" + "\x05files\x18\x05 \x03(\v2\x17.sgard.v1.ManifestEntryR\x05files\x124\n" +
"\n" + "\n" +
"encryption\x18\x06 \x01(\v2\x14.sgard.v1.EncryptionR\n" + "encryption\x18\x06 \x01(\v2\x14.sgard.v1.EncryptionR\n" +
"encryption\"3\n" + "encryption\x12\x18\n" +
"\aexclude\x18\a \x03(\tR\aexclude\"3\n" +
"\tBlobChunk\x12\x12\n" + "\tBlobChunk\x12\x12\n" +
"\x04hash\x18\x01 \x01(\tR\x04hash\x12\x12\n" + "\x04hash\x18\x01 \x01(\tR\x04hash\x12\x12\n" +
"\x04data\x18\x02 \x01(\fR\x04data\"E\n" + "\x04data\x18\x02 \x01(\fR\x04data\"E\n" +

View File

@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT. // Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions: // versions:
// - protoc-gen-go-grpc v1.6.1 // - protoc-gen-go-grpc v1.6.1
// - protoc v7.34.0 // - protoc v6.32.1
// source: sgard/v1/sgard.proto // source: sgard/v1/sgard.proto
package sgardpb package sgardpb