9 Commits

Author SHA1 Message Date
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
bffe7bde12 Add remote listing support to sgard list via -r flag.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 09:22:59 -07:00
3e0aabef4a Suppress passphrase echo in terminal prompts.
Use golang.org/x/term.ReadPassword so passphrases are not displayed
while typing, matching ssh behavior.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 21:49:56 -07:00
22 changed files with 694 additions and 52 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.
**Last updated:** 2026-03-24 **Last updated:** 2026-03-27
## Completed Steps ## Completed Steps
@@ -111,3 +111,6 @@ Phase 6: Manifest Signing (to be planned).
| 2026-03-25 | — | `sgard info` command: shows detailed file information (status, hash, timestamps, mode, encryption, targeting). 5 tests. | | 2026-03-25 | — | `sgard info` command: shows detailed file information (status, hash, timestamps, mode, encryption, targeting). 5 tests. |
| 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 | — | 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. |

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.0

View File

@@ -8,6 +8,7 @@ import (
"io" "io"
"github.com/kisom/sgard/garden" "github.com/kisom/sgard/garden"
"github.com/kisom/sgard/manifest"
"github.com/kisom/sgard/server" "github.com/kisom/sgard/server"
"github.com/kisom/sgard/sgardpb" "github.com/kisom/sgard/sgardpb"
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
@@ -205,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
} }
@@ -273,6 +274,22 @@ func (c *Client) doPull(ctx context.Context, g *garden.Garden) (int, error) {
return blobCount, nil return blobCount, nil
} }
// List fetches the server's manifest and returns its entries without
// downloading any blobs. Automatically re-authenticates if needed.
func (c *Client) List(ctx context.Context) ([]manifest.Entry, error) {
var entries []manifest.Entry
err := c.retryOnAuth(ctx, func() error {
resp, err := c.rpc.PullManifest(ctx, &sgardpb.PullManifestRequest{})
if err != nil {
return fmt.Errorf("list remote: %w", err)
}
m := server.ProtoToManifest(resp.GetManifest())
entries = m.Files
return nil
})
return entries, err
}
// Prune requests the server to remove orphaned blobs. Returns the count removed. // Prune requests the server to remove orphaned blobs. Returns the count removed.
// Automatically re-authenticates if needed. // Automatically re-authenticates if needed.
func (c *Client) Prune(ctx context.Context) (int, error) { func (c *Client) Prune(ctx context.Context) (int, error) {

View File

@@ -1,13 +1,12 @@
package main package main
import ( import (
"bufio"
"fmt" "fmt"
"os" "os"
"strings"
"github.com/kisom/sgard/garden" "github.com/kisom/sgard/garden"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"golang.org/x/term"
) )
var ( var (
@@ -60,12 +59,17 @@ var addCmd = &cobra.Command{
func promptPassphrase() (string, error) { func promptPassphrase() (string, error) {
fmt.Fprint(os.Stderr, "Passphrase: ") fmt.Fprint(os.Stderr, "Passphrase: ")
scanner := bufio.NewScanner(os.Stdin) fd := int(os.Stdin.Fd())
if scanner.Scan() { passphrase, err := term.ReadPassword(fd)
return strings.TrimSpace(scanner.Text()), nil fmt.Fprintln(os.Stderr)
if err != nil {
return "", fmt.Errorf("reading passphrase: %w", err)
} }
if len(passphrase) == 0 {
return "", fmt.Errorf("no passphrase provided") return "", fmt.Errorf("no passphrase provided")
} }
return string(passphrase), nil
}
func init() { func init() {
addCmd.Flags().BoolVar(&encryptFlag, "encrypt", false, "encrypt file contents before storing") addCmd.Flags().BoolVar(&encryptFlag, "encrypt", false, "encrypt file contents before storing")

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

@@ -1,22 +1,56 @@
package main package main
import ( import (
"context"
"fmt" "fmt"
"github.com/kisom/sgard/garden" "github.com/kisom/sgard/garden"
"github.com/kisom/sgard/manifest"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
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.",
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
addr, _, _, _ := resolveRemoteConfig()
if addr != "" {
return listRemote()
}
return listLocal()
},
}
func listLocal() error {
g, err := garden.Open(repoFlag) g, err := garden.Open(repoFlag)
if err != nil { if err != nil {
return err return err
} }
entries := g.List() printEntries(g.List())
return nil
}
func listRemote() error {
ctx := context.Background()
c, cleanup, err := dialRemote(ctx)
if err != nil {
return err
}
defer cleanup()
entries, err := c.List(ctx)
if err != nil {
return err
}
printEntries(entries)
return nil
}
func printEntries(entries []manifest.Entry) {
for _, e := range entries { for _, e := range entries {
switch e.Type { switch e.Type {
case "file": case "file":
@@ -31,9 +65,6 @@ var listCmd = &cobra.Command{
fmt.Printf("%-6s %s\n", "dir", e.Path) fmt.Printf("%-6s %s\n", "dir", e.Path)
} }
} }
return nil
},
} }
func init() { func init() {

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-LSz15iFsP4N3Cif1PFHEKg3udeqH/9WQQbZ50sxtWTk="; 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,19 +32,19 @@
}; };
}; };
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" ];
vendorHash = "sha256-LSz15iFsP4N3Cif1PFHEKg3udeqH/9WQQbZ50sxtWTk="; vendorHash = "sha256-Z/Ja4j7YesNYefQQcWWRG2v8WuIL+UNqPGwYD5AipZY=";
buildInputs = [ pkgs.libfido2 ]; buildInputs = [ pkgs.libfido2 ];
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

@@ -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
} }

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) {

1
go.mod
View File

@@ -8,6 +8,7 @@ require (
github.com/keys-pub/go-libfido2 v1.5.3 github.com/keys-pub/go-libfido2 v1.5.3
github.com/spf13/cobra v1.10.2 github.com/spf13/cobra v1.10.2
golang.org/x/crypto v0.49.0 golang.org/x/crypto v0.49.0
golang.org/x/term v0.41.0
google.golang.org/grpc v1.79.3 google.golang.org/grpc v1.79.3
google.golang.org/protobuf v1.36.11 google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1

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