Skip to content

Commit b45f1e8

Browse files
authored
feat: add examples command and machine-readable json output (#60)
* feat: add examples command and machine-readable json output * docs: add migrate examples topic * docs: make examples full-catalog and outcome-focused * feat: improve examples UX and add json help envelopes * docs: add config-aware path examples to examples output * docs: add concrete text and json output samples to examples * docs: include concrete samples for all examples entries * fix: align config parity and expand strategy examples * test: harden e2e string matching across shells * test: relax powershell-variant unknown-command assertion * test: stabilize powershell e2e output assertions * feat: enforce examples and json parity across commands * test: relax shellenv json substring assertion
1 parent d8af8db commit b45f1e8

File tree

15 files changed

+2211
-114
lines changed

15 files changed

+2211
-114
lines changed

README.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,8 +185,36 @@ wt config path # Print the config file path
185185
# Show help
186186
wt --help
187187
wt <command> --help
188+
189+
# Show practical examples
190+
wt examples
191+
192+
# Use machine-readable output
193+
wt --format json version
194+
wt --format json list
195+
wt --format json
196+
```
197+
198+
### JSON Output (`--format json`)
199+
200+
Most commands support machine-readable JSON output:
201+
202+
```bash
203+
wt --format json version
204+
wt --format json info
205+
wt --format json config show
206+
wt --format json list
207+
wt --format json examples
208+
wt --format json # root help in JSON envelope
188209
```
189210

211+
Important behavior for shell integration:
212+
213+
- In `text` mode (default), shell integration may auto-navigate to the target worktree.
214+
- In `json` mode, output is kept machine-readable and shell integration does **not** auto-navigate.
215+
216+
For commands that normally prompt interactively (`wt co`, `wt rm`, `wt pr`, `wt mr`), pass explicit arguments when using `--format json`.
217+
190218
### Interactive Selection
191219

192220
When you run `wt co`, `wt rm`, `wt pr`, or `wt mr` without arguments, you'll get an interactive selection menu:
@@ -248,6 +276,16 @@ wt list
248276

249277
# Remove a worktree when done
250278
wt rm add-auth-feature
279+
280+
# Show full examples catalog (filter with rg/grep if needed)
281+
wt examples
282+
283+
# Each example includes outcome + path illustration based on config pattern
284+
# e.g. path example: $WORKTREE_ROOT/<repo>/<branch> -> (removed)
285+
# and concrete text/json output samples where relevant
286+
287+
# JSON mode does not auto-navigate; use returned navigate_to
288+
wt --format json create add-auth-feature
251289
```
252290

253291
## Configuration

config_test.go

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
package main
22

33
import (
4+
"bytes"
5+
"encoding/json"
6+
"io"
47
"os"
58
"path/filepath"
9+
"regexp"
610
"runtime"
711
"strings"
812
"testing"
@@ -403,3 +407,140 @@ strategy = "global"
403407
}
404408
})
405409
}
410+
411+
func TestConfigShowPatternParityBetweenTextAndJSON_Config(t *testing.T) {
412+
origRoot := worktreeRoot
413+
origStrategy := worktreeStrategy
414+
origPattern := worktreePattern
415+
origSeparator := worktreeSeparator
416+
origConfigFilePath := configFilePath
417+
origConfigFileFound := configFileFound
418+
origConfigSources := configSources
419+
origOutputFormat := outputFormat
420+
421+
t.Cleanup(func() {
422+
worktreeRoot = origRoot
423+
worktreeStrategy = origStrategy
424+
worktreePattern = origPattern
425+
worktreeSeparator = origSeparator
426+
configFilePath = origConfigFilePath
427+
configFileFound = origConfigFileFound
428+
configSources = origConfigSources
429+
outputFormat = origOutputFormat
430+
})
431+
432+
runConfigShow := func(t *testing.T, format string) string {
433+
t.Helper()
434+
435+
origStdout := os.Stdout
436+
r, w, err := os.Pipe()
437+
if err != nil {
438+
t.Fatalf("failed to create pipe: %v", err)
439+
}
440+
os.Stdout = w
441+
defer func() {
442+
os.Stdout = origStdout
443+
}()
444+
445+
outputFormat = format
446+
if err := configShowCmd.RunE(configShowCmd, nil); err != nil {
447+
t.Fatalf("config show failed for format %s: %v", format, err)
448+
}
449+
450+
if err := w.Close(); err != nil {
451+
t.Fatalf("failed to close write pipe: %v", err)
452+
}
453+
454+
var buf bytes.Buffer
455+
if _, err := io.Copy(&buf, r); err != nil {
456+
t.Fatalf("failed to read command output: %v", err)
457+
}
458+
459+
return buf.String()
460+
}
461+
462+
tests := []struct {
463+
name string
464+
strategy string
465+
workPattern string
466+
patternSource string
467+
expected string
468+
}{
469+
{
470+
name: "strategy default pattern",
471+
strategy: "global",
472+
workPattern: "",
473+
patternSource: "strategy default",
474+
expected: "{.worktreeRoot}/{.repo.Name}/{.branch}",
475+
},
476+
{
477+
name: "explicit configured pattern",
478+
strategy: "global",
479+
workPattern: "{.worktreeRoot}/custom/{.branch}",
480+
patternSource: "config file",
481+
expected: "{.worktreeRoot}/custom/{.branch}",
482+
},
483+
{
484+
name: "custom strategy without explicit pattern",
485+
strategy: "custom",
486+
workPattern: "",
487+
patternSource: "default",
488+
expected: "(none)",
489+
},
490+
}
491+
492+
for _, tt := range tests {
493+
t.Run(tt.name, func(t *testing.T) {
494+
worktreeRoot = "/tmp/worktrees"
495+
worktreeStrategy = tt.strategy
496+
worktreePattern = tt.workPattern
497+
worktreeSeparator = "-"
498+
configFilePath = "/tmp/config.toml"
499+
configFileFound = true
500+
configSources = configSource{
501+
Root: "config file",
502+
Strategy: "config file",
503+
Pattern: tt.patternSource,
504+
Separator: "default",
505+
}
506+
507+
textOut := runConfigShow(t, "text")
508+
jsonOut := runConfigShow(t, "json")
509+
510+
textPatternRe := regexp.MustCompile(`(?m)^\s*pattern\s*=\s*(.*?)\s+\(`)
511+
textMatch := textPatternRe.FindStringSubmatch(textOut)
512+
if len(textMatch) != 2 {
513+
t.Fatalf("failed to parse pattern from text output: %q", textOut)
514+
}
515+
textPattern := textMatch[1]
516+
517+
var payload struct {
518+
Data struct {
519+
Effective struct {
520+
Pattern struct {
521+
Value string `json:"value"`
522+
} `json:"pattern"`
523+
} `json:"effective"`
524+
} `json:"data"`
525+
}
526+
if err := json.Unmarshal([]byte(jsonOut), &payload); err != nil {
527+
t.Fatalf("failed to parse json output: %v\noutput=%q", err, jsonOut)
528+
}
529+
530+
if payload.Data.Effective.Pattern.Value != textPattern {
531+
t.Fatalf("pattern mismatch between text and json: text=%q json=%q", textPattern, payload.Data.Effective.Pattern.Value)
532+
}
533+
534+
expectedPattern := configShowPatternValue()
535+
if expectedPattern != tt.expected {
536+
t.Fatalf("resolved test expectation mismatch: got=%q want=%q", expectedPattern, tt.expected)
537+
}
538+
if textPattern != expectedPattern {
539+
t.Fatalf("text output pattern should use resolved value: got=%q want=%q", textPattern, expectedPattern)
540+
}
541+
if payload.Data.Effective.Pattern.Value != expectedPattern {
542+
t.Fatalf("json output pattern should use resolved value: got=%q want=%q", payload.Data.Effective.Pattern.Value, expectedPattern)
543+
}
544+
})
545+
}
546+
}

e2e/run.go

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -449,12 +449,14 @@ func generatePosixScript(wtBinary, shell string, scenario Scenario, verbose, sho
449449
step.Expect.Branch, step.Expect.Branch))
450450
}
451451
if step.Expect.OutputContains != "" {
452-
sb.WriteString(fmt.Sprintf("echo \"$__output\" | grep -q '%s' || { echo \"Output missing '%s'\"; exit 1; }\n",
453-
step.Expect.OutputContains, step.Expect.OutputContains))
452+
contains := strings.ReplaceAll(step.Expect.OutputContains, "'", "'\\''")
453+
sb.WriteString(fmt.Sprintf("echo \"$__output\" | grep -F -q -- '%s' || { echo \"Output missing expected substring\"; exit 1; }\n",
454+
contains))
454455
}
455456
if step.Expect.OutputNotContains != "" {
456-
sb.WriteString(fmt.Sprintf("echo \"$__output\" | grep -q '%s' && { echo \"Output should not contain '%s'\"; exit 1; } || true\n",
457-
step.Expect.OutputNotContains, step.Expect.OutputNotContains))
457+
notContains := strings.ReplaceAll(step.Expect.OutputNotContains, "'", "'\\''")
458+
sb.WriteString(fmt.Sprintf("echo \"$__output\" | grep -F -q -- '%s' && { echo \"Output should not contain expected substring\"; exit 1; } || true\n",
459+
notContains))
458460
}
459461
}
460462
}
@@ -488,7 +490,11 @@ func generatePowerShellScript(wtBinary string, scenario Scenario, verbose, showO
488490
// Header
489491
sb.WriteString("$ErrorActionPreference = 'Stop'\n")
490492
sb.WriteString(fmt.Sprintf("$env:WT_BIN = '%s'\n", wtBinary))
491-
sb.WriteString("$TestDir = Join-Path $env:TEMP \"wt-e2e-$(Get-Random)\"\n")
493+
sb.WriteString("$__tmpBase = [System.IO.Path]::GetTempPath()\n")
494+
sb.WriteString("if (-not $__tmpBase) { $__tmpBase = $env:TEMP }\n")
495+
sb.WriteString("if (-not $__tmpBase) { $__tmpBase = $env:TMP }\n")
496+
sb.WriteString("if (-not $__tmpBase) { $__tmpBase = $PWD.Path }\n")
497+
sb.WriteString("$TestDir = Join-Path $__tmpBase \"wt-e2e-$(Get-Random)\"\n")
492498
sb.WriteString("$RepoDir = Join-Path $TestDir 'test-repo'\n")
493499
sb.WriteString("$env:WORKTREE_ROOT = Join-Path $TestDir 'worktrees'\n")
494500
sb.WriteString("New-Item -ItemType Directory -Path $RepoDir -Force | Out-Null\n")
@@ -563,6 +569,10 @@ func generatePowerShellScript(wtBinary string, scenario Scenario, verbose, showO
563569
needsOutput := step.Expect != nil && (step.Expect.OutputContains != "" || step.Expect.OutputNotContains != "")
564570
expectsNonZero := step.Expect != nil && step.Expect.ExitCode != nil && *step.Expect.ExitCode != 0
565571

572+
if needsOutput {
573+
sb.WriteString("$__output = ''\n")
574+
}
575+
566576
if expectsNonZero {
567577
// Handle expected non-zero exit codes
568578
sb.WriteString("$__exit_code = 0\n")
@@ -574,6 +584,9 @@ func generatePowerShellScript(wtBinary string, scenario Scenario, verbose, showO
574584
}
575585
sb.WriteString(" $__exit_code = $LASTEXITCODE\n")
576586
sb.WriteString("} catch {\n")
587+
if needsOutput {
588+
sb.WriteString(" $__output = ($_ | Out-String)\n")
589+
}
577590
sb.WriteString(" $__exit_code = 1\n")
578591
sb.WriteString("}\n")
579592
} else if needsOutput {
@@ -604,12 +617,16 @@ func generatePowerShellScript(wtBinary string, scenario Scenario, verbose, showO
604617
step.Expect.Branch, step.Expect.Branch))
605618
}
606619
if step.Expect.OutputContains != "" {
607-
sb.WriteString(fmt.Sprintf("if (-not $__output.Contains('%s')) { throw \"Output missing '%s'\" }\n",
608-
step.Expect.OutputContains, step.Expect.OutputContains))
620+
contains := strings.ReplaceAll(step.Expect.OutputContains, "'", "''")
621+
sb.WriteString("if ($null -eq $__output) { $__output = '' }\n")
622+
sb.WriteString(fmt.Sprintf("if (-not $__output.Contains('%s')) { throw \"Output missing expected substring\" }\n",
623+
contains))
609624
}
610625
if step.Expect.OutputNotContains != "" {
611-
sb.WriteString(fmt.Sprintf("if ($__output.Contains('%s')) { throw \"Output should not contain '%s'\" }\n",
612-
step.Expect.OutputNotContains, step.Expect.OutputNotContains))
626+
notContains := strings.ReplaceAll(step.Expect.OutputNotContains, "'", "''")
627+
sb.WriteString("if ($null -eq $__output) { $__output = '' }\n")
628+
sb.WriteString(fmt.Sprintf("if ($__output.Contains('%s')) { throw \"Output should not contain expected substring\" }\n",
629+
notContains))
613630
}
614631
}
615632
}

0 commit comments

Comments
 (0)