agy-powerline 1.2.0 → 1.4.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/src/lib/git.js CHANGED
@@ -13,6 +13,15 @@ const SYNC_CACHE_NAME = ".git-sync-cache.json";
13
13
  const SYNC_CACHE_MAX = 20;
14
14
  const COMMIT = /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/;
15
15
 
16
+ // A git call that runs past its time is skipped until a later refresh. Tests
17
+ // give git more time: on a busy CI machine a plain call can take longer.
18
+ const timeouts = { sync: 150, detail: 300 };
19
+ function setGitTimeouts(next) {
20
+ const previous = { ...timeouts };
21
+ Object.assign(timeouts, next);
22
+ return previous;
23
+ }
24
+
16
25
  // Only an absolute folder that exists can belong to the user's session. A
17
26
  // relative path (a Windows path read on Linux, for one) resolves against
18
27
  // wherever this process runs, and the upward walk would then report that
@@ -89,6 +98,84 @@ function gitHasDirtyMarkers(gitDir) {
89
98
  });
90
99
  }
91
100
 
101
+ function readText(file) {
102
+ try {
103
+ return fs.readFileSync(file, "utf8").trim();
104
+ } catch {
105
+ return null;
106
+ }
107
+ }
108
+
109
+ function pathExists(file) {
110
+ try {
111
+ fs.accessSync(file);
112
+ return true;
113
+ } catch {
114
+ return false;
115
+ }
116
+ }
117
+
118
+ const STEP_MAX = 100000;
119
+
120
+ // The repo controls these files: only a plain whole number in range counts.
121
+ function readStep(file) {
122
+ const text = readText(file);
123
+ const n = text !== null && /^\d{1,6}$/.test(text) ? Number(text) : 0;
124
+ return n >= 1 && n <= STEP_MAX ? n : null;
125
+ }
126
+
127
+ // What an unfinished rebase keeps, by backend: [folder, step file, total file].
128
+ const REBASE_STATES = [
129
+ ["rebase-merge", "msgnum", "end"],
130
+ ["rebase-apply", "next", "last"],
131
+ ];
132
+
133
+ // Checked in this order after a rebase or `git am`; the first one found wins.
134
+ const OPERATION_MARKERS = [
135
+ ["MERGE_HEAD", "merge"],
136
+ ["CHERRY_PICK_HEAD", "cherry-pick"],
137
+ ["REVERT_HEAD", "revert"],
138
+ ["BISECT_LOG", "bisect"],
139
+ ];
140
+
141
+ // The operation git is in the middle of in this worktree, from files only:
142
+ // { name, step, total, folder } or null. A linked worktree keeps these in its
143
+ // own git dir, which findGitDir() returns.
144
+ function readOperation(gitDir) {
145
+ for (const [folder, stepFile, totalFile] of REBASE_STATES) {
146
+ const dir = path.join(gitDir, folder);
147
+ let name = folder === "rebase-merge" || pathExists(path.join(dir, "rebasing")) ? "rebase" : "";
148
+ if (!name && pathExists(path.join(dir, "applying"))) {
149
+ name = "am";
150
+ }
151
+ if (!name || !pathExists(dir)) {
152
+ continue;
153
+ }
154
+ const step = readStep(path.join(dir, stepFile));
155
+ const total = readStep(path.join(dir, totalFile));
156
+ const counted = step !== null && total !== null;
157
+ return { name, step: counted ? step : null, total: counted ? total : null, folder };
158
+ }
159
+
160
+ for (const [file, name] of OPERATION_MARKERS) {
161
+ if (pathExists(path.join(gitDir, file))) {
162
+ return { name, step: null, total: null, folder: "" };
163
+ }
164
+ }
165
+
166
+ return null;
167
+ }
168
+
169
+ // A rebase detaches HEAD; the branch being rebased is named in head-name.
170
+ function rebasedBranch(gitDir, operation) {
171
+ if (operation?.name !== "rebase") {
172
+ return "";
173
+ }
174
+ const ref = readText(path.join(gitDir, operation.folder, "head-name")) || "";
175
+ const branch = ref.startsWith("refs/heads/") ? ref.slice("refs/heads/".length) : "";
176
+ return isSafeBranchName(branch) ? branch : "";
177
+ }
178
+
92
179
  function getGitInfo(cwd) {
93
180
  if (!cwd) {
94
181
  return null;
@@ -102,12 +189,13 @@ function getGitInfo(cwd) {
102
189
  }
103
190
 
104
191
  const head = fs.readFileSync(path.join(gitDir, "HEAD"), "utf8").trim();
192
+ const operation = readOperation(gitDir);
105
193
  let branch = "";
106
194
 
107
195
  if (head.startsWith("ref: ")) {
108
196
  branch = head.replace(/^ref:\s+refs\/heads\//, "").replace(/^ref:\s+/, "");
109
197
  } else if (head) {
110
- branch = head.slice(0, 7);
198
+ branch = rebasedBranch(gitDir, operation) || head.slice(0, 7);
111
199
  }
112
200
 
113
201
  if (!branch) {
@@ -121,6 +209,11 @@ function getGitInfo(cwd) {
121
209
  dirty: gitHasDirtyMarkers(gitDir),
122
210
  ahead: sync.ahead,
123
211
  behind: sync.behind,
212
+ operation: operation && {
213
+ name: operation.name,
214
+ step: operation.step,
215
+ total: operation.total,
216
+ },
124
217
  };
125
218
  } catch {
126
219
  return null;
@@ -304,10 +397,11 @@ function getGitSync(gitDir, branch) {
304
397
  const { execFileSync } = require("child_process");
305
398
  const out = execFileSync(
306
399
  "git",
307
- ["rev-list", "--left-right", "--count", `${branch}...@{upstream}`, "--"],
400
+ // The branch's own upstream: during a rebase HEAD is detached and has none.
401
+ ["rev-list", "--left-right", "--count", `${branch}...${branch}@{upstream}`, "--"],
308
402
  {
309
403
  cwd: path.dirname(gitDir),
310
- timeout: 150,
404
+ timeout: timeouts.sync,
311
405
  stdio: ["ignore", "pipe", "ignore"],
312
406
  encoding: "utf8",
313
407
  // Antigravity may run the statusline without a console; without this,
@@ -329,7 +423,7 @@ function getGitSync(gitDir, branch) {
329
423
  // A timeout or missing git is worth knowing about when ahead/behind
330
424
  // counts never show.
331
425
  if (err.code === "ETIMEDOUT" || err.signal === "SIGTERM") {
332
- logWarning("GIT_TIMEOUT", { timeoutMs: 150 });
426
+ logWarning("GIT_TIMEOUT", { timeoutMs: timeouts.sync });
333
427
  } else if (err.code === "ENOENT") {
334
428
  logWarning("GIT_NOT_FOUND");
335
429
  }
@@ -338,6 +432,279 @@ function getGitSync(gitDir, branch) {
338
432
  return { ahead: 0, behind: 0 };
339
433
  }
340
434
 
435
+ // Working-tree details for the gitChanges, gitFiles, gitSha, gitStash and
436
+ // gitWorktree widgets. SHA, stash and worktree come from files; file and line
437
+ // counts need `git status` / `git diff`, so those run only for the widgets
438
+ // that show them and are reused for STATUS_TTL_MS per repo and commit.
439
+ const DETAIL_NEEDS = {
440
+ gitChanges: "diff",
441
+ gitFiles: "status",
442
+ gitSha: "sha",
443
+ gitStash: "stash",
444
+ gitWorktree: "worktree",
445
+ };
446
+ const STATUS_CACHE_NAME = ".git-status-cache.json";
447
+ const STATUS_CACHE_MAX = 20;
448
+ const STATUS_TTL_MS = 5000;
449
+
450
+ // No optional locks: the bar must never hold the index lock and make the
451
+ // user's own git commands fail. The -c flags and the submodule skip keep git
452
+ // from starting programs named in the repo's own config.
453
+ const STATUS_ARGS = [
454
+ "--no-optional-locks",
455
+ "-c",
456
+ "core.fsmonitor=false",
457
+ "-c",
458
+ "core.untrackedCache=false",
459
+ "status",
460
+ "--porcelain=v2",
461
+ "-z",
462
+ "--ignore-submodules=all",
463
+ ];
464
+ const DIFF_ARGS = [
465
+ "--no-optional-locks",
466
+ "-c",
467
+ "core.fsmonitor=false",
468
+ "diff",
469
+ "--shortstat",
470
+ "--no-ext-diff",
471
+ "--no-textconv",
472
+ "--ignore-submodules=all",
473
+ "HEAD",
474
+ "--",
475
+ ];
476
+
477
+ // A repo opened from an archive controls its own .git/config, and git status
478
+ // and diff run the programs it names: filters on changed files, diff drivers,
479
+ // fsmonitor hooks, and anything an included file adds. A repo config with any
480
+ // of these is read for SHA, stash and worktree only; git itself isn't started.
481
+ const RISKY_CONFIG = /^\s*(\[\s*(filter|include|includeif|diff)\b|fsmonitor\s*=)/im;
482
+
483
+ const statusMemory = new Map();
484
+
485
+ function hasRiskyRepoConfig(gitDir, commonDir) {
486
+ const files = [path.join(commonDir, "config"), path.join(gitDir, "config.worktree")];
487
+ if (gitDir !== commonDir) {
488
+ files.push(path.join(commonDir, "config.worktree"));
489
+ }
490
+ return files.some((file) => {
491
+ try {
492
+ return RISKY_CONFIG.test(fs.readFileSync(file, "utf8"));
493
+ } catch {
494
+ return false;
495
+ }
496
+ });
497
+ }
498
+
499
+ // The branch checked out and its commit ("" before the first commit).
500
+ function readHead(gitDir, commonDir) {
501
+ let head;
502
+ try {
503
+ head = fs.readFileSync(path.join(gitDir, "HEAD"), "utf8").trim();
504
+ } catch {
505
+ return { branch: "", commit: "" };
506
+ }
507
+ if (COMMIT.test(head)) {
508
+ return { branch: head.slice(0, 7), commit: head };
509
+ }
510
+ const ref = head.replace(/^ref:\s+/, "");
511
+ const branch = ref.replace(/^refs\/heads\//, "");
512
+ if (!ref.startsWith("refs/heads/") || !isSafeBranchName(branch)) {
513
+ return { branch: "", commit: "" };
514
+ }
515
+ const commit = readRef(commonDir, ref);
516
+ return { branch, commit: COMMIT.test(commit) ? commit : "" };
517
+ }
518
+
519
+ // One line per stash in the stash reflog.
520
+ function stashCount(commonDir) {
521
+ try {
522
+ const text = fs.readFileSync(path.join(commonDir, "logs", "refs", "stash"), "utf8");
523
+ return Math.min(text.split("\n").filter((line) => line.trim()).length, 99999);
524
+ } catch {
525
+ return 0;
526
+ }
527
+ }
528
+
529
+ // A linked worktree's git dir is <main>/.git/worktrees/<name>.
530
+ function worktreeName(gitDir, commonDir) {
531
+ if (gitDir === commonDir || path.basename(path.dirname(gitDir)) !== "worktrees") {
532
+ return "";
533
+ }
534
+ return path.basename(gitDir);
535
+ }
536
+
537
+ // Porcelain v2 with -z: "1 XY ..." and "2 XY ..." (a rename, followed by its
538
+ // old path as its own entry), "u XY ..." for conflicts, "? path" for untracked.
539
+ function parseStatus(out) {
540
+ const files = { staged: 0, modified: 0, untracked: 0, conflicts: 0 };
541
+ const entries = String(out).split("\0");
542
+ for (let i = 0; i < entries.length; i++) {
543
+ const entry = entries[i];
544
+ const kind = entry[0];
545
+ if (kind === "1" || kind === "2") {
546
+ if (entry[2] !== ".") files.staged++;
547
+ if (entry[3] !== ".") files.modified++;
548
+ if (kind === "2") i++;
549
+ } else if (kind === "u") {
550
+ files.conflicts++;
551
+ } else if (kind === "?") {
552
+ files.untracked++;
553
+ }
554
+ }
555
+ return files;
556
+ }
557
+
558
+ // " 3 files changed, 128 insertions(+), 34 deletions(-)"; nothing when clean.
559
+ function parseShortstat(out) {
560
+ const text = String(out);
561
+ const number = (pattern) => parseInt(text.match(pattern)?.[1] ?? "0", 10) || 0;
562
+ return { added: number(/(\d+) insertions?\(\+\)/), removed: number(/(\d+) deletions?\(-\)/) };
563
+ }
564
+
565
+ function runGitDetail(cwd, args) {
566
+ try {
567
+ const { execFileSync } = require("child_process");
568
+ return execFileSync("git", args, {
569
+ cwd,
570
+ timeout: timeouts.detail,
571
+ stdio: ["ignore", "pipe", "ignore"],
572
+ encoding: "utf8",
573
+ maxBuffer: 16 * 1024 * 1024,
574
+ windowsHide: true,
575
+ });
576
+ } catch (err) {
577
+ if (err.code === "ETIMEDOUT" || err.signal === "SIGTERM") {
578
+ logWarning("GIT_TIMEOUT", { timeoutMs: timeouts.detail });
579
+ } else if (err.code === "ENOENT") {
580
+ logWarning("GIT_NOT_FOUND");
581
+ }
582
+ return null;
583
+ }
584
+ }
585
+
586
+ function statusCachePath() {
587
+ const sync = syncCachePath();
588
+ return sync ? path.join(path.dirname(sync), STATUS_CACHE_NAME) : null;
589
+ }
590
+
591
+ const DETAIL_FIELDS = {
592
+ files: ["staged", "modified", "untracked", "conflicts"],
593
+ changes: ["added", "removed"],
594
+ };
595
+
596
+ // The cache file can be edited or torn like any file; only whole counts count.
597
+ function validDetail(field, cached) {
598
+ const v = cached?.v;
599
+ return (
600
+ typeof cached?.t === "number" &&
601
+ v !== null &&
602
+ typeof v === "object" &&
603
+ DETAIL_FIELDS[field].every(
604
+ (name) => Number.isInteger(v[name]) && v[name] >= 0 && v[name] <= 1e9
605
+ )
606
+ );
607
+ }
608
+
609
+ // A fresh entry is used as is; when git fails, an older one for the same repo
610
+ // and commit is still better than nothing.
611
+ function cachedDetail(key, field, compute) {
612
+ const now = Date.now();
613
+ const file = statusCachePath();
614
+ let entry = statusMemory.get(key);
615
+ if (!entry?.[field] && file) {
616
+ entry = readSyncCache(file)[key];
617
+ }
618
+ const cached = validDetail(field, entry?.[field]) ? entry[field] : null;
619
+ if (cached && now - cached.t < STATUS_TTL_MS) {
620
+ return cached.v;
621
+ }
622
+
623
+ const value = compute();
624
+ if (value === null) {
625
+ return cached?.v ?? null;
626
+ }
627
+
628
+ const previous = entry && typeof entry === "object" && !Array.isArray(entry) ? entry : {};
629
+ const next = { ...previous, [field]: { t: now, v: value } };
630
+ statusMemory.set(key, next);
631
+ if (file) {
632
+ try {
633
+ const cache = readSyncCache(file);
634
+ delete cache[key];
635
+ cache[key] = next;
636
+ const kept = Object.fromEntries(Object.entries(cache).slice(-STATUS_CACHE_MAX));
637
+ writeFileAtomic(file, JSON.stringify(kept), { mode: PRIVATE_FILE_MODE });
638
+ } catch {
639
+ // Best effort: the next refresh runs git again.
640
+ }
641
+ }
642
+ return value;
643
+ }
644
+
645
+ function getGitDetails(cwd, needs = {}) {
646
+ try {
647
+ const gitDir = findGitDir(cwd);
648
+ if (!gitDir) {
649
+ return null;
650
+ }
651
+ const commonDir = commonGitDir(gitDir);
652
+ const { branch, commit } = readHead(gitDir, commonDir);
653
+ const details = { branch };
654
+
655
+ if (needs.sha) {
656
+ details.sha = commit.slice(0, 7);
657
+ }
658
+ if (needs.stash) {
659
+ details.stash = stashCount(commonDir);
660
+ }
661
+ if (needs.worktree) {
662
+ details.worktree = worktreeName(gitDir, commonDir);
663
+ }
664
+
665
+ const wantsGit = needs.status || (needs.diff && commit);
666
+ if (wantsGit && hasRiskyRepoConfig(gitDir, commonDir)) {
667
+ logWarning("GIT_CONFIG_SKIPPED");
668
+ return details;
669
+ }
670
+
671
+ const key = `${gitDir}\0${commit || "unborn"}`;
672
+ if (needs.status) {
673
+ details.files = cachedDetail(key, "files", () => {
674
+ const out = runGitDetail(cwd, STATUS_ARGS);
675
+ return out === null ? null : parseStatus(out);
676
+ });
677
+ }
678
+ if (needs.diff && commit) {
679
+ details.changes = cachedDetail(key, "changes", () => {
680
+ const out = runGitDetail(cwd, DIFF_ARGS);
681
+ return out === null ? null : parseShortstat(out);
682
+ });
683
+ }
684
+ return details;
685
+ } catch {
686
+ return null;
687
+ }
688
+ }
689
+
690
+ // Only what the configured widgets show is read.
691
+ function resolveGitDetails(data, config) {
692
+ const needs = {};
693
+ for (const widget of config?.widgets ?? []) {
694
+ if (DETAIL_NEEDS[widget?.id]) {
695
+ needs[DETAIL_NEEDS[widget.id]] = true;
696
+ }
697
+ }
698
+ if (!Object.keys(needs).length) {
699
+ return null;
700
+ }
701
+ return getGitDetails(firstText(data?.cwd, data?.workspace?.current_dir), needs);
702
+ }
703
+
704
+ function clearGitDetailsCache() {
705
+ statusMemory.clear();
706
+ }
707
+
341
708
  // The bar uses vcs.branch and vcs.dirty from Antigravity's payload. Ahead and
342
709
  // behind are not in it; they come from the local repository.
343
710
  function vcsFromPayload(data) {
@@ -369,6 +736,7 @@ function resolveGit(data, config) {
369
736
  ...fromPayload,
370
737
  ahead: same ? local.ahead : 0,
371
738
  behind: same ? local.behind : 0,
739
+ operation: same ? local.operation : null,
372
740
  };
373
741
  }
374
742
 
@@ -378,6 +746,7 @@ function resolveGit(data, config) {
378
746
  dirty: Boolean(local.dirty || fromPayload?.dirty),
379
747
  ahead: local.ahead,
380
748
  behind: local.behind,
749
+ operation: local.operation,
381
750
  };
382
751
  }
383
752
 
@@ -388,6 +757,7 @@ function resolveGit(data, config) {
388
757
  module.exports = {
389
758
  findGitDir,
390
759
  getGitInfo,
760
+ readOperation,
391
761
  getGitSync,
392
762
  isSafeBranchName,
393
763
  GIT_DIRTY_MARKERS,
@@ -395,4 +765,15 @@ module.exports = {
395
765
  vcsFromPayload,
396
766
  resolveGit,
397
767
  SYNC_CACHE_NAME,
768
+ STATUS_CACHE_NAME,
769
+ STATUS_TTL_MS,
770
+ STATUS_ARGS,
771
+ DIFF_ARGS,
772
+ hasRiskyRepoConfig,
773
+ parseStatus,
774
+ parseShortstat,
775
+ getGitDetails,
776
+ resolveGitDetails,
777
+ clearGitDetailsCache,
778
+ setGitTimeouts,
398
779
  };