Skip to content

Commit f846675

Browse files
committed
feat(jujutsu)!: expose the bookmark distance as a template method
BREAKING CHANGE: the fetch_ahead_counter and ahead_icon options are gone, finishing the fetch-option removal for jujutsu. ClosestBookmarks now returns only the undecorated bookmark names, memoized so repeated template references cost a single jj call. The distance to the closest bookmark moves to a new lazy AheadCount template method - referencing it is the fetch trigger, following git's StashCount pattern: the extra jj log call runs on first use, memoized per render through unexported fields gob snapshots never carry, reuses ClosestBookmarks' memoized query for the bookmark it measures from, and returns 0 on error or when no bookmark exists. Configs with fetch_ahead_counter: true lose the inline decoration inside .ClosestBookmarks and compose it in the template instead, e.g. {{ if gt .AheadCount 0 }}{{ .AheadCount }}{{ end }} with an icon of choice - ahead_icon existed only for that decoration. The dead keys keep parsing silently. Docs, schema and the studio's recorded segment data follow. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M4RA9ZrP7vuqVSR7nddNiQ
1 parent 2d81119 commit f846675

5 files changed

Lines changed: 195 additions & 49 deletions

File tree

src/segments/jujutsu.go

Lines changed: 43 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,6 @@ const (
1616

1717
IgnoreWorkingCopy options.Option = "ignore_working_copy"
1818
ChangeIDMinLen options.Option = "change_id_min_len"
19-
FetchAhead options.Option = "fetch_ahead_counter"
20-
AheadIcon options.Option = "ahead_icon"
2119
)
2220

2321
type JujutsuStatus struct {
@@ -42,12 +40,16 @@ func (s *JujutsuStatus) add(code byte) {
4240
var jujutsuStatusFields = []string{workingField, "ChangeID", "ChangeIDPrefix", "ChangeIDRest"}
4341

4442
type Jujutsu struct {
45-
Working *JujutsuStatus
46-
ChangeID string
47-
ChangeIDPrefix string
48-
ChangeIDRest string
43+
Working *JujutsuStatus
44+
ChangeID string
45+
ChangeIDPrefix string
46+
ChangeIDRest string
47+
closestBookmarks string
4948
Scm
5049
FieldRefs
50+
aheadCount int
51+
closestBookmarksSet bool
52+
aheadCountSet bool
5153
}
5254

5355
func (jj *Jujutsu) Template() string {
@@ -85,52 +87,60 @@ func (jj *Jujutsu) CacheKey() (string, bool) {
8587
return dir.Path, true
8688
}
8789

90+
// ClosestBookmarks returns the bookmark name(s) on the closest bookmarked
91+
// ancestor(s) of the working copy, undecorated. Resolved lazily on first
92+
// template use and memoized, so a template can reference it more than once
93+
// per render at the cost of a single jj call.
8894
func (jj *Jujutsu) ClosestBookmarks() string {
95+
if jj.closestBookmarksSet {
96+
return jj.closestBookmarks
97+
}
98+
99+
jj.closestBookmarksSet = true
100+
89101
statusString, err := jj.getJujutsuCommandOutput("log", "-r", "heads(::@ & bookmarks())", "--no-graph", "-T", "bookmarks")
90102
if err != nil {
91103
return ""
92104
}
93105

94-
line, _, _ := strings.Cut(statusString, "\n")
106+
jj.closestBookmarks, _, _ = strings.Cut(statusString, "\n")
95107

96-
if !jj.options.Bool(FetchAhead, false) || len(line) == 0 {
97-
return line
108+
return jj.closestBookmarks
109+
}
110+
111+
// AheadCount returns the number of changes between the working copy and the
112+
// closest bookmark (see ClosestBookmarks). Referencing it in a template is
113+
// what triggers the extra jj call: it runs on first use, memoized, and
114+
// returns 0 when there is no bookmark or the call fails. Templates compose
115+
// their own decoration, e.g. {{ if gt .AheadCount 0 }}\u21e1{{ .AheadCount }}{{ end }}.
116+
func (jj *Jujutsu) AheadCount() int {
117+
if jj.aheadCountSet {
118+
return jj.aheadCount
98119
}
99120

100-
aheadIcon := jj.options.String(AheadIcon, "\u21e1")
101-
marks := strings.Split(line, " ")
102-
// String to return for status
103-
var endString strings.Builder
121+
jj.aheadCountSet = true
104122

105-
// Closest bookmarks are all the same distance away from the working copy
106-
// so retrieve the distance to the first one and use it for all of them
123+
// closest bookmarks all share the same distance from the working copy,
124+
// so the first one measures for all of them - reusing ClosestBookmarks'
125+
// memoized call instead of querying the bookmarks twice
126+
line := jj.ClosestBookmarks()
127+
if line == "" {
128+
return 0
129+
}
107130

131+
marks := strings.Split(line, " ")
108132
rangeString := strings.Trim(marks[0], "*") + "..@"
109133

110134
aheadString, err := jj.getJujutsuCommandOutput("log", "--no-graph", "-T", "'.'", "-r", rangeString)
111135
if err != nil {
112-
return line
113-
}
114-
115-
aheadCounter := len(aheadString)
116-
aheadCounterString := ""
117-
118-
if aheadCounter != 0 {
119-
aheadCounterString = aheadIcon + strconv.Itoa(aheadCounter)
136+
return 0
120137
}
121138

122-
log.Debug("distance to nearest jj bookmark:" + aheadCounterString)
139+
jj.aheadCount = len(aheadString)
123140

124-
// Loop through each bookmark
125-
for index, mark := range marks {
126-
if index > 0 {
127-
endString.WriteString(" ")
128-
}
129-
130-
endString.WriteString(mark + aheadCounterString)
131-
}
141+
log.Debug("distance to nearest jj bookmark: " + strconv.Itoa(jj.aheadCount))
132142

133-
return endString.String()
143+
return jj.aheadCount
134144
}
135145

136146
func (jj *Jujutsu) shouldDisplay(displayStatus bool) bool {

src/segments/jujutsu_test.go

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,3 +217,153 @@ R {renamed_file => new_file}`,
217217
})
218218
}
219219
}
220+
221+
func TestJujutsuClosestBookmarks(t *testing.T) {
222+
cases := []struct {
223+
Options options.Map
224+
Case string
225+
Output string
226+
Expected string
227+
Error bool
228+
}{
229+
{
230+
Case: "undecorated bookmark names",
231+
Output: "main feature/x\nnoise",
232+
Expected: "main feature/x",
233+
},
234+
{
235+
Case: "command error",
236+
Error: true,
237+
},
238+
{
239+
Case: "no bookmarks",
240+
},
241+
{
242+
// the fetch_ahead_counter/ahead_icon options no longer exist;
243+
// leftover keys must parse silently and change nothing
244+
Case: "dead option keys are ignored",
245+
Options: options.Map{"fetch_ahead_counter": true, "ahead_icon": "⇡"},
246+
Output: "main",
247+
Expected: "main",
248+
},
249+
}
250+
251+
for _, tc := range cases {
252+
env := new(mock.Environment)
253+
254+
bookmarkArgs := []string{"--repository", "/repo", "--no-pager", "--color", "never", "--ignore-working-copy", "log", "-r", "heads(::@ & bookmarks())", "--no-graph", "-T", "bookmarks"} //nolint:lll
255+
if tc.Error {
256+
env.On("RunCommand", "jj", bookmarkArgs).Return("", errors.New("failed")).Once()
257+
} else {
258+
env.On("RunCommand", "jj", bookmarkArgs).Return(tc.Output, nil).Once()
259+
}
260+
261+
opts := tc.Options
262+
if opts == nil {
263+
opts = options.Map{}
264+
}
265+
266+
jj := &Jujutsu{
267+
command: JUJUTSUCOMMAND,
268+
repoRootDir: "/repo",
269+
}
270+
jj.Init(opts, env)
271+
272+
// a second call must serve the memo, not a second jj invocation
273+
// (the .Once() mock above panics otherwise)
274+
assert.Equal(t, tc.Expected, jj.ClosestBookmarks(), tc.Case)
275+
assert.Equal(t, tc.Expected, jj.ClosestBookmarks(), tc.Case)
276+
env.AssertNumberOfCalls(t, "RunCommand", 1)
277+
}
278+
}
279+
280+
func TestJujutsuAheadCount(t *testing.T) {
281+
cases := []struct {
282+
Case string
283+
Bookmarks string
284+
ExpectedRange string
285+
AheadOutput string
286+
Expected int
287+
AheadError bool
288+
Invoke bool
289+
}{
290+
{
291+
// referencing the method is the fetch trigger: without an
292+
// invocation the distance query must never run
293+
Case: "not referenced, not fetched",
294+
Bookmarks: "main",
295+
Invoke: false,
296+
},
297+
{
298+
Case: "distance to the closest bookmark",
299+
Bookmarks: "main feature/x",
300+
ExpectedRange: "main..@",
301+
AheadOutput: "...",
302+
Expected: 3,
303+
Invoke: true,
304+
},
305+
{
306+
// conflicted bookmarks are marked with a trailing *
307+
Case: "conflicted bookmark marker is trimmed",
308+
Bookmarks: "main*",
309+
ExpectedRange: "main..@",
310+
AheadOutput: ".",
311+
Expected: 1,
312+
Invoke: true,
313+
},
314+
{
315+
Case: "no bookmarks skips the distance query",
316+
Bookmarks: "",
317+
Expected: 0,
318+
Invoke: true,
319+
},
320+
{
321+
Case: "distance query error",
322+
Bookmarks: "main",
323+
ExpectedRange: "main..@",
324+
AheadError: true,
325+
Expected: 0,
326+
Invoke: true,
327+
},
328+
}
329+
330+
for _, tc := range cases {
331+
env := new(mock.Environment)
332+
333+
cli := []string{"--repository", "/repo", "--no-pager", "--color", "never", "--ignore-working-copy"}
334+
bookmarkArgs := append(append([]string{}, cli...), "log", "-r", "heads(::@ & bookmarks())", "--no-graph", "-T", "bookmarks")
335+
env.On("RunCommand", "jj", bookmarkArgs).Return(tc.Bookmarks, nil).Once()
336+
337+
aheadCalls := 0
338+
if tc.ExpectedRange != "" {
339+
aheadCalls = 1
340+
aheadArgs := append(append([]string{}, cli...), "log", "--no-graph", "-T", "'.'", "-r", tc.ExpectedRange)
341+
342+
if tc.AheadError {
343+
env.On("RunCommand", "jj", aheadArgs).Return("", errors.New("failed")).Once()
344+
} else {
345+
env.On("RunCommand", "jj", aheadArgs).Return(tc.AheadOutput, nil).Once()
346+
}
347+
}
348+
349+
jj := &Jujutsu{
350+
command: JUJUTSUCOMMAND,
351+
repoRootDir: "/repo",
352+
}
353+
jj.Init(options.Map{}, env)
354+
355+
// the bookmarks memo is shared: AheadCount reuses ClosestBookmarks'
356+
// single jj call instead of querying the bookmarks again
357+
assert.Equal(t, tc.Bookmarks, jj.ClosestBookmarks(), tc.Case)
358+
359+
if !tc.Invoke {
360+
env.AssertNumberOfCalls(t, "RunCommand", 1)
361+
continue
362+
}
363+
364+
// a second call must serve the memo (the .Once() mocks panic otherwise)
365+
assert.Equal(t, tc.Expected, jj.AheadCount(), tc.Case)
366+
assert.Equal(t, tc.Expected, jj.AheadCount(), tc.Case)
367+
env.AssertNumberOfCalls(t, "RunCommand", 1+aheadCalls)
368+
}
369+
}

themes/schema.json

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2552,18 +2552,6 @@
25522552
"description": "Don't snapshot the working copy, and don't update it.",
25532553
"default": true
25542554
},
2555-
"fetch_ahead_counter": {
2556-
"type": "boolean",
2557-
"title": "Fetch Ahead Counter",
2558-
"description": "Fetch working copy # of changes ahead of the nearest bookmark.",
2559-
"default": false
2560-
},
2561-
"ahead_icon": {
2562-
"type": "string",
2563-
"title": "Ahead icon",
2564-
"description": "Icon to separate bookmark name and ahead counter.",
2565-
"default": "\u21e1"
2566-
},
25672555
"native_fallback": {
25682556
"$ref": "#/definitions/native_fallback"
25692557
},

website/docs/segments/scm/jujutsu.mdx

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,6 @@ import Config from "@site/src/components/Config.js";
2121
background: "#ffeb3b",
2222
options: {
2323
ignore_working_copy: false,
24-
fetch_ahead_counter: true,
25-
ahead_icon: "\u21e1",
2624
},
2725
}}
2826
/>
@@ -40,8 +38,6 @@ ignored silently in existing configs.
4038
| --------------------- | :-----------------: | :------: | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
4139
| `change_id_min_len` | `int` | `0` | `ChangeID` will be at least this many characters, even if a shorter one would be unique |
4240
| `ignore_working_copy` | `boolean` | `true` | don't snapshot/update the working copy |
43-
| `fetch_ahead_counter` | `boolean` | `false` | fetch a counter for number of changes between working copy and closest bookmark |
44-
| `ahead_icon` | `string` | `\u21e1` | icon/character between bookmark and ahead counter |
4541
| `native_fallback` | `boolean` | `false` | when set to `true` and `jj.exe` is not available when inside a WSL2 shared Windows drive, we will fallback to the native `jj` executable to fetch data. Not all information can be displayed in this case |
4642
| `status_formats` | `map[string]string` | | a key, value map allowing to override how individual status items are displayed. For example, `"status_formats": { "Added": "Added: %d" }` will display the added count as `Added: 1` instead of `+1`. See the [Status](#status) section for available overrides |
4743

@@ -64,6 +60,7 @@ ignored silently in existing configs.
6460
| `.ChangeIDPrefix` | `string` | The shortest unique prefix of the working copy change ID |
6561
| `.ChangeIDRest` | `string` | The additional portion displayed after `.ChangeIDPrefix` to satisfy `change_id_min_len`; empty when the unique prefix already meets or exceeds that minimum |
6662
| `.ClosestBookmarks` | `string` | Closest bookmark(s) on ancestors |
63+
| `.AheadCount` | `int` | Number of changes between the working copy and the closest bookmark; fetched lazily when referenced, e.g. `{{ if gt .AheadCount 0 }}\u21e1{{ .AheadCount }}{{ end }}` |
6764

6865
`.ChangeIDRest` is not the remainder of the full canonical change ID and isn't independently usable as an identifier.
6966

website/segment_data.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1565,6 +1565,7 @@
15651565
},
15661566
"jujutsu": {
15671567
"data": {
1568+
"AheadCount": 0,
15681569
"ChangeID": "mzvwutnw",
15691570
"ChangeIDPrefix": "mz",
15701571
"ChangeIDRest": "vwutnw",

0 commit comments

Comments
 (0)