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

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) },
}
}