8 Commits

Author SHA1 Message Date
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
b9b9082008 Bump VERSION to 3.1.7.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:57:26 -07:00
bd54491c1d Pull auto-inits repo, restores files, and add -r global shorthand.
pull now works on a fresh machine: inits ~/.sgard if missing, always
pulls when local manifest is empty, and restores all files after
downloading blobs. -r is now a global shorthand for --remote; list
uses resolveRemoteConfig() like prune.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:57:16 -07:00
57d252cee4 Bump VERSION to 3.1.6.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:27:17 -07:00
78030230c5 Update docs for VERSION file and build versioning.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:27:12 -07:00
adfb087037 Derive build version from git tags via VERSION file.
flake.nix reads from VERSION instead of hardcoding; Makefile gains
a version target that syncs VERSION from the latest git tag and
injects it into go build ldflags.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:26:16 -07:00
5570f82eb4 Add version in flake. 2026-03-26 11:14:28 -07:00
21 changed files with 630 additions and 32 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
@@ -708,7 +712,8 @@ sgard/
sgardpb/ # Generated protobuf + gRPC Go code sgardpb/ # Generated protobuf + gRPC Go code
proto/sgard/v1/ # Proto source definitions proto/sgard/v1/ # Proto source definitions
flake.nix # Nix flake (builds sgard + sgardd) VERSION # Semver string, read by flake.nix; synced from latest git tag via `make version`
flake.nix # Nix flake (builds sgard + sgardd, version from VERSION file)
.goreleaser.yaml # GoReleaser (builds both binaries) .goreleaser.yaml # GoReleaser (builds both binaries)
``` ```
@@ -742,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
@@ -775,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

@@ -1,4 +1,6 @@
.PHONY: proto build test lint clean VERSION := $(shell git describe --tags --abbrev=0 | sed 's/^v//')
.PHONY: proto build test lint clean version
proto: proto:
protoc \ protoc \
@@ -7,8 +9,11 @@ proto:
-I proto \ -I proto \
proto/sgard/v1/sgard.proto proto/sgard/v1/sgard.proto
version:
@echo $(VERSION) > VERSION
build: build:
go build ./... go build -ldflags "-X main.version=$(VERSION)" ./...
test: test:
go test ./... go test ./...

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
@@ -112,3 +112,6 @@ Phase 6: Manifest Signing (to be planned).
| 2026-03-25 | — | Deploy sgardd to rift: Dockerfile, docker-compose, mc-proxy L4 route on :9443, Metacrypt TLS cert, DNS. | | 2026-03-25 | — | Deploy sgardd to rift: Dockerfile, docker-compose, mc-proxy L4 route on :9443, Metacrypt TLS cert, DNS. |
| 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-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)

1
VERSION Normal file
View File

@@ -0,0 +1 @@
3.2.1

View File

@@ -206,8 +206,8 @@ func (c *Client) doPull(ctx context.Context, g *garden.Garden) (int, error) {
serverManifest := server.ProtoToManifest(pullResp.GetManifest()) serverManifest := server.ProtoToManifest(pullResp.GetManifest())
localManifest := g.GetManifest() localManifest := g.GetManifest()
// If local is newer or equal, nothing to do. // If local has files and is newer or equal, nothing to do.
if !serverManifest.Updated.After(localManifest.Updated) { if len(localManifest.Files) > 0 && !serverManifest.Updated.After(localManifest.Updated) {
return 0, nil return 0, nil
} }

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

@@ -9,14 +9,13 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
var listRemoteFlag bool
var listCmd = &cobra.Command{ var listCmd = &cobra.Command{
Use: "list", Use: "list",
Short: "List all tracked files", Short: "List all tracked files",
Long: "List all tracked files locally, or on the remote server with -r.", Long: "List all tracked files locally, or on the remote server with -r.",
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
if listRemoteFlag { addr, _, _, _ := resolveRemoteConfig()
if addr != "" {
return listRemote() return listRemote()
} }
return listLocal() return listLocal()
@@ -69,6 +68,5 @@ func printEntries(entries []manifest.Entry) {
} }
func init() { func init() {
listCmd.Flags().BoolVarP(&listRemoteFlag, "use-remote", "r", false, "list files on the remote server")
rootCmd.AddCommand(listCmd) rootCmd.AddCommand(listCmd)
} }

View File

@@ -133,7 +133,7 @@ func dialRemote(ctx context.Context) (*client.Client, func(), error) {
func main() { func main() {
rootCmd.PersistentFlags().StringVar(&repoFlag, "repo", defaultRepo(), "path to sgard repository") rootCmd.PersistentFlags().StringVar(&repoFlag, "repo", defaultRepo(), "path to sgard repository")
rootCmd.PersistentFlags().StringVar(&remoteFlag, "remote", "", "gRPC server address (host:port)") rootCmd.PersistentFlags().StringVarP(&remoteFlag, "remote", "r", "", "gRPC server address (host:port)")
rootCmd.PersistentFlags().StringVar(&sshKeyFlag, "ssh-key", "", "path to SSH private key") rootCmd.PersistentFlags().StringVar(&sshKeyFlag, "ssh-key", "", "path to SSH private key")
rootCmd.PersistentFlags().BoolVar(&tlsFlag, "tls", false, "use TLS for remote connection") rootCmd.PersistentFlags().BoolVar(&tlsFlag, "tls", false, "use TLS for remote connection")
rootCmd.PersistentFlags().StringVar(&tlsCAFlag, "tls-ca", "", "path to CA certificate for TLS verification") rootCmd.PersistentFlags().StringVar(&tlsCAFlag, "tls-ca", "", "path to CA certificate for TLS verification")

View File

@@ -10,13 +10,17 @@ import (
var pullCmd = &cobra.Command{ var pullCmd = &cobra.Command{
Use: "pull", Use: "pull",
Short: "Pull checkpoint from remote server", Short: "Pull checkpoint from remote server and restore files",
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
ctx := context.Background() ctx := context.Background()
g, err := garden.Open(repoFlag) g, err := garden.Open(repoFlag)
if err != nil { if err != nil {
return err // Repo doesn't exist yet — init it so pull can populate it.
g, err = garden.Init(repoFlag)
if err != nil {
return fmt.Errorf("init repo for pull: %w", err)
}
} }
c, cleanup, err := dialRemote(ctx) c, cleanup, err := dialRemote(ctx)
@@ -32,9 +36,22 @@ var pullCmd = &cobra.Command{
if pulled == 0 { if pulled == 0 {
fmt.Println("Already up to date.") fmt.Println("Already up to date.")
} else { return nil
fmt.Printf("Pulled %d blob(s).\n", pulled)
} }
fmt.Printf("Pulled %d blob(s).\n", pulled)
if g.HasEncryption() && g.NeedsDEK(g.List()) {
if err := unlockDEK(g); err != nil {
return err
}
}
if err := g.Restore(nil, true, nil); err != nil {
return fmt.Errorf("restore after pull: %w", err)
}
fmt.Println("Restore complete.")
return nil return nil
}, },
} }

View File

@@ -11,17 +11,20 @@
let let
pkgs = import nixpkgs { inherit system; }; pkgs = import nixpkgs { inherit system; };
in in
let
version = builtins.replaceStrings [ "\n" ] [ "" ] (builtins.readFile ./VERSION);
in
{ {
packages = { packages = {
sgard = pkgs.buildGoModule { sgard = pkgs.buildGoModule rec {
pname = "sgard"; pname = "sgard";
version = "2.1.0"; inherit version;
src = pkgs.lib.cleanSource ./.; src = pkgs.lib.cleanSource ./.;
subPackages = [ "cmd/sgard" "cmd/sgardd" ]; subPackages = [ "cmd/sgard" "cmd/sgardd" ];
vendorHash = "sha256-Z/Ja4j7YesNYefQQcWWRG2v8WuIL+UNqPGwYD5AipZY="; vendorHash = "sha256-Z/Ja4j7YesNYefQQcWWRG2v8WuIL+UNqPGwYD5AipZY=";
ldflags = [ "-s" "-w" ]; ldflags = [ "-s" "-w" "-X main.version=${version}" ];
meta = { meta = {
description = "Shimmering Clarity Gardener: dotfile management"; description = "Shimmering Clarity Gardener: dotfile management";
@@ -29,9 +32,9 @@
}; };
}; };
sgard-fido2 = pkgs.buildGoModule { sgard-fido2 = pkgs.buildGoModule rec {
pname = "sgard-fido2"; pname = "sgard-fido2";
version = "2.1.0"; inherit version;
src = pkgs.lib.cleanSource ./.; src = pkgs.lib.cleanSource ./.;
subPackages = [ "cmd/sgard" "cmd/sgardd" ]; subPackages = [ "cmd/sgard" "cmd/sgardd" ];
@@ -41,7 +44,7 @@
nativeBuildInputs = [ pkgs.pkg-config ]; nativeBuildInputs = [ pkgs.pkg-config ];
tags = [ "fido2" ]; tags = [ "fido2" ];
ldflags = [ "-s" "-w" ]; ldflags = [ "-s" "-w" "-X main.version=${version}" ];
meta = { meta = {
description = "Shimmering Clarity Gardener: dotfile management (with FIDO2 hardware support)"; description = "Shimmering Clarity Gardener: dotfile management (with FIDO2 hardware support)";

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