dsh-git-badge 0.5.0 → 0.5.2

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/lib/client.js CHANGED
@@ -111,7 +111,8 @@ window.__ModuleLoader__.load({
111
111
  }
112
112
 
113
113
  /**
114
- * Shared sync/dirty suffix for all surfaces. Rule set:
114
+ * Sync/dirty suffix — INPUT CHIP ONLY. The sidebar row badge shows
115
+ * status + branch alone; the chip is the detailed surface. Rule set:
115
116
  * - dirty files render as ✎n (pencil = worktree files, distinct from
116
117
  * the ↑/↓ commit-sync axis)
117
118
  * - while dirty, BOTH sync counts render including zeros (↑0 ↓2 ✎3),
@@ -140,10 +141,11 @@ window.__ModuleLoader__.load({
140
141
  };
141
142
 
142
143
  /**
143
- * Row badge, Claude Code statusline style:
144
- * the_paragliding_app | 🟢 main ↑1 ↓2
144
+ * Row badge — status + branch only (sync/dirty detail lives on the
145
+ * input chip):
146
+ * the_paragliding_app | 🟢 main
145
147
  * Always renders the workspace name (so the row keeps its identity);
146
- * appends the muted `| emoji branch sync` part only for git workspaces.
148
+ * appends the muted `| emoji branch` part only for git workspaces.
147
149
  */
148
150
  function WorkspaceGitBadge({ label, cwd }) {
149
151
  const info = useGitStatus(cwd, false);
@@ -152,7 +154,7 @@ window.__ModuleLoader__.load({
152
154
  children.push(
153
155
  react_jsx_runtime.jsx("span", { style: { ...META_STYLE, margin: "0 7px" }, children: "|" }),
154
156
  react_jsx_runtime.jsx("span", { style: { ...META_STYLE, marginRight: "4px" }, children: info.dirty ? "🟡" : "🟢" }),
155
- react_jsx_runtime.jsx("span", { style: META_STYLE, children: info.branch + formatGitSuffix(info) })
157
+ react_jsx_runtime.jsx("span", { style: META_STYLE, children: info.branch })
156
158
  );
157
159
  }
158
160
  return react_jsx_runtime.jsx("span", { style: { display: "flex", alignItems: "center", minWidth: 0 }, children });
package/lib/index.js CHANGED
@@ -12,6 +12,11 @@
12
12
  * changed / untracked counts in a single call (`--no-optional-locks`
13
13
  * guarantees the read never contends with the user's own git
14
14
  * operations on the index).
15
+ * - ahead/behind compare against the LOCAL remote-tracking ref, which
16
+ * only moves on fetch — so a TTL-bounded background fetch (60s per
17
+ * toplevel) runs before status when the ref can be stale. `GIT_TERMINAL_PROMPT=0`
18
+ * and the shared timeout keep an offline/slow remote from stalling the
19
+ * route; on fetch failure the stale-ref answer is served as-is.
15
20
  * - `detail=1` adds one `log -1` for the hover card; row refresh never
16
21
  * pays for it.
17
22
  *
@@ -41,9 +46,13 @@ const name = "dsh-git-badge";
41
46
  * The exitCode/timeout distinction separates a definitive answer (non-repo)
42
47
  * from a transient one (degraded).
43
48
  */
44
- function runGit(dir, args) {
49
+ function runGit(dir, args, opts) {
50
+ const { env, timeout = 3000 } = opts ?? {};
51
+ // env is a full replacement for execFile; overlay onto process.env so
52
+ // PATH/HOME (credential helpers, global config) survive
53
+ const fullEnv = env === void 0 ? void 0 : { ...process.env, ...env };
45
54
  return new Promise((resolve) => {
46
- execFile("git", args, { cwd: dir, timeout: 3000 }, (error, stdout) => {
55
+ execFile("git", args, { cwd: dir, timeout, env: fullEnv }, (error, stdout) => {
47
56
  if (error === void 0 || error === null) resolve({ stdout: String(stdout) });
48
57
  else if (error.killed) resolve({ stdout: null, timeout: true });
49
58
  else if (error.code === "ENOENT") resolve({ stdout: null, missing: true });
@@ -55,6 +64,57 @@ function runGit(dir, args) {
55
64
  /** Marker response for a git invocation that failed or timed out (transient). */
56
65
  const GIT_DEGRADED = { git: false, error: "git unavailable (timeout or failure)" };
57
66
 
67
+ //#region TTL-bounded background fetch
68
+ /**
69
+ * ahead/behind come from the local remote-tracking ref, which only moves on
70
+ * `git fetch` — without one, a remote update (push from elsewhere, GitHub
71
+ * edit) is invisible to the badge until the user happens to fetch. A
72
+ * TTL-bounded fetch closes that gap: at most one network round trip per
73
+ * FETCH_TTL_MS per repository, regardless of how often the route is hit
74
+ * (watcher bursts included). Mirrors what IDEs do (VS Code's throttled
75
+ * auto-fetch), with a TTL rather than a timer: fetch only happens when
76
+ * someone is actually looking at the badge.
77
+ */
78
+ const FETCH_TTL_MS = 60000;
79
+ /** lastFetchAt per toplevel; in-flight promise per toplevel collapses races. */
80
+ const fetchState = new Map();
81
+
82
+ /**
83
+ * Refresh the remote-tracking refs for toplevel, bounded by TTL. Resolves
84
+ * true only when a fetch actually ran and succeeded; every failure mode
85
+ * (TTL fresh, no upstream configured, git failure, timeout) resolves false
86
+ * and the caller just serves the status it already has. The in-flight map
87
+ * means concurrent requests share one fetch instead of stampeding.
88
+ */
89
+ async function maybeFetch(toplevel) {
90
+ const now = Date.now();
91
+ const state = fetchState.get(toplevel);
92
+ if (state !== void 0) {
93
+ if (state.inFlight !== null) return state.inFlight;
94
+ if (now - state.lastAttemptAt < FETCH_TTL_MS) return false;
95
+ }
96
+ const inFlight = (async () => {
97
+ // --no-tags --prune: refs-only refresh, cheapest correct form.
98
+ // No remote name: `git fetch` uses the branch's configured upstream,
99
+ // and a bare `fetch --all` would probe every remote for every badge.
100
+ // GIT_TERMINAL_PROMPT=0: a credential prompt must never hang the route.
101
+ const out = await runGit(toplevel, ["fetch", "--quiet", "--no-tags", "--prune"], {
102
+ env: { GIT_TERMINAL_PROMPT: "0" },
103
+ // measured real-world fetch is ~3s on SSH; the status-call 3s budget
104
+ // would kill healthy fetches on a slow link. 8s is the worst-case
105
+ // route latency, paid at most once per FETCH_TTL_MS per workspace.
106
+ timeout: 8000
107
+ });
108
+ const ok = out.stdout !== null;
109
+ fetchState.set(toplevel, { lastAttemptAt: Date.now(), inFlight: null });
110
+ return ok;
111
+ })();
112
+ if (state === void 0) fetchState.set(toplevel, { lastAttemptAt: now, inFlight });
113
+ else state.inFlight = inFlight;
114
+ return inFlight;
115
+ }
116
+ //#endregion
117
+
58
118
  /**
59
119
  * Parse `git status --porcelain=v2 --branch` output.
60
120
  * Headers: `# branch.head <name>` (or `(detached)`), `# branch.upstream
@@ -108,9 +168,22 @@ async function gitStatus(dir, wantDetail) {
108
168
  }
109
169
  const toplevel = top.stdout.trim();
110
170
  if (toplevel === "") return { git: false };
111
- const statusOut = await runGit(toplevel, ["--no-optional-locks", "status", "--porcelain=v2", "--branch"]);
171
+ const sample = () => runGit(toplevel, ["--no-optional-locks", "status", "--porcelain=v2", "--branch"]);
172
+ let statusOut = await sample();
112
173
  if (statusOut.stdout === null) return GIT_DEGRADED;
113
- const parsed = parseStatusV2(statusOut.stdout);
174
+ let parsed = parseStatusV2(statusOut.stdout);
175
+ // The upstream header is the cheap gate: no upstream configured → the
176
+ // fetch would be a no-op probe, skip it entirely. When it fires and the
177
+ // fetch succeeds, re-sample so this very response already carries the
178
+ // fresh ahead/behind instead of lagging one cycle behind.
179
+ if (parsed.upstream !== void 0) {
180
+ const fetched = await maybeFetch(toplevel);
181
+ if (fetched) {
182
+ statusOut = await sample();
183
+ if (statusOut.stdout === null) return GIT_DEGRADED;
184
+ parsed = parseStatusV2(statusOut.stdout);
185
+ }
186
+ }
114
187
  const dirtyFiles = parsed.staged + parsed.unstaged + parsed.unmerged + parsed.untracked;
115
188
  const info = {
116
189
  git: true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-git-badge",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "Git status badges for DeepSeek Harness: sidebar workspace rows (via the sidebar.workspaces.row seam) and an input-row chip showing the current conversation's workspace git state. Event-driven freshness over SSE.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",