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
This commit is contained in:
Claude
2026-08-18 21:00:31 +00:00
parent 9f6894b6ee
commit 6157a41398
7 changed files with 194 additions and 37 deletions
+1 -1
View File
@@ -33,7 +33,7 @@ Options are declared as `options.Option` string constants in the segment's `cons
```go ```go
const ( const (
BranchIcon options.Option = "branch_icon" // option name used in config BranchIcon options.Option = "branch_icon" // option name used in config
FetchStatus options.Option = "fetch_status" NativeStatus options.Option = "native_status"
) )
``` ```
+91 -23
View File
@@ -1,6 +1,8 @@
package config package config
import ( import (
"fmt"
"maps"
"slices" "slices"
"strings" "strings"
@@ -20,10 +22,12 @@ type FieldSetConsumer interface {
} }
// refSet assembles the delivery for FieldSetConsumer writers from the // refSet assembles the delivery for FieldSetConsumer writers from the
// stamped analysis. The heuristic sources are only materialized for an // stamped analysis. The heuristic sources only exist for an unanalyzable
// unanalyzable set - they are recomputed from the segment's own (persisted) // set: the whole-config corpus ResolveFieldSets stamped (unanalyzability can
// config fields rather than stamped, so a session-cache round trip cannot // originate outside the segment - a cross-segment reference laundered
// desynchronize them from the config content. // through a variable, a global template - so the scan must cover every text
// that can reference this segment, not just its own), or the segment's own
// texts when the config was never analyzed (library callers).
func (segment *Segment) refSet() template.RefSet { func (segment *Segment) refSet() template.RefSet {
refs := template.RefSet{ refs := template.RefSet{
Fields: segment.ReferencedFields, Fields: segment.ReferencedFields,
@@ -31,16 +35,27 @@ func (segment *Segment) refSet() template.RefSet {
} }
if !segment.FieldsAnalyzable { if !segment.FieldsAnalyzable {
refs.Sources = segment.heuristicSources() refs.Sources = segment.fallbackSources()
} }
return refs return refs
} }
// heuristicSources returns every raw text the fallback heuristic may scan: // fallbackSources returns the raw texts the substring heuristic scans for an
// the template sources (with the writer default substituted for an empty // unanalyzable segment: the stamped whole-config corpus when the config went
// template) plus any templated option values. // through ResolveFieldSets, the segment's own texts otherwise.
func (segment *Segment) heuristicSources() []string { func (segment *Segment) fallbackSources() []string {
if segment.HeuristicSources != nil {
return segment.HeuristicSources
}
return segment.ownSources()
}
// ownSources returns every raw text of this segment itself: the template
// sources (with the writer default substituted for an empty template) plus
// any templated option values.
func (segment *Segment) ownSources() []string {
sources := segment.templateSources() sources := segment.templateSources()
sources[0] = segment.analysisTemplate() sources[0] = segment.analysisTemplate()
@@ -57,7 +72,7 @@ var analyzeFields = template.AnalyzeFields
// stamp shape - so a config stamped by another binary generation is treated // stamp shape - so a config stamped by another binary generation is treated
// as unstamped (see Get) and a long-lived session (tmux) re-analyzes once // as unstamped (see Get) and a long-lived session (tmux) re-analyzes once
// after a binary upgrade instead of trusting stale stamps. // after a binary upgrade instead of trusting stale stamps.
const fieldSetAnalysisVersion = 2 const fieldSetAnalysisVersion = 3
// ResolveFieldSets analyzes every template in the config and stamps each // ResolveFieldSets analyzes every template in the config and stamps each
// renderable segment with the set of top-level context fields those templates // renderable segment with the set of top-level context fields those templates
@@ -90,8 +105,19 @@ func (cfg *Config) ResolveFieldSets() {
analysis.analyzeSegment(segment) analysis.analyzeSegment(segment)
} }
// The corpus for the unanalyzable fallback: every text in the config that
// can reference a segment. Unanalyzability can be caused by a text
// outside the segment (another segment laundering a cross-reference, a
// global template), so scanning only the segment's own texts would miss
// exactly the reference that defeated the analysis. Assembled once and
// shared by every unanalyzable segment.
corpus := cfg.globalTemplateSources()
for _, segment := range segments { for _, segment := range segments {
analysis.stamp(segment) corpus = append(corpus, segment.ownSources()...)
}
for _, segment := range segments {
analysis.stamp(segment, corpus)
} }
} }
@@ -173,11 +199,22 @@ func (segment *Segment) templateSources() []string {
// templatedOptionValues collects the segment's option values (nested ones // templatedOptionValues collects the segment's option values (nested ones
// included) that carry template syntax. These render through // included) that carry template syntax. These render through
// options.Map.Template against contexts this analysis cannot model per // options.Map.Template against contexts this analysis cannot model per
// option, so analyzeSegment treats any hit conservatively. // option, so analyzeSegment treats any hit conservatively. Maps iterate
// with their keys sorted, recursively: the collected order feeds the
// segment cache key fingerprint, which must be identical across processes -
// Go's randomized map order would otherwise produce a fresh key per prompt
// and the cache would never hit.
func (segment *Segment) templatedOptionValues() []string { func (segment *Segment) templatedOptionValues() []string {
var sources []string var sources []string
var collect func(value any) var collect func(value any)
collectMap := func(m map[string]any) {
for _, key := range slices.Sorted(maps.Keys(m)) {
collect(m[key])
}
}
collect = func(value any) { collect = func(value any) {
switch v := value.(type) { switch v := value.(type) {
case string: case string:
@@ -185,16 +222,33 @@ func (segment *Segment) templatedOptionValues() []string {
sources = append(sources, v) sources = append(sources, v)
} }
case map[string]any: case map[string]any:
for _, nested := range v { collectMap(v)
collect(nested)
}
case map[any]any: case map[any]any:
for _, nested := range v { type pair struct {
collect(nested) value any
key string
}
pairs := make([]pair, 0, len(v))
for key, nested := range v {
pairs = append(pairs, pair{key: fmt.Sprint(key), value: nested})
}
slices.SortFunc(pairs, func(a, b pair) int { return strings.Compare(a.key, b.key) })
for _, entry := range pairs {
collect(entry.value)
} }
case options.Map: case options.Map:
for _, nested := range v { keys := make([]string, 0, len(v))
collect(nested) for key := range v {
keys = append(keys, string(key))
}
slices.Sort(keys)
for _, key := range keys {
collect(v[options.Option(key)])
} }
case []any: case []any:
for _, nested := range v { for _, nested := range v {
@@ -207,8 +261,15 @@ func (segment *Segment) templatedOptionValues() []string {
} }
} }
for _, value := range segment.Options { keys := make([]string, 0, len(segment.Options))
collect(value) for key := range segment.Options {
keys = append(keys, string(key))
}
slices.Sort(keys)
for _, key := range keys {
collect(segment.Options[options.Option(key)])
} }
return sources return sources
@@ -321,8 +382,10 @@ func (a *fieldAnalysis) mergeCross(refs *template.Refs) {
// stamp fixes the analysis outcome onto the segment: the sorted union of its // stamp fixes the analysis outcome onto the segment: the sorted union of its
// own references and the cross-references to its data key, plus whether that // own references and the cross-references to its data key, plus whether that
// union is trustworthy. Cross-references resolve by Name(), the key // union is trustworthy. Cross-references resolve by Name(), the key
// AddSegmentData stores segment data under. // AddSegmentData stores segment data under. corpus is the whole-config text
func (a *fieldAnalysis) stamp(segment *Segment) { // collection an unanalyzable segment's fallback heuristic scans; nil stays
// stamped for analyzable segments, whose exact field set needs no fallback.
func (a *fieldAnalysis) stamp(segment *Segment, corpus []string) {
name := segment.Name() name := segment.Name()
set := a.own[segment] set := a.own[segment]
@@ -339,4 +402,9 @@ func (a *fieldAnalysis) stamp(segment *Segment) {
segment.ReferencedFields = fields segment.ReferencedFields = fields
segment.FieldsAnalyzable = !a.opaque && !a.ownOpaque[segment] && !a.crossOpaque[name] segment.FieldsAnalyzable = !a.opaque && !a.ownOpaque[segment] && !a.crossOpaque[name]
segment.HeuristicSources = nil
if !segment.FieldsAnalyzable {
segment.HeuristicSources = corpus
}
} }
+86 -3
View File
@@ -252,8 +252,10 @@ func TestGitFetchDerivedFromTemplates(t *testing.T) {
cases := []struct { cases := []struct {
Options options.Map Options options.Map
Extra *Segment
Case string Case string
Template string Template string
ConsoleTitle string
ExpectStatus bool ExpectStatus bool
}{ }{
{ {
@@ -282,6 +284,36 @@ func TestGitFetchDerivedFromTemplates(t *testing.T) {
Options: options.Map{"custom": "{{ .Env.POSH_UNUSED }}"}, Options: options.Map{"custom": "{{ .Env.POSH_UNUSED }}"},
ExpectStatus: true, ExpectStatus: true,
}, },
{
// the reviewer scenario: another segment launders the git
// reference through a variable, defeating exact analysis for
// git - the heuristic must scan that OTHER segment's text too,
// or the laundered .Working reference renders permanent zeros
Case: "cross-segment laundering triggers the status probe",
Template: "{{ .HEAD }}",
Extra: &Segment{
Type: TEXT,
Template: "{{ $g := .Segments.Git }}{{ $g.Working.String }}",
},
ExpectStatus: true,
},
{
// same shape from a global nil-context text
Case: "console title laundering triggers the status probe",
Template: "{{ .HEAD }}",
ConsoleTitle: "{{ $g := .Segments.Git }}{{ $g.Working.String }}",
ExpectStatus: true,
},
{
// laundering elsewhere must not force units nobody names
Case: "cross-segment laundering without status mentions skips the probe",
Template: "{{ .HEAD }}",
Extra: &Segment{
Type: TEXT,
Template: "{{ $g := .Segments.Git }}{{ $g.RepoName }}",
},
ExpectStatus: false,
},
} }
for _, tc := range cases { for _, tc := range cases {
@@ -303,10 +335,16 @@ func TestGitFetchDerivedFromTemplates(t *testing.T) {
env.On("HasFilesInDir", testify_.Anything, testify_.Anything).Return(false) env.On("HasFilesInDir", testify_.Anything, testify_.Anything).Return(false)
env.MockGitCommand(repoRoot, "", statusArgs...) env.MockGitCommand(repoRoot, "", statusArgs...)
segments := []*Segment{
{Type: GIT, Template: tc.Template, Options: tc.Options},
}
if tc.Extra != nil {
segments = append(segments, tc.Extra)
}
cfg := &Config{ cfg := &Config{
Blocks: []*Block{{Segments: []*Segment{ Blocks: []*Block{{Segments: segments}},
{Type: GIT, Template: tc.Template, Options: tc.Options}, ConsoleTitleTemplate: tc.ConsoleTitle,
}}},
} }
cfg.ResolveFieldSets() cfg.ResolveFieldSets()
@@ -448,3 +486,48 @@ func TestGetRefreshesOutdatedStampVersion(t *testing.T) {
assert.Equal(t, fieldSetAnalysisVersion, cfg.FieldSetsVersion) assert.Equal(t, fieldSetAnalysisVersion, cfg.FieldSetsVersion)
assert.Zero(t, *count, "the refreshed entry must restore with current stamps") assert.Zero(t, *count, "the refreshed entry must restore with current stamps")
} }
// TestFieldSetFingerprintDeterministic pins the segment cache key against
// Go's randomized map iteration: an unanalyzable segment's fingerprint
// hashes its heuristic sources, which include templated option values
// collected from (nested) option maps - without sorted iteration the key
// would change per process and the segment cache would never hit.
func TestFieldSetFingerprintDeterministic(t *testing.T) {
build := func() *Segment {
// fresh maps per call, as an independently parsed config would have
return &Segment{
Type: GIT,
Template: "{{ .HEAD }}",
Options: options.Map{
"cab": "{{ .Env.C }}",
"abc": "{{ .Env.A }}",
"bca": "{{ .Env.B }}",
"nested": map[string]any{
"zed": "{{ .Env.Z }}",
"alpha": "{{ .Env.AA }}",
"mixed": map[any]any{
"two": "{{ .Env.TWO }}",
"one": "{{ .Env.ONE }}",
},
},
},
}
}
reference := build()
cfg := &Config{Blocks: []*Block{{Segments: []*Segment{reference}}}}
cfg.ResolveFieldSets()
require.False(t, reference.FieldsAnalyzable, "templated options must make the segment unanalyzable")
expected := reference.fieldSetFingerprint()
for range 32 {
segment := build()
fresh := &Config{Blocks: []*Block{{Segments: []*Segment{segment}}}}
fresh.ResolveFieldSets()
assert.Equal(t, expected, segment.fieldSetFingerprint(), "fingerprint must be stable across processes/parses")
}
}
+9 -2
View File
@@ -87,7 +87,14 @@ type Segment struct {
// true; see FieldSetConsumer. Exported (but kept out of every config // true; see FieldSetConsumer. Exported (but kept out of every config
// format, like Needs) so the session cache's gob round trip preserves // format, like Needs) so the session cache's gob round trip preserves
// the analysis instead of forcing a re-run on every render. // the analysis instead of forcing a re-run on every render.
ReferencedFields []string `json:"-" toml:"-" yaml:"-"` ReferencedFields []string `json:"-" toml:"-" yaml:"-"`
// HeuristicSources is the whole-config text corpus the fallback
// heuristic scans when FieldsAnalyzable is false - stamped (and
// gob-persisted) because the reference that defeated the analysis can
// live outside this segment, in texts the segment cannot reconstruct
// from its own fields. Nil for analyzable segments and for configs that
// never went through ResolveFieldSets.
HeuristicSources []string `json:"-" toml:"-" yaml:"-"`
Index int `json:"index,omitempty" toml:"index,omitempty" yaml:"index,omitempty"` Index int `json:"index,omitempty" toml:"index,omitempty" yaml:"index,omitempty"`
MinWidth int `json:"min_width,omitempty" toml:"min_width,omitempty" yaml:"min_width,omitempty"` MinWidth int `json:"min_width,omitempty" toml:"min_width,omitempty" yaml:"min_width,omitempty"`
Duration time.Duration `json:"-" toml:"-" yaml:"-"` Duration time.Duration `json:"-" toml:"-" yaml:"-"`
@@ -851,7 +858,7 @@ func (segment *Segment) fieldSetFingerprint() string {
} }
if !segment.FieldsAnalyzable { if !segment.FieldsAnalyzable {
for _, source := range segment.heuristicSources() { for _, source := range segment.fallbackSources() {
_, _ = h.Write([]byte(source)) _, _ = h.Write([]byte(source))
_, _ = h.Write([]byte{0}) _, _ = h.Write([]byte{0})
} }
-4
View File
@@ -37,10 +37,6 @@
"foreground": "p:git-foreground", "foreground": "p:git-foreground",
"leading_diamond": "\ue0b6", "leading_diamond": "\ue0b6",
"powerline_symbol": "\ue0b0", "powerline_symbol": "\ue0b0",
"options": {
"fetch_status": true,
"fetch_upstream_icon": true
},
"style": "powerline", "style": "powerline",
"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 }} ", "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 }} ",
"trailing_diamond": "\ue0b4", "trailing_diamond": "\ue0b4",
-4
View File
@@ -37,10 +37,6 @@
"foreground": "#193549", "foreground": "#193549",
"leading_diamond": "\ue0b6", "leading_diamond": "\ue0b6",
"powerline_symbol": "\ue0b0", "powerline_symbol": "\ue0b0",
"options": {
"fetch_status": true,
"fetch_upstream_icon": true
},
"style": "powerline", "style": "powerline",
"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 }} ", "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 }} ",
"trailing_diamond": "\ue0b4", "trailing_diamond": "\ue0b4",
+7
View File
@@ -60,6 +60,13 @@ is a template that prints its whole context (`{{ . }}`) without naming any field
The `fetch_status`, `fetch_push_status`, `fetch_upstream_icon`, `fetch_bare_info` and `fetch_user` The `fetch_status`, `fetch_push_status`, `fetch_upstream_icon`, `fetch_bare_info` and `fetch_user`
options no longer exist: fetching is driven entirely by what your templates reference. To stop fetching options no longer exist: fetching is driven entirely by what your templates reference. To stop fetching
something, stop rendering it. Unknown `fetch_*` keys in an existing config are ignored silently. something, stop rendering it. Unknown `fetch_*` keys in an existing config are ignored silently.
Defaults that changed with this, because the default templates reference the matching fields:
- the default config's statusline segment now fetches the upstream icon its explicit
`fetch_upstream_icon: false` used to suppress
- the svn, mercurial and jujutsu segments now fetch their working-copy status by default
- the jujutsu segment's default rendering therefore requires the `jj` binary
::: :::
| Name | Type | Default | Description | | Name | Type | Default | Description |