feat(commit): text templating in commit messages
This commit is contained in:
parent
4e78cefd56
commit
2ca32769d5
30 changed files with 281 additions and 115 deletions
48
cmd/commit_message.go
Normal file
48
cmd/commit_message.go
Normal 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()
|
||||
}
|
||||
53
cmd/commit_message_test.go
Normal file
53
cmd/commit_message_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -117,7 +117,7 @@ func defaultConfig() Config {
|
|||
AutoFetch: false,
|
||||
AutoCommit: false,
|
||||
AutoPush: false,
|
||||
DefaultCommitMessage: "sync: {{.Time}}",
|
||||
DefaultCommitMessage: "{{ summary }} {{ time }}",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
50
cmd/get.go
50
cmd/get.go
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
13
cmd/sync.go
13
cmd/sync.go
|
|
@ -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
85
cmd/template.go
Normal 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) },
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue