Architecture¶
Technical architecture and design decisions.
Design Principles¶
- Interface-based DI: All external dependencies behind interfaces
- Testability: Mocks for all interfaces
- Separation of concerns: CLI, business logic, and infrastructure are separate
Core Interfaces¶
Storage¶
type Storage interface {
Upload(ctx context.Context, path string, r io.Reader) error
Download(ctx context.Context, path string) (io.ReadCloser, error)
List(ctx context.Context, prefix string) ([]string, error)
Delete(ctx context.Context, path string) error
Exists(ctx context.Context, path string) (bool, error)
}
Encrypter¶
type Encrypter interface {
Encrypt(plaintext []byte) ([]byte, error)
Decrypt(ciphertext []byte) ([]byte, error)
}
Repository¶
type Repository interface {
Init() error
Add(paths ...string) error
Commit(message string) (string, error)
Log(n int, includeFiles bool) ([]Commit, error)
Checkout(ref string) error
ListFiles() ([]string, error)
ReadFile(path, ref string) ([]byte, error)
WriteFile(path string, content []byte) error
RemoveFile(path string) error
Head() (string, error)
PackAll(w io.Writer) error
UnpackAll(r io.Reader) error
GetAllRefs() (map[string]string, error)
SetRef(name, hash string) error
DeleteRef(name string) error
}
Package Dependencies¶
cmd/envsecrets
└── internal/cli
├── internal/config
├── internal/sync
│ ├── internal/storage
│ ├── internal/crypto
│ ├── internal/git
│ └── internal/cache
├── internal/project
└── internal/ui
Data Flow¶
Push¶
- CLI parses flags and loads config
- Project discovery finds repo identity and env files
- Read this machine's
LAST_SYNCEDbaseline (per-machine marker, never uploaded) - Sync from GCS: download packfile + refs, restore full git history locally
- Fast-forward local branch to remote HEAD if behind
- Divergence safety check — if
LAST_SYNCED != remote HEADAND any tracked file changed both locally (vsLAST_SYNCED) AND remotely (betweenLAST_SYNCEDand HEAD), refuse withErrDivergedHistoryunless--forceis set - For each file:
- Read plaintext from project directory
- Encrypt with age
- Write encrypted file to cache
- Commit changes to cache git repo (author =
$USER@<machine_id-or-hostname>) - Optimistic locking check: verify remote HEAD hasn't changed since step 4
- Sync to GCS: create packfile of all objects + refs, upload FORMAT version marker, upload HEAD last (HEAD is the existence marker)
- Update
LAST_SYNCEDto the new commit. Failure here surfaces aWarningon the result but does NOT roll back the successful remote push
Pull¶
- CLI parses flags and loads config
- Project discovery finds repo identity
- Sync from GCS: download packfile + refs, validate FORMAT version, restore full git history locally
- Checkout requested ref (or HEAD) to populate working tree
- Read this machine's
LAST_SYNCEDbaseline - For each tracked file, classify against (working tree, baseline, remote HEAD):
- No local edits, remote moved → overwrite (catch-up case, no prompt)
- Local edits, remote unchanged for this file → preserve local (push will publish)
- Both sides changed → real conflict (resolver /
--force/ abort) - No baseline available → fall back to old pessimistic behavior
- Decrypt and write the files chosen for overwrite
- Update
LAST_SYNCEDto the new HEAD (only on full-HEAD pull;--refcheckouts do NOT update the marker)
Status / Sync¶
- Read
LAST_SYNCEDbaseline + sync from GCS (so cache reflects remote) - Run the same 3-way classification as pull, producing per-file
LocalChanges/RemoteChanges/Conflictsslices - Map to a
SyncAction:in_sync/push/pull/pull_then_push/reconcile/first_push_init/first_pull/nothing_tracked statusrenders the action plus provenance (remote HEAD's author/timestamp, this machine's last-synced commit + age) for the usersyncexecutes the action automatically — push, pull, or pull-then-push — and refuses with exit 16 (ExitActionRequired) onreconcileorfirst_push_init(initialization is intentionally manual)
Cache Structure¶
~/.envsecrets/
├── config.yaml
└── cache/
└── {owner}/
└── {repo}/
├── .git/ # Full git history (restored from packfile)
│ └── .envsecrets-last-synced # Per-machine baseline marker; never uploaded
├── .env.age # Encrypted files (working tree, populated by checkout)
└── .env.local.age
The cache is a git repository containing only encrypted files. Full git history is synced between machines via packfiles stored in GCS.
The LAST_SYNCED marker lives inside .git/ so go-git's force-checkout
during pull --ref cannot wipe it as an "untracked" file. It contains a
single 40-char hex commit hash recording the most recent push or full-HEAD
pull this machine successfully completed. cache.Reset() (which removes
the entire cache directory) intentionally clears it — Reset implies the
cache is no longer trusted, so the baseline must also be discarded.
GCS Storage Layout¶
{owner}/{repo}/FORMAT # Storage format version marker (contains "1")
{owner}/{repo}/objects.pack # Packfile containing all git objects
{owner}/{repo}/refs # Text file: refname SP hash LF
{owner}/{repo}/HEAD # Current HEAD commit hash (written last; existence marker)
Every sync downloads the packfile and restores full git history locally. This
enables log, diff, and revert to work correctly across machines with
shared commit history.
Error Handling¶
Sentinel errors in internal/domain/errors.go map to exit codes:
| Error | Exit Code | Description |
|---|---|---|
| ErrNotConfigured | 1 | Missing configuration |
| ErrNotInRepo | 2 | Not in a git repository |
| ErrNoEnvFiles | 3 | No .envsecrets file found |
| ErrConflict | 4 | Local/remote conflict |
| ErrDivergedHistory | 4 | Push refused: remote moved AND files overlap (use pull or --force) |
| ErrRemoteChanged | 4 | Push optimistic locking: remote moved during the push window |
| ErrDecryptFailed | 5 | Decryption failed |
| ErrUploadFailed | 6 | GCS upload failed |
| ErrDownloadFailed | 7 | GCS download failed |
| ErrVersionTooNew | 15 | Storage format version not supported by this client |
| ErrVersionUnknown | 15 | Storage format marker missing (legacy repository) |
| ErrActionRequired | 16 | sync reached a state requiring user action (reconcile or first_push_init) |
Configuration Loading¶
- Check
--configflag - Check
ENVSECRETS_CONFIGenvironment variable - Use default
~/.envsecrets/config.yaml - Validate required fields
- Resolve passphrase (env, command, or prompt)