Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b1313c1048 | |||
| 8953090718 | |||
| caf1698c16 | |||
| db9aa7bbff |
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 ./...
|
||||||
|
|||||||
@@ -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.0–v0.1.2). |
|
||||||
|
|||||||
105
README.md
Normal file
105
README.md
Normal 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
61
flake.lock
generated
Normal 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
43
flake.nix
Normal 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
|
||||||
|
];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user