4 Commits

Author SHA1 Message Date
b1313c1048 Update docs for v1.0.0.
ARCHITECTURE.md: update package structure to reflect actual file layout
(per-operation files, version command, flake, goreleaser), fix Garden
struct (clock field, Restore confirm callback, List method), add
.gitignore to repo layout, remove stale C++ note.

README.md: add NixOS installation instructions.
CLAUDE.md: add nix build command.
PROGRESS.md: add post-Step-8 release work to change log.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 22:38:53 -07:00
8953090718 Add Nix flake for building and installing on NixOS.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 22:32:10 -07:00
caf1698c16 Create .gitignore in sgard init to exclude blobs/ from git.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 22:26:34 -07:00
db9aa7bbff Add README, fix Node.js 24 deprecation in release workflow.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 22:21:34 -07:00
9 changed files with 255 additions and 32 deletions

View File

@@ -41,6 +41,8 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: read contents: read
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
steps: steps:
- name: Extract version - name: Extract version
id: extract-version id: extract-version

View File

@@ -34,6 +34,7 @@ A sgard repository is a single directory with this structure:
``` ```
<repo>/ <repo>/
manifest.yaml # single manifest tracking all files manifest.yaml # single manifest tracking all files
.gitignore # excludes blobs/ (created by sgard init)
blobs/ blobs/
a1/b2/a1b2c3d4... # content-addressable file storage a1/b2/a1b2c3d4... # content-addressable file storage
``` ```
@@ -134,39 +135,28 @@ sgard restore --repo /mnt/usb/dotfiles
``` ```
sgard/ sgard/
cmd/ cmd/sgard/ # CLI entry point — one file per command
sgard/ # CLI entry point
main.go # cobra root command, --repo flag main.go # cobra root command, --repo flag
init.go # sgard init version.go # sgard version (ldflags-injected)
add.go # sgard add init.go add.go remove.go checkpoint.go
remove.go # sgard remove restore.go status.go verify.go list.go diff.go
checkpoint.go # sgard checkpoint
restore.go # sgard restore
status.go # sgard status
verify.go # sgard verify
list.go # sgard list
diff.go # sgard diff
sgardd/ # gRPC server entry point (Phase 2)
garden/ # Core business logic garden/ # Core business logic — one file per operation
garden.go # Garden struct: orchestrates manifest + store + filesystem garden.go # Garden struct, Init, Open, Add, Checkpoint, Status
garden_test.go restore.go # Restore with timestamp comparison and confirm callback
remove.go verify.go list.go diff.go
hasher.go # SHA-256 file hashing hasher.go # SHA-256 file hashing
diff.go # File diff generation e2e_test.go # Full lifecycle integration test
manifest/ # YAML manifest parsing manifest/ # YAML manifest parsing
manifest.go # Manifest and Entry structs, Load/Save manifest.go # Manifest and Entry structs, Load/Save
manifest_test.go
store/ # Content-addressable blob storage store/ # Content-addressable blob storage
store.go # Store struct: Write/Read/Exists/Delete store.go # Store struct: Write/Read/Exists/Delete
store_test.go
proto/ # gRPC service definition (Phase 2) flake.nix # Nix flake for building on NixOS
sgard/v1/ .goreleaser.yaml # GoReleaser config for releases
sgard.proto .github/workflows/ # GitHub Actions release pipeline
server/ # gRPC server implementation (Phase 2)
``` ```
### Key Architectural Rule ### Key Architectural Rule
@@ -180,14 +170,17 @@ type Garden struct {
manifest *manifest.Manifest manifest *manifest.Manifest
store *store.Store store *store.Store
root string // repository root directory root string // repository root directory
manifestPath string
clock clockwork.Clock // injectable for testing
} }
func (g *Garden) Add(paths []string) error func (g *Garden) Add(paths []string) error
func (g *Garden) Remove(paths []string) error func (g *Garden) Remove(paths []string) error
func (g *Garden) Checkpoint(message string) error func (g *Garden) Checkpoint(message string) error
func (g *Garden) Restore(paths []string, force bool) error func (g *Garden) Restore(paths []string, force bool, confirm func(path string) bool) error
func (g *Garden) Status() ([]FileStatus, error) func (g *Garden) Status() ([]FileStatus, error)
func (g *Garden) Verify() ([]VerifyResult, error) func (g *Garden) Verify() ([]VerifyResult, error)
func (g *Garden) List() []manifest.Entry
func (g *Garden) Diff(path string) (string, error) func (g *Garden) Diff(path string) (string, error)
``` ```
@@ -201,8 +194,8 @@ as the CLI — no logic duplication.
different usernames. different usernames.
**No history.** Phase 1 stores only the latest checkpoint. For versioning, **No history.** Phase 1 stores only the latest checkpoint. For versioning,
place the manifest under git. The `blobs/` directory should be gitignored — place the repo under git`sgard init` creates a `.gitignore` that excludes
blob durability (backup, replication) is deferred to a future phase. `blobs/`. Blob durability (backup, replication) is deferred to a future phase.
**Per-file timestamps.** Each manifest entry records an `updated` timestamp **Per-file timestamps.** Each manifest entry records an `updated` timestamp
set at checkpoint time. On restore, if the manifest entry is newer than the set at checkpoint time. On restore, if the manifest entry is newer than the
@@ -213,5 +206,5 @@ on disk is newer or the times match, sgard prompts for confirmation.
**Atomic writes.** Checkpoint writes `manifest.yaml.tmp` then renames to **Atomic writes.** Checkpoint writes `manifest.yaml.tmp` then renames to
`manifest.yaml`. A crash cannot corrupt the manifest. `manifest.yaml`. A crash cannot corrupt the manifest.
**Old C++/proto source files** are retained in the git history for reference **Timestamp comparison truncates to seconds** for cross-platform filesystem
and will be removed as part of the Go rewrite. compatibility.

View File

@@ -24,6 +24,11 @@ Module: `github.com/kisom/sgard`. Author: K. Isom <kyle@imap.cc>.
go build ./cmd/sgard go build ./cmd/sgard
``` ```
Nix:
```bash
nix build .#sgard
```
Run tests: Run tests:
```bash ```bash
go test ./... go test ./...

View File

@@ -66,3 +66,4 @@ Phase 1 is complete. Future work: blob durability, gRPC remote mode.
| 2026-03-23 | 6 | Restore complete. Selective paths, force/confirm, timestamp logic, symlinks, permissions. 6 tests. | | 2026-03-23 | 6 | Restore complete. Selective paths, force/confirm, timestamp logic, symlinks, permissions. 6 tests. |
| 2026-03-23 | 7 | Remaining commands complete. Remove, Verify, List, Diff — 10 tests across 4 parallel units. | | 2026-03-23 | 7 | Remaining commands complete. Remove, Verify, List, Diff — 10 tests across 4 parallel units. |
| 2026-03-23 | 8 | Polish complete. golangci-lint, clockwork, e2e test, doc updates. | | 2026-03-23 | 8 | Polish complete. golangci-lint, clockwork, e2e test, doc updates. |
| 2026-03-23 | — | README, goreleaser config, version command, Nix flake, homebrew formula, release pipeline validated (v0.1.0v0.1.2). |

105
README.md Normal file
View File

@@ -0,0 +1,105 @@
# sgard — Shimmering Clarity Gardener
A dotfiles manager that checkpoints files into a portable repository and
restores them on demand.
The repository is a single directory that can live anywhere — local disk,
USB drive, NFS mount — making it portable between machines.
## Installation
Homebrew:
```
brew tap kisom/homebrew-tap
brew install sgard
```
From source:
```
git clone https://github.com/kisom/sgard && cd sgard
go build -o sgard ./cmd/sgard
```
Or install into `$GOBIN`:
```
go install github.com/kisom/sgard/cmd/sgard@latest
```
NixOS (flake):
```
nix profile install github:kisom/sgard
```
Or add to your flake inputs and include `sgard.packages.${system}.default`
in your packages.
Binaries are also available on the
[releases page](https://github.com/kisom/sgard/releases).
## Quick start
```sh
# Initialize a repo (default: ~/.sgard)
sgard init
# Track some dotfiles
sgard add ~/.bashrc ~/.gitconfig ~/.ssh/config
# Checkpoint current state
sgard checkpoint -m "initial"
# Check what's changed
sgard status
# Restore from the repo
sgard restore
```
Use `--repo` to put the repository somewhere else, like a USB drive:
```sh
sgard init --repo /mnt/usb/dotfiles
sgard add ~/.bashrc --repo /mnt/usb/dotfiles
sgard restore --repo /mnt/usb/dotfiles
```
## Commands
| Command | Description |
|---|---|
| `init` | Create a new repository |
| `add <path>...` | Track files, directories, or symlinks |
| `remove <path>...` | Stop tracking files |
| `checkpoint [-m msg]` | Re-hash tracked files and update the manifest |
| `restore [path...] [-f]` | Restore files to their original locations |
| `status` | Show which tracked files have changed |
| `diff <path>` | Show content diff between stored and current file |
| `list` | List all tracked files |
| `verify` | Check blob store integrity against manifest hashes |
| `version` | Print the version |
## How it works
sgard stores files in a content-addressable blob store keyed by SHA-256.
A YAML manifest tracks each file's original path, hash, type, permissions,
and timestamp.
```
~/.sgard/
manifest.yaml # human-readable manifest
blobs/
a1/b2/a1b2c3d4... # file contents stored by hash
```
On `restore`, sgard compares the manifest timestamp against the file's
mtime. If the manifest is newer, the file is restored without prompting.
Otherwise, sgard asks for confirmation (`--force` skips the prompt).
Paths under `$HOME` are stored as `~/...` in the manifest, making it
portable across machines with different usernames.
See [ARCHITECTURE.md](ARCHITECTURE.md) for full design details.

61
flake.lock generated Normal file
View File

@@ -0,0 +1,61 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1774273680,
"narHash": "sha256-a++tZ1RQsDb1I0NHrFwdGuRlR5TORvCEUksM459wKUA=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "fdc7b8f7b30fdbedec91b71ed82f36e1637483ed",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

43
flake.nix Normal file
View File

@@ -0,0 +1,43 @@
{
description = "sgard Shimmering Clarity Gardener: dotfile management";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = import nixpkgs { inherit system; };
in
{
packages = {
sgard = pkgs.buildGoModule {
pname = "sgard";
version = "0.1.0";
src = pkgs.lib.cleanSource ./.;
subPackages = [ "cmd/sgard" ];
vendorHash = "sha256-uJMkp08SqZaZ6d64Li4Tx8I9OYjaErLexBrJaf6Vb60=";
ldflags = [ "-s" "-w" ];
meta = {
description = "Shimmering Clarity Gardener: dotfile management";
mainProgram = "sgard";
};
};
default = self.packages.${system}.sgard;
};
devShells.default = pkgs.mkShell {
buildInputs = with pkgs; [
go
golangci-lint
];
};
}
);
}

View File

@@ -46,6 +46,11 @@ func Init(root string) (*Garden, error) {
return nil, fmt.Errorf("creating store: %w", err) return nil, fmt.Errorf("creating store: %w", err)
} }
gitignorePath := filepath.Join(absRoot, ".gitignore")
if err := os.WriteFile(gitignorePath, []byte("blobs/\n"), 0o644); err != nil {
return nil, fmt.Errorf("creating .gitignore: %w", err)
}
clk := clockwork.NewRealClock() clk := clockwork.NewRealClock()
m := manifest.NewWithTime(clk.Now().UTC()) m := manifest.NewWithTime(clk.Now().UTC())
if err := m.Save(manifestPath); err != nil { if err := m.Save(manifestPath); err != nil {

View File

@@ -25,6 +25,14 @@ func TestInitCreatesStructure(t *testing.T) {
t.Errorf("blobs/ not found: %v", err) t.Errorf("blobs/ not found: %v", err)
} }
// .gitignore should exist and exclude blobs/
gitignore, err := os.ReadFile(filepath.Join(repoDir, ".gitignore"))
if err != nil {
t.Errorf(".gitignore not found: %v", err)
} else if string(gitignore) != "blobs/\n" {
t.Errorf(".gitignore content = %q, want %q", gitignore, "blobs/\n")
}
if g.manifest.Version != 1 { if g.manifest.Version != 1 {
t.Errorf("expected version 1, got %d", g.manifest.Version) t.Errorf("expected version 1, got %d", g.manifest.Version)
} }