feat(commit): text templating in commit messages

This commit is contained in:
Lewis Wynne 2026-02-12 20:00:57 +00:00
parent 4e78cefd56
commit 2ca32769d5
30 changed files with 281 additions and 115 deletions

View file

@ -394,7 +394,7 @@ Values support effectively all of Go's `text/template` syntax. Templates are eva
`text/template` is a Turing-complete templating library that supports most of what you'd expect in a scripting language. Actions are given with ``{{ action }}`` syntax and support pipelines and nested templates, along with a lot more. I recommend reading the documentation if you want to do anything more complicated than described here.
To fit `text/template` nicely into this tool, pda has a sparse set of additional functions built-in. For example, `default` values, `enum`s, `require`d values, `lists`, among others.
To fit `text/template` nicely into this tool, pda has a sparse set of additional functions built-in. For example, `default` values, `enum`s, `require`d values, `time`, `lists`, among others. These same functions are also available in `git.default_commit_message` templates, along with `{{ summary }}` which returns the action that triggered the commit (e.g. "set foo", "removed bar").
Below is more detail on the extra functions added by this tool.
@ -438,6 +438,15 @@ pda get my_name
<p align="center"></p><!-- spacer -->
`time` returns the current UTC time in RFC3339 format.
```bash
pda set note "Created at {{ time }}"
pda get note
# Created at 2025-01-15T12:00:00Z
```
<p align="center"></p><!-- spacer -->
`enum` restricts acceptable values.
```bash
pda set level "Log level: {{ enum .LEVEL "info" "warn" "error" }}"
@ -854,8 +863,9 @@ auto_fetch = false
auto_commit = false
# auto push after committing
auto_push = false
# commit message template ({{.Time}} is replaced with RFC3339 timestamp)
default_commit_message = "sync: {{.Time}}"
# commit message if none manually specified
# supports templates, see: #templates section
default_commit_message = "{{ summary }} {{ time }}"
```
<p align="center"></p><!-- spacer -->

48
cmd/commit_message.go Normal file
View file

@ -0,0 +1,48 @@
/*
Copyright © 2025 Lewis Wynne <lew@ily.rs>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
*/
package cmd
import (
"bytes"
"text/template"
)
// renderCommitMessage renders a commit message template. It extends the
// shared template FuncMap with {{ summary }}, which returns the action
// description for the current commit. On any error the raw template string
// is returned so that commits are never blocked by a bad template.
func renderCommitMessage(tmpl string, summary string) string {
funcMap := templateFuncMap()
funcMap["summary"] = func() string { return summary }
t, err := template.New("commit").Option("missingkey=zero").Funcs(funcMap).Parse(tmpl)
if err != nil {
return tmpl
}
var buf bytes.Buffer
if err := t.Execute(&buf, nil); err != nil {
return tmpl
}
return buf.String()
}

View file

@ -0,0 +1,53 @@
package cmd
import (
"strings"
"testing"
)
func TestRenderCommitMessage(t *testing.T) {
t.Run("summary and time", func(t *testing.T) {
msg := renderCommitMessage("{{ summary }} {{ time }}", "set foo")
if !strings.HasPrefix(msg, "set foo ") {
t.Errorf("expected prefix 'set foo ', got %q", msg)
}
parts := strings.SplitN(msg, " ", 3)
if len(parts) < 3 || !strings.Contains(parts[2], "T") {
t.Errorf("expected RFC3339 time, got %q", msg)
}
})
t.Run("empty summary", func(t *testing.T) {
msg := renderCommitMessage("{{ summary }} {{ time }}", "")
if !strings.HasPrefix(msg, " ") {
t.Errorf("expected leading space (empty summary), got %q", msg)
}
})
t.Run("default function", func(t *testing.T) {
msg := renderCommitMessage(`{{ default "sync" (summary) }}`, "")
if msg != "sync" {
t.Errorf("expected 'sync', got %q", msg)
}
msg = renderCommitMessage(`{{ default "sync" (summary) }}`, "set foo")
if msg != "set foo" {
t.Errorf("expected 'set foo', got %q", msg)
}
})
t.Run("env function", func(t *testing.T) {
t.Setenv("PDA_TEST_USER", "alice")
msg := renderCommitMessage(`{{ env "PDA_TEST_USER" }}: {{ summary }}`, "set foo")
if msg != "alice: set foo" {
t.Errorf("expected 'alice: set foo', got %q", msg)
}
})
t.Run("bad template returns raw", func(t *testing.T) {
raw := "{{ bad template"
msg := renderCommitMessage(raw, "test")
if msg != raw {
t.Errorf("expected raw %q, got %q", raw, msg)
}
})
}

View file

@ -117,7 +117,7 @@ func defaultConfig() Config {
AutoFetch: false,
AutoCommit: false,
AutoPush: false,
DefaultCommitMessage: "sync: {{.Time}}",
DefaultCommitMessage: "{{ summary }} {{ time }}",
},
}
}

View file

@ -79,7 +79,7 @@ func delStore(cmd *cobra.Command, args []string) error {
if err := executeDeletion(path); err != nil {
return err
}
return autoSync()
return autoSync(fmt.Sprintf("removed @%s", dbName))
}
func executeDeletion(path string) error {

View file

@ -107,6 +107,7 @@ func del(cmd *cobra.Command, args []string) error {
return nil
}
var removedNames []string
for _, dbName := range storeOrder {
st := byStore[dbName]
p, err := store.storePath(dbName)
@ -123,13 +124,14 @@ func del(cmd *cobra.Command, args []string) error {
return fmt.Errorf("cannot remove '%s': no such key", t.full)
}
entries = append(entries[:idx], entries[idx+1:]...)
removedNames = append(removedNames, t.display)
}
if err := writeStoreFile(p, entries, nil); err != nil {
return err
}
}
return autoSync()
return autoSync("removed " + strings.Join(removedNames, ", "))
}
func init() {

View file

@ -27,8 +27,6 @@ import (
"fmt"
"os"
"os/exec"
"slices"
"strconv"
"strings"
"text/template"
@ -150,57 +148,11 @@ func applyTemplate(tplBytes []byte, substitutions []string) ([]byte, error) {
val := parts[1]
vars[key] = val
}
funcMap := template.FuncMap{
"require": func(v any) (string, error) {
s := fmt.Sprint(v)
if s == "" {
return "", fmt.Errorf("required value is missing or empty")
}
return s, nil
},
"default": func(def string, v any) string {
s := fmt.Sprint(v)
if s == "" {
return def
}
return s
},
"env": os.Getenv,
"enum": func(v any, allowed ...string) (string, error) {
s := fmt.Sprint(v)
if s == "" {
return "", fmt.Errorf("enum value is missing or empty")
}
if slices.Contains(allowed, s) {
return s, nil
}
return "", fmt.Errorf("invalid value '%s', allowed: %v", s, allowed)
},
"int": func(v any) (int, error) {
s := fmt.Sprint(v)
i, err := strconv.Atoi(s)
if err != nil {
return 0, fmt.Errorf("cannot convert to int: %w", err)
}
return i, nil
},
"list": func(v any) []string {
s := fmt.Sprint(v)
if s == "" {
return nil
}
parts := strings.Split(s, ",")
for i := range parts {
parts[i] = strings.TrimSpace(parts[i])
}
return parts
},
}
tpl, err := template.New("cmd").
Delims("{{", "}}").
// Render missing map keys as zero values so the default helper can decide on fallbacks.
Option("missingkey=zero").
Funcs(funcMap).
Funcs(templateFuncMap()).
Parse(string(tplBytes))
if err != nil {
return nil, err

View file

@ -102,6 +102,7 @@ func mvStore(cmd *cobra.Command, args []string) error {
}
copy, _ := cmd.Flags().GetBool("copy")
var summary string
if copy {
data, err := os.ReadFile(fromPath)
if err != nil {
@ -111,13 +112,15 @@ func mvStore(cmd *cobra.Command, args []string) error {
return fmt.Errorf("cannot copy store '%s': %v", fromName, err)
}
okf("copied @%s to @%s", fromName, toName)
summary = fmt.Sprintf("copied @%s to @%s", fromName, toName)
} else {
if err := os.Rename(fromPath, toPath); err != nil {
return fmt.Errorf("cannot rename store '%s': %v", fromName, err)
}
okf("renamed @%s to @%s", fromName, toName)
summary = fmt.Sprintf("moved @%s to @%s", fromName, toName)
}
return autoSync()
return autoSync(summary)
}
func init() {

View file

@ -182,12 +182,15 @@ func mvImpl(cmd *cobra.Command, args []string, keepSource bool) error {
}
}
var summary string
if keepSource {
okf("copied %s to %s", fromSpec.Display(), toSpec.Display())
summary = "copied " + fromSpec.Display() + " to " + toSpec.Display()
} else {
okf("renamed %s to %s", fromSpec.Display(), toSpec.Display())
summary = "moved " + fromSpec.Display() + " to " + toSpec.Display()
}
return autoSync()
return autoSync(summary)
}
func init() {

View file

@ -127,6 +127,7 @@ func restore(cmd *cobra.Command, args []string) error {
// When a specific store is given, all entries go there (original behaviour).
// Otherwise, route entries to their original store via the "store" field.
var summary string
if explicitStore {
p, err := store.storePath(targetDB)
if err != nil {
@ -140,6 +141,7 @@ func restore(cmd *cobra.Command, args []string) error {
return err
}
okf("restored %d entries into @%s", restored, targetDB)
summary = fmt.Sprintf("imported %d entries into @%s", restored, targetDB)
} else {
restored, err := restoreEntries(decoder, nil, targetDB, opts)
if err != nil {
@ -149,9 +151,10 @@ func restore(cmd *cobra.Command, args []string) error {
return err
}
okf("restored %d entries", restored)
summary = fmt.Sprintf("imported %d entries", restored)
}
return autoSync()
return autoSync(summary)
}
func reportRestoreFilters(displayTarget string, restored int, matchers []glob.Glob, keyPatterns []string, storeMatchers []glob.Glob, storePatterns []string) error {

View file

@ -176,7 +176,7 @@ func set(cmd *cobra.Command, args []string) error {
return fmt.Errorf("cannot set '%s': %v", args[0], err)
}
return autoSync()
return autoSync("set " + spec.Display())
}
func init() {

View file

@ -23,9 +23,6 @@ THE SOFTWARE.
package cmd
import (
"strings"
"time"
"github.com/spf13/cobra"
)
@ -35,7 +32,7 @@ var syncCmd = &cobra.Command{
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
msg, _ := cmd.Flags().GetString("message")
return sync(true, msg)
return sync(true, msg, "sync")
},
}
@ -44,7 +41,7 @@ func init() {
rootCmd.AddCommand(syncCmd)
}
func sync(manual bool, customMsg string) error {
func sync(manual bool, customMsg string, summary string) error {
repoDir, err := ensureVCSInitialized()
if err != nil {
return err
@ -66,7 +63,7 @@ func sync(manual bool, customMsg string) error {
if changed {
msg := customMsg
if msg == "" {
msg = strings.ReplaceAll(config.Git.DefaultCommitMessage, "{{.Time}}", time.Now().UTC().Format(time.RFC3339))
msg = renderCommitMessage(config.Git.DefaultCommitMessage, summary)
if manual {
printHint("use -m to set a custom commit message")
}
@ -123,12 +120,12 @@ func sync(manual bool, customMsg string) error {
return nil
}
func autoSync() error {
func autoSync(summary string) error {
if !config.Git.AutoCommit {
return nil
}
if _, err := ensureVCSInitialized(); err != nil {
return nil
}
return sync(false, "")
return sync(false, "", summary)
}

85
cmd/template.go Normal file
View file

@ -0,0 +1,85 @@
/*
Copyright © 2025 Lewis Wynne <lew@ily.rs>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
*/
package cmd
import (
"fmt"
"os"
"slices"
"strconv"
"strings"
"text/template"
"time"
)
// templateFuncMap returns the shared FuncMap used by both value templates
// (pda get) and commit message templates.
func templateFuncMap() template.FuncMap {
return template.FuncMap{
"require": func(v any) (string, error) {
s := fmt.Sprint(v)
if s == "" {
return "", fmt.Errorf("required value is missing or empty")
}
return s, nil
},
"default": func(def string, v any) string {
s := fmt.Sprint(v)
if s == "" {
return def
}
return s
},
"env": os.Getenv,
"enum": func(v any, allowed ...string) (string, error) {
s := fmt.Sprint(v)
if s == "" {
return "", fmt.Errorf("enum value is missing or empty")
}
if slices.Contains(allowed, s) {
return s, nil
}
return "", fmt.Errorf("invalid value '%s', allowed: %v", s, allowed)
},
"int": func(v any) (int, error) {
s := fmt.Sprint(v)
i, err := strconv.Atoi(s)
if err != nil {
return 0, fmt.Errorf("cannot convert to int: %w", err)
}
return i, nil
},
"list": func(v any) []string {
s := fmt.Sprint(v)
if s == "" {
return nil
}
parts := strings.Split(s, ",")
for i := range parts {
parts[i] = strings.TrimSpace(parts[i])
}
return parts
},
"time": func() string { return time.Now().UTC().Format(time.RFC3339) },
}
}

View file

@ -36,20 +36,6 @@ import (
var update = flag.Bool("update", false, "update test files with results")
func TestMain(t *testing.T) {
t.Setenv("PDA_DATA", t.TempDir())
configDir := t.TempDir()
t.Setenv("PDA_CONFIG", configDir)
// Pre-create an age identity so encryption tests don't print a
// creation message with a non-deterministic path.
id, err := age.GenerateX25519Identity()
if err != nil {
t.Fatalf("generate identity: %v", err)
}
if err := os.WriteFile(filepath.Join(configDir, "identity.txt"), []byte(id.String()+"\n"), 0o600); err != nil {
t.Fatalf("write identity: %v", err)
}
ts, err := cmdtest.Read("testdata")
if err != nil {
t.Fatalf("read testdata: %v", err)
@ -59,5 +45,29 @@ func TestMain(t *testing.T) {
t.Fatal(err)
}
ts.Commands["pda"] = cmdtest.Program(bin)
// Each .ct file gets its own isolated data and config directories
// inside its ROOTDIR, so tests cannot leak state to each other.
ts.Setup = func(rootDir string) error {
dataDir := filepath.Join(rootDir, "data")
configDir := filepath.Join(rootDir, "config")
if err := os.MkdirAll(dataDir, 0o755); err != nil {
return err
}
if err := os.MkdirAll(configDir, 0o755); err != nil {
return err
}
os.Setenv("PDA_DATA", dataDir)
os.Setenv("PDA_CONFIG", configDir)
// Pre-create an age identity so encryption tests don't print
// a creation message with a non-deterministic path.
id, err := age.GenerateX25519Identity()
if err != nil {
return err
}
return os.WriteFile(filepath.Join(configDir, "identity.txt"), []byte(id.String()+"\n"), 0o600)
}
ts.Run(t, *update)
}

View file

@ -1,6 +1,6 @@
# Init creates a config file
$ pda config init
ok generated config: /tmp/TestMain2533282848/002/config.toml
ok generated config: ${ROOTDIR}/config/config.toml
# Second init fails
$ pda config init --> FAIL
@ -9,7 +9,7 @@ hint use '--update' to update your config, or '--new' to get a fresh copy
# Init --new overwrites
$ pda config init --new
ok generated config: /tmp/TestMain2533282848/002/config.toml
ok generated config: ${ROOTDIR}/config/config.toml
# --update preserves user changes
$ pda config set list.always_show_all_stores false
@ -18,7 +18,7 @@ $ pda config get list.always_show_all_stores
false
$ pda config init --update
$ pda config get list.always_show_all_stores
ok updated config: /tmp/TestMain2533282848/002/config.toml
ok updated config: ${ROOTDIR}/config/config.toml
false
# --new and --update are mutually exclusive
@ -27,4 +27,4 @@ FAIL --new and --update are mutually exclusive
# Reset for other tests
$ pda config init --new
ok generated config: /tmp/TestMain2533282848/002/config.toml
ok generated config: ${ROOTDIR}/config/config.toml

View file

@ -15,4 +15,4 @@ list.default_columns = key,store,value,ttl
git.auto_fetch = false
git.auto_commit = false
git.auto_push = false
git.default_commit_message = sync: {{.Time}}
git.default_commit_message = {{ summary }} {{ time }}

12
testdata/list-all.ct vendored
View file

@ -3,8 +3,8 @@ $ pda set lax@laa 1
$ pda set lax@lab 2
$ pda ls --key "lax" --format tsv
Key Store Value TTL
lax laa 1 no expiry
lax lab 2 no expiry
lax laa 1 none
lax lab 2 none
$ pda ls --key "lax" --count
2
$ pda ls --key "lax" --format json
@ -12,15 +12,15 @@ $ pda ls --key "lax" --format json
# Positional arg narrows to one store
$ pda ls laa --key "lax" --format tsv
Key Store Value TTL
lax laa 1 no expiry
lax laa 1 none
# --store glob filter
$ pda ls --store "la?" --key "lax" --format tsv
Key Store Value TTL
lax laa 1 no expiry
lax lab 2 no expiry
lax laa 1 none
lax lab 2 none
$ pda ls --store "laa" --key "lax" --format tsv
Key Store Value TTL
lax laa 1 no expiry
lax laa 1 none
# --store cannot be combined with positional arg
$ pda ls --store "laa" laa --> FAIL
FAIL cannot use --store with a store argument

View file

@ -3,7 +3,7 @@ $ pda config set list.always_hide_header true
$ pda set a@lchh 1
$ pda ls lchh --format tsv
ok list.always_hide_header set to 'true'
a lchh 1 no expiry
a lchh 1 none
# Reset
$ pda config set list.always_hide_header false

View file

@ -3,5 +3,5 @@ $ pda set a@csv 1
$ pda set b@csv 2
$ pda ls csv --format csv
Key,Store,Value,TTL
a,csv,1,no expiry
b,csv,2,no expiry
a,csv,1,none
b,csv,2,none

View file

@ -4,5 +4,5 @@ $ pda set b@md 2
$ pda ls md --format markdown
| Key | Store | Value | TTL |
| --- | --- | --- | --- |
| a | md | 1 | no expiry |
| b | md | 2 | no expiry |
| a | md | 1 | none |
| b | md | 2 | none |

View file

@ -3,10 +3,10 @@ $ pda set a2@lg 2
$ pda set b1@lg 3
$ pda ls lg --key "a*" --format tsv
Key Store Value TTL
a1 lg 1 no expiry
a2 lg 2 no expiry
a1 lg 1 none
a2 lg 2 none
$ pda ls lg --key "b*" --format tsv
Key Store Value TTL
b1 lg 3 no expiry
b1 lg 3 none
$ pda ls lg --key "c*" --> FAIL
FAIL cannot ls '@lg': no matches for key pattern 'c*'

View file

@ -3,9 +3,9 @@ $ pda set apiurl@kv https://api.example.com
$ pda set dbpass@kv s3cret
$ pda ls kv -k "db*" -v "**localhost**" --format tsv
Key Store Value TTL
dburl kv postgres://localhost:5432 no expiry
dburl kv postgres://localhost:5432 none
$ pda ls kv -k "*url*" -v "**example**" --format tsv
Key Store Value TTL
apiurl kv https://api.example.com no expiry
apiurl kv https://api.example.com none
$ pda ls kv -k "db*" -v "**nomatch**" --> FAIL
FAIL cannot ls '@kv': no matches for key pattern 'db*' and value pattern '**nomatch**'

View file

@ -1,4 +1,4 @@
# --no-header suppresses the header row
$ pda set a@nh 1
$ pda ls nh --format tsv --no-header
a nh 1 no expiry
a nh 1 none

View file

@ -2,4 +2,4 @@
$ pda set a@nk 1
$ pda ls nk --format tsv --no-keys
Store Value TTL
nk 1 no expiry
nk 1 none

View file

@ -2,4 +2,4 @@
$ pda set a@nv 1
$ pda ls nv --format tsv --no-values
Key Store TTL
a nv no expiry
a nv none

View file

@ -3,7 +3,7 @@ $ pda set a@lsalpha 1
$ pda set b@lsbeta 2
$ pda ls lsalpha --format tsv
Key Store Value TTL
a lsalpha 1 no expiry
a lsalpha 1 none
$ pda ls lsbeta --format tsv
Key Store Value TTL
b lsbeta 2 no expiry
b lsbeta 2 none

View file

@ -4,12 +4,12 @@ $ pda set greeting@vt < tmpval
$ pda set number@vt 42
$ pda ls vt --value "**world**" --format tsv
Key Store Value TTL
greeting vt hello world (..1 more chars) no expiry
greeting vt hello world (..1 more chars) none
$ pda ls vt --value "**https**" --format tsv
Key Store Value TTL
url vt https://example.com no expiry
url vt https://example.com none
$ pda ls vt --value "*" --format tsv
Key Store Value TTL
number vt 42 no expiry
number vt 42 none
$ pda ls vt --value "**nomatch**" --> FAIL
FAIL cannot ls '@vt': no matches for value pattern '**nomatch**'

View file

@ -4,5 +4,5 @@ $ pda set greeting@vm < tmpval
$ pda set number@vm 42
$ pda ls vm --value "**world**" --value "42" --format tsv
Key Store Value TTL
greeting vm hello world (..1 more chars) no expiry
number vm 42 no expiry
greeting vm hello world (..1 more chars) none
number vm 42 none

View file

@ -7,4 +7,4 @@ $ pda get x@ms2
y
$ pda ls ms2 --format tsv
Key Store Value TTL
x ms2 y no expiry
x ms2 y none

View file

@ -3,8 +3,8 @@ $ pda set foo@rdd 1
$ pda set bar@rdd 2
$ pda ls rdd --format tsv
Key Store Value TTL
bar rdd 2 no expiry
foo rdd 1 no expiry
bar rdd 2 none
foo rdd 1 none
$ pda rm foo@rdd --key "*@rdd" -y
$ pda get bar@rdd --> FAIL
FAIL cannot get 'bar@rdd': no such key