Skip to content

Commit 7112919

Browse files
authored
Fix column alignment in list output and unblock pre-push hook (#96)
* test: add column alignment test for `stave -l` Verifies that USAGE and SYNOPSIS headers land at the same horizontal position across Local, Namespace, and Import groups. Also checks that data row synopsis text starts at the expected column. This test fails against the current implementation because each group computes its own column widths independently. * fix: align columns across groups in `stave -l` Each writeTable call was computing maxUsage from only its own group's items, so groups with shorter target names got narrower columns and the USAGE/SYNOPSIS headers landed at different horizontal positions. Pre-compute a single global maxUsage across all sections after groupTargets runs, then pass it into each writeTable call. This also removes the dead maxSyn variable that was computed but never read. * fix: isolate git hook tests from user global config testGitInit in both internal/hooks and pkg/stave now sets GIT_CONFIG_GLOBAL and GIT_CONFIG_SYSTEM to /dev/null via cmd.Env and writes a local core.hooksPath override so production code under test doesn't inherit the user's global hooks path. Also adds .worktrees/ to .gitignore. Closes #90 * chore: add CHANGELOG entry for v0.13.3
1 parent ad33230 commit 7112919

File tree

6 files changed

+165
-25
lines changed

6 files changed

+165
-25
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,3 +73,6 @@ stave_test_out
7373

7474
# gomdlint
7575
*.gomdlint.bak
76+
77+
# Worktrees
78+
.worktrees/

CHANGELOG.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.13.3] - 2026-03-24
11+
12+
### Fixed
13+
14+
- Align USAGE and SYNOPSIS columns across all groups in `stave -l` output.
15+
- Isolate git hook tests from user global config (`core.hooksPath`). Closes #90.
16+
1017
## [0.13.2] - 2026-03-07
1118

1219
### Changed
@@ -491,7 +498,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
491498

492499
- Parallelized tests where possible, including locking mechanism to prevent parallel tests in same `testdata/(xyz/)` subdir.
493500

494-
[unreleased]: https://github.com/yaklabco/stave/compare/v0.13.2...HEAD
501+
[unreleased]: https://github.com/yaklabco/stave/compare/v0.13.3...HEAD
502+
[0.13.3]: https://github.com/yaklabco/stave/compare/v0.13.2...v0.13.3
495503
[0.13.2]: https://github.com/yaklabco/stave/compare/v0.13.1...v0.13.2
496504
[0.13.1]: https://github.com/yaklabco/stave/compare/v0.13.0...v0.13.1
497505
[0.13.0]: https://github.com/yaklabco/stave/compare/v0.12.0...v0.13.0

internal/hooks/git_test.go

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,31 @@ import (
1010
"github.com/stretchr/testify/require"
1111
)
1212

13-
// testGitInit initializes a git repository in the given directory.
14-
// It uses --template= to avoid inheriting hooks from user git templates,
15-
// ensuring test isolation regardless of the user's git configuration.
16-
// It also creates the hooks directory since --template= skips creating it.
13+
// testGitInit initializes an isolated git repository in the given directory.
14+
// It uses --template= to avoid inheriting hooks from user git templates and
15+
// sets GIT_CONFIG_GLOBAL/SYSTEM to /dev/null so user git config (e.g.
16+
// core.hooksPath) doesn't leak into the test repo. It also writes a local
17+
// git config that unsets core.hooksPath for any subsequent git commands that
18+
// run without the env overrides (e.g. in production code under test).
1719
func testGitInit(t *testing.T, dir string) {
1820
t.Helper()
21+
1922
cmd := exec.Command("git", "init", "--template=")
2023
cmd.Dir = dir
24+
cmd.Env = append(os.Environ(),
25+
"GIT_CONFIG_GLOBAL="+os.DevNull,
26+
"GIT_CONFIG_SYSTEM="+os.DevNull,
27+
)
2128
if err := cmd.Run(); err != nil {
2229
t.Fatalf("git init failed: %v", err)
2330
}
31+
32+
// Ensure core.hooksPath is not inherited from global config for
33+
// subsequent git commands run by the code under test.
34+
unset := exec.Command("git", "config", "--local", "core.hooksPath", "")
35+
unset.Dir = dir
36+
_ = unset.Run() //nolint:errcheck // non-zero exit if key absent is fine
37+
2438
// Create hooks directory since --template= skips it
2539
hooksDir := filepath.Join(dir, ".git", "hooks")
2640
if err := os.MkdirAll(hooksDir, 0o755); err != nil {

pkg/stave/hooks_cmd_test.go

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,17 +28,31 @@ hooks:
2828
- target: fmt
2929
`
3030

31-
// testGitInit initializes a git repository in the given directory.
32-
// It uses --template= to avoid inheriting hooks from user git templates,
33-
// ensuring test isolation regardless of the user's git configuration.
34-
// It also creates the hooks directory since --template= skips creating it.
31+
// testGitInit initializes an isolated git repository in the given directory.
32+
// It uses --template= to avoid inheriting hooks from user git templates and
33+
// sets GIT_CONFIG_GLOBAL/SYSTEM to /dev/null so user git config (e.g.
34+
// core.hooksPath) doesn't leak into the test repo. It also writes a local
35+
// git config that unsets core.hooksPath for any subsequent git commands that
36+
// run without the env overrides (e.g. in production code under test).
3537
func testGitInit(t *testing.T, dir string) {
3638
t.Helper()
39+
3740
cmd := exec.Command("git", "init", "--template=")
3841
cmd.Dir = dir
42+
cmd.Env = append(os.Environ(),
43+
"GIT_CONFIG_GLOBAL="+os.DevNull,
44+
"GIT_CONFIG_SYSTEM="+os.DevNull,
45+
)
3946
if err := cmd.Run(); err != nil {
4047
t.Fatalf("git init failed: %v", err)
4148
}
49+
50+
// Ensure core.hooksPath is not inherited from global config for
51+
// subsequent git commands run by the code under test.
52+
unset := exec.Command("git", "config", "--local", "core.hooksPath", "")
53+
unset.Dir = dir
54+
_ = unset.Run() //nolint:errcheck // non-zero exit if key absent is fine
55+
4256
// Create hooks directory since --template= skips it
4357
hooksDir := filepath.Join(dir, ".git", "hooks")
4458
if err := os.MkdirAll(hooksDir, 0o755); err != nil {

pkg/stave/list.go

Lines changed: 28 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -151,14 +151,16 @@ func renderTargetList(out io.Writer, info *parse.PkgInfo, filters []string) erro
151151
_, _ = fmt.Fprintln(out, titleStyle.Render("Targets:"))
152152

153153
sections := groupTargets(items)
154+
maxUsage := globalUsageWidth(sections)
155+
154156
writeSection := func(title string, groups []targetGroup) {
155157
if len(groups) == 0 {
156158
return
157159
}
158160
_, _ = fmt.Fprintln(out)
159161
_, _ = fmt.Fprintln(out, sectionStyle.Render(title))
160162
for _, g := range groups {
161-
writeTable(out, tableHeaderStyle, subsectionStyle, g, renderName, indent)
163+
writeTable(out, tableHeaderStyle, subsectionStyle, g, renderName, indent, maxUsage)
162164
}
163165
}
164166

@@ -284,6 +286,30 @@ func usageFor(binaryName, display string, args []parse.Arg) string {
284286
return sb.String()
285287
}
286288

289+
func usageWidthFor(name string, args []parse.Arg, isWatch bool) int {
290+
usage := usageFor("", name, args)
291+
if isWatch {
292+
usage += " [W]"
293+
}
294+
return lipgloss.Width(usage)
295+
}
296+
297+
func globalUsageWidth(sections targetSections) int {
298+
maxWidth := lipgloss.Width("USAGE")
299+
for _, groups := range [][]targetGroup{sections.local, sections.namespaces, sections.imports} {
300+
for _, g := range groups {
301+
for _, it := range g.items {
302+
name := it.displayName
303+
if len(it.aliases) > 0 {
304+
name = fmt.Sprintf("%s (%s)", name, strings.Join(it.aliases, ", "))
305+
}
306+
maxWidth = max(maxWidth, usageWidthFor(name, it.args, it.isWatch))
307+
}
308+
}
309+
}
310+
return maxWidth
311+
}
312+
287313
func applyTargetFilters(items []targetItem, filters []string) []targetItem {
288314
if len(filters) == 0 {
289315
return items
@@ -407,6 +433,7 @@ func writeTable(
407433
group targetGroup,
408434
renderName func(name string, isDefault, isWatch bool, args []parse.Arg) string,
409435
indent string,
436+
maxUsage int,
410437
) {
411438
if len(group.items) == 0 {
412439
return
@@ -453,21 +480,6 @@ func writeTable(
453480
})
454481
}
455482

456-
// Column widths (ANSI-aware via lipgloss.Width).
457-
maxUsage, maxSyn := 0, 0
458-
for i, theRow := range rows {
459-
usage := theRow.name
460-
if i > 0 {
461-
// For data rows, use the non-colored usage string to calculate width
462-
usage = usageFor("", theRow.name, theRow.args)
463-
if theRow.isWatch {
464-
usage += " [W]"
465-
}
466-
}
467-
maxUsage = max(maxUsage, lipgloss.Width(usage))
468-
maxSyn = max(maxSyn, lipgloss.Width(theRow.synopsis))
469-
}
470-
471483
pad := func(text string, width int) string {
472484
if width <= 0 {
473485
return text

pkg/stave/list_test.go

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,102 @@ package stave
22

33
import (
44
"bytes"
5+
"strings"
56
"testing"
67

78
"github.com/stretchr/testify/assert"
89
"github.com/stretchr/testify/require"
910
"github.com/yaklabco/stave/internal/parse"
1011
)
1112

13+
func TestRenderTargetList_ColumnsAlignAcrossGroups(t *testing.T) {
14+
t.Setenv("NO_COLOR", "1")
15+
16+
info := &parse.PkgInfo{
17+
PkgName: "main",
18+
Funcs: []*parse.Function{
19+
// Short local target.
20+
{Name: "Build", Synopsis: "Compile the project"},
21+
// Namespace with a long target name + args → wider USAGE column.
22+
{
23+
Name: "VeryLongTargetName",
24+
Receiver: "LongNamespace",
25+
Synopsis: "Does something elaborate",
26+
Args: []parse.Arg{{Name: "file"}, {Name: "output"}},
27+
},
28+
},
29+
Imports: []*parse.Import{
30+
{
31+
Name: "ext",
32+
Path: "example.com/ext",
33+
Info: parse.PkgInfo{
34+
PkgName: "ext",
35+
Funcs: []*parse.Function{
36+
{Name: "Run", Synopsis: "Run the external tool"},
37+
},
38+
},
39+
},
40+
},
41+
}
42+
43+
var buf bytes.Buffer
44+
err := renderTargetList(&buf, info, nil)
45+
require.NoError(t, err)
46+
47+
output := buf.String()
48+
lines := strings.Split(output, "\n")
49+
50+
// Collect horizontal positions of "USAGE" and "SYNOPSIS" headers.
51+
var usagePositions, synPositions []int
52+
for _, line := range lines {
53+
if idx := strings.Index(line, "USAGE"); idx >= 0 {
54+
usagePositions = append(usagePositions, idx)
55+
}
56+
if idx := strings.Index(line, "SYNOPSIS"); idx >= 0 {
57+
synPositions = append(synPositions, idx)
58+
}
59+
}
60+
61+
require.GreaterOrEqual(t, len(usagePositions), 3, "expected USAGE header in at least 3 groups, got %d", len(usagePositions))
62+
for i := 1; i < len(usagePositions); i++ {
63+
assert.Equal(t, usagePositions[0], usagePositions[i],
64+
"USAGE column misaligned: group 0 at %d, group %d at %d", usagePositions[0], i, usagePositions[i])
65+
}
66+
67+
require.GreaterOrEqual(t, len(synPositions), 3, "expected SYNOPSIS header in at least 3 groups, got %d", len(synPositions))
68+
for i := 1; i < len(synPositions); i++ {
69+
assert.Equal(t, synPositions[0], synPositions[i],
70+
"SYNOPSIS column misaligned: group 0 at %d, group %d at %d", synPositions[0], i, synPositions[i])
71+
}
72+
73+
// Verify data rows also align: every non-blank, non-header content line should
74+
// have its synopsis text starting at the same column as the SYNOPSIS header.
75+
synCol := synPositions[0]
76+
for _, line := range lines {
77+
if strings.TrimSpace(line) == "" {
78+
continue
79+
}
80+
// Skip header lines, section titles, and short lines that don't reach the synopsis column.
81+
if strings.Contains(line, "SYNOPSIS") || strings.Contains(line, "USAGE") {
82+
continue
83+
}
84+
if len(line) <= synCol {
85+
continue
86+
}
87+
// Data rows have synopsis text; check that the character at synCol is non-space
88+
// (i.e. the synopsis starts there, not shifted left or right). Skip continuation
89+
// lines (indented wrapped text) which start with spaces at synCol.
90+
if strings.HasPrefix(strings.TrimLeft(line, " "), "stave ") ||
91+
strings.HasPrefix(strings.TrimLeft(line, " "), "build") ||
92+
strings.HasPrefix(strings.TrimLeft(line, " "), "ext:") ||
93+
strings.HasPrefix(strings.TrimLeft(line, " "), "longNamespace:") {
94+
// This is a data row — the synopsis column should have non-space content.
95+
assert.NotEqual(t, ' ', rune(line[synCol]),
96+
"data row synopsis not aligned at column %d: %q", synCol, line)
97+
}
98+
}
99+
}
100+
12101
func TestRenderTargetList_Watch(t *testing.T) {
13102
info := &parse.PkgInfo{
14103
PkgName: "main",

0 commit comments

Comments
 (0)