Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8953090718 | |||
| caf1698c16 | |||
| db9aa7bbff |
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -41,6 +41,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
steps:
|
||||
- name: Extract version
|
||||
id: extract-version
|
||||
|
||||
96
README.md
Normal file
96
README.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# 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
|
||||
```
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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()
|
||||
m := manifest.NewWithTime(clk.Now().UTC())
|
||||
if err := m.Save(manifestPath); err != nil {
|
||||
|
||||
@@ -25,6 +25,14 @@ func TestInitCreatesStructure(t *testing.T) {
|
||||
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 {
|
||||
t.Errorf("expected version 1, got %d", g.manifest.Version)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user