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