dsh-git-badge 0.14.1 → 0.16.0
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 +165 -0
- package/README.md +2 -2
- package/lib/client.js +604 -72
- package/lib/index.js +387 -187
- package/package.json +8 -3
- package/seam/checkout.js +82 -0
- package/seam/store.js +66 -1
- package/skill/gh/SKILL.md +46 -0
package/lib/index.js
CHANGED
|
@@ -69,7 +69,7 @@
|
|
|
69
69
|
import { execFile } from "node:child_process";
|
|
70
70
|
import { watch, existsSync, readFileSync } from "node:fs";
|
|
71
71
|
import { basename, isAbsolute, join, resolve } from "node:path";
|
|
72
|
-
import { clearRestartMarker, readRestartMarker } from "../seam/store.js";
|
|
72
|
+
import { SESSION_CHECKOUT_TTL_MS, clearRestartMarker, readRestartMarker, readSessionCheckouts } from "../seam/store.js";
|
|
73
73
|
|
|
74
74
|
const inject = ["webServer", "workspaceRegistry"];
|
|
75
75
|
const name = "dsh-git-badge";
|
|
@@ -82,6 +82,13 @@ const name = "dsh-git-badge";
|
|
|
82
82
|
* single documented seam.
|
|
83
83
|
*/
|
|
84
84
|
const config = {
|
|
85
|
+
/**
|
|
86
|
+
* Test seam: the session-checkout registrations file. Null (the default)
|
|
87
|
+
* resolves it under the plugin's data dir (SEAM_DATA_DIR-aware store.js).
|
|
88
|
+
*/
|
|
89
|
+
sessionCheckoutsFile: null,
|
|
90
|
+
/** Test seam: resolves the schema lib the settings namespace is built from. */
|
|
91
|
+
settingsSchemaLoader: async () => (await import("@deepseek-ai/schemastery")).default,
|
|
85
92
|
/** budget for every git invocation; an expired call is killed */
|
|
86
93
|
gitTimeoutMs: 3000,
|
|
87
94
|
/** network fetch budget; worst-case route latency, paid once per TTL */
|
|
@@ -380,43 +387,6 @@ async function readPrStatus(toplevel, branch) {
|
|
|
380
387
|
return summarizePr(json);
|
|
381
388
|
}
|
|
382
389
|
|
|
383
|
-
/** toplevel -> { lastAttemptAt, inFlight, value: Map<branch, pr> }. */
|
|
384
|
-
const prListState = new Map();
|
|
385
|
-
|
|
386
|
-
/**
|
|
387
|
-
* Every OPEN pull request in `toplevel`'s repository, keyed by its head branch.
|
|
388
|
-
* ONE `gh pr list` answers the whole repository, which is what makes worktree
|
|
389
|
-
* selection affordable: N worktrees cost one forge call, not N.
|
|
390
|
-
*
|
|
391
|
-
* Degrades exactly like readPrStatus, and for the same reason: a non-array body,
|
|
392
|
-
* unparseable output, a logged-out `gh`, a non-GitHub `origin` and a timeout all
|
|
393
|
-
* resolve an empty map. "No PRs" and "no forge" are the same answer here — both
|
|
394
|
-
* mean no worktree can be shown to be the one the session is working in.
|
|
395
|
-
*/
|
|
396
|
-
async function readOpenPrs(toplevel) {
|
|
397
|
-
if (!(await originIsGitHub(toplevel))) return new Map();
|
|
398
|
-
const out = await runPrCli("gh", ["pr", "list", "--state", "open", "--limit", String(config.prListLimit), "--json", "number,headRefName,state,isDraft,reviewDecision,statusCheckRollup,url"], {
|
|
399
|
-
cwd: toplevel,
|
|
400
|
-
timeout: config.prTimeoutMs,
|
|
401
|
-
env: { GH_PROMPT_DISABLED: "1", GH_NO_UPDATE_NOTIFIER: "1", GH_PAGER: "cat", NO_COLOR: "1" }
|
|
402
|
-
});
|
|
403
|
-
if (out.stdout === null) return new Map();
|
|
404
|
-
let json;
|
|
405
|
-
try {
|
|
406
|
-
json = JSON.parse(out.stdout);
|
|
407
|
-
} catch {
|
|
408
|
-
return new Map();
|
|
409
|
-
}
|
|
410
|
-
if (!Array.isArray(json)) return new Map();
|
|
411
|
-
const byBranch = new Map();
|
|
412
|
-
for (const row of json) {
|
|
413
|
-
const branch = row?.headRefName;
|
|
414
|
-
if (typeof branch !== "string" || branch === "") continue;
|
|
415
|
-
const pr = summarizePr(row);
|
|
416
|
-
if (pr !== void 0) byBranch.set(branch, pr);
|
|
417
|
-
}
|
|
418
|
-
return byBranch;
|
|
419
|
-
}
|
|
420
390
|
|
|
421
391
|
/** Order-insensitive identity for an open-PR set, so "did it change?" is exact. */
|
|
422
392
|
function prListKey(byBranch) {
|
|
@@ -424,63 +394,6 @@ function prListKey(byBranch) {
|
|
|
424
394
|
return JSON.stringify([...byBranch].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
|
|
425
395
|
}
|
|
426
396
|
|
|
427
|
-
/**
|
|
428
|
-
* Open PRs for toplevel: the value to serve NOW (possibly undefined), spawning an
|
|
429
|
-
* out-of-band refresh when the TTL has lapsed — the same shape and the same
|
|
430
|
-
* reasoning as prStatusFor, because the forge is never route latency.
|
|
431
|
-
*
|
|
432
|
-
* `notify` fires only when the set CHANGES: a changed set can change which
|
|
433
|
-
* worktree a session badge follows, so the client has to be told to refetch
|
|
434
|
-
* rather than waiting for its 60s safety-net poll.
|
|
435
|
-
*/
|
|
436
|
-
function openPrsFor(toplevel, notify) {
|
|
437
|
-
if (config.prStatus === "off") return void 0;
|
|
438
|
-
const now = Date.now();
|
|
439
|
-
const state = prListState.get(toplevel);
|
|
440
|
-
if (state !== void 0) {
|
|
441
|
-
// Fresh, or a refresh is already running: serve what we have either way.
|
|
442
|
-
if (now - state.lastAttemptAt < config.prTtlMs || state.inFlight !== null) return state.value;
|
|
443
|
-
}
|
|
444
|
-
const record = { lastAttemptAt: now, inFlight: null, value: state?.value };
|
|
445
|
-
const inFlight = (async () => {
|
|
446
|
-
const value = await readOpenPrs(toplevel).catch(() => void 0);
|
|
447
|
-
if (value !== void 0) {
|
|
448
|
-
const changed = prListKey(record.value) !== prListKey(value);
|
|
449
|
-
record.value = value;
|
|
450
|
-
if (changed) notify(toplevel);
|
|
451
|
-
}
|
|
452
|
-
record.lastAttemptAt = Date.now();
|
|
453
|
-
record.inFlight = null;
|
|
454
|
-
return value;
|
|
455
|
-
})();
|
|
456
|
-
record.inFlight = inFlight;
|
|
457
|
-
prListState.set(toplevel, record);
|
|
458
|
-
return record.value;
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
/**
|
|
462
|
-
* The one linked worktree a session badge may follow: exactly one entry other
|
|
463
|
-
* than the checkout itself that is checked out on a branch with an OPEN PR.
|
|
464
|
-
*
|
|
465
|
-
* Exactly one, because the plugin cannot know which tree a session uses — DSH
|
|
466
|
-
* records no session→worktree link at all — so an ambiguous repository stays on
|
|
467
|
-
* its own checkout rather than guessing. A second open PR is a reason to say
|
|
468
|
-
* nothing, not a reason to pick one. Detached, bare and prunable entries are
|
|
469
|
-
* never candidates: they have no branch a PR could belong to.
|
|
470
|
-
*/
|
|
471
|
-
function selectSessionWorktree(worktrees, openPrs, checkoutPath) {
|
|
472
|
-
if (openPrs === void 0 || openPrs.size === 0) return void 0;
|
|
473
|
-
const candidates = worktrees.filter((wt) =>
|
|
474
|
-
wt.path !== checkoutPath &&
|
|
475
|
-
wt.detached !== true &&
|
|
476
|
-
wt.bare !== true &&
|
|
477
|
-
wt.prunable !== true &&
|
|
478
|
-
typeof wt.branch === "string" &&
|
|
479
|
-
openPrs.has(wt.branch)
|
|
480
|
-
);
|
|
481
|
-
return candidates.length === 1 ? candidates[0] : void 0;
|
|
482
|
-
}
|
|
483
|
-
|
|
484
397
|
/**
|
|
485
398
|
* PR state for toplevel: the value to serve NOW (possibly undefined, possibly
|
|
486
399
|
* stale), spawning an out-of-band refresh when the TTL has lapsed. `notify` is
|
|
@@ -522,6 +435,32 @@ function prStatusFor(toplevel, branch, notify) {
|
|
|
522
435
|
* two-column XY status (X = index/staged, Y = worktree), `u` unmerged,
|
|
523
436
|
* `?` untracked.
|
|
524
437
|
*/
|
|
438
|
+
/** Relative path shortened to its last two segments — the hover-name contract. */
|
|
439
|
+
function shortenPath(name) {
|
|
440
|
+
const parts = name.split("/");
|
|
441
|
+
return parts.length <= 2 ? name : parts.slice(-2).join("/");
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Convert a remote URL to the hosted repo's WEB root: https stays https,
|
|
446
|
+
* `git@host:path` ssh syntax converts, a `.git` suffix is stripped. Null when
|
|
447
|
+
* the URL is neither form — the caller decides what a null is worth.
|
|
448
|
+
*/
|
|
449
|
+
function remoteToWebUrl(raw) {
|
|
450
|
+
const trimmed = raw.trim().replace(/\.git$/u, "");
|
|
451
|
+
const https = trimmed.match(/^https:\/\/([^/]+)\/(.+?)\/?$/u);
|
|
452
|
+
const ssh = trimmed.match(/^[^@]+@([^:]+):(.+)$/u);
|
|
453
|
+
const web = https !== null ? https[1] + "/" + https[2] : ssh !== null ? ssh[1] + "/" + ssh[2] : null;
|
|
454
|
+
if (web === null) return null;
|
|
455
|
+
try {
|
|
456
|
+
const url = new URL("https://" + web);
|
|
457
|
+
if (url.protocol === "https:" && url.pathname.length > 1) return url.origin + url.pathname.replace(/\/$/u, "");
|
|
458
|
+
} catch {
|
|
459
|
+
// not a URL we can vouch for
|
|
460
|
+
}
|
|
461
|
+
return null;
|
|
462
|
+
}
|
|
463
|
+
|
|
525
464
|
function parseStatusV2(out) {
|
|
526
465
|
let branch = "HEAD (detached)";
|
|
527
466
|
let upstream;
|
|
@@ -531,6 +470,9 @@ function parseStatusV2(out) {
|
|
|
531
470
|
let unstaged = 0;
|
|
532
471
|
let unmerged = 0;
|
|
533
472
|
let untracked = 0;
|
|
473
|
+
const untrackedNames = [];
|
|
474
|
+
const unstagedNames = [];
|
|
475
|
+
const stagedNames = [];
|
|
534
476
|
for (const line of out.split("\n")) {
|
|
535
477
|
if (line.startsWith("# branch.head ")) branch = line.slice(14).trim();
|
|
536
478
|
else if (line.startsWith("# branch.upstream ")) upstream = line.slice(18).trim();
|
|
@@ -543,12 +485,34 @@ function parseStatusV2(out) {
|
|
|
543
485
|
if (Number.isFinite(b)) behind = b;
|
|
544
486
|
} else if (line.startsWith("1 ") || line.startsWith("2 ")) {
|
|
545
487
|
// XY columns: X = index (staged), Y = worktree (unstaged)
|
|
546
|
-
if (line[2] !== ".")
|
|
547
|
-
|
|
488
|
+
if (line[2] !== ".") {
|
|
489
|
+
staged += 1;
|
|
490
|
+
const path = line.startsWith("2 ")
|
|
491
|
+
? line.split("\t").pop().trim()
|
|
492
|
+
: line.split(" ").slice(8).join(" ").trim();
|
|
493
|
+
if (path !== "") stagedNames.push(path);
|
|
494
|
+
}
|
|
495
|
+
if (line[3] !== ".") {
|
|
496
|
+
unstaged += 1;
|
|
497
|
+
// the path is the LAST field in both records (space-separated, and
|
|
498
|
+
// the path itself may contain spaces) — collected untruncated and
|
|
499
|
+
// capped by the caller, exactly like the untracked names
|
|
500
|
+
const path = line.startsWith("2 ")
|
|
501
|
+
? line.split("\t").pop().trim()
|
|
502
|
+
: line.split(" ").slice(8).join(" ").trim();
|
|
503
|
+
if (path !== "") unstagedNames.push(path);
|
|
504
|
+
}
|
|
548
505
|
} else if (line.startsWith("u ")) unmerged += 1;
|
|
549
|
-
else if (line.startsWith("? "))
|
|
506
|
+
else if (line.startsWith("? ")) {
|
|
507
|
+
untracked += 1;
|
|
508
|
+
// `-uall` prints one `? <path>` per untracked FILE, so the names are
|
|
509
|
+
// the lines themselves — collected untruncated here and capped by the
|
|
510
|
+
// caller, so the hover's "and k more" arithmetic stays honest.
|
|
511
|
+
const name = line.slice(2).trim();
|
|
512
|
+
if (name !== "") untrackedNames.push(name);
|
|
513
|
+
}
|
|
550
514
|
}
|
|
551
|
-
return { branch, upstream, ahead, behind, staged, unstaged, unmerged, untracked };
|
|
515
|
+
return { branch, upstream, ahead, behind, staged, unstaged, unmerged, untracked, untrackedNames, unstagedNames, stagedNames };
|
|
552
516
|
}
|
|
553
517
|
|
|
554
518
|
/**
|
|
@@ -678,60 +642,136 @@ const OPERATION_NEXT = {
|
|
|
678
642
|
};
|
|
679
643
|
|
|
680
644
|
/**
|
|
681
|
-
* The
|
|
682
|
-
*
|
|
683
|
-
*
|
|
684
|
-
*
|
|
685
|
-
*
|
|
645
|
+
* The configurable action ranking. Every rule carries a category; the order
|
|
646
|
+
* below (first match wins) is the default, and a user can override it by
|
|
647
|
+
* naming categories in `~/.dsh/git-badge-next.json`:
|
|
648
|
+
*
|
|
649
|
+
* { "order": ["operation", "unmerged", "sync", "publish", "merge", "commit", "checks"] }
|
|
650
|
+
*
|
|
651
|
+
* Any subset in any order; categories not listed rank after the listed ones in
|
|
652
|
+
* default order, unknown names are ignored. Typically edited for you by the
|
|
653
|
+
* agent via `/gh order`.
|
|
654
|
+
*/
|
|
655
|
+
const DEFAULT_NEXT_ORDER = ["operation", "unmerged", "sync", "publish", "merge", "commit", "checks"];
|
|
656
|
+
|
|
657
|
+
/** Normalise any order list: known categories first, then the rest in default order. */
|
|
658
|
+
function normalizeOrder(order) {
|
|
659
|
+
if (!Array.isArray(order)) return DEFAULT_NEXT_ORDER;
|
|
660
|
+
const known = order.filter((cat) => DEFAULT_NEXT_ORDER.includes(cat));
|
|
661
|
+
return [...known, ...DEFAULT_NEXT_ORDER.filter((cat) => !known.includes(cat))];
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/** The settings namespace this plugin owns — its order lives there, live. */
|
|
665
|
+
const SETTINGS_NS = "git-badge";
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* The namespace schema, built from the host-provided schema lib — imported
|
|
669
|
+
* LAZILY because this plugin's module graph must load without it (the test
|
|
670
|
+
* suite runs from a bare checkout, and a host without the settings stack should
|
|
671
|
+
* lose only the settings card, never the badge).
|
|
672
|
+
*/
|
|
673
|
+
function orderSettingsSchema(Schema) {
|
|
674
|
+
return Schema.object({
|
|
675
|
+
order: Schema.array(Schema.union(DEFAULT_NEXT_ORDER)).default(DEFAULT_NEXT_ORDER)
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* The pre-settings override file. Still honoured as the namespace's BASE (a
|
|
681
|
+
* composition default, so a user who configured the JSON keeps their order
|
|
682
|
+
* after the upgrade) and as a fallback when the settings service is absent.
|
|
683
|
+
*/
|
|
684
|
+
function legacyOrderFile() {
|
|
685
|
+
try {
|
|
686
|
+
const home = process.env.DSH_HOME || join(process.env.HOME || "", ".dsh");
|
|
687
|
+
const raw = JSON.parse(readFileSync(join(home, "git-badge-next.json"), "utf8"));
|
|
688
|
+
return normalizeOrder(raw?.order);
|
|
689
|
+
} catch {
|
|
690
|
+
return DEFAULT_NEXT_ORDER;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* The live order — one in-memory value, updated by the settings scope. It is
|
|
696
|
+
* authoritative only once the namespace actually registered; until then (and on
|
|
697
|
+
* a host without the settings stack, or in a unit test that never calls apply)
|
|
698
|
+
* the legacy file is read per call, which is the pre-settings behaviour.
|
|
699
|
+
*/
|
|
700
|
+
let currentOrder = DEFAULT_NEXT_ORDER;
|
|
701
|
+
let settingsOrderActive = false;
|
|
702
|
+
|
|
703
|
+
/** The ranking order every status read uses. */
|
|
704
|
+
function nextOrder() {
|
|
705
|
+
return settingsOrderActive ? currentOrder : legacyOrderFile();
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* The hover card's "action" row and the (+) menu's picker: every sub-action the
|
|
710
|
+
* checkout justifies, each as a `gh`-skill invocation ({ args, why, what } —
|
|
711
|
+
* what the invocation DOES, why THIS checkout justifies it now), ranked. Pure
|
|
712
|
+
* derivation over fields the status response already carries — no extra git
|
|
713
|
+
* invocations, so it rides the base response.
|
|
714
|
+
*
|
|
715
|
+
* Ranking: rule candidates are collected with a category, then sorted by the
|
|
716
|
+
* configured order (nextOrder) — NOT first-match — so a configuration can, for
|
|
717
|
+
* example, rank "commit" above "sync" without touching code. Ties keep
|
|
718
|
+
* collection order.
|
|
686
719
|
*
|
|
687
|
-
*
|
|
688
|
-
*
|
|
689
|
-
*
|
|
690
|
-
*
|
|
691
|
-
*
|
|
692
|
-
*
|
|
693
|
-
*
|
|
694
|
-
* 6. PR checks failing — watch them land
|
|
720
|
+
* The new rules this generation adds:
|
|
721
|
+
* - diverged (ahead AND behind) → `sync`: reconciling means rebase-then-push,
|
|
722
|
+
* which the agent must confirm step by step — a plain pull would discard
|
|
723
|
+
* the local-commit context
|
|
724
|
+
* - merge-ready (`pr.mergeState === "CLEAN"`, GitHub's own verdict, or checks
|
|
725
|
+
* passing + review approved) → `merge <n>`: the endgame action the old
|
|
726
|
+
* table could never see
|
|
695
727
|
*
|
|
696
728
|
* Clean, synced, nothing failing → null: the row is omitted entirely, because
|
|
697
|
-
* "no suggestion" is also a suggestion.
|
|
698
|
-
* the card COPIES them into the user's terminal, it never runs them, but the
|
|
699
|
-
* register still avoids anything destructive.
|
|
729
|
+
* "no suggestion" is also a suggestion.
|
|
700
730
|
*
|
|
701
|
-
* @returns {{ command: string|undefined, why: string } | null}
|
|
731
|
+
* @returns {{ args: string, command: string|undefined, why: string, what: string } | null}
|
|
702
732
|
*/
|
|
703
733
|
function nextStep(info) {
|
|
704
734
|
if (info === void 0 || info === null || info.git !== true) return null;
|
|
705
735
|
const unmerged = info.unmergedFiles || 0;
|
|
706
736
|
const plural = (n) => (n === 1 ? "" : "s");
|
|
737
|
+
const ahead = info.ahead || 0;
|
|
738
|
+
const behind = info.behind || 0;
|
|
739
|
+
const staged = info.stagedFiles || 0;
|
|
740
|
+
const unstaged = info.unstagedFiles || 0;
|
|
741
|
+
const untracked = info.untrackedFiles || 0;
|
|
742
|
+
const dirty = staged + unstaged + untracked;
|
|
743
|
+
const rules = [];
|
|
744
|
+
const add = (cat, args, why, what, command) => rules.push({ cat, args, why, what, command });
|
|
745
|
+
|
|
707
746
|
if (info.operation !== void 0 && info.operation !== null) {
|
|
708
747
|
const op = String(info.operation);
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
why: unmerged > 0
|
|
748
|
+
add("operation", "next",
|
|
749
|
+
unmerged > 0
|
|
712
750
|
? `${unmerged} unmerged file${plural(unmerged)} blocking the paused ${op}`
|
|
713
|
-
: `a ${op} is paused mid-operation
|
|
714
|
-
|
|
751
|
+
: `a ${op} is paused mid-operation`,
|
|
752
|
+
`resume the paused ${op}`,
|
|
753
|
+
OPERATION_NEXT[op]);
|
|
715
754
|
}
|
|
716
755
|
if (unmerged > 0) {
|
|
717
|
-
|
|
756
|
+
add("unmerged", "next", `${unmerged} unmerged file${plural(unmerged)} to resolve`, "resolve the unmerged files (with you)", "git status");
|
|
718
757
|
}
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
if (behind > 0) {
|
|
722
|
-
|
|
758
|
+
if (behind > 0 && ahead > 0) {
|
|
759
|
+
add("sync", "sync", `${ahead} ahead, ${behind} behind — diverged`, "rebase your commits onto upstream and push (asks before any force)");
|
|
760
|
+
} else if (behind > 0) {
|
|
761
|
+
add("sync", "pull", `${behind} behind ${info.upstream ?? "upstream"}`, "update this branch from upstream", "git pull --ff-only");
|
|
723
762
|
}
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
return { command: `git push -u origin ${info.branch}`, why: "no upstream configured" };
|
|
763
|
+
if (info.upstream === void 0 && dirty > 0) {
|
|
764
|
+
add("publish", "push", "no upstream configured", `publish ${info.branch} for the first time`, `git push -u origin ${info.branch}`);
|
|
765
|
+
} else if (ahead > 0) {
|
|
766
|
+
add("publish", "push", `${ahead} ahead of ${info.upstream ?? "upstream"}`, "push local commits to the remote", "git push");
|
|
729
767
|
}
|
|
730
|
-
|
|
731
|
-
|
|
768
|
+
const pr = info.pr === void 0 || info.pr === null ? void 0 : info.pr;
|
|
769
|
+
if (pr !== void 0 && pr.number !== void 0 && pr.open !== false
|
|
770
|
+
&& (pr.mergeState === "CLEAN" || (pr.state === "passing" && pr.review === "APPROVED"))) {
|
|
771
|
+
add("merge", `merge ${pr.number}`, `pull request ${pr.number} is ready to merge`, `merge pull request ${pr.number} (squash)`);
|
|
732
772
|
}
|
|
733
|
-
if (
|
|
734
|
-
const
|
|
773
|
+
if (dirty > 0) {
|
|
774
|
+
const parts = [
|
|
735
775
|
staged > 0 ? `${staged} staged` : null,
|
|
736
776
|
unstaged > 0 ? `${unstaged} unstaged` : null,
|
|
737
777
|
untracked > 0 ? `${untracked} untracked` : null
|
|
@@ -741,12 +781,20 @@ function nextStep(info) {
|
|
|
741
781
|
: untracked > 0 && unstaged === 0
|
|
742
782
|
? "git add -A && git commit"
|
|
743
783
|
: "git add -p && git commit";
|
|
744
|
-
|
|
784
|
+
add("commit", "commit", `work to commit: ${parts}`, staged > 0 ? "commit the staged changes" : "stage and commit the working changes", command);
|
|
745
785
|
}
|
|
746
|
-
if (
|
|
747
|
-
|
|
786
|
+
if (pr !== void 0 && pr.number !== void 0 && pr.state === "failing") {
|
|
787
|
+
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`);
|
|
748
788
|
}
|
|
749
|
-
return null;
|
|
789
|
+
if (rules.length === 0) return null;
|
|
790
|
+
|
|
791
|
+
const order = nextOrder();
|
|
792
|
+
const rank = (cat) => {
|
|
793
|
+
const at = order.indexOf(cat);
|
|
794
|
+
return at === -1 ? order.length : at;
|
|
795
|
+
};
|
|
796
|
+
const [top] = [...rules].sort((a, b) => rank(a.cat) - rank(b.cat));
|
|
797
|
+
return { args: top.args, command: top.command, why: top.why, what: top.what };
|
|
750
798
|
}
|
|
751
799
|
|
|
752
800
|
/**
|
|
@@ -850,16 +898,103 @@ async function gitStatusUncached(dir, wantDetail, wantPr) {
|
|
|
850
898
|
behind: parsed.behind
|
|
851
899
|
};
|
|
852
900
|
if (wantDetail) {
|
|
853
|
-
//
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
901
|
+
// The ✎n NAMES — the one part of the count the numbers cannot answer
|
|
902
|
+
// ("what are these?"). Hover-gated with the rest of detail=1, so no
|
|
903
|
+
// refresh pays for it. Posture: RELATIVE names only (git's own output,
|
|
904
|
+
// never absolute — AGENTS.md rule 7 is relaxed by an inch, not a mile),
|
|
905
|
+
// shortened to the last two path segments, capped at 20 with the total
|
|
906
|
+
// carried separately so the client can say "… and k more" honestly.
|
|
907
|
+
// Staged and unstaged names are TRACKED files: the collapsed untracked
|
|
908
|
+
// retry never affects them, so they ride regardless of untrackedMode.
|
|
909
|
+
const capped = (names) => names.slice(0, 20).map(shortenPath);
|
|
910
|
+
if (untrackedMode === "all" && parsed.untrackedNames.length > 0) {
|
|
911
|
+
info.untrackedNames = capped(parsed.untrackedNames);
|
|
912
|
+
info.untrackedNamesTotal = parsed.untrackedNames.length;
|
|
913
|
+
}
|
|
914
|
+
if (parsed.unstagedNames.length > 0) {
|
|
915
|
+
info.unstagedNames = capped(parsed.unstagedNames);
|
|
916
|
+
info.unstagedNamesTotal = parsed.unstagedNames.length;
|
|
917
|
+
}
|
|
918
|
+
if (parsed.stagedNames.length > 0) {
|
|
919
|
+
info.stagedNames = capped(parsed.stagedNames);
|
|
920
|
+
info.stagedNamesTotal = parsed.stagedNames.length;
|
|
921
|
+
}
|
|
922
|
+
// The signed commit list and the counts-vs-main row: ahead/behind against origin/main (then
|
|
923
|
+
// origin/master) is the merge-state line — "3 ahead, 0 behind" reads as
|
|
924
|
+
// "this is the PR's content"; "0 ahead, 5 behind" as "stale, rebase
|
|
925
|
+
// first". One rev-list --left-right, hover-gated like everything here,
|
|
926
|
+
// absent entirely when both are zero (in sync needs no row).
|
|
927
|
+
// The repo's WEB URL — the branch hover links the branch name to the
|
|
928
|
+
// compare view (`<repo>/compare/<base>...<branch>`), and that URL must be
|
|
929
|
+
// vouched for like pr.url is: http(s) only, rebuilt from the parsed parts
|
|
930
|
+
// so no payload value can reach an anchor as a scheme. Derived from
|
|
931
|
+
// `remote get-url origin` with ssh syntax converted; absent when there is
|
|
932
|
+
// no origin or the URL is not recognisably a hosted repo.
|
|
933
|
+
// These reads are independent — one batch, so hover latency is the
|
|
934
|
+
// longest call rather than their sum. The only dependent call (the
|
|
935
|
+
// left-right count needs a probed base ref) runs after the batch and
|
|
936
|
+
// tolerates its own failure.
|
|
937
|
+
const [remoteUrl, probeMain, probeMaster, logOut2, stashOut] = await Promise.all([
|
|
938
|
+
runGit(toplevel, ["remote", "get-url", "origin"]),
|
|
939
|
+
runGit(toplevel, ["rev-parse", "--verify", "--quiet", "origin/main"]),
|
|
940
|
+
runGit(toplevel, ["rev-parse", "--verify", "--quiet", "origin/master"]),
|
|
941
|
+
runGit(toplevel, ["log", "-10", "--format=%h%x09%s%x09%cr", "HEAD"]),
|
|
942
|
+
runGit(toplevel, ["stash", "list"])
|
|
943
|
+
]);
|
|
944
|
+
if (remoteUrl.stdout !== null && remoteUrl.stdout.trim() !== "") {
|
|
945
|
+
info.repoUrl = remoteToWebUrl(remoteUrl.stdout);
|
|
946
|
+
}
|
|
947
|
+
const baseRef = probeMain.stdout !== null && probeMain.stdout.trim() !== ""
|
|
948
|
+
? "origin/main"
|
|
949
|
+
: probeMaster.stdout !== null && probeMaster.stdout.trim() !== "" ? "origin/master" : null;
|
|
950
|
+
if (baseRef !== null && info.repoUrl !== void 0) {
|
|
951
|
+
const baseName = baseRef.replace(/^origin\//u, "");
|
|
952
|
+
try {
|
|
953
|
+
const compare = new URL(info.repoUrl + "/compare/" + encodeURIComponent(baseName) + "..." + encodeURIComponent(info.branch));
|
|
954
|
+
if (compare.protocol === "https:") info.compareUrl = compare.href;
|
|
955
|
+
} catch {
|
|
956
|
+
// absent rather than wrong
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
if (baseRef !== null) {
|
|
960
|
+
const lrOut = await runGit(toplevel, ["rev-list", "--count", "--left-right", baseRef + "...HEAD"]);
|
|
961
|
+
const [mBehind, mAhead] = (lrOut.stdout ?? "").trim().split("\t").map((v) => Number.parseInt(v, 10));
|
|
962
|
+
if (Number.isFinite(mBehind) && Number.isFinite(mAhead) && (mBehind > 0 || mAhead > 0)) {
|
|
963
|
+
info.mainAhead = mAhead;
|
|
964
|
+
info.mainBehind = mBehind;
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
if (baseRef !== null) {
|
|
968
|
+
// The signed commit list: mixed newest-first from base...HEAD, each
|
|
969
|
+
// entry signed "+" (only on this branch - the PR's content) or "−"
|
|
970
|
+
// (only on main - what a rebase/merge brings in), git's own left-right
|
|
971
|
+
// verdict mapped onto the diff convention. Capped at 10 with the total
|
|
972
|
+
// from the same base so "... and k more" counts both sides. Without a
|
|
973
|
+
// default branch the list degrades to the plain last 10 on HEAD.
|
|
974
|
+
const signedOut = await runGit(toplevel, ["log", "--left-right", "-10", "--format=%m%x09%h%x09%s%x09%cr", baseRef + "...HEAD"]);
|
|
975
|
+
if (signedOut.stdout !== null && signedOut.stdout.trim() !== "") {
|
|
976
|
+
info.branchCommits = signedOut.stdout.trim().split("\n").map((line) => {
|
|
977
|
+
const [marker, hash, subject, when] = line.split("\t");
|
|
978
|
+
return {
|
|
979
|
+
sign: marker === ">" ? "+" : marker === "<" ? "−" : void 0,
|
|
980
|
+
hash,
|
|
981
|
+
subject: subject ?? "",
|
|
982
|
+
when: when ?? ""
|
|
983
|
+
};
|
|
984
|
+
}).filter((c) => c.hash !== void 0);
|
|
985
|
+
const totalOut = await runGit(toplevel, ["rev-list", "--count", baseRef + "...HEAD"]);
|
|
986
|
+
const total = Number.parseInt((totalOut.stdout ?? "").trim(), 10);
|
|
987
|
+
if (Number.isFinite(total)) info.branchCommitsTotal = total;
|
|
988
|
+
}
|
|
989
|
+
} else {
|
|
990
|
+
const plainOut = await runGit(toplevel, ["log", "-10", "--format=%h%x09%s%x09%cr", "HEAD"]);
|
|
991
|
+
if (plainOut.stdout !== null && plainOut.stdout.trim() !== "") {
|
|
992
|
+
info.branchCommits = plainOut.stdout.trim().split("\n").map((line) => {
|
|
993
|
+
const [hash, subject, when] = line.split("\t");
|
|
994
|
+
return { hash, subject: subject ?? "", when: when ?? "" };
|
|
995
|
+
}).filter((c) => c.hash !== void 0);
|
|
996
|
+
}
|
|
860
997
|
}
|
|
861
|
-
// stash count, only surfaced when nonzero
|
|
862
|
-
const stashOut = await runGit(toplevel, ["stash", "list"]);
|
|
863
998
|
if (stashOut.stdout !== null) {
|
|
864
999
|
const count = stashOut.stdout.split("\n").filter((l) => l.trim() !== "").length;
|
|
865
1000
|
if (count > 0) info.stashCount = count;
|
|
@@ -1149,10 +1284,10 @@ function watchWorkspace(root, key, workspaceId) {
|
|
|
1149
1284
|
}
|
|
1150
1285
|
|
|
1151
1286
|
/**
|
|
1152
|
-
* Workspace path -> { worktreePath, workspaceId, owned } for the
|
|
1153
|
-
*
|
|
1154
|
-
*
|
|
1155
|
-
*
|
|
1287
|
+
* Workspace path -> { worktreePath, workspaceId, owned } for the worktree a
|
|
1288
|
+
* session badge is currently following. One per workspace: a session follows at
|
|
1289
|
+
* most one tree, so following a different tree (or none) retires the previous
|
|
1290
|
+
* entry here.
|
|
1156
1291
|
*/
|
|
1157
1292
|
const selectedWorktrees = new Map();
|
|
1158
1293
|
|
|
@@ -1266,48 +1401,117 @@ async function resolveWorkspace(ctx, params) {
|
|
|
1266
1401
|
return { status: 404, error: { code: "session-not-found", message: "no registered workspace owns that session" } };
|
|
1267
1402
|
}
|
|
1268
1403
|
|
|
1404
|
+
/**
|
|
1405
|
+
* The checkout a session registered for itself, or undefined.
|
|
1406
|
+
*
|
|
1407
|
+
* This is the ONLY source for "which worktree is this session using", and it has
|
|
1408
|
+
* to be explicit: DSH records no session→worktree link (a session's cwd is
|
|
1409
|
+
* immutable creation metadata and always the main checkout), so nothing in the
|
|
1410
|
+
* host can answer it. The agent writes the registration with
|
|
1411
|
+
* `dsh-git-badge-checkout <path>`; the file is advisory, so it is validated
|
|
1412
|
+
* here before it is believed — the path must be a worktree git itself lists,
|
|
1413
|
+
* the entry must be fresher than SESSION_CHECKOUT_TTL_MS, and the name/branch
|
|
1414
|
+
* come from git rather than from the file. A stale, hand-edited or removed
|
|
1415
|
+
* registration can therefore only fail to resolve, never point the badge at a
|
|
1416
|
+
* directory that is not a worktree of this repository.
|
|
1417
|
+
*/
|
|
1418
|
+
function sessionCheckouts() {
|
|
1419
|
+
if (typeof config.sessionCheckoutsFile === "string") {
|
|
1420
|
+
try {
|
|
1421
|
+
const raw = JSON.parse(readFileSync(config.sessionCheckoutsFile, "utf8"));
|
|
1422
|
+
return raw !== null && typeof raw === "object" && typeof raw.sessions === "object" && raw.sessions !== null
|
|
1423
|
+
? raw.sessions
|
|
1424
|
+
: {};
|
|
1425
|
+
} catch {
|
|
1426
|
+
return {};
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
return readSessionCheckouts();
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
async function registeredCheckout(target, sessionId) {
|
|
1433
|
+
if (typeof sessionId !== "string" || sessionId === "") return void 0;
|
|
1434
|
+
const entry = sessionCheckouts()[sessionId];
|
|
1435
|
+
if (entry === void 0 || typeof entry?.path !== "string") return void 0;
|
|
1436
|
+
const at = Date.parse(String(entry.at ?? ""));
|
|
1437
|
+
if (!Number.isFinite(at) || Date.now() - at > SESSION_CHECKOUT_TTL_MS) return void 0;
|
|
1438
|
+
const { toplevel, worktrees } = await worktreesFor(target.path);
|
|
1439
|
+
// Git lists the main worktree first, and a registration only means something
|
|
1440
|
+
// when the session's own directory IS that main checkout: a session already
|
|
1441
|
+
// inside a linked worktree is its own answer and is never swapped sideways.
|
|
1442
|
+
const main = worktrees[0];
|
|
1443
|
+
if (toplevel === "" || main === void 0 || main.path !== toplevel || target.path !== toplevel || worktrees.length <= 1) return void 0;
|
|
1444
|
+
const found = worktrees.find((tree) => tree.path === entry.path);
|
|
1445
|
+
if (found === void 0 || found.path === toplevel) return void 0;
|
|
1446
|
+
return { path: found.path, name: found.name, branch: found.branch };
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1269
1449
|
/**
|
|
1270
1450
|
* The directory a request is ABOUT.
|
|
1271
1451
|
*
|
|
1272
|
-
* A SESSION-targeted chip
|
|
1273
|
-
*
|
|
1274
|
-
*
|
|
1275
|
-
*
|
|
1276
|
-
*
|
|
1277
|
-
*
|
|
1278
|
-
* place that knows which tree was chosen.
|
|
1452
|
+
* A SESSION-targeted chip follows the checkout that session REGISTERED — the one
|
|
1453
|
+
* signal that knows where the agent is actually working. Without a registration
|
|
1454
|
+
* the request stays on the session's own directory: the plugin never guesses a
|
|
1455
|
+
* worktree from pull requests (an earlier build inferred one from the branch
|
|
1456
|
+
* with the open PR; the guess was invisible to the session that made it and
|
|
1457
|
+
* silently wrong whenever two trees were in play).
|
|
1279
1458
|
*
|
|
1280
1459
|
* A WORKSPACE-targeted row never follows a worktree: a row surveys the checkout
|
|
1281
1460
|
* the registry owns, and substituting a tree would make the row lie about the
|
|
1282
1461
|
* branch it is on.
|
|
1283
|
-
*
|
|
1284
|
-
* Gated three ways so the common case costs nothing: the caller must name a
|
|
1285
|
-
* session, must be asking about PR state at all (selection is defined by an open
|
|
1286
|
-
* PR, so a caller not asking cannot benefit), and the repository must actually
|
|
1287
|
-
* have a linked worktree. Only then is the forge consulted.
|
|
1288
1462
|
*/
|
|
1289
|
-
async function effectiveTarget(target,
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
selectWorktreeWatch(target.path, target.id, void 0);
|
|
1299
|
-
return { id: target.id, path: target.path };
|
|
1463
|
+
async function effectiveTarget(target, sessionId) {
|
|
1464
|
+
const bySession = typeof sessionId === "string" && sessionId !== "";
|
|
1465
|
+
if (config.worktreeStatus !== "off") {
|
|
1466
|
+
const registered = bySession ? await registeredCheckout(target, sessionId) : void 0;
|
|
1467
|
+
if (registered !== void 0) {
|
|
1468
|
+
selectWorktreeWatch(target.path, target.id, registered.path);
|
|
1469
|
+
// The name and branch travel; the PATH never does (AGENTS.md rule 7).
|
|
1470
|
+
return { id: target.id, path: registered.path, worktree: { name: registered.name, branch: registered.branch } };
|
|
1471
|
+
}
|
|
1300
1472
|
}
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
selectWorktreeWatch(target.path, target.id,
|
|
1304
|
-
|
|
1305
|
-
// The name and branch travel; the PATH never does (AGENTS.md rule 7).
|
|
1306
|
-
return { id: target.id, path: chosen.path, worktree: { name: chosen.name, branch: chosen.branch } };
|
|
1473
|
+
// No registration (or a stale one): retire any follow this workspace had, so
|
|
1474
|
+
// clearing the registration actually stops the extra watcher.
|
|
1475
|
+
selectWorktreeWatch(target.path, target.id, void 0);
|
|
1476
|
+
return { id: target.id, path: target.path };
|
|
1307
1477
|
}
|
|
1308
1478
|
|
|
1309
1479
|
/** Host plugin body — register the status route and the SSE change feed. */
|
|
1310
1480
|
function apply(ctx) {
|
|
1481
|
+
// The ranking order lives in this plugin's own settings namespace: declared
|
|
1482
|
+
// with a schema so the host validates it, stored in the user's settings
|
|
1483
|
+
// document, and observed live — a change applies to the next status read
|
|
1484
|
+
// with no file poll and no restart. The pre-settings JSON file, when it
|
|
1485
|
+
// exists, seeds the namespace as its composition BASE so an order configured
|
|
1486
|
+
// before this existed survives the upgrade; it is also the fallback when the
|
|
1487
|
+
// settings service is absent (a host composed without dsh-settings).
|
|
1488
|
+
currentOrder = legacyOrderFile();
|
|
1489
|
+
if (typeof ctx.inject === "function") {
|
|
1490
|
+
try {
|
|
1491
|
+
ctx.inject(["settings"], async (settingsCtx) => {
|
|
1492
|
+
let Schema;
|
|
1493
|
+
try {
|
|
1494
|
+
Schema = await config.settingsSchemaLoader();
|
|
1495
|
+
} catch (error) {
|
|
1496
|
+
console.error("[dsh-git-badge] schema lib unavailable; keeping the legacy order file:", error);
|
|
1497
|
+
return;
|
|
1498
|
+
}
|
|
1499
|
+
const scope = settingsCtx.settings.register(SETTINGS_NS, orderSettingsSchema(Schema), {
|
|
1500
|
+
base: { order: legacyOrderFile() }
|
|
1501
|
+
});
|
|
1502
|
+
currentOrder = normalizeOrder(scope.get()?.order);
|
|
1503
|
+
settingsOrderActive = true;
|
|
1504
|
+
settingsCtx.effect(() => scope.watch((next) => {
|
|
1505
|
+
currentOrder = normalizeOrder(next?.order);
|
|
1506
|
+
}), "dsh-git-badge: order settings");
|
|
1507
|
+
settingsCtx.effect(() => () => {
|
|
1508
|
+
settingsOrderActive = false;
|
|
1509
|
+
}, "dsh-git-badge: order settings teardown");
|
|
1510
|
+
});
|
|
1511
|
+
} catch (error) {
|
|
1512
|
+
console.error("[dsh-git-badge] settings namespace unavailable, using the legacy order file:", error);
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1311
1515
|
// A fresh boot composed the files on disk into the new bundles — any
|
|
1312
1516
|
// restart-pending marker was written by an install/apply against the PREVIOUS
|
|
1313
1517
|
// boot and its change is now live. Clearing here is what makes the marker
|
|
@@ -1332,7 +1536,7 @@ function apply(ctx) {
|
|
|
1332
1536
|
// session's own directory — see effectiveTarget. A workspace-targeted
|
|
1333
1537
|
// row never leaves the checkout the registry owns.
|
|
1334
1538
|
const session = url.searchParams.get("session");
|
|
1335
|
-
const effective = await effectiveTarget(target, session
|
|
1539
|
+
const effective = await effectiveTarget(target, session ?? void 0);
|
|
1336
1540
|
const info = await gitStatus(effective.path, detail, pr);
|
|
1337
1541
|
const body = { ...info, workspace: target.id };
|
|
1338
1542
|
if (effective.worktree !== void 0 && info.git === true) {
|
|
@@ -1340,7 +1544,7 @@ function apply(ctx) {
|
|
|
1340
1544
|
// name. Saying so is the whole honesty of the feature: the chip's
|
|
1341
1545
|
// mark says "worktree", its name says which, and this flag tells the
|
|
1342
1546
|
// card why the checkout below differs from the branch beside it.
|
|
1343
|
-
body.
|
|
1547
|
+
body.worktreeFollowed = true;
|
|
1344
1548
|
if (detail) {
|
|
1345
1549
|
// The session's OWN directory, for the card's `checkout` row, so the
|
|
1346
1550
|
// main checkout stays visible once the badge follows a tree. Gated
|
|
@@ -1461,10 +1665,6 @@ export {
|
|
|
1461
1665
|
summarizeChecks,
|
|
1462
1666
|
prStatusFor,
|
|
1463
1667
|
prState,
|
|
1464
|
-
readOpenPrs,
|
|
1465
|
-
openPrsFor,
|
|
1466
|
-
prListState,
|
|
1467
|
-
selectSessionWorktree,
|
|
1468
1668
|
selectWorktreeWatch,
|
|
1469
1669
|
selectedWorktrees,
|
|
1470
1670
|
statusInFlight,
|