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/restore/nothing_tracked.restoreis chosen before classification when none of the tracked files exist locally but remote has them, so a wiped working tree is restored rather than pushed as deletions 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), or when its push would delete files from remote
Cache Structure¶
~/.envsecrets/
├── config.yaml
└── cache/
└── {owner}/
└── {repo}/
├── .git/ # Full git history (restored from packfile)
│ └── .envsecrets-last-synced # Legacy baseline marker; adopted by one checkout, then removed
├── .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 baseline describes one working tree, but the cache is
shared by every clone and worktree of a repo on the machine. So it lives in
the project checkout's git directory, at
<git dir>/envsecrets/{owner}/{repo}/last-synced (Discovery.SyncBaselinePath;
a worktree's git dir is .git/worktrees/<name>/). It contains a single
40-char hex commit hash recording the most recent push or full-HEAD pull
this checkout successfully completed. cache.Reset() intentionally clears
it — Reset implies the cache is no longer trusted, so the baseline must also
be discarded.
Older versions kept the marker inside the cache's .git/, and it doesn't
record which checkout wrote it. A checkout with no baseline of its own adopts
that legacy marker only if its working tree matches the marker's commit
(Syncer.readBaseline), which identifies it as the checkout that synced it.
It claims the file with an atomic rename, so only one checkout can adopt it;
a non-matching checkout puts it back and starts with no baseline (first
pull). Adopting it in a stale checkout would make old content look like
local edits that sync would push over newer remote content.
Push never deletes a file from remote unless the caller approved it:
either blanket PushOptions.AllowDeletes (push --allow-missing/--force)
or by name in PushOptions.ConfirmedDeletions (the interactive push
confirmation, rm for the file it removes, revert --push for the tracked
files absent at the target ref). Otherwise it returns
*domain.PendingDeletionsError listing every pending deletion, before
touching the cache. Push reads all tracked files once up front and uses that
snapshot for both approval and the commit, so a file deleted mid-push can't
slip through, and the check runs after syncing from storage, so a file
another machine published after confirmation is caught too.
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)