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
16
README.md
16
README.md
|
|
@ -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.
|
`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.
|
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 -->
|
<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.
|
`enum` restricts acceptable values.
|
||||||
```bash
|
```bash
|
||||||
pda set level "Log level: {{ enum .LEVEL "info" "warn" "error" }}"
|
pda set level "Log level: {{ enum .LEVEL "info" "warn" "error" }}"
|
||||||
|
|
@ -854,8 +863,9 @@ auto_fetch = false
|
||||||
auto_commit = false
|
auto_commit = false
|
||||||
# auto push after committing
|
# auto push after committing
|
||||||
auto_push = false
|
auto_push = false
|
||||||
# commit message template ({{.Time}} is replaced with RFC3339 timestamp)
|
# commit message if none manually specified
|
||||||
default_commit_message = "sync: {{.Time}}"
|
# supports templates, see: #templates section
|
||||||
|
default_commit_message = "{{ summary }} {{ time }}"
|
||||||
```
|
```
|
||||||
|
|
||||||
<p align="center"></p><!-- spacer -->
|
<p align="center"></p><!-- spacer -->
|
||||||
|
|
|
||||||
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,
|
AutoFetch: false,
|
||||||
AutoCommit: false,
|
AutoCommit: false,
|
||||||
AutoPush: 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 {
|
if err := executeDeletion(path); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return autoSync()
|
return autoSync(fmt.Sprintf("removed @%s", dbName))
|
||||||
}
|
}
|
||||||
|
|
||||||
func executeDeletion(path string) error {
|
func executeDeletion(path string) error {
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,7 @@ func del(cmd *cobra.Command, args []string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var removedNames []string
|
||||||
for _, dbName := range storeOrder {
|
for _, dbName := range storeOrder {
|
||||||
st := byStore[dbName]
|
st := byStore[dbName]
|
||||||
p, err := store.storePath(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)
|
return fmt.Errorf("cannot remove '%s': no such key", t.full)
|
||||||
}
|
}
|
||||||
entries = append(entries[:idx], entries[idx+1:]...)
|
entries = append(entries[:idx], entries[idx+1:]...)
|
||||||
|
removedNames = append(removedNames, t.display)
|
||||||
}
|
}
|
||||||
if err := writeStoreFile(p, entries, nil); err != nil {
|
if err := writeStoreFile(p, entries, nil); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return autoSync()
|
return autoSync("removed " + strings.Join(removedNames, ", "))
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
|
|
|
||||||
50
cmd/get.go
50
cmd/get.go
|
|
@ -27,8 +27,6 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"slices"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"text/template"
|
"text/template"
|
||||||
|
|
||||||
|
|
@ -150,57 +148,11 @@ func applyTemplate(tplBytes []byte, substitutions []string) ([]byte, error) {
|
||||||
val := parts[1]
|
val := parts[1]
|
||||||
vars[key] = val
|
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").
|
tpl, err := template.New("cmd").
|
||||||
Delims("{{", "}}").
|
Delims("{{", "}}").
|
||||||
// Render missing map keys as zero values so the default helper can decide on fallbacks.
|
// Render missing map keys as zero values so the default helper can decide on fallbacks.
|
||||||
Option("missingkey=zero").
|
Option("missingkey=zero").
|
||||||
Funcs(funcMap).
|
Funcs(templateFuncMap()).
|
||||||
Parse(string(tplBytes))
|
Parse(string(tplBytes))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,7 @@ func mvStore(cmd *cobra.Command, args []string) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
copy, _ := cmd.Flags().GetBool("copy")
|
copy, _ := cmd.Flags().GetBool("copy")
|
||||||
|
var summary string
|
||||||
if copy {
|
if copy {
|
||||||
data, err := os.ReadFile(fromPath)
|
data, err := os.ReadFile(fromPath)
|
||||||
if err != nil {
|
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)
|
return fmt.Errorf("cannot copy store '%s': %v", fromName, err)
|
||||||
}
|
}
|
||||||
okf("copied @%s to @%s", fromName, toName)
|
okf("copied @%s to @%s", fromName, toName)
|
||||||
|
summary = fmt.Sprintf("copied @%s to @%s", fromName, toName)
|
||||||
} else {
|
} else {
|
||||||
if err := os.Rename(fromPath, toPath); err != nil {
|
if err := os.Rename(fromPath, toPath); err != nil {
|
||||||
return fmt.Errorf("cannot rename store '%s': %v", fromName, err)
|
return fmt.Errorf("cannot rename store '%s': %v", fromName, err)
|
||||||
}
|
}
|
||||||
okf("renamed @%s to @%s", fromName, toName)
|
okf("renamed @%s to @%s", fromName, toName)
|
||||||
|
summary = fmt.Sprintf("moved @%s to @%s", fromName, toName)
|
||||||
}
|
}
|
||||||
return autoSync()
|
return autoSync(summary)
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
|
|
|
||||||
|
|
@ -182,12 +182,15 @@ func mvImpl(cmd *cobra.Command, args []string, keepSource bool) error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var summary string
|
||||||
if keepSource {
|
if keepSource {
|
||||||
okf("copied %s to %s", fromSpec.Display(), toSpec.Display())
|
okf("copied %s to %s", fromSpec.Display(), toSpec.Display())
|
||||||
|
summary = "copied " + fromSpec.Display() + " to " + toSpec.Display()
|
||||||
} else {
|
} else {
|
||||||
okf("renamed %s to %s", fromSpec.Display(), toSpec.Display())
|
okf("renamed %s to %s", fromSpec.Display(), toSpec.Display())
|
||||||
|
summary = "moved " + fromSpec.Display() + " to " + toSpec.Display()
|
||||||
}
|
}
|
||||||
return autoSync()
|
return autoSync(summary)
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
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).
|
// When a specific store is given, all entries go there (original behaviour).
|
||||||
// Otherwise, route entries to their original store via the "store" field.
|
// Otherwise, route entries to their original store via the "store" field.
|
||||||
|
var summary string
|
||||||
if explicitStore {
|
if explicitStore {
|
||||||
p, err := store.storePath(targetDB)
|
p, err := store.storePath(targetDB)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -140,6 +141,7 @@ func restore(cmd *cobra.Command, args []string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
okf("restored %d entries into @%s", restored, targetDB)
|
okf("restored %d entries into @%s", restored, targetDB)
|
||||||
|
summary = fmt.Sprintf("imported %d entries into @%s", restored, targetDB)
|
||||||
} else {
|
} else {
|
||||||
restored, err := restoreEntries(decoder, nil, targetDB, opts)
|
restored, err := restoreEntries(decoder, nil, targetDB, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -149,9 +151,10 @@ func restore(cmd *cobra.Command, args []string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
okf("restored %d entries", restored)
|
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 {
|
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 fmt.Errorf("cannot set '%s': %v", args[0], err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return autoSync()
|
return autoSync("set " + spec.Display())
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
|
|
|
||||||
13
cmd/sync.go
13
cmd/sync.go
|
|
@ -23,9 +23,6 @@ THE SOFTWARE.
|
||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -35,7 +32,7 @@ var syncCmd = &cobra.Command{
|
||||||
SilenceUsage: true,
|
SilenceUsage: true,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
msg, _ := cmd.Flags().GetString("message")
|
msg, _ := cmd.Flags().GetString("message")
|
||||||
return sync(true, msg)
|
return sync(true, msg, "sync")
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -44,7 +41,7 @@ func init() {
|
||||||
rootCmd.AddCommand(syncCmd)
|
rootCmd.AddCommand(syncCmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
func sync(manual bool, customMsg string) error {
|
func sync(manual bool, customMsg string, summary string) error {
|
||||||
repoDir, err := ensureVCSInitialized()
|
repoDir, err := ensureVCSInitialized()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -66,7 +63,7 @@ func sync(manual bool, customMsg string) error {
|
||||||
if changed {
|
if changed {
|
||||||
msg := customMsg
|
msg := customMsg
|
||||||
if msg == "" {
|
if msg == "" {
|
||||||
msg = strings.ReplaceAll(config.Git.DefaultCommitMessage, "{{.Time}}", time.Now().UTC().Format(time.RFC3339))
|
msg = renderCommitMessage(config.Git.DefaultCommitMessage, summary)
|
||||||
if manual {
|
if manual {
|
||||||
printHint("use -m to set a custom commit message")
|
printHint("use -m to set a custom commit message")
|
||||||
}
|
}
|
||||||
|
|
@ -123,12 +120,12 @@ func sync(manual bool, customMsg string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func autoSync() error {
|
func autoSync(summary string) error {
|
||||||
if !config.Git.AutoCommit {
|
if !config.Git.AutoCommit {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if _, err := ensureVCSInitialized(); err != nil {
|
if _, err := ensureVCSInitialized(); err != nil {
|
||||||
return 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) },
|
||||||
|
}
|
||||||
|
}
|
||||||
38
main_test.go
38
main_test.go
|
|
@ -36,20 +36,6 @@ import (
|
||||||
var update = flag.Bool("update", false, "update test files with results")
|
var update = flag.Bool("update", false, "update test files with results")
|
||||||
|
|
||||||
func TestMain(t *testing.T) {
|
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")
|
ts, err := cmdtest.Read("testdata")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read testdata: %v", err)
|
t.Fatalf("read testdata: %v", err)
|
||||||
|
|
@ -59,5 +45,29 @@ func TestMain(t *testing.T) {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
ts.Commands["pda"] = cmdtest.Program(bin)
|
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)
|
ts.Run(t, *update)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
8
testdata/config-init.ct
vendored
8
testdata/config-init.ct
vendored
|
|
@ -1,6 +1,6 @@
|
||||||
# Init creates a config file
|
# Init creates a config file
|
||||||
$ pda config init
|
$ pda config init
|
||||||
ok generated config: /tmp/TestMain2533282848/002/config.toml
|
ok generated config: ${ROOTDIR}/config/config.toml
|
||||||
|
|
||||||
# Second init fails
|
# Second init fails
|
||||||
$ pda config init --> FAIL
|
$ 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
|
# Init --new overwrites
|
||||||
$ pda config init --new
|
$ pda config init --new
|
||||||
ok generated config: /tmp/TestMain2533282848/002/config.toml
|
ok generated config: ${ROOTDIR}/config/config.toml
|
||||||
|
|
||||||
# --update preserves user changes
|
# --update preserves user changes
|
||||||
$ pda config set list.always_show_all_stores false
|
$ pda config set list.always_show_all_stores false
|
||||||
|
|
@ -18,7 +18,7 @@ $ pda config get list.always_show_all_stores
|
||||||
false
|
false
|
||||||
$ pda config init --update
|
$ pda config init --update
|
||||||
$ pda config get list.always_show_all_stores
|
$ pda config get list.always_show_all_stores
|
||||||
ok updated config: /tmp/TestMain2533282848/002/config.toml
|
ok updated config: ${ROOTDIR}/config/config.toml
|
||||||
false
|
false
|
||||||
|
|
||||||
# --new and --update are mutually exclusive
|
# --new and --update are mutually exclusive
|
||||||
|
|
@ -27,4 +27,4 @@ FAIL --new and --update are mutually exclusive
|
||||||
|
|
||||||
# Reset for other tests
|
# Reset for other tests
|
||||||
$ pda config init --new
|
$ pda config init --new
|
||||||
ok generated config: /tmp/TestMain2533282848/002/config.toml
|
ok generated config: ${ROOTDIR}/config/config.toml
|
||||||
|
|
|
||||||
2
testdata/config-list.ct
vendored
2
testdata/config-list.ct
vendored
|
|
@ -15,4 +15,4 @@ list.default_columns = key,store,value,ttl
|
||||||
git.auto_fetch = false
|
git.auto_fetch = false
|
||||||
git.auto_commit = false
|
git.auto_commit = false
|
||||||
git.auto_push = false
|
git.auto_push = false
|
||||||
git.default_commit_message = sync: {{.Time}}
|
git.default_commit_message = {{ summary }} {{ time }}
|
||||||
|
|
|
||||||
12
testdata/list-all.ct
vendored
12
testdata/list-all.ct
vendored
|
|
@ -3,8 +3,8 @@ $ pda set lax@laa 1
|
||||||
$ pda set lax@lab 2
|
$ pda set lax@lab 2
|
||||||
$ pda ls --key "lax" --format tsv
|
$ pda ls --key "lax" --format tsv
|
||||||
Key Store Value TTL
|
Key Store Value TTL
|
||||||
lax laa 1 no expiry
|
lax laa 1 none
|
||||||
lax lab 2 no expiry
|
lax lab 2 none
|
||||||
$ pda ls --key "lax" --count
|
$ pda ls --key "lax" --count
|
||||||
2
|
2
|
||||||
$ pda ls --key "lax" --format json
|
$ pda ls --key "lax" --format json
|
||||||
|
|
@ -12,15 +12,15 @@ $ pda ls --key "lax" --format json
|
||||||
# Positional arg narrows to one store
|
# Positional arg narrows to one store
|
||||||
$ pda ls laa --key "lax" --format tsv
|
$ pda ls laa --key "lax" --format tsv
|
||||||
Key Store Value TTL
|
Key Store Value TTL
|
||||||
lax laa 1 no expiry
|
lax laa 1 none
|
||||||
# --store glob filter
|
# --store glob filter
|
||||||
$ pda ls --store "la?" --key "lax" --format tsv
|
$ pda ls --store "la?" --key "lax" --format tsv
|
||||||
Key Store Value TTL
|
Key Store Value TTL
|
||||||
lax laa 1 no expiry
|
lax laa 1 none
|
||||||
lax lab 2 no expiry
|
lax lab 2 none
|
||||||
$ pda ls --store "laa" --key "lax" --format tsv
|
$ pda ls --store "laa" --key "lax" --format tsv
|
||||||
Key Store Value TTL
|
Key Store Value TTL
|
||||||
lax laa 1 no expiry
|
lax laa 1 none
|
||||||
# --store cannot be combined with positional arg
|
# --store cannot be combined with positional arg
|
||||||
$ pda ls --store "laa" laa --> FAIL
|
$ pda ls --store "laa" laa --> FAIL
|
||||||
FAIL cannot use --store with a store argument
|
FAIL cannot use --store with a store argument
|
||||||
|
|
|
||||||
2
testdata/list-config-hide-header.ct
vendored
2
testdata/list-config-hide-header.ct
vendored
|
|
@ -3,7 +3,7 @@ $ pda config set list.always_hide_header true
|
||||||
$ pda set a@lchh 1
|
$ pda set a@lchh 1
|
||||||
$ pda ls lchh --format tsv
|
$ pda ls lchh --format tsv
|
||||||
ok list.always_hide_header set to 'true'
|
ok list.always_hide_header set to 'true'
|
||||||
a lchh 1 no expiry
|
a lchh 1 none
|
||||||
|
|
||||||
# Reset
|
# Reset
|
||||||
$ pda config set list.always_hide_header false
|
$ pda config set list.always_hide_header false
|
||||||
|
|
|
||||||
4
testdata/list-format-csv.ct
vendored
4
testdata/list-format-csv.ct
vendored
|
|
@ -3,5 +3,5 @@ $ pda set a@csv 1
|
||||||
$ pda set b@csv 2
|
$ pda set b@csv 2
|
||||||
$ pda ls csv --format csv
|
$ pda ls csv --format csv
|
||||||
Key,Store,Value,TTL
|
Key,Store,Value,TTL
|
||||||
a,csv,1,no expiry
|
a,csv,1,none
|
||||||
b,csv,2,no expiry
|
b,csv,2,none
|
||||||
|
|
|
||||||
4
testdata/list-format-markdown.ct
vendored
4
testdata/list-format-markdown.ct
vendored
|
|
@ -4,5 +4,5 @@ $ pda set b@md 2
|
||||||
$ pda ls md --format markdown
|
$ pda ls md --format markdown
|
||||||
| Key | Store | Value | TTL |
|
| Key | Store | Value | TTL |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| a | md | 1 | no expiry |
|
| a | md | 1 | none |
|
||||||
| b | md | 2 | no expiry |
|
| b | md | 2 | none |
|
||||||
|
|
|
||||||
6
testdata/list-key-filter.ct
vendored
6
testdata/list-key-filter.ct
vendored
|
|
@ -3,10 +3,10 @@ $ pda set a2@lg 2
|
||||||
$ pda set b1@lg 3
|
$ pda set b1@lg 3
|
||||||
$ pda ls lg --key "a*" --format tsv
|
$ pda ls lg --key "a*" --format tsv
|
||||||
Key Store Value TTL
|
Key Store Value TTL
|
||||||
a1 lg 1 no expiry
|
a1 lg 1 none
|
||||||
a2 lg 2 no expiry
|
a2 lg 2 none
|
||||||
$ pda ls lg --key "b*" --format tsv
|
$ pda ls lg --key "b*" --format tsv
|
||||||
Key Store Value TTL
|
Key Store Value TTL
|
||||||
b1 lg 3 no expiry
|
b1 lg 3 none
|
||||||
$ pda ls lg --key "c*" --> FAIL
|
$ pda ls lg --key "c*" --> FAIL
|
||||||
FAIL cannot ls '@lg': no matches for key pattern 'c*'
|
FAIL cannot ls '@lg': no matches for key pattern 'c*'
|
||||||
|
|
|
||||||
4
testdata/list-key-value-filter.ct
vendored
4
testdata/list-key-value-filter.ct
vendored
|
|
@ -3,9 +3,9 @@ $ pda set apiurl@kv https://api.example.com
|
||||||
$ pda set dbpass@kv s3cret
|
$ pda set dbpass@kv s3cret
|
||||||
$ pda ls kv -k "db*" -v "**localhost**" --format tsv
|
$ pda ls kv -k "db*" -v "**localhost**" --format tsv
|
||||||
Key Store Value TTL
|
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
|
$ pda ls kv -k "*url*" -v "**example**" --format tsv
|
||||||
Key Store Value TTL
|
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
|
$ pda ls kv -k "db*" -v "**nomatch**" --> FAIL
|
||||||
FAIL cannot ls '@kv': no matches for key pattern 'db*' and value pattern '**nomatch**'
|
FAIL cannot ls '@kv': no matches for key pattern 'db*' and value pattern '**nomatch**'
|
||||||
|
|
|
||||||
2
testdata/list-no-header.ct
vendored
2
testdata/list-no-header.ct
vendored
|
|
@ -1,4 +1,4 @@
|
||||||
# --no-header suppresses the header row
|
# --no-header suppresses the header row
|
||||||
$ pda set a@nh 1
|
$ pda set a@nh 1
|
||||||
$ pda ls nh --format tsv --no-header
|
$ pda ls nh --format tsv --no-header
|
||||||
a nh 1 no expiry
|
a nh 1 none
|
||||||
|
|
|
||||||
2
testdata/list-no-keys.ct
vendored
2
testdata/list-no-keys.ct
vendored
|
|
@ -2,4 +2,4 @@
|
||||||
$ pda set a@nk 1
|
$ pda set a@nk 1
|
||||||
$ pda ls nk --format tsv --no-keys
|
$ pda ls nk --format tsv --no-keys
|
||||||
Store Value TTL
|
Store Value TTL
|
||||||
nk 1 no expiry
|
nk 1 none
|
||||||
|
|
|
||||||
2
testdata/list-no-values.ct
vendored
2
testdata/list-no-values.ct
vendored
|
|
@ -2,4 +2,4 @@
|
||||||
$ pda set a@nv 1
|
$ pda set a@nv 1
|
||||||
$ pda ls nv --format tsv --no-values
|
$ pda ls nv --format tsv --no-values
|
||||||
Key Store TTL
|
Key Store TTL
|
||||||
a nv no expiry
|
a nv none
|
||||||
|
|
|
||||||
4
testdata/list-stores.ct
vendored
4
testdata/list-stores.ct
vendored
|
|
@ -3,7 +3,7 @@ $ pda set a@lsalpha 1
|
||||||
$ pda set b@lsbeta 2
|
$ pda set b@lsbeta 2
|
||||||
$ pda ls lsalpha --format tsv
|
$ pda ls lsalpha --format tsv
|
||||||
Key Store Value TTL
|
Key Store Value TTL
|
||||||
a lsalpha 1 no expiry
|
a lsalpha 1 none
|
||||||
$ pda ls lsbeta --format tsv
|
$ pda ls lsbeta --format tsv
|
||||||
Key Store Value TTL
|
Key Store Value TTL
|
||||||
b lsbeta 2 no expiry
|
b lsbeta 2 none
|
||||||
|
|
|
||||||
6
testdata/list-value-filter.ct
vendored
6
testdata/list-value-filter.ct
vendored
|
|
@ -4,12 +4,12 @@ $ pda set greeting@vt < tmpval
|
||||||
$ pda set number@vt 42
|
$ pda set number@vt 42
|
||||||
$ pda ls vt --value "**world**" --format tsv
|
$ pda ls vt --value "**world**" --format tsv
|
||||||
Key Store Value TTL
|
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
|
$ pda ls vt --value "**https**" --format tsv
|
||||||
Key Store Value TTL
|
Key Store Value TTL
|
||||||
url vt https://example.com no expiry
|
url vt https://example.com none
|
||||||
$ pda ls vt --value "*" --format tsv
|
$ pda ls vt --value "*" --format tsv
|
||||||
Key Store Value TTL
|
Key Store Value TTL
|
||||||
number vt 42 no expiry
|
number vt 42 none
|
||||||
$ pda ls vt --value "**nomatch**" --> FAIL
|
$ pda ls vt --value "**nomatch**" --> FAIL
|
||||||
FAIL cannot ls '@vt': no matches for value pattern '**nomatch**'
|
FAIL cannot ls '@vt': no matches for value pattern '**nomatch**'
|
||||||
|
|
|
||||||
4
testdata/list-value-multi-filter.ct
vendored
4
testdata/list-value-multi-filter.ct
vendored
|
|
@ -4,5 +4,5 @@ $ pda set greeting@vm < tmpval
|
||||||
$ pda set number@vm 42
|
$ pda set number@vm 42
|
||||||
$ pda ls vm --value "**world**" --value "42" --format tsv
|
$ pda ls vm --value "**world**" --value "42" --format tsv
|
||||||
Key Store Value TTL
|
Key Store Value TTL
|
||||||
greeting vm hello world (..1 more chars) no expiry
|
greeting vm hello world (..1 more chars) none
|
||||||
number vm 42 no expiry
|
number vm 42 none
|
||||||
|
|
|
||||||
2
testdata/multistore.ct
vendored
2
testdata/multistore.ct
vendored
|
|
@ -7,4 +7,4 @@ $ pda get x@ms2
|
||||||
y
|
y
|
||||||
$ pda ls ms2 --format tsv
|
$ pda ls ms2 --format tsv
|
||||||
Key Store Value TTL
|
Key Store Value TTL
|
||||||
x ms2 y no expiry
|
x ms2 y none
|
||||||
|
|
|
||||||
4
testdata/remove-dedupe.ct
vendored
4
testdata/remove-dedupe.ct
vendored
|
|
@ -3,8 +3,8 @@ $ pda set foo@rdd 1
|
||||||
$ pda set bar@rdd 2
|
$ pda set bar@rdd 2
|
||||||
$ pda ls rdd --format tsv
|
$ pda ls rdd --format tsv
|
||||||
Key Store Value TTL
|
Key Store Value TTL
|
||||||
bar rdd 2 no expiry
|
bar rdd 2 none
|
||||||
foo rdd 1 no expiry
|
foo rdd 1 none
|
||||||
$ pda rm foo@rdd --key "*@rdd" -y
|
$ pda rm foo@rdd --key "*@rdd" -y
|
||||||
$ pda get bar@rdd --> FAIL
|
$ pda get bar@rdd --> FAIL
|
||||||
FAIL cannot get 'bar@rdd': no such key
|
FAIL cannot get 'bar@rdd': no such key
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue