mirror of
https://github.com/JanDeDobbeleer/oh-my-posh.git
synced 2026-08-24 02:34:19 -05:00
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:
@@ -33,7 +33,7 @@ Options are declared as `options.Option` string constants in the segment's `cons
|
||||
```go
|
||||
const (
|
||||
BranchIcon options.Option = "branch_icon" // option name used in config
|
||||
FetchStatus options.Option = "fetch_status"
|
||||
NativeStatus options.Option = "native_status"
|
||||
)
|
||||
```
|
||||
|
||||
|
||||
+91
-23
@@ -1,6 +1,8 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
@@ -20,10 +22,12 @@ type FieldSetConsumer interface {
|
||||
}
|
||||
|
||||
// refSet assembles the delivery for FieldSetConsumer writers from the
|
||||
// stamped analysis. The heuristic sources are only materialized for an
|
||||
// unanalyzable set - they are recomputed from the segment's own (persisted)
|
||||
// config fields rather than stamped, so a session-cache round trip cannot
|
||||
// desynchronize them from the config content.
|
||||
// stamped analysis. The heuristic sources only exist for an unanalyzable
|
||||
// set: the whole-config corpus ResolveFieldSets stamped (unanalyzability can
|
||||
// originate outside the segment - a cross-segment reference laundered
|
||||
// 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 {
|
||||
refs := template.RefSet{
|
||||
Fields: segment.ReferencedFields,
|
||||
@@ -31,16 +35,27 @@ func (segment *Segment) refSet() template.RefSet {
|
||||
}
|
||||
|
||||
if !segment.FieldsAnalyzable {
|
||||
refs.Sources = segment.heuristicSources()
|
||||
refs.Sources = segment.fallbackSources()
|
||||
}
|
||||
|
||||
return refs
|
||||
}
|
||||
|
||||
// heuristicSources returns every raw text the fallback heuristic may scan:
|
||||
// the template sources (with the writer default substituted for an empty
|
||||
// template) plus any templated option values.
|
||||
func (segment *Segment) heuristicSources() []string {
|
||||
// fallbackSources returns the raw texts the substring heuristic scans for an
|
||||
// unanalyzable segment: the stamped whole-config corpus when the config went
|
||||
// through ResolveFieldSets, the segment's own texts otherwise.
|
||||
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[0] = segment.analysisTemplate()
|
||||
|
||||
@@ -57,7 +72,7 @@ var analyzeFields = template.AnalyzeFields
|
||||
// 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
|
||||
// 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
|
||||
// renderable segment with the set of top-level context fields those templates
|
||||
@@ -90,8 +105,19 @@ func (cfg *Config) ResolveFieldSets() {
|
||||
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 {
|
||||
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
|
||||
// included) that carry template syntax. These render through
|
||||
// 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 {
|
||||
var sources []string
|
||||
|
||||
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) {
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
@@ -185,16 +222,33 @@ func (segment *Segment) templatedOptionValues() []string {
|
||||
sources = append(sources, v)
|
||||
}
|
||||
case map[string]any:
|
||||
for _, nested := range v {
|
||||
collect(nested)
|
||||
}
|
||||
collectMap(v)
|
||||
case map[any]any:
|
||||
for _, nested := range v {
|
||||
collect(nested)
|
||||
type pair struct {
|
||||
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:
|
||||
for _, nested := range v {
|
||||
collect(nested)
|
||||
keys := make([]string, 0, len(v))
|
||||
for key := range v {
|
||||
keys = append(keys, string(key))
|
||||
}
|
||||
|
||||
slices.Sort(keys)
|
||||
|
||||
for _, key := range keys {
|
||||
collect(v[options.Option(key)])
|
||||
}
|
||||
case []any:
|
||||
for _, nested := range v {
|
||||
@@ -207,8 +261,15 @@ func (segment *Segment) templatedOptionValues() []string {
|
||||
}
|
||||
}
|
||||
|
||||
for _, value := range segment.Options {
|
||||
collect(value)
|
||||
keys := make([]string, 0, len(segment.Options))
|
||||
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
|
||||
@@ -321,8 +382,10 @@ func (a *fieldAnalysis) mergeCross(refs *template.Refs) {
|
||||
// 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
|
||||
// union is trustworthy. Cross-references resolve by Name(), the key
|
||||
// AddSegmentData stores segment data under.
|
||||
func (a *fieldAnalysis) stamp(segment *Segment) {
|
||||
// AddSegmentData stores segment data under. corpus is the whole-config text
|
||||
// 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()
|
||||
|
||||
set := a.own[segment]
|
||||
@@ -339,4 +402,9 @@ func (a *fieldAnalysis) stamp(segment *Segment) {
|
||||
|
||||
segment.ReferencedFields = fields
|
||||
segment.FieldsAnalyzable = !a.opaque && !a.ownOpaque[segment] && !a.crossOpaque[name]
|
||||
segment.HeuristicSources = nil
|
||||
|
||||
if !segment.FieldsAnalyzable {
|
||||
segment.HeuristicSources = corpus
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,8 +252,10 @@ func TestGitFetchDerivedFromTemplates(t *testing.T) {
|
||||
|
||||
cases := []struct {
|
||||
Options options.Map
|
||||
Extra *Segment
|
||||
Case string
|
||||
Template string
|
||||
ConsoleTitle string
|
||||
ExpectStatus bool
|
||||
}{
|
||||
{
|
||||
@@ -282,6 +284,36 @@ func TestGitFetchDerivedFromTemplates(t *testing.T) {
|
||||
Options: options.Map{"custom": "{{ .Env.POSH_UNUSED }}"},
|
||||
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 {
|
||||
@@ -303,10 +335,16 @@ func TestGitFetchDerivedFromTemplates(t *testing.T) {
|
||||
env.On("HasFilesInDir", testify_.Anything, testify_.Anything).Return(false)
|
||||
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{
|
||||
Blocks: []*Block{{Segments: []*Segment{
|
||||
{Type: GIT, Template: tc.Template, Options: tc.Options},
|
||||
}}},
|
||||
Blocks: []*Block{{Segments: segments}},
|
||||
ConsoleTitleTemplate: tc.ConsoleTitle,
|
||||
}
|
||||
|
||||
cfg.ResolveFieldSets()
|
||||
@@ -448,3 +486,48 @@ func TestGetRefreshesOutdatedStampVersion(t *testing.T) {
|
||||
assert.Equal(t, fieldSetAnalysisVersion, cfg.FieldSetsVersion)
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,7 +87,14 @@ type Segment struct {
|
||||
// true; see FieldSetConsumer. Exported (but kept out of every config
|
||||
// format, like Needs) so the session cache's gob round trip preserves
|
||||
// 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"`
|
||||
MinWidth int `json:"min_width,omitempty" toml:"min_width,omitempty" yaml:"min_width,omitempty"`
|
||||
Duration time.Duration `json:"-" toml:"-" yaml:"-"`
|
||||
@@ -851,7 +858,7 @@ func (segment *Segment) fieldSetFingerprint() string {
|
||||
}
|
||||
|
||||
if !segment.FieldsAnalyzable {
|
||||
for _, source := range segment.heuristicSources() {
|
||||
for _, source := range segment.fallbackSources() {
|
||||
_, _ = h.Write([]byte(source))
|
||||
_, _ = h.Write([]byte{0})
|
||||
}
|
||||
|
||||
@@ -37,10 +37,6 @@
|
||||
"foreground": "p:git-foreground",
|
||||
"leading_diamond": "\ue0b6",
|
||||
"powerline_symbol": "\ue0b0",
|
||||
"options": {
|
||||
"fetch_status": true,
|
||||
"fetch_upstream_icon": true
|
||||
},
|
||||
"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 }} ",
|
||||
"trailing_diamond": "\ue0b4",
|
||||
|
||||
@@ -37,10 +37,6 @@
|
||||
"foreground": "#193549",
|
||||
"leading_diamond": "\ue0b6",
|
||||
"powerline_symbol": "\ue0b0",
|
||||
"options": {
|
||||
"fetch_status": true,
|
||||
"fetch_upstream_icon": true
|
||||
},
|
||||
"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 }} ",
|
||||
"trailing_diamond": "\ue0b4",
|
||||
|
||||
@@ -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`
|
||||
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.
|
||||
|
||||
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 |
|
||||
|
||||
Reference in New Issue
Block a user