dsh-git-badge 0.16.1 → 0.17.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +148 -7
- package/README.md +3 -3
- package/lib/client.js +329 -184
- package/lib/index.js +215 -106
- package/package.json +2 -4
- package/seam/apply.js +34 -154
- package/seam/checkout.js +3 -5
- package/seam/patch.js +142 -0
- package/seam/postinstall.js +38 -50
- package/seam/store.js +8 -4
- package/skill/gh/SKILL.md +23 -1
package/lib/index.js
CHANGED
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
* on the first refresh after each TTL window. `GIT_TERMINAL_PROMPT=0`, the
|
|
45
45
|
* shared timeout and the TTL bound keep an offline or slow remote harmless;
|
|
46
46
|
* on failure the stale-ref answer simply stands.
|
|
47
|
-
* - `detail=1` adds
|
|
47
|
+
* - `detail=1` adds a `log -10` plus a stash count, and `pr=1` adds the
|
|
48
48
|
* branch's GitHub PR/CI state read through the user's own `gh` CLI. Both
|
|
49
49
|
* serve the input chip only — the hover card and the PR token — so a
|
|
50
50
|
* sidebar row refresh pays for neither. The PR read is TTL-bounded and
|
|
@@ -95,17 +95,10 @@ const config = {
|
|
|
95
95
|
fetchTimeoutMs: 8000,
|
|
96
96
|
/** minimum interval between background fetches per toplevel */
|
|
97
97
|
fetchTtlMs: 60000,
|
|
98
|
+
/** how long a resolved default-branch name is trusted (per toplevel) */
|
|
99
|
+
defaultBranchTtlMs: 60000,
|
|
98
100
|
/** fs-event burst collapse window */
|
|
99
101
|
debounceMs: 200,
|
|
100
|
-
/**
|
|
101
|
-
* Coalescing window for identical status reads. 0 (the default) collapses only
|
|
102
|
-
* CONCURRENT identical reads — which is exactly what a burst of sidebar
|
|
103
|
-
* session rows mounting together produces — and leaves sequential reads
|
|
104
|
-
* honest, so a caller that mutates a repository and asks again always sees the
|
|
105
|
-
* new state. Raise it to also absorb serial bursts, at the cost of serving a
|
|
106
|
-
* change up to that many milliseconds late.
|
|
107
|
-
*/
|
|
108
|
-
statusCacheMs: 0,
|
|
109
102
|
/** backoff before re-attempting a failed watcher (or a missing .git) */
|
|
110
103
|
watchRetryMs: 60000,
|
|
111
104
|
/**
|
|
@@ -130,8 +123,16 @@ const config = {
|
|
|
130
123
|
* process — see the PR region.
|
|
131
124
|
*/
|
|
132
125
|
prStatus: "auto",
|
|
133
|
-
/**
|
|
134
|
-
|
|
126
|
+
/**
|
|
127
|
+
* Minimum interval between forge refreshes per repository. It must stay BELOW
|
|
128
|
+
* the client's idle poll (FALLBACK_POLL_MS, 60s in lib/client.js): the refresh
|
|
129
|
+
* is request-driven, so a TTL longer than that poll silently suppresses every
|
|
130
|
+
* OTHER request — and a pull request that appears on the forge with no local
|
|
131
|
+
* git event to announce it then waits out TTL + poll before it shows. At 90s
|
|
132
|
+
* that worst case was ~2.5 minutes; 20s bounds it at roughly one poll, for up
|
|
133
|
+
* to one `gh pr view` per open chip per minute.
|
|
134
|
+
*/
|
|
135
|
+
prTtlMs: 20000,
|
|
135
136
|
/**
|
|
136
137
|
* Forge call budget. Unlike git this is a network round trip to GitHub, so
|
|
137
138
|
* it gets a more generous budget than the status sample — but it is bounded
|
|
@@ -199,6 +200,41 @@ function runGit(dir, args, opts) {
|
|
|
199
200
|
/** Marker response for a git invocation that failed or timed out (transient). */
|
|
200
201
|
const GIT_DEGRADED = { git: false, error: "git unavailable (timeout or failure)" };
|
|
201
202
|
|
|
203
|
+
/**
|
|
204
|
+
* One in-flight run per key, with optional TTL rate-limiting — the shape every
|
|
205
|
+
* cache in this half needs, written once. A caller that arrives while a run is
|
|
206
|
+
* in flight shares its promise instead of stampeding, and one whose record is
|
|
207
|
+
* fresher than `ttlMs` is served from the record instead of starting a new
|
|
208
|
+
* run. The store maps key -> { lastAttemptAt, inFlight, value }: `value` is
|
|
209
|
+
* the last settled answer, kept only when `keepValue` so a lapsed caller can
|
|
210
|
+
* be served the PREVIOUS answer while a refresh runs; `run(record)` receives
|
|
211
|
+
* the record (its `value` is that previous answer) and resolves to the next
|
|
212
|
+
* one. TTL-less stores drop the record when the run settles; a failed run is
|
|
213
|
+
* not remembered except as a TTL'd store's attempt time, which rate-limits
|
|
214
|
+
* the retry. `serve` decides what a busy-or-fresh call returns (default: the
|
|
215
|
+
* in-flight promise, or the kept value).
|
|
216
|
+
*/
|
|
217
|
+
function singleFlight(store, key, { ttlMs = 0, keepValue = false, serve }, run) {
|
|
218
|
+
const previous = store.get(key);
|
|
219
|
+
if (previous !== void 0 && (previous.inFlight !== null || Date.now() - previous.lastAttemptAt < ttlMs)) {
|
|
220
|
+
return serve !== void 0 ? serve(previous) : keepValue ? previous.value : previous.inFlight;
|
|
221
|
+
}
|
|
222
|
+
const record = { lastAttemptAt: Date.now(), inFlight: null, value: keepValue ? previous?.value : void 0 };
|
|
223
|
+
record.inFlight = (async () => {
|
|
224
|
+
try {
|
|
225
|
+
const value = await run(record);
|
|
226
|
+
if (keepValue) record.value = value;
|
|
227
|
+
return value;
|
|
228
|
+
} finally {
|
|
229
|
+
record.lastAttemptAt = Date.now();
|
|
230
|
+
record.inFlight = null;
|
|
231
|
+
if (ttlMs === 0) store.delete(key);
|
|
232
|
+
}
|
|
233
|
+
})();
|
|
234
|
+
store.set(key, record);
|
|
235
|
+
return keepValue ? record.value : record.inFlight;
|
|
236
|
+
}
|
|
237
|
+
|
|
202
238
|
//#region TTL-bounded background fetch
|
|
203
239
|
/**
|
|
204
240
|
* ahead/behind come from the local remote-tracking ref, which only moves on
|
|
@@ -210,6 +246,30 @@ const GIT_DEGRADED = { git: false, error: "git unavailable (timeout or failure)"
|
|
|
210
246
|
* auto-fetch), with a TTL rather than a timer: fetch only happens when
|
|
211
247
|
* someone is actually looking at the badge.
|
|
212
248
|
*/
|
|
249
|
+
/**
|
|
250
|
+
* toplevel -> { at, value } for the repository's default branch name ("main"),
|
|
251
|
+
* resolved from `refs/remotes/origin/HEAD`. Read on the HOT path and refreshed
|
|
252
|
+
* out of band, exactly like the fetch: a status call must never pay a process
|
|
253
|
+
* for it, so the first request after a cache miss simply answers without the
|
|
254
|
+
* flag and the next one carries it.
|
|
255
|
+
*/
|
|
256
|
+
const defaultBranchState = new Map();
|
|
257
|
+
|
|
258
|
+
/** The cached default branch, kicking a refresh when stale. Never throws. */
|
|
259
|
+
function defaultBranchFor(toplevel, notify) {
|
|
260
|
+
return singleFlight(defaultBranchState, toplevel, {
|
|
261
|
+
ttlMs: config.defaultBranchTtlMs,
|
|
262
|
+
keepValue: true,
|
|
263
|
+
serve: (state) => state.value
|
|
264
|
+
}, async (record) => {
|
|
265
|
+
const out = await runGit(toplevel, ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"]);
|
|
266
|
+
const name = out.stdout === null ? "" : out.stdout.trim().replace(/^origin\//u, "");
|
|
267
|
+
const value = name === "" ? void 0 : name;
|
|
268
|
+
if (record.value !== value) notify?.();
|
|
269
|
+
return value;
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
213
273
|
/** lastFetchAt per toplevel; in-flight promise per toplevel collapses races. */
|
|
214
274
|
const fetchState = new Map();
|
|
215
275
|
|
|
@@ -221,13 +281,12 @@ const fetchState = new Map();
|
|
|
221
281
|
* means concurrent requests share one fetch instead of stampeding.
|
|
222
282
|
*/
|
|
223
283
|
async function maybeFetch(toplevel) {
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
}
|
|
230
|
-
const inFlight = (async () => {
|
|
284
|
+
return singleFlight(fetchState, toplevel, {
|
|
285
|
+
ttlMs: config.fetchTtlMs,
|
|
286
|
+
// a lapsed-but-idle caller is served "nothing fetched"; only an in-flight
|
|
287
|
+
// fetch is worth sharing
|
|
288
|
+
serve: (state) => (state.inFlight ?? false)
|
|
289
|
+
}, async () => {
|
|
231
290
|
// --no-tags --prune: refs-only refresh, cheapest correct form.
|
|
232
291
|
// No remote name: `git fetch` uses the branch's configured upstream,
|
|
233
292
|
// and a bare `fetch --all` would probe every remote for every badge.
|
|
@@ -239,13 +298,8 @@ async function maybeFetch(toplevel) {
|
|
|
239
298
|
// route latency, paid at most once per fetchTtlMs per workspace.
|
|
240
299
|
timeout: config.fetchTimeoutMs
|
|
241
300
|
});
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
return ok;
|
|
245
|
-
})();
|
|
246
|
-
if (state === void 0) fetchState.set(toplevel, { lastAttemptAt: now, inFlight });
|
|
247
|
-
else state.inFlight = inFlight;
|
|
248
|
-
return inFlight;
|
|
301
|
+
return out.stdout !== null;
|
|
302
|
+
});
|
|
249
303
|
}
|
|
250
304
|
//#endregion
|
|
251
305
|
|
|
@@ -405,26 +459,19 @@ function prListKey(byBranch) {
|
|
|
405
459
|
*/
|
|
406
460
|
function prStatusFor(toplevel, branch, notify) {
|
|
407
461
|
if (config.prStatus === "off") return void 0;
|
|
408
|
-
const now = Date.now();
|
|
409
462
|
const key = toplevel + "\u0000" + branch;
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
463
|
+
return singleFlight(prState, key, {
|
|
464
|
+
ttlMs: config.prTtlMs,
|
|
465
|
+
keepValue: true,
|
|
466
|
+
// the previous answer is served while a refresh runs, so a forge round
|
|
467
|
+
// trip is never route latency
|
|
468
|
+
serve: (state) => state.value
|
|
469
|
+
}, async (record) => {
|
|
417
470
|
const value = await readPrStatus(toplevel, branch).catch(() => void 0);
|
|
418
471
|
const changed = JSON.stringify(record.value ?? null) !== JSON.stringify(value ?? null);
|
|
419
|
-
record.lastAttemptAt = Date.now();
|
|
420
|
-
record.inFlight = null;
|
|
421
|
-
record.value = value;
|
|
422
472
|
if (changed) notify(toplevel);
|
|
423
473
|
return value;
|
|
424
|
-
})
|
|
425
|
-
record.inFlight = inFlight;
|
|
426
|
-
prState.set(key, record);
|
|
427
|
-
return record.value;
|
|
474
|
+
});
|
|
428
475
|
}
|
|
429
476
|
//#endregion
|
|
430
477
|
|
|
@@ -586,13 +633,7 @@ const worktreeInFlight = new Map();
|
|
|
586
633
|
* local git calls, and a worktree created a moment ago must be visible at once.
|
|
587
634
|
*/
|
|
588
635
|
function worktreesFor(dir) {
|
|
589
|
-
|
|
590
|
-
if (pending !== void 0) return pending;
|
|
591
|
-
const run = readWorktrees(dir).finally(() => {
|
|
592
|
-
worktreeInFlight.delete(dir);
|
|
593
|
-
});
|
|
594
|
-
worktreeInFlight.set(dir, run);
|
|
595
|
-
return run;
|
|
636
|
+
return singleFlight(worktreeInFlight, dir, {}, () => readWorktrees(dir));
|
|
596
637
|
}
|
|
597
638
|
|
|
598
639
|
/**
|
|
@@ -692,17 +733,24 @@ function legacyOrderFile() {
|
|
|
692
733
|
}
|
|
693
734
|
|
|
694
735
|
/**
|
|
695
|
-
* The live order — one in-memory value
|
|
696
|
-
*
|
|
697
|
-
*
|
|
698
|
-
*
|
|
736
|
+
* The live order — one in-memory value. apply() seeds it from the pre-settings
|
|
737
|
+
* JSON file once at boot; the settings scope then owns it, so a change applies
|
|
738
|
+
* to the next status read with no file poll and no restart. A host without the
|
|
739
|
+
* settings stack (and a unit test that never calls apply) simply keeps the
|
|
740
|
+
* default. Reading the file once per boot instead of per status read keeps a
|
|
741
|
+
* sync disk read off the hot path; the file's contract is a boot-time seed,
|
|
742
|
+
* which is all it ever was once the namespace exists.
|
|
699
743
|
*/
|
|
700
744
|
let currentOrder = DEFAULT_NEXT_ORDER;
|
|
701
|
-
|
|
745
|
+
|
|
746
|
+
/** Seed the live order from the pre-settings JSON file — apply()'s boot step. */
|
|
747
|
+
function seedOrder() {
|
|
748
|
+
currentOrder = legacyOrderFile();
|
|
749
|
+
}
|
|
702
750
|
|
|
703
751
|
/** The ranking order every status read uses. */
|
|
704
752
|
function nextOrder() {
|
|
705
|
-
return
|
|
753
|
+
return currentOrder;
|
|
706
754
|
}
|
|
707
755
|
|
|
708
756
|
/**
|
|
@@ -730,7 +778,7 @@ function nextOrder() {
|
|
|
730
778
|
*
|
|
731
779
|
* @returns {{ args: string, command: string|undefined, why: string, what: string } | null}
|
|
732
780
|
*/
|
|
733
|
-
function
|
|
781
|
+
function nextStepRules(info) {
|
|
734
782
|
if (info === void 0 || info === null || info.git !== true) return null;
|
|
735
783
|
const unmerged = info.unmergedFiles || 0;
|
|
736
784
|
const plural = (n) => (n === 1 ? "" : "s");
|
|
@@ -786,15 +834,71 @@ function nextStep(info) {
|
|
|
786
834
|
if (pr !== void 0 && pr.number !== void 0 && pr.state === "failing") {
|
|
787
835
|
add("checks", `checks ${pr.number}`, `checks failing on #${pr.number}`, "watch the CI checks on pull request " + pr.number, `gh pr checks ${pr.number} --watch`);
|
|
788
836
|
}
|
|
789
|
-
|
|
837
|
+
|
|
838
|
+
// STANDING options: never ranked, never the primary suggestion — they are
|
|
839
|
+
// things you may do, not things the checkout is asking for. `pr view` exists
|
|
840
|
+
// whenever a pull request does; `pr` (create) only for a pushed branch that
|
|
841
|
+
// is not the default; `clean` last and flagged, because a destructive verb
|
|
842
|
+
// whose intent git status cannot reveal must never be suggested.
|
|
843
|
+
const standing = [];
|
|
844
|
+
if (pr !== void 0 && pr.number !== void 0) {
|
|
845
|
+
standing.push({
|
|
846
|
+
args: `pr view ${pr.number}`,
|
|
847
|
+
why: `open pull request #${pr.number}`,
|
|
848
|
+
what: `show pull request ${pr.number} on GitHub`
|
|
849
|
+
});
|
|
850
|
+
} else if (ahead === 0 && behind === 0 && info.upstream !== void 0 && dirty === 0 && info.defaultBranch !== true) {
|
|
851
|
+
standing.push({ args: "pr", why: "branch is pushed and has no pull request", what: "open a pull request for this branch" });
|
|
852
|
+
}
|
|
853
|
+
if (untracked > 0) {
|
|
854
|
+
standing.push({
|
|
855
|
+
args: "clean",
|
|
856
|
+
why: `${untracked} untracked file${plural(untracked)}`,
|
|
857
|
+
what: "remove untracked files — lists them and asks first",
|
|
858
|
+
danger: true
|
|
859
|
+
});
|
|
860
|
+
}
|
|
790
861
|
|
|
791
862
|
const order = nextOrder();
|
|
792
863
|
const rank = (cat) => {
|
|
793
864
|
const at = order.indexOf(cat);
|
|
794
865
|
return at === -1 ? order.length : at;
|
|
795
866
|
};
|
|
796
|
-
const
|
|
797
|
-
return {
|
|
867
|
+
const ranked = [...rules].sort((a, b) => rank(a.cat) - rank(b.cat)).slice(0, 5);
|
|
868
|
+
return { ranked, standing };
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/**
|
|
872
|
+
* Every action the checkout justifies, in order — the ONE list both surfaces
|
|
873
|
+
* render: the card shows the primary entry, the (+) picker lists them all.
|
|
874
|
+
*
|
|
875
|
+
* The server owns the whole list because both halves previously carried their
|
|
876
|
+
* own copy of these conditions and drifted: the card promoted a client-only
|
|
877
|
+
* extra into its action row, and `/gh pr` was offered on the default branch.
|
|
878
|
+
* One authority, one list, no reconciliation.
|
|
879
|
+
*
|
|
880
|
+
* Exactly one entry can be `primary` (the top RANKED rule) or none at all —
|
|
881
|
+
* a clean, synced checkout has actions (look at the PR, clean up) but no
|
|
882
|
+
* suggestion, because "no suggestion" is also a suggestion.
|
|
883
|
+
*
|
|
884
|
+
* @returns {{ actions: Array<{args, why, what, command?, danger?, primary?}>, next: object|null }}
|
|
885
|
+
*/
|
|
886
|
+
function nextActions(info) {
|
|
887
|
+
const built = nextStepRules(info);
|
|
888
|
+
if (built === null) return { actions: [], next: null };
|
|
889
|
+
const { ranked, standing } = built;
|
|
890
|
+
const actions = [
|
|
891
|
+
...ranked.map((rule, index) => ({
|
|
892
|
+
args: rule.args,
|
|
893
|
+
why: rule.why,
|
|
894
|
+
what: rule.what,
|
|
895
|
+
...(rule.command === void 0 ? {} : { command: rule.command }),
|
|
896
|
+
...(index === 0 ? { primary: true } : {})
|
|
897
|
+
})),
|
|
898
|
+
...standing
|
|
899
|
+
];
|
|
900
|
+
const primary = actions.find((action) => action.primary === true) ?? null;
|
|
901
|
+
return { actions, next: primary };
|
|
798
902
|
}
|
|
799
903
|
|
|
800
904
|
/**
|
|
@@ -897,6 +1001,15 @@ async function gitStatusUncached(dir, wantDetail, wantPr) {
|
|
|
897
1001
|
ahead: parsed.ahead,
|
|
898
1002
|
behind: parsed.behind
|
|
899
1003
|
};
|
|
1004
|
+
// Is this the repository's DEFAULT branch? main/master with no pull request
|
|
1005
|
+
// is that branch's normal state, and "open a pull request for this branch" is
|
|
1006
|
+
// nonsense there, so the surfaces suppress it. Cached and refreshed out of
|
|
1007
|
+
// band: the flag may be absent on the very first read, present on the next.
|
|
1008
|
+
const defaultBranch = defaultBranchFor(toplevel, notifyChange);
|
|
1009
|
+
if (defaultBranch !== void 0 && defaultBranch === parsed.branch) info.defaultBranch = true;
|
|
1010
|
+
// Resolved inside the detail block; the PR-token hover below reuses it, so it
|
|
1011
|
+
// is declared here rather than scoped to that block.
|
|
1012
|
+
let baseRef = null;
|
|
900
1013
|
if (wantDetail) {
|
|
901
1014
|
// The ✎n NAMES — the one part of the count the numbers cannot answer
|
|
902
1015
|
// ("what are these?"). Hover-gated with the rest of detail=1, so no
|
|
@@ -931,10 +1044,11 @@ async function gitStatusUncached(dir, wantDetail, wantPr) {
|
|
|
931
1044
|
// `remote get-url origin` with ssh syntax converted; absent when there is
|
|
932
1045
|
// no origin or the URL is not recognisably a hosted repo.
|
|
933
1046
|
// These reads are independent — one batch, so hover latency is the
|
|
934
|
-
// longest call rather than their sum.
|
|
935
|
-
//
|
|
936
|
-
//
|
|
937
|
-
|
|
1047
|
+
// longest call rather than their sum. `logHead` is the plain HEAD log
|
|
1048
|
+
// the no-base-branch fallback below serves as the branch's commit list;
|
|
1049
|
+
// the only dependent calls (the left-right count and the signed log both
|
|
1050
|
+
// need a probed base ref) run after the batch and tolerate failure.
|
|
1051
|
+
const [remoteUrl, probeMain, probeMaster, logHead, stashOut] = await Promise.all([
|
|
938
1052
|
runGit(toplevel, ["remote", "get-url", "origin"]),
|
|
939
1053
|
runGit(toplevel, ["rev-parse", "--verify", "--quiet", "origin/main"]),
|
|
940
1054
|
runGit(toplevel, ["rev-parse", "--verify", "--quiet", "origin/master"]),
|
|
@@ -944,7 +1058,7 @@ async function gitStatusUncached(dir, wantDetail, wantPr) {
|
|
|
944
1058
|
if (remoteUrl.stdout !== null && remoteUrl.stdout.trim() !== "") {
|
|
945
1059
|
info.repoUrl = remoteToWebUrl(remoteUrl.stdout);
|
|
946
1060
|
}
|
|
947
|
-
|
|
1061
|
+
baseRef = probeMain.stdout !== null && probeMain.stdout.trim() !== ""
|
|
948
1062
|
? "origin/main"
|
|
949
1063
|
: probeMaster.stdout !== null && probeMaster.stdout.trim() !== "" ? "origin/master" : null;
|
|
950
1064
|
if (baseRef !== null && info.repoUrl !== void 0) {
|
|
@@ -987,9 +1101,9 @@ async function gitStatusUncached(dir, wantDetail, wantPr) {
|
|
|
987
1101
|
if (Number.isFinite(total)) info.branchCommitsTotal = total;
|
|
988
1102
|
}
|
|
989
1103
|
} else {
|
|
990
|
-
|
|
991
|
-
if (
|
|
992
|
-
info.branchCommits =
|
|
1104
|
+
// already fetched in the batch above — no second invocation
|
|
1105
|
+
if (logHead.stdout !== null && logHead.stdout.trim() !== "") {
|
|
1106
|
+
info.branchCommits = logHead.stdout.trim().split("\n").map((line) => {
|
|
993
1107
|
const [hash, subject, when] = line.split("\t");
|
|
994
1108
|
return { hash, subject: subject ?? "", when: when ?? "" };
|
|
995
1109
|
}).filter((c) => c.hash !== void 0);
|
|
@@ -1009,45 +1123,46 @@ async function gitStatusUncached(dir, wantDetail, wantPr) {
|
|
|
1009
1123
|
const pr = prStatusFor(toplevel, parsed.branch, notifyChange);
|
|
1010
1124
|
if (pr !== void 0) info.pr = pr;
|
|
1011
1125
|
}
|
|
1012
|
-
|
|
1126
|
+
if (baseRef !== null && info.pr !== void 0 && info.pr !== null && info.pr.number !== void 0
|
|
1127
|
+
&& info.pr.open !== false && (info.mainAhead || 0) > 0) {
|
|
1128
|
+
// The PR's OWN commits (base..HEAD) — the token's hover answers "what is in
|
|
1129
|
+
// this pull request", so it lists exactly that: not the branch's recent
|
|
1130
|
+
// history, nothing else. Hover-gated with the rest of detail=1, and the
|
|
1131
|
+
// total is the ahead count already computed above (no second rev-list).
|
|
1132
|
+
const prOut = await runGit(toplevel, ["log", "-10", "--format=%h%x09%s%x09%cr", baseRef + "..HEAD"]);
|
|
1133
|
+
if (prOut.stdout !== null && prOut.stdout.trim() !== "") {
|
|
1134
|
+
info.prCommits = prOut.stdout.trim().split("\n").map((line) => {
|
|
1135
|
+
const [hash, subject, when] = line.split("\t");
|
|
1136
|
+
return { hash, subject: subject ?? "", when: when ?? "" };
|
|
1137
|
+
}).filter((c) => c.hash !== void 0);
|
|
1138
|
+
info.prCommitsTotal = info.mainAhead;
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
// The ranked suggestion rides EVERY response (it is pure derivation, and a
|
|
1142
|
+
// sidebar row may one day use it instead of its own copy of these rules);
|
|
1143
|
+
// the full action list is sent only for `pr=1`, the surfaces that have a
|
|
1144
|
+
// picker, so a row's payload stays lean.
|
|
1145
|
+
const { actions, next } = nextActions(info);
|
|
1013
1146
|
if (next !== null) info.next = next;
|
|
1147
|
+
if (wantPr && actions.length > 0) info.actions = actions;
|
|
1014
1148
|
return info;
|
|
1015
1149
|
}
|
|
1016
1150
|
|
|
1017
1151
|
/** In-flight status reads, so N callers asking at once share one `git status`. */
|
|
1018
1152
|
const statusInFlight = new Map();
|
|
1019
|
-
/** Completed reads inside `config.statusCacheMs`; unused at the 0 default. */
|
|
1020
|
-
const statusCache = new Map();
|
|
1021
1153
|
|
|
1022
1154
|
/**
|
|
1023
1155
|
* Status for dir, with identical CONCURRENT reads collapsed into one. That is the
|
|
1024
1156
|
* shape a burst of sidebar session rows produces when they mount together: N rows
|
|
1025
1157
|
* of one workspace resolve to the same directory, and without this they would run
|
|
1026
|
-
* N `git status` walks of the same tree at the same instant.
|
|
1027
|
-
*
|
|
1028
|
-
*
|
|
1029
|
-
*
|
|
1030
|
-
* that mutates a repository and asks again read a stale answer, and correctness
|
|
1031
|
-
* of a *status* badge outranks saving a walk.
|
|
1158
|
+
* N `git status` walks of the same tree at the same instant. Deliberately NO
|
|
1159
|
+
* completed-read cache: one that outlived the call would let a caller that
|
|
1160
|
+
* mutates a repository and asks again read a stale answer, and correctness of a
|
|
1161
|
+
* *status* badge outranks saving a walk.
|
|
1032
1162
|
*/
|
|
1033
1163
|
function gitStatus(dir, wantDetail, wantPr) {
|
|
1034
1164
|
const key = dir + "\u0000" + (wantDetail === true) + "\u0000" + (wantPr === true);
|
|
1035
|
-
|
|
1036
|
-
const hit = statusCache.get(key);
|
|
1037
|
-
if (hit !== void 0 && Date.now() - hit.at < config.statusCacheMs) return Promise.resolve(hit.value);
|
|
1038
|
-
}
|
|
1039
|
-
const pending = statusInFlight.get(key);
|
|
1040
|
-
if (pending !== void 0) return pending;
|
|
1041
|
-
const run = gitStatusUncached(dir, wantDetail, wantPr)
|
|
1042
|
-
.then((value) => {
|
|
1043
|
-
if (config.statusCacheMs > 0) statusCache.set(key, { at: Date.now(), value });
|
|
1044
|
-
return value;
|
|
1045
|
-
})
|
|
1046
|
-
.finally(() => {
|
|
1047
|
-
statusInFlight.delete(key);
|
|
1048
|
-
});
|
|
1049
|
-
statusInFlight.set(key, run);
|
|
1050
|
-
return run;
|
|
1165
|
+
return singleFlight(statusInFlight, key, {}, () => gitStatusUncached(dir, wantDetail, wantPr));
|
|
1051
1166
|
}
|
|
1052
1167
|
|
|
1053
1168
|
//#region git-state watcher
|
|
@@ -1416,17 +1531,10 @@ async function resolveWorkspace(ctx, params) {
|
|
|
1416
1531
|
* directory that is not a worktree of this repository.
|
|
1417
1532
|
*/
|
|
1418
1533
|
function sessionCheckouts() {
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
? raw.sessions
|
|
1424
|
-
: {};
|
|
1425
|
-
} catch {
|
|
1426
|
-
return {};
|
|
1427
|
-
}
|
|
1428
|
-
}
|
|
1429
|
-
return readSessionCheckouts();
|
|
1534
|
+
// The store owns the file's shape; the test seam only redirects the path.
|
|
1535
|
+
return config.sessionCheckoutsFile === null
|
|
1536
|
+
? readSessionCheckouts()
|
|
1537
|
+
: readSessionCheckouts(config.sessionCheckoutsFile);
|
|
1430
1538
|
}
|
|
1431
1539
|
|
|
1432
1540
|
async function registeredCheckout(target, sessionId) {
|
|
@@ -1485,7 +1593,7 @@ function apply(ctx) {
|
|
|
1485
1593
|
// exists, seeds the namespace as its composition BASE so an order configured
|
|
1486
1594
|
// before this existed survives the upgrade; it is also the fallback when the
|
|
1487
1595
|
// settings service is absent (a host composed without dsh-settings).
|
|
1488
|
-
|
|
1596
|
+
seedOrder();
|
|
1489
1597
|
if (typeof ctx.inject === "function") {
|
|
1490
1598
|
try {
|
|
1491
1599
|
ctx.inject(["settings"], async (settingsCtx) => {
|
|
@@ -1500,12 +1608,12 @@ function apply(ctx) {
|
|
|
1500
1608
|
base: { order: legacyOrderFile() }
|
|
1501
1609
|
});
|
|
1502
1610
|
currentOrder = normalizeOrder(scope.get()?.order);
|
|
1503
|
-
settingsOrderActive = true;
|
|
1504
1611
|
settingsCtx.effect(() => scope.watch((next) => {
|
|
1505
1612
|
currentOrder = normalizeOrder(next?.order);
|
|
1506
1613
|
}), "dsh-git-badge: order settings");
|
|
1507
1614
|
settingsCtx.effect(() => () => {
|
|
1508
|
-
|
|
1615
|
+
// the namespace is gone: fall back to the boot-time seed
|
|
1616
|
+
seedOrder();
|
|
1509
1617
|
}, "dsh-git-badge: order settings teardown");
|
|
1510
1618
|
});
|
|
1511
1619
|
} catch (error) {
|
|
@@ -1648,7 +1756,8 @@ export {
|
|
|
1648
1756
|
config,
|
|
1649
1757
|
OPERATION_MARKERS,
|
|
1650
1758
|
operationMarker,
|
|
1651
|
-
|
|
1759
|
+
nextActions,
|
|
1760
|
+
seedOrder,
|
|
1652
1761
|
outerGitDir,
|
|
1653
1762
|
parseStatusV2,
|
|
1654
1763
|
parseWorktreeList,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-git-badge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.1",
|
|
4
4
|
"description": "Git status badges for DeepSeek Harness: an input-row chip on every install, plus sidebar session-row badges (git status, PR/CI action tokens) after one manual step: `npx dsh-git-badge apply`, then restart dsh web. Event-driven freshness over SSE.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -22,9 +22,6 @@
|
|
|
22
22
|
"patch": "./cordis.patch.yml"
|
|
23
23
|
},
|
|
24
24
|
"client": {
|
|
25
|
-
"inject": [
|
|
26
|
-
"@deepseek-ai/dsh-client-runtime"
|
|
27
|
-
],
|
|
28
25
|
"platform": "web"
|
|
29
26
|
}
|
|
30
27
|
},
|
|
@@ -58,6 +55,7 @@
|
|
|
58
55
|
"seam/anchors.js",
|
|
59
56
|
"seam/apply.js",
|
|
60
57
|
"seam/checkout.js",
|
|
58
|
+
"seam/patch.js",
|
|
61
59
|
"seam/postinstall.js",
|
|
62
60
|
"seam/skill.js",
|
|
63
61
|
"seam/store.js",
|