Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).
## [0.7.0]

### Changed
- `vars history <key>` renamed to `vars log <key>`. It now also shows local date+time, works for removed keys (git keeps their history), and needs no SSH key (reads git metadata only).
- `vars history <key>` renamed to `vars log <key>`. It lists the key's committed states, each tagged with the `~N` you pass to `vars get <key>~N` (`~0` = latest, `~1` = before) instead of a commit hash, with local date+time. A commit that removed the key shows as `(removed)` (a state with no value); it needs no SSH key (reads git metadata only).
- `vars get <key>~N` is the key's state `N` commits back in its own history, not global git history (it always was per-key; the docs wrongly implied git's `HEAD~N`). If state `N` was a removal it has no value: nothing is printed and the command exits non-zero (a removed key's last value is then `~1`).
- The `[n]ew name` conflict option in `set`/`import` asks for a full key (scopes allowed, e.g. `prod/K`) instead of appending a `_<suffix>`.
- Key-free commands (`ls`, `scope`, `mv`, `rm`) no longer require the SSH key; it's resolved lazily, only when a command actually encrypts or decrypts.

## [0.6.0] (unreleased)

Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,9 +206,9 @@ cat .env | vars resolve --partial --origin # annotate where each value came from
When the store is a git repo, every change is committed automatically.

```sh
vars log RPC_URL # this key's change log (newest first, local time)
vars get RPC_URL~1 # the previous value
vars get RPC_URL~2 # two versions ago (git's HEAD~N convention)
vars log RPC_URL # this key's committed states, newest first, tagged ~0, ~1, …
vars get RPC_URL~1 # the previous value of this key
vars get RPC_URL~2 # two states back (counts only commits to this key)
vars git remote add origin git@github.com:me/store.git
vars git log # run any git command in the store dir
vars sync # pull --rebase, then push
Expand Down
13 changes: 9 additions & 4 deletions cmd/02_set.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,11 +75,11 @@ shell history).
break // new key — no conflict
}
if string(existing) == value {
fmt.Fprintln(os.Stderr, "Already set, nothing to do.")
fmt.Fprintln(os.Stderr, "Already set")
return nil
}
if setSkip {
fmt.Fprintln(os.Stderr, "Skipped.")
fmt.Fprintln(os.Stderr, "Skipped")
return nil
}
if setReplace {
Expand All @@ -101,18 +101,23 @@ shell history).
key = newKey
continue // the new key may also exist (or be invalid): re-check it
default: // actionSkip
fmt.Fprintln(os.Stderr, "Skipped.")
fmt.Fprintln(os.Stderr, "Skipped")
return nil
}
break
}

// A first write is silent; an update names the key. Has is decrypt-free,
// so this holds even when the loop above couldn't read the old value.
existed := v.Has(key)
if err := v.Set(key, []byte(value)); err != nil {
return UserError(err.Error())
}

printManifestHint(key)
fmt.Fprintln(os.Stderr, "Saved.")
if existed {
fmt.Fprintf(os.Stderr, "%s updated\n", key)
}
hintSync(storeDir())
return nil
},
Expand Down
7 changes: 5 additions & 2 deletions cmd/03_get.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,11 @@ var getCmd = &cobra.Command{
Short: "Get a key from the store",
Long: `Print one value to stdout with no trailing newline. Pipes cleanly.

KEY~N retrieves the value N versions ago from git history (like git's HEAD~N):
KEY~1 is the previous value, KEY~2 the one before that.`,
KEY~N is this key's state N commits back in its own history (not a global commit):
KEY~0 = latest committed state, KEY~1 the one before, and so on. The ~N matches the
tags shown by ` + "`vars log <key>`" + `. If that state was a removal, it has no value:
nothing is printed and the command exits non-zero (a removed key's last value is
then KEY~1). The ~ borrows git's syntax, but counts only commits to this key.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
v, err := openVault()
Expand Down
4 changes: 2 additions & 2 deletions cmd/06_mv.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ var mvCmd = &cobra.Command{
return UserError(err.Error())
}
if !strings.HasPrefix(strings.ToLower(strings.TrimSpace(answer)), "y") {
fmt.Fprintln(os.Stderr, "Aborted.")
fmt.Fprintln(os.Stderr, "Aborted")
return nil
}
}
Expand All @@ -45,7 +45,7 @@ var mvCmd = &cobra.Command{
return UserError(err.Error())
}

fmt.Fprintf(os.Stderr, "Renamed %s → %s\n", args[0], args[1])
fmt.Fprintf(os.Stderr, "Renamed: %s → %s\n", args[0], args[1])
hintSync(storeDir())
return nil
},
Expand Down
8 changes: 4 additions & 4 deletions cmd/07_rm.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ var rmCmd = &cobra.Command{

if !rmForce {
if len(args) == 1 {
fmt.Fprintf(os.Stderr, "Removing %s.\n", args[0])
fmt.Fprintf(os.Stderr, "Removing %s\n", args[0])
} else {
fmt.Fprintf(os.Stderr, "Removing %d keys:\n", len(args))
for _, key := range args {
Expand All @@ -53,7 +53,7 @@ var rmCmd = &cobra.Command{
return UserError(err.Error())
}
if !strings.HasPrefix(strings.ToLower(strings.TrimSpace(answer)), "y") {
fmt.Fprintln(os.Stderr, "Aborted.")
fmt.Fprintln(os.Stderr, "Aborted")
return nil
}
}
Expand All @@ -69,9 +69,9 @@ var rmCmd = &cobra.Command{
}

if len(args) == 1 {
fmt.Fprintln(os.Stderr, "Removed.")
fmt.Fprintf(os.Stderr, "%s removed\n", args[0])
} else {
fmt.Fprintf(os.Stderr, "Removed %d keys.\n", len(args))
fmt.Fprintf(os.Stderr, "%d keys removed\n", len(args))
}
hintSync(storeDir())
return nil
Expand Down
15 changes: 9 additions & 6 deletions cmd/07b_log.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,10 @@ func init() {
var logCmd = &cobra.Command{
Use: "log <key>",
Short: "Show a key's change history (newest first)",
Long: `List the git commits that touched a key, newest first, with local time.
Removed keys still show their history (git keeps it). Like ` + "`git log <path>`" + `.
Requires the store to be a git repo.`,
Long: `List a key's committed states, newest first, with local time. Each line is
tagged with the ~N you pass to ` + "`vars get <key>~N`" + ` (~0 = latest state, ~1 = before).
A commit that removed the key shows as "(removed)", a state with no value; every
other line is a stored value. Requires the store to be a git repo.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
// Reads git metadata only (no decryption), so it needs neither the SSH key
Expand All @@ -36,11 +37,13 @@ Requires the store to be a git repo.`,
return InternalError(err.Error())
}
if len(lines) == 0 {
fmt.Fprintf(os.Stderr, "No history for %q.\n", args[0])
fmt.Fprintf(os.Stderr, "No history for %q\n", args[0])
return nil
}
for _, l := range lines {
fmt.Fprintln(os.Stdout, l)
// Tag each line with the ~N that retrieves it (`vars get <key>~N`): the
// list is newest-first, so its index is exactly that N.
for i, l := range lines {
fmt.Fprintf(os.Stdout, "~%d %s\n", i, l)
}
return nil
},
Expand Down
4 changes: 2 additions & 2 deletions cmd/08_import.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ With a scope, keys are prefixed: vars import prod .env → prod/KEY.`,
return UserError(fmt.Sprintf("parsing file: %v", err))
}
if len(entries) == 0 {
fmt.Fprintln(os.Stderr, "No entries found.")
fmt.Fprintln(os.Stderr, "No entries found")
return nil
}
if scope != "" {
Expand Down Expand Up @@ -133,7 +133,7 @@ With a scope, keys are prefixed: vars import prod .env → prod/KEY.`,
}
}

fmt.Fprintf(os.Stderr, "Imported %d, replaced %d, skipped %d.\n", imported, replaced, skipped)
fmt.Fprintf(os.Stderr, "Imported %d, replaced %d, skipped %d\n", imported, replaced, skipped)
hintSync(storeDir())
return nil
},
Expand Down
2 changes: 1 addition & 1 deletion cmd/12_git.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ var syncCmd = &cobra.Command{
if err := git.New(dir).Sync(); err != nil {
return UserError(err.Error())
}
fmt.Fprintln(os.Stderr, "Synced.")
fmt.Fprintln(os.Stderr, "Synced")
return nil
},
}
8 changes: 4 additions & 4 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,10 @@ var Version = "dev"

var rootCmd = &cobra.Command{
Use: "vars",
Short: "A central vault for environment variables",
Long: `vars is a single encrypted store for environment variables,
shared across multiple projects. It replaces scattered .env files with
a single age-encrypted store.`,
Short: "An encrypted store for your environment variables",
Long: `vars keeps your project secrets in one encrypted store, unlocked by the
SSH key you already have. Each value is a separate age-encrypted file in an optional
git repo, so history and cross-machine sync are just git.`,
SilenceUsage: true,
SilenceErrors: true,
PersistentPreRun: func(cmd *cobra.Command, args []string) {
Expand Down
49 changes: 41 additions & 8 deletions internal/git/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,23 +118,53 @@ func (r *Repo) Sync() error {
return nil
}

// Log returns commit lines (newest first) touching relpath, formatted
// "<short-hash> <subject> (<local date+time>)". Empty when relpath has no history.
// Log returns a key's committed states (newest first), one line each, formatted
// "<local date+time> <subject>" for a stored value or "<local date+time>
// (removed)" for a commit that deleted the key. The caller numbers them, so each
// line's index is the N for `vars get <key>~N` (a "(removed)" line is a state
// with no value). Empty when relpath has no history.
func (r *Repo) Log(relpath string) ([]string, error) {
out, err := r.run("log", "--date=format-local:%Y-%m-%d %H:%M", "--format=%h %s (%cd)", "--", relpath)
const us = "\x1f" // unit separator: a field delimiter that can't occur in the data
out, err := r.run("log", "--date=format-local:%Y-%m-%d %H:%M", "--format=%H"+us+"%cd"+us+"%s", "--", relpath)
if err != nil {
return nil, fmt.Errorf("git log: %w: %s", err, out)
}
out = strings.TrimSpace(out)
if out == "" {
return nil, nil
}
return strings.Split(out, "\n"), nil
// Which of those commits stored a value (vs removed the key): AMR keeps
// add/modify/rename, so any commit NOT listed here deleted the key.
valOut, err := r.run("log", "--diff-filter=AMR", "--format=%H", "--", relpath)
if err != nil {
return nil, fmt.Errorf("git log: %w: %s", err, valOut)
}
hasValue := map[string]bool{}
for _, h := range strings.Fields(valOut) {
hasValue[h] = true
}
var lines []string
for _, row := range strings.Split(out, "\n") {
f := strings.SplitN(row, us, 3)
if len(f) != 3 {
continue
}
hash, when, subject := f[0], f[1], f[2]
if hasValue[hash] {
lines = append(lines, when+" "+subject)
} else {
lines = append(lines, when+" (removed)")
}
}
return lines, nil
}

// VersionContent returns the raw stored bytes of relpath as of n commits back
// that touched it (n=0 = current, n=1 = previous, …), via git. The caller
// decrypts. .age files are binary, so git emits them unmodified.
// VersionContent returns relpath's content at its n-th most recent committed
// state (n=0 = latest commit touching it, n=1 = the one before, …). Every commit
// that touched the key is a state, including removals: if state n was a removal,
// it has no value and a clear error is returned (cat-file -e tests blob existence
// by exit code, locale-safe). The caller decrypts; .age files are binary, so git
// emits them unmodified.
func (r *Repo) VersionContent(relpath string, n int) ([]byte, error) {
logOut, err := r.run("log", "--format=%H", "--", relpath)
if err != nil {
Expand All @@ -145,7 +175,10 @@ func (r *Repo) VersionContent(relpath string, n int) ([]byte, error) {
return nil, fmt.Errorf("%q has no history", relpath)
}
if n < 0 || n >= len(commits) {
return nil, fmt.Errorf("only %d previous version(s) exist", len(commits)-1)
return nil, fmt.Errorf("~%d is out of range; history goes back to ~%d", n, len(commits)-1)
}
if _, err := r.run("cat-file", "-e", commits[n]+":"+relpath); err != nil {
return nil, fmt.Errorf("~%d has no value (the key was removed at that point)", n)
}
out, err := r.run("show", commits[n]+":"+relpath)
if err != nil {
Expand Down
54 changes: 44 additions & 10 deletions internal/git/git_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ func (f *fakeGit) run(args ...string) (string, error) {
return f.remotes, nil
case cmd == "config user.email":
return f.configEmail, nil
case strings.HasPrefix(cmd, "log --format=%H"):
case strings.Contains(cmd, "--format=%H"):
return f.logOutput, nil
case strings.HasPrefix(cmd, "show "):
return f.showOutput, nil
Expand Down Expand Up @@ -213,29 +213,63 @@ func TestVersionContent_OutOfBounds(t *testing.T) {
}
}

func TestVersionContent_RemovalHasNoValue(t *testing.T) {
// commits[0] is the commit that removed the key: cat-file -e fails, so the
// version reports "no value" rather than leaking a raw git show failure.
f := &fakeGit{logOutput: "h0\nh1\n", failOn: "cat-file"}
_, err := repoWith(f).VersionContent("K.age", 0)
if err == nil || !strings.Contains(err.Error(), "no value") {
t.Fatalf("expected a clear no-value error, got %v", err)
}
}

func TestVersionContent_NoHistory(t *testing.T) {
f := &fakeGit{logOutput: ""}
if _, err := repoWith(f).VersionContent("K.age", 1); err == nil {
t.Fatal("expected no-history error")
}
}

func TestLog_ParsesLines(t *testing.T) {
f := &fakeGit{}
// Override run to return canned log output.
func TestLog_RendersValuesAndRemovals(t *testing.T) {
var calls []string
// Full history h0..h2 where h1 was a removal (absent from the AMR value set).
r := &Repo{dir: "/store", run: func(args ...string) (string, error) {
f.calls = append(f.calls, strings.Join(args, " "))
return "abc123 set RPC_URL (2026-06-13)\ndef456 mv RPC_URL R2 (2026-06-12)\n", nil
cmd := strings.Join(args, " ")
calls = append(calls, cmd)
switch {
case strings.Contains(cmd, "--diff-filter=AMR"):
return "h0\nh2\n", nil // value-bearing commits only
case strings.Contains(cmd, "--format=%H"):
return "h0\x1f2026-06-21 09:10\x1fset RPC_URL\n" +
"h1\x1f2026-06-21 09:05\x1frm RPC_URL\n" +
"h2\x1f2026-06-21 09:01\x1fset RPC_URL\n", nil
}
return "", nil
}}
lines, err := r.Log("RPC_URL.age")
if err != nil {
t.Fatalf("log: %v", err)
}
if len(lines) != 2 || !strings.Contains(lines[0], "set RPC_URL") {
t.Fatalf("log lines = %v", lines)
if len(lines) != 3 {
t.Fatalf("want 3 state lines, got %v", lines)
}
if !strings.Contains(lines[0], "set RPC_URL") {
t.Fatalf("line 0 should be a value: %q", lines[0])
}
if !strings.Contains(lines[1], "(removed)") || strings.Contains(lines[1], "rm RPC_URL") {
t.Fatalf("line 1 should render the removal as a no-value state, got %q", lines[1])
}
if !strings.Contains(lines[2], "set RPC_URL") {
t.Fatalf("line 2 should be a value: %q", lines[2])
}
scoped := false
for _, c := range calls {
if strings.Contains(c, "-- RPC_URL.age") {
scoped = true
}
}
if !f.issued("-- RPC_URL.age") {
t.Fatalf("log should scope to the file, calls = %v", f.calls)
if !scoped {
t.Fatalf("log should scope to the file, calls = %v", calls)
}
}

Expand Down
Loading
Loading