Skip to content

Commit 8bc08c0

Browse files
committed
Replace message affixes with subject and body modifiers
1 parent 4c436e2 commit 8bc08c0

10 files changed

Lines changed: 226 additions & 318 deletions

File tree

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -250,8 +250,10 @@ Useful flags:
250250
| `--no-checkout` | Commit without materializing touched paths into the checkout. |
251251
| `--untracked` | Admit untracked source paths under CWD. |
252252
| `--message <m>` | Override the generated commit message. |
253-
| `--message-prefix <m>` | Prepend the generated commit message. |
254-
| `--message-suffix <m>` | Append the generated commit message. |
253+
| `--subject-prefix <s>` | Prepend literal text to the generated commit subject. |
254+
| `--subject-suffix <s>` | Append literal text to the generated commit subject. |
255+
| `--body-prefix <s>` | Prepend a body block before the generated commit body. |
256+
| `--body-suffix <s>` | Append a body block after the generated commit body. |
255257
| `--retries <n>` | Retry optimistic ref-update conflicts. The default is `3`. |
256258
| `--allow-empty` | Permit an empty commit for mutating invocations. |
257259

docs/spec-review.md

Lines changed: 39 additions & 282 deletions
Large diffs are not rendered by default.

internal/etch/catalog.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -541,8 +541,10 @@ Core flags:
541541
--no-checkout commit without materializing touched paths
542542
--untracked admit untracked source paths under CWD
543543
--message <m> override generated commit message
544-
--message-prefix <m> prepend generated commit message
545-
--message-suffix <m> append generated commit message
544+
--subject-prefix <s> prepend literal text to generated commit subject
545+
--subject-suffix <s> append literal text to generated commit subject
546+
--body-prefix <s> prepend a block to generated commit body
547+
--body-suffix <s> append a block to generated commit body
546548
--retries <n> retry CAS conflicts, default 3
547549
--allow-empty permit empty commit for mutating invocations
548550
--version print version and exit

internal/etch/cli.go

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,10 @@ func runCLIAt(cwd string, args []string, stdout, stderr io.Writer) (exitCode, er
5050
&cli.BoolFlag{Name: "no-checkout", Usage: "commit without materializing touched paths", Destination: &opts.NoCheckout},
5151
&cli.BoolFlag{Name: "untracked", Usage: "admit untracked source paths under CWD", Destination: &opts.Untracked},
5252
&cli.StringFlag{Name: "message", Usage: "override generated commit message", Destination: &opts.Message},
53-
&cli.StringFlag{Name: "message-prefix", Usage: "prepend generated commit message", Destination: &opts.MessagePrefix},
54-
&cli.StringFlag{Name: "message-suffix", Usage: "append generated commit message", Destination: &opts.MessageSuffix},
53+
&cli.StringFlag{Name: "subject-prefix", Usage: "prepend literal text to generated commit subject", Destination: &opts.SubjectPrefix},
54+
&cli.StringFlag{Name: "subject-suffix", Usage: "append literal text to generated commit subject", Destination: &opts.SubjectSuffix},
55+
&cli.StringFlag{Name: "body-prefix", Usage: "prepend a block to generated commit body", Destination: &opts.BodyPrefix},
56+
&cli.StringFlag{Name: "body-suffix", Usage: "append a block to generated commit body", Destination: &opts.BodySuffix},
5557
&cli.IntFlag{Name: "retries", Usage: "retry CAS conflicts", Value: 3, Destination: &opts.Retries},
5658
&cli.BoolFlag{Name: "allow-empty", Usage: "permit empty commit for mutating invocations", Destination: &opts.AllowEmpty},
5759
&cli.BoolFlag{Name: "version", Usage: "print version and exit"},
@@ -92,8 +94,8 @@ func runParsedCLI(opts GlobalOptions, rest []string, stdout, stderr io.Writer) (
9294
if opts.Plan && opts.DryRun {
9395
return exitUsage, usagef("--plan and --dry-run are mutually exclusive")
9496
}
95-
if opts.Message != "" && (opts.MessagePrefix != "" || opts.MessageSuffix != "") {
96-
return exitUsage, usagef("--message is mutually exclusive with --message-prefix and --message-suffix")
97+
if opts.Message != "" && (opts.SubjectPrefix != "" || opts.SubjectSuffix != "" || opts.BodyPrefix != "" || opts.BodySuffix != "") {
98+
return exitUsage, usagef("--message is mutually exclusive with subject/body message modifiers")
9799
}
98100
if opts.Retries < 0 {
99101
return exitUsage, usagef("--retries must be non-negative")
@@ -205,8 +207,10 @@ func globalFlagCompletions() []string {
205207
"--no-checkout",
206208
"--untracked",
207209
"--message",
208-
"--message-prefix",
209-
"--message-suffix",
210+
"--subject-prefix",
211+
"--subject-suffix",
212+
"--body-prefix",
213+
"--body-suffix",
210214
"--retries",
211215
"--allow-empty",
212216
"--version",

internal/etch/errors_test.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package etch
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"testing"
7+
)
8+
9+
func TestClassifyErrUsesErrorsAs(t *testing.T) {
10+
wrapped := fmt.Errorf("wrapped: %w", usagef("bad usage"))
11+
if got := classifyErr(wrapped); got != exitUsage {
12+
t.Fatalf("classifyErr(wrapped usage) = %d, want %d", got, exitUsage)
13+
}
14+
15+
joined := errors.Join(fmt.Errorf("other"), usagef("bad usage"))
16+
if got := classifyErr(joined); got != exitUsage {
17+
t.Fatalf("classifyErr(joined usage) = %d, want %d", got, exitUsage)
18+
}
19+
}

internal/etch/help_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ func TestShellCompletionThroughCLI(t *testing.T) {
107107
if err != nil || code != exitOK {
108108
t.Fatalf("flag completion code=%d err=%v stderr=%s", code, err, errb.String())
109109
}
110-
for _, want := range []string{"--plan\n", "-n\n"} {
110+
for _, want := range []string{"--plan\n", "-n\n", "--subject-prefix\n", "--body-suffix\n"} {
111111
if !strings.Contains(out.String(), want) {
112112
t.Fatalf("flag completion missing %q:\n%s", want, out.String())
113113
}

internal/etch/planner.go

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -503,7 +503,7 @@ func buildCommitMessage(opts GlobalOptions, ops []Operation) string {
503503
msg := "etch: no changes"
504504
if len(mut) == 1 {
505505
subj := "etch " + mut[0].Descriptor
506-
if len(subj) <= 72 && !strings.Contains(subj, "\n") {
506+
if commitSubjectFits(subj, opts) {
507507
msg = subj
508508
} else {
509509
msg = "etch " + descriptorWithoutValue(mut[0])
@@ -528,13 +528,45 @@ func buildCommitMessage(opts GlobalOptions, ops []Operation) string {
528528
msg += "\n- " + op.Descriptor
529529
}
530530
}
531-
if opts.MessagePrefix != "" {
532-
msg = opts.MessagePrefix + msg
531+
return applyCommitMessageModifiers(msg, opts)
532+
}
533+
534+
func commitSubjectFits(subject string, opts GlobalOptions) bool {
535+
subject = opts.SubjectPrefix + subject + opts.SubjectSuffix
536+
return len(subject) <= 72 && !strings.Contains(subject, "\n")
537+
}
538+
539+
func applyCommitMessageModifiers(msg string, opts GlobalOptions) string {
540+
subject, body := splitCommitMessage(msg)
541+
subject = opts.SubjectPrefix + subject + opts.SubjectSuffix
542+
body = joinCommitBodyBlocks(opts.BodyPrefix, body, opts.BodySuffix)
543+
if body == "" {
544+
return subject
545+
}
546+
return subject + "\n\n" + body
547+
}
548+
549+
func splitCommitMessage(msg string) (subject, body string) {
550+
subject, body, ok := strings.Cut(msg, "\n\n")
551+
if !ok {
552+
return msg, ""
533553
}
534-
if opts.MessageSuffix != "" {
535-
msg += opts.MessageSuffix
554+
return subject, body
555+
}
556+
557+
func joinCommitBodyBlocks(blocks ...string) string {
558+
var out string
559+
for _, block := range blocks {
560+
if block == "" {
561+
continue
562+
}
563+
if out == "" {
564+
out = block
565+
continue
566+
}
567+
out = strings.TrimRight(out, "\n") + "\n\n" + strings.TrimLeft(block, "\n")
536568
}
537-
return msg
569+
return out
538570
}
539571

540572
func descriptorWithoutValue(op Operation) string {

internal/etch/planner_test.go

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,89 @@ func TestPlanHashUsesJCSCanonicalBytes(t *testing.T) {
142142
}
143143
}
144144

145+
func TestBuildCommitMessageAppliesSubjectAndBodyModifiers(t *testing.T) {
146+
ops := []Operation{{
147+
Verb: "set",
148+
Class: ClassIdempotent,
149+
Descriptor: `set state.json $.status "done"`,
150+
Value: `"done"`,
151+
}}
152+
153+
got := buildCommitMessage(GlobalOptions{
154+
SubjectPrefix: "feat: ",
155+
SubjectSuffix: " [skip ci]",
156+
}, ops)
157+
want := `feat: etch set state.json $.status "done" [skip ci]`
158+
if got != want {
159+
t.Fatalf("message = %q, want %q", got, want)
160+
}
161+
162+
got = buildCommitMessage(GlobalOptions{
163+
BodySuffix: "Refs: #1",
164+
}, ops)
165+
want = `etch set state.json $.status "done"` + "\n\nRefs: #1"
166+
if got != want {
167+
t.Fatalf("message = %q, want %q", got, want)
168+
}
169+
}
170+
171+
func TestBuildCommitMessageJoinsBodyModifiersWithGeneratedBody(t *testing.T) {
172+
ops := []Operation{
173+
{
174+
Verb: "set",
175+
Class: ClassIdempotent,
176+
Target: PlanTarget{Path: "state.json"},
177+
Descriptor: `set state.json $.status "done"`,
178+
Value: `"done"`,
179+
},
180+
{
181+
Verb: "delete",
182+
Class: ClassIdempotent,
183+
Target: PlanTarget{Path: "state.json"},
184+
Descriptor: `delete state.json $.old`,
185+
},
186+
}
187+
188+
got := buildCommitMessage(GlobalOptions{
189+
SubjectPrefix: "feat: ",
190+
BodyPrefix: "Context: generated",
191+
BodySuffix: "\n\nRefs: #1",
192+
}, ops)
193+
want := strings.Join([]string{
194+
"feat: etch: 2 changes in state.json",
195+
"",
196+
"Context: generated",
197+
"",
198+
"Changes:",
199+
`- set state.json $.status "done"`,
200+
"- delete state.json $.old",
201+
"",
202+
"Refs: #1",
203+
}, "\n")
204+
if got != want {
205+
t.Fatalf("message = %q, want %q", got, want)
206+
}
207+
}
208+
209+
func TestBuildCommitMessageSubjectModifiersAffectValueFallback(t *testing.T) {
210+
ops := []Operation{{
211+
Verb: "set",
212+
Class: ClassIdempotent,
213+
Descriptor: `set state.json $.status "1234567890123456789012345678901234567890"`,
214+
Value: `"1234567890123456789012345678901234567890"`,
215+
}}
216+
217+
got := buildCommitMessage(GlobalOptions{SubjectPrefix: "feat: "}, ops)
218+
want := strings.Join([]string{
219+
"feat: etch set state.json $.status",
220+
"",
221+
`Value: "1234567890123456789012345678901234567890"`,
222+
}, "\n")
223+
if got != want {
224+
t.Fatalf("message = %q, want %q", got, want)
225+
}
226+
}
227+
145228
func filepathJoin(elem ...string) string {
146229
return filepath.Join(elem...)
147230
}

internal/etch/types.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,10 @@ type GlobalOptions struct {
4949
NoCheckout bool
5050
Untracked bool
5151
Message string
52-
MessagePrefix string
53-
MessageSuffix string
52+
SubjectPrefix string
53+
SubjectSuffix string
54+
BodyPrefix string
55+
BodySuffix string
5456
Retries int
5557
AllowEmpty bool
5658
}
@@ -72,6 +74,9 @@ type Statement struct {
7274
Loc SourceLoc
7375
}
7476

77+
// CommandClass describes a command's content-change behavior within one
78+
// transaction. It is not a promise that re-running the same command after a
79+
// successful commit will still succeed against the new base.
7580
type CommandClass string
7681

7782
const (

0 commit comments

Comments
 (0)