Skip to content

Commit 3e8bf85

Browse files
committed
fix(config): widen heuristic scan to all config texts, stabilize fingerprints
Two review findings on the template-derived fetch fallback: The heuristic scanned only the unanalyzable segment's own templates and options, but unanalyzability can be caused by a text OUTSIDE the segment - another segment laundering a cross-reference through a variable, or a global template. In that shape the field name that defeated the analysis was never scanned, the probe stayed off, and with the fetch options gone there was no user-side fix. ResolveFieldSets now assembles the whole-config text corpus (every segment's templates and templated options plus the global nil-context strings) once and stamps it on each unanalyzable segment; the fallback scan covers it all. The stamp gained a field, so fieldSetAnalysisVersion bumps. templatedOptionValues iterated option maps in Go's randomized order, and the collected sources feed the unanalyzable cache-key fingerprint: any segment with two or more templated option values got a fresh key per process, so its snapshots never hit and stale entries piled up. Collection now sorts map keys recursively, making the delivered order - and the fingerprint - stable across parses. Also strips the removed fetch_* keys from the src/test fixtures and updates the segment-docs skill's worked example off the deleted FetchStatus const. Acknowledged default changes now stated in the git segment docs' migration note: the default config's statusline derives the upstream icon its old explicit fetch_upstream_icon: false suppressed; the svn, mercurial and jujutsu default templates fetch status by default; and jujutsu's default rendering requires the jj binary. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M4RA9ZrP7vuqVSR7nddNiQ
1 parent 9dc5e8b commit 3e8bf85

7 files changed

Lines changed: 194 additions & 37 deletions

File tree

.agents/skills/segment-docs/SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ Options are declared as `options.Option` string constants in the segment's `cons
3333
```go
3434
const (
3535
BranchIcon options.Option = "branch_icon" // option name used in config
36-
FetchStatus options.Option = "fetch_status"
36+
NativeStatus options.Option = "native_status"
3737
)
3838
```
3939

src/config/fieldset.go

Lines changed: 91 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package config
22

33
import (
4+
"fmt"
5+
"maps"
46
"slices"
57
"strings"
68

@@ -20,27 +22,40 @@ type FieldSetConsumer interface {
2022
}
2123

2224
// refSet assembles the delivery for FieldSetConsumer writers from the
23-
// stamped analysis. The heuristic sources are only materialized for an
24-
// unanalyzable set - they are recomputed from the segment's own (persisted)
25-
// config fields rather than stamped, so a session-cache round trip cannot
26-
// desynchronize them from the config content.
25+
// stamped analysis. The heuristic sources only exist for an unanalyzable
26+
// set: the whole-config corpus ResolveFieldSets stamped (unanalyzability can
27+
// originate outside the segment - a cross-segment reference laundered
28+
// through a variable, a global template - so the scan must cover every text
29+
// that can reference this segment, not just its own), or the segment's own
30+
// texts when the config was never analyzed (library callers).
2731
func (segment *Segment) refSet() template.RefSet {
2832
refs := template.RefSet{
2933
Fields: segment.ReferencedFields,
3034
Analyzable: segment.FieldsAnalyzable,
3135
}
3236

3337
if !segment.FieldsAnalyzable {
34-
refs.Sources = segment.heuristicSources()
38+
refs.Sources = segment.fallbackSources()
3539
}
3640

3741
return refs
3842
}
3943

40-
// heuristicSources returns every raw text the fallback heuristic may scan:
41-
// the template sources (with the writer default substituted for an empty
42-
// template) plus any templated option values.
43-
func (segment *Segment) heuristicSources() []string {
44+
// fallbackSources returns the raw texts the substring heuristic scans for an
45+
// unanalyzable segment: the stamped whole-config corpus when the config went
46+
// through ResolveFieldSets, the segment's own texts otherwise.
47+
func (segment *Segment) fallbackSources() []string {
48+
if segment.HeuristicSources != nil {
49+
return segment.HeuristicSources
50+
}
51+
52+
return segment.ownSources()
53+
}
54+
55+
// ownSources returns every raw text of this segment itself: the template
56+
// sources (with the writer default substituted for an empty template) plus
57+
// any templated option values.
58+
func (segment *Segment) ownSources() []string {
4459
sources := segment.templateSources()
4560
sources[0] = segment.analysisTemplate()
4661

@@ -57,7 +72,7 @@ var analyzeFields = template.AnalyzeFields
5772
// stamp shape - so a config stamped by another binary generation is treated
5873
// as unstamped (see Get) and a long-lived session (tmux) re-analyzes once
5974
// after a binary upgrade instead of trusting stale stamps.
60-
const fieldSetAnalysisVersion = 2
75+
const fieldSetAnalysisVersion = 3
6176

6277
// ResolveFieldSets analyzes every template in the config and stamps each
6378
// renderable segment with the set of top-level context fields those templates
@@ -90,8 +105,19 @@ func (cfg *Config) ResolveFieldSets() {
90105
analysis.analyzeSegment(segment)
91106
}
92107

108+
// The corpus for the unanalyzable fallback: every text in the config that
109+
// can reference a segment. Unanalyzability can be caused by a text
110+
// outside the segment (another segment laundering a cross-reference, a
111+
// global template), so scanning only the segment's own texts would miss
112+
// exactly the reference that defeated the analysis. Assembled once and
113+
// shared by every unanalyzable segment.
114+
corpus := cfg.globalTemplateSources()
93115
for _, segment := range segments {
94-
analysis.stamp(segment)
116+
corpus = append(corpus, segment.ownSources()...)
117+
}
118+
119+
for _, segment := range segments {
120+
analysis.stamp(segment, corpus)
95121
}
96122
}
97123

@@ -173,28 +199,56 @@ func (segment *Segment) templateSources() []string {
173199
// templatedOptionValues collects the segment's option values (nested ones
174200
// included) that carry template syntax. These render through
175201
// options.Map.Template against contexts this analysis cannot model per
176-
// option, so analyzeSegment treats any hit conservatively.
202+
// option, so analyzeSegment treats any hit conservatively. Maps iterate
203+
// with their keys sorted, recursively: the collected order feeds the
204+
// segment cache key fingerprint, which must be identical across processes -
205+
// Go's randomized map order would otherwise produce a fresh key per prompt
206+
// and the cache would never hit.
177207
func (segment *Segment) templatedOptionValues() []string {
178208
var sources []string
179209

180210
var collect func(value any)
211+
212+
collectMap := func(m map[string]any) {
213+
for _, key := range slices.Sorted(maps.Keys(m)) {
214+
collect(m[key])
215+
}
216+
}
217+
181218
collect = func(value any) {
182219
switch v := value.(type) {
183220
case string:
184221
if strings.Contains(v, "{{") {
185222
sources = append(sources, v)
186223
}
187224
case map[string]any:
188-
for _, nested := range v {
189-
collect(nested)
190-
}
225+
collectMap(v)
191226
case map[any]any:
192-
for _, nested := range v {
193-
collect(nested)
227+
type pair struct {
228+
value any
229+
key string
230+
}
231+
232+
pairs := make([]pair, 0, len(v))
233+
for key, nested := range v {
234+
pairs = append(pairs, pair{key: fmt.Sprint(key), value: nested})
235+
}
236+
237+
slices.SortFunc(pairs, func(a, b pair) int { return strings.Compare(a.key, b.key) })
238+
239+
for _, entry := range pairs {
240+
collect(entry.value)
194241
}
195242
case options.Map:
196-
for _, nested := range v {
197-
collect(nested)
243+
keys := make([]string, 0, len(v))
244+
for key := range v {
245+
keys = append(keys, string(key))
246+
}
247+
248+
slices.Sort(keys)
249+
250+
for _, key := range keys {
251+
collect(v[options.Option(key)])
198252
}
199253
case []any:
200254
for _, nested := range v {
@@ -207,8 +261,15 @@ func (segment *Segment) templatedOptionValues() []string {
207261
}
208262
}
209263

210-
for _, value := range segment.Options {
211-
collect(value)
264+
keys := make([]string, 0, len(segment.Options))
265+
for key := range segment.Options {
266+
keys = append(keys, string(key))
267+
}
268+
269+
slices.Sort(keys)
270+
271+
for _, key := range keys {
272+
collect(segment.Options[options.Option(key)])
212273
}
213274

214275
return sources
@@ -321,8 +382,10 @@ func (a *fieldAnalysis) mergeCross(refs *template.Refs) {
321382
// stamp fixes the analysis outcome onto the segment: the sorted union of its
322383
// own references and the cross-references to its data key, plus whether that
323384
// union is trustworthy. Cross-references resolve by Name(), the key
324-
// AddSegmentData stores segment data under.
325-
func (a *fieldAnalysis) stamp(segment *Segment) {
385+
// AddSegmentData stores segment data under. corpus is the whole-config text
386+
// collection an unanalyzable segment's fallback heuristic scans; nil stays
387+
// stamped for analyzable segments, whose exact field set needs no fallback.
388+
func (a *fieldAnalysis) stamp(segment *Segment, corpus []string) {
326389
name := segment.Name()
327390

328391
set := a.own[segment]
@@ -339,4 +402,9 @@ func (a *fieldAnalysis) stamp(segment *Segment) {
339402

340403
segment.ReferencedFields = fields
341404
segment.FieldsAnalyzable = !a.opaque && !a.ownOpaque[segment] && !a.crossOpaque[name]
405+
segment.HeuristicSources = nil
406+
407+
if !segment.FieldsAnalyzable {
408+
segment.HeuristicSources = corpus
409+
}
342410
}

src/config/fieldset_test.go

Lines changed: 86 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -252,8 +252,10 @@ func TestGitFetchDerivedFromTemplates(t *testing.T) {
252252

253253
cases := []struct {
254254
Options options.Map
255+
Extra *Segment
255256
Case string
256257
Template string
258+
ConsoleTitle string
257259
ExpectStatus bool
258260
}{
259261
{
@@ -282,6 +284,36 @@ func TestGitFetchDerivedFromTemplates(t *testing.T) {
282284
Options: options.Map{"custom": "{{ .Env.POSH_UNUSED }}"},
283285
ExpectStatus: true,
284286
},
287+
{
288+
// the reviewer scenario: another segment launders the git
289+
// reference through a variable, defeating exact analysis for
290+
// git - the heuristic must scan that OTHER segment's text too,
291+
// or the laundered .Working reference renders permanent zeros
292+
Case: "cross-segment laundering triggers the status probe",
293+
Template: "{{ .HEAD }}",
294+
Extra: &Segment{
295+
Type: TEXT,
296+
Template: "{{ $g := .Segments.Git }}{{ $g.Working.String }}",
297+
},
298+
ExpectStatus: true,
299+
},
300+
{
301+
// same shape from a global nil-context text
302+
Case: "console title laundering triggers the status probe",
303+
Template: "{{ .HEAD }}",
304+
ConsoleTitle: "{{ $g := .Segments.Git }}{{ $g.Working.String }}",
305+
ExpectStatus: true,
306+
},
307+
{
308+
// laundering elsewhere must not force units nobody names
309+
Case: "cross-segment laundering without status mentions skips the probe",
310+
Template: "{{ .HEAD }}",
311+
Extra: &Segment{
312+
Type: TEXT,
313+
Template: "{{ $g := .Segments.Git }}{{ $g.RepoName }}",
314+
},
315+
ExpectStatus: false,
316+
},
285317
}
286318

287319
for _, tc := range cases {
@@ -303,10 +335,16 @@ func TestGitFetchDerivedFromTemplates(t *testing.T) {
303335
env.On("HasFilesInDir", testify_.Anything, testify_.Anything).Return(false)
304336
env.MockGitCommand(repoRoot, "", statusArgs...)
305337

338+
segments := []*Segment{
339+
{Type: GIT, Template: tc.Template, Options: tc.Options},
340+
}
341+
if tc.Extra != nil {
342+
segments = append(segments, tc.Extra)
343+
}
344+
306345
cfg := &Config{
307-
Blocks: []*Block{{Segments: []*Segment{
308-
{Type: GIT, Template: tc.Template, Options: tc.Options},
309-
}}},
346+
Blocks: []*Block{{Segments: segments}},
347+
ConsoleTitleTemplate: tc.ConsoleTitle,
310348
}
311349

312350
cfg.ResolveFieldSets()
@@ -448,3 +486,48 @@ func TestGetRefreshesOutdatedStampVersion(t *testing.T) {
448486
assert.Equal(t, fieldSetAnalysisVersion, cfg.FieldSetsVersion)
449487
assert.Zero(t, *count, "the refreshed entry must restore with current stamps")
450488
}
489+
490+
// TestFieldSetFingerprintDeterministic pins the segment cache key against
491+
// Go's randomized map iteration: an unanalyzable segment's fingerprint
492+
// hashes its heuristic sources, which include templated option values
493+
// collected from (nested) option maps - without sorted iteration the key
494+
// would change per process and the segment cache would never hit.
495+
func TestFieldSetFingerprintDeterministic(t *testing.T) {
496+
build := func() *Segment {
497+
// fresh maps per call, as an independently parsed config would have
498+
return &Segment{
499+
Type: GIT,
500+
Template: "{{ .HEAD }}",
501+
Options: options.Map{
502+
"cab": "{{ .Env.C }}",
503+
"abc": "{{ .Env.A }}",
504+
"bca": "{{ .Env.B }}",
505+
"nested": map[string]any{
506+
"zed": "{{ .Env.Z }}",
507+
"alpha": "{{ .Env.AA }}",
508+
"mixed": map[any]any{
509+
"two": "{{ .Env.TWO }}",
510+
"one": "{{ .Env.ONE }}",
511+
},
512+
},
513+
},
514+
}
515+
}
516+
517+
reference := build()
518+
519+
cfg := &Config{Blocks: []*Block{{Segments: []*Segment{reference}}}}
520+
cfg.ResolveFieldSets()
521+
require.False(t, reference.FieldsAnalyzable, "templated options must make the segment unanalyzable")
522+
523+
expected := reference.fieldSetFingerprint()
524+
525+
for range 32 {
526+
segment := build()
527+
528+
fresh := &Config{Blocks: []*Block{{Segments: []*Segment{segment}}}}
529+
fresh.ResolveFieldSets()
530+
531+
assert.Equal(t, expected, segment.fieldSetFingerprint(), "fingerprint must be stable across processes/parses")
532+
}
533+
}

src/config/segment.go

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,14 @@ type Segment struct {
8787
// true; see FieldSetConsumer. Exported (but kept out of every config
8888
// format, like Needs) so the session cache's gob round trip preserves
8989
// the analysis instead of forcing a re-run on every render.
90-
ReferencedFields []string `json:"-" toml:"-" yaml:"-"`
90+
ReferencedFields []string `json:"-" toml:"-" yaml:"-"`
91+
// HeuristicSources is the whole-config text corpus the fallback
92+
// heuristic scans when FieldsAnalyzable is false - stamped (and
93+
// gob-persisted) because the reference that defeated the analysis can
94+
// live outside this segment, in texts the segment cannot reconstruct
95+
// from its own fields. Nil for analyzable segments and for configs that
96+
// never went through ResolveFieldSets.
97+
HeuristicSources []string `json:"-" toml:"-" yaml:"-"`
9198
Index int `json:"index,omitempty" toml:"index,omitempty" yaml:"index,omitempty"`
9299
MinWidth int `json:"min_width,omitempty" toml:"min_width,omitempty" yaml:"min_width,omitempty"`
93100
Duration time.Duration `json:"-" toml:"-" yaml:"-"`
@@ -851,7 +858,7 @@ func (segment *Segment) fieldSetFingerprint() string {
851858
}
852859

853860
if !segment.FieldsAnalyzable {
854-
for _, source := range segment.heuristicSources() {
861+
for _, source := range segment.fallbackSources() {
855862
_, _ = h.Write([]byte(source))
856863
_, _ = h.Write([]byte{0})
857864
}

src/test/jandedobbeleer-palette.omp.json

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,6 @@
3737
"foreground": "p:git-foreground",
3838
"leading_diamond": "\ue0b6",
3939
"powerline_symbol": "\ue0b0",
40-
"options": {
41-
"fetch_status": true,
42-
"fetch_upstream_icon": true
43-
},
4440
"style": "powerline",
4541
"template": " {{ .UpstreamIcon }}{{ .HEAD }}{{if .BranchStatus }} {{ .BranchStatus }}{{ end }}{{ if .Working.Changed }} \uf044 {{ .Working.String }}{{ end }}{{ if and (.Working.Changed) (.Staging.Changed) }} |{{ end }}{{ if .Staging.Changed }} \uf046 {{ .Staging.String }}{{ end }}{{ if gt .StashCount 0 }} \ueb4b {{ .StashCount }}{{ end }} ",
4642
"trailing_diamond": "\ue0b4",

src/test/jandedobbeleer.omp.json

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,6 @@
3737
"foreground": "#193549",
3838
"leading_diamond": "\ue0b6",
3939
"powerline_symbol": "\ue0b0",
40-
"options": {
41-
"fetch_status": true,
42-
"fetch_upstream_icon": true
43-
},
4440
"style": "powerline",
4541
"template": " {{ .UpstreamIcon }}{{ .HEAD }}{{if .BranchStatus }} {{ .BranchStatus }}{{ end }}{{ if .Working.Changed }} \uf044 {{ .Working.String }}{{ end }}{{ if and (.Working.Changed) (.Staging.Changed) }} |{{ end }}{{ if .Staging.Changed }} \uf046 {{ .Staging.String }}{{ end }}{{ if gt .StashCount 0 }} \ueb4b {{ .StashCount }}{{ end }} ",
4642
"trailing_diamond": "\ue0b4",

website/docs/segments/scm/git.mdx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,13 @@ is a template that prints its whole context (`{{ . }}`) without naming any field
6060
The `fetch_status`, `fetch_push_status`, `fetch_upstream_icon`, `fetch_bare_info` and `fetch_user`
6161
options no longer exist: fetching is driven entirely by what your templates reference. To stop fetching
6262
something, stop rendering it. Unknown `fetch_*` keys in an existing config are ignored silently.
63+
64+
Defaults that changed with this, because the default templates reference the matching fields:
65+
66+
- the default config's statusline segment now fetches the upstream icon its explicit
67+
`fetch_upstream_icon: false` used to suppress
68+
- the svn, mercurial and jujutsu segments now fetch their working-copy status by default
69+
- the jujutsu segment's default rendering therefore requires the `jj` binary
6370
:::
6471

6572
| Name | Type | Default | Description |

0 commit comments

Comments
 (0)