@chrysb/alphaclaw 0.3.5-beta.0 → 0.3.5-beta.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.
Files changed (44) hide show
  1. package/bin/alphaclaw.js +65 -1
  2. package/lib/public/css/explorer.css +201 -6
  3. package/lib/public/js/app.js +45 -1
  4. package/lib/public/js/components/channels.js +1 -0
  5. package/lib/public/js/components/file-tree.js +56 -67
  6. package/lib/public/js/components/file-viewer/constants.js +6 -0
  7. package/lib/public/js/components/file-viewer/diff-viewer.js +46 -0
  8. package/lib/public/js/components/file-viewer/editor-surface.js +120 -0
  9. package/lib/public/js/components/file-viewer/frontmatter-panel.js +56 -0
  10. package/lib/public/js/components/file-viewer/index.js +164 -0
  11. package/lib/public/js/components/file-viewer/markdown-split-view.js +51 -0
  12. package/lib/public/js/components/file-viewer/media-preview.js +44 -0
  13. package/lib/public/js/components/file-viewer/scroll-sync.js +95 -0
  14. package/lib/public/js/components/file-viewer/sqlite-viewer.js +167 -0
  15. package/lib/public/js/components/file-viewer/status-banners.js +59 -0
  16. package/lib/public/js/components/file-viewer/storage.js +58 -0
  17. package/lib/public/js/components/file-viewer/toolbar.js +77 -0
  18. package/lib/public/js/components/file-viewer/use-editor-selection-restore.js +87 -0
  19. package/lib/public/js/components/file-viewer/use-file-diff.js +49 -0
  20. package/lib/public/js/components/file-viewer/use-file-loader.js +302 -0
  21. package/lib/public/js/components/file-viewer/use-file-viewer-draft-sync.js +32 -0
  22. package/lib/public/js/components/file-viewer/use-file-viewer-hotkeys.js +25 -0
  23. package/lib/public/js/components/file-viewer/use-file-viewer.js +379 -0
  24. package/lib/public/js/components/file-viewer/utils.js +11 -0
  25. package/lib/public/js/components/gateway.js +83 -30
  26. package/lib/public/js/components/icons.js +13 -0
  27. package/lib/public/js/components/sidebar-git-panel.js +72 -11
  28. package/lib/public/js/components/usage-tab.js +4 -1
  29. package/lib/public/js/components/watchdog-tab.js +6 -0
  30. package/lib/public/js/lib/api.js +16 -0
  31. package/lib/public/js/lib/browse-file-policies.js +34 -0
  32. package/lib/scripts/git +40 -0
  33. package/lib/scripts/git-askpass +6 -0
  34. package/lib/server/constants.js +8 -0
  35. package/lib/server/routes/browse/constants.js +51 -0
  36. package/lib/server/routes/browse/file-helpers.js +43 -0
  37. package/lib/server/routes/browse/git.js +131 -0
  38. package/lib/server/routes/{browse.js → browse/index.js} +290 -218
  39. package/lib/server/routes/browse/path-utils.js +53 -0
  40. package/lib/server/routes/browse/sqlite.js +140 -0
  41. package/lib/server/routes/proxy.js +11 -5
  42. package/lib/setup/core-prompts/TOOLS.md +0 -4
  43. package/package.json +1 -1
  44. package/lib/public/js/components/file-viewer.js +0 -1095
@@ -10,6 +10,7 @@ import { showToast } from "./toast.js";
10
10
  const html = htm.bind(h);
11
11
  const kRefreshMs = 10000;
12
12
  const kSyncCommitFileNameLimit = 4;
13
+ const kCommitHistoryLimit = 12;
13
14
 
14
15
  const formatCommitTime = (unixSeconds) => {
15
16
  if (!unixSeconds) return "";
@@ -55,11 +56,58 @@ const getChangedFilePresentation = (changedFile) => {
55
56
  };
56
57
 
57
58
  const formatDelta = (value, prefix) => {
59
+ if (value === null || value === undefined || value === "") return "";
58
60
  const numericValue = Number(value);
59
- if (!Number.isFinite(numericValue) || numericValue < 0) return "";
61
+ if (!Number.isFinite(numericValue) || numericValue <= 0) return "";
60
62
  return `${prefix}${numericValue}`;
61
63
  };
62
64
 
65
+ const getRemoteSyncPresentation = (summary) => {
66
+ const safeState = String(summary?.syncState || "").trim();
67
+ const aheadCount = Number(summary?.aheadCount) || 0;
68
+ const behindCount = Number(summary?.behindCount) || 0;
69
+ if (safeState === "ahead") {
70
+ return {
71
+ label: "↑",
72
+ title: `Ahead by ${aheadCount}`,
73
+ className: "is-ahead",
74
+ };
75
+ }
76
+ if (safeState === "behind") {
77
+ return {
78
+ label: "↓",
79
+ title: `Behind by ${behindCount}`,
80
+ className: "is-behind",
81
+ };
82
+ }
83
+ if (safeState === "diverged") {
84
+ return {
85
+ label: "↕",
86
+ title: `Diverged (${aheadCount} ahead, ${behindCount} behind)`,
87
+ className: "is-diverged",
88
+ };
89
+ }
90
+ if (safeState === "upstream-gone") {
91
+ return {
92
+ label: "!",
93
+ title: "Upstream missing",
94
+ className: "is-upstream-gone",
95
+ };
96
+ }
97
+ if (safeState === "no-upstream" || !summary?.hasUpstream) {
98
+ return {
99
+ label: "!",
100
+ title: "Not linked",
101
+ className: "is-no-upstream",
102
+ };
103
+ }
104
+ return {
105
+ label: "",
106
+ title: "Up to date",
107
+ className: "is-up-to-date",
108
+ };
109
+ };
110
+
63
111
  const buildSyncCommitMessage = (changedFiles) => {
64
112
  const filePaths = Array.isArray(changedFiles)
65
113
  ? changedFiles
@@ -138,16 +186,19 @@ export const SidebarGitPanel = ({ onSelectFile = () => {} }) => {
138
186
  `;
139
187
  }
140
188
 
141
- const hasUnsyncedChanges = (summary.changedFiles || []).length > 0;
189
+ const hasUncommittedChanges = (summary.changedFiles || []).length > 0;
190
+ const aheadCount = Number(summary?.aheadCount) || 0;
191
+ const canSyncChanges = hasUncommittedChanges || aheadCount > 0;
192
+ const remoteSync = getRemoteSyncPresentation(summary);
142
193
  const handleSyncChanges = async () => {
143
- if (!hasUnsyncedChanges || syncing) return;
194
+ if (!canSyncChanges || syncing) return;
144
195
  try {
145
196
  setSyncing(true);
146
197
  const commitMessage = buildSyncCommitMessage(summary?.changedFiles || []);
147
198
  const syncResult = await syncBrowseChanges(commitMessage);
148
- if (syncResult?.committed) {
199
+ if (syncResult?.committed || syncResult?.pushed) {
149
200
  window.dispatchEvent(new CustomEvent("alphaclaw:browse-git-synced"));
150
- showToast(syncResult.message || "Changes committed", "success");
201
+ showToast(syncResult.message || "Changes synced", "success");
151
202
  } else {
152
203
  showToast(syncResult?.message || "No changes to sync", "info");
153
204
  }
@@ -189,14 +240,24 @@ export const SidebarGitPanel = ({ onSelectFile = () => {} }) => {
189
240
  <${GitBranchLineIcon} className="sidebar-git-bar-icon" />
190
241
  <span class="sidebar-git-branch">${summary.branch || "unknown"}</span>
191
242
  </span>
192
- <span class=${`sidebar-git-dirty ${summary.isDirty ? "is-dirty" : "is-clean"}`}>
193
- ${summary.isDirty ? "dirty" : "clean"}
194
- </span>
243
+ ${remoteSync.label
244
+ ? html`
245
+ <span
246
+ class=${`sidebar-git-sync-status ${remoteSync.className}`.trim()}
247
+ title=${remoteSync.title || ""}
248
+ aria-label=${remoteSync.title || ""}
249
+ >
250
+ ${remoteSync.label}
251
+ </span>
252
+ `
253
+ : null}
195
254
  </div>
196
255
  <div class="sidebar-git-scroll">
197
256
  ${(summary.changedFiles || []).length > 0
198
257
  ? html`
199
- <div class="sidebar-git-changes-label">unsynced changes</div>
258
+ <div class="sidebar-git-changes-label">
259
+ ${`Unsynced Changes (${summary.changedFilesCount || (summary.changedFiles || []).length})`}
260
+ </div>
200
261
  <ul class="sidebar-git-changes-list">
201
262
  ${(summary.changedFiles || []).map((changedFile) => {
202
263
  const presentation = getChangedFilePresentation(changedFile);
@@ -229,7 +290,7 @@ export const SidebarGitPanel = ({ onSelectFile = () => {} }) => {
229
290
  <div class="sidebar-git-actions">
230
291
  <${ActionButton}
231
292
  onClick=${handleSyncChanges}
232
- disabled=${!hasUnsyncedChanges}
293
+ disabled=${!canSyncChanges}
233
294
  loading=${syncing}
234
295
  loadingMode="inline"
235
296
  idleLabel="Sync Changes"
@@ -245,7 +306,7 @@ export const SidebarGitPanel = ({ onSelectFile = () => {} }) => {
245
306
  ? html`
246
307
  <div class="sidebar-git-changes-label">commit history</div>
247
308
  <ul class="sidebar-git-list">
248
- ${(summary.commits || []).slice(0, 4).map(
309
+ ${(summary.commits || []).slice(0, kCommitHistoryLimit).map(
249
310
  (commit) => html`
250
311
  <li title=${formatCommitTime(commit.timestamp)}>
251
312
  ${commit.url
@@ -122,7 +122,10 @@ const kUsageMetricUiSettingKey = "usageMetric";
122
122
  const SummaryCard = ({ title, tokens, cost }) => html`
123
123
  <div class="bg-surface border border-border rounded-xl p-4">
124
124
  <h3 class="card-label text-xs">${title}</h3>
125
- <div class="text-lg font-semibold mt-1">${formatTokens(tokens)} tokens</div>
125
+ <div class="text-lg font-semibold mt-1">
126
+ ${formatTokens(tokens)}
127
+ <span class="text-xs text-[var(--text-muted)] ml-1">tokens</span>
128
+ </div>
126
129
  <div class="text-xs text-[var(--text-muted)] mt-1">${formatUsd(cost)}</div>
127
130
  </div>
128
131
  `;
@@ -137,6 +137,9 @@ export const WatchdogTab = ({
137
137
  restartingGateway = false,
138
138
  onRestartGateway,
139
139
  restartSignal = 0,
140
+ openclawUpdateInProgress = false,
141
+ onOpenclawVersionActionComplete = () => {},
142
+ onOpenclawUpdate,
140
143
  }) => {
141
144
  const eventsPoll = usePolling(() => fetchWatchdogEvents(20), 15000);
142
145
  const resourcesPoll = usePolling(() => fetchWatchdogResources(), 5000);
@@ -300,6 +303,9 @@ export const WatchdogTab = ({
300
303
  watchdogStatus=${currentWatchdogStatus}
301
304
  onRepair=${onRepair}
302
305
  repairing=${isRepairInProgress}
306
+ openclawUpdateInProgress=${openclawUpdateInProgress}
307
+ onOpenclawVersionActionComplete=${onOpenclawVersionActionComplete}
308
+ onOpenclawUpdate=${onOpenclawUpdate}
303
309
  />
304
310
 
305
311
  ${(() => {
@@ -400,6 +400,22 @@ export const fetchBrowseFileDiff = async (filePath) => {
400
400
  return parseJsonOrThrow(res, 'Could not load file diff');
401
401
  };
402
402
 
403
+ export const fetchBrowseSqliteTable = async ({
404
+ filePath,
405
+ table,
406
+ limit = 50,
407
+ offset = 0,
408
+ }) => {
409
+ const params = new URLSearchParams({
410
+ path: String(filePath || ""),
411
+ table: String(table || ""),
412
+ limit: String(limit),
413
+ offset: String(offset),
414
+ });
415
+ const res = await authFetch(`/api/browse/sqlite-table?${params.toString()}`);
416
+ return parseJsonOrThrow(res, "Could not load sqlite table data");
417
+ };
418
+
403
419
  export const syncBrowseChanges = async (message = "") => {
404
420
  const res = await authFetch('/api/browse/git-sync', {
405
421
  method: 'POST',
@@ -0,0 +1,34 @@
1
+ export const kProtectedBrowsePaths = new Set([
2
+ "openclaw.json",
3
+ "devices/paired.json",
4
+ ]);
5
+
6
+ export const kLockedBrowsePaths = new Set([
7
+ "hooks/bootstrap/agents.md",
8
+ "hooks/bootstrap/tools.md",
9
+ "skills/control-ui/skill.md",
10
+ ".alphaclaw/hourly-git-sync.sh",
11
+ ".alphaclaw/.cli-device-auto-approved",
12
+ ]);
13
+
14
+ export const normalizeBrowsePolicyPath = (inputPath) =>
15
+ String(inputPath || "")
16
+ .replaceAll("\\", "/")
17
+ .replace(/^\.\/+/, "")
18
+ .replace(/^\/+/, "")
19
+ .trim()
20
+ .toLowerCase();
21
+
22
+ export const matchesBrowsePolicyPath = (policyPathSet, normalizedPath) => {
23
+ const safeNormalizedPath = String(normalizedPath || "").trim();
24
+ if (!safeNormalizedPath) return false;
25
+ for (const policyPath of policyPathSet) {
26
+ if (
27
+ safeNormalizedPath === policyPath ||
28
+ safeNormalizedPath.endsWith(`/${policyPath}`)
29
+ ) {
30
+ return true;
31
+ }
32
+ }
33
+ return false;
34
+ };
@@ -0,0 +1,40 @@
1
+ #!/bin/bash
2
+ # Git auth shim -- injects GITHUB_TOKEN credentials for network operations
3
+ # inside the configured OpenClaw repo so the agent can use plain git commands.
4
+
5
+ REAL_GIT="@@REAL_GIT@@"
6
+ OPENCLAW_REPO_ROOT="@@OPENCLAW_REPO_ROOT@@"
7
+ ASKPASS_PATH="/tmp/alphaclaw-git-askpass.sh"
8
+
9
+ SUBCMD=""
10
+ for arg in "$@"; do
11
+ case "$arg" in
12
+ -*) ;;
13
+ *) SUBCMD="$arg"; break ;;
14
+ esac
15
+ done
16
+
17
+ needs_auth() {
18
+ case "$SUBCMD" in
19
+ push|pull|fetch|clone|ls-remote) return 0 ;;
20
+ *) return 1 ;;
21
+ esac
22
+ }
23
+
24
+ in_openclaw_root() {
25
+ if [ -z "$OPENCLAW_REPO_ROOT" ]; then
26
+ return 1
27
+ fi
28
+ case "$(pwd)" in
29
+ "$OPENCLAW_REPO_ROOT"|"${OPENCLAW_REPO_ROOT}"/*) return 0 ;;
30
+ *) return 1 ;;
31
+ esac
32
+ }
33
+
34
+ if [ "${ALPHACLAW_GIT_NO_AUTH:-}" = "1" ] || [ -z "${GITHUB_TOKEN:-}" ] || ! needs_auth || ! in_openclaw_root; then
35
+ exec "$REAL_GIT" "$@"
36
+ fi
37
+
38
+ export GIT_TERMINAL_PROMPT=0
39
+ export GIT_ASKPASS="$ASKPASS_PATH"
40
+ exec "$REAL_GIT" "$@"
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env sh
2
+ case "$1" in
3
+ *Username*) echo "x-access-token" ;;
4
+ *Password*) echo "${GITHUB_TOKEN:-}" ;;
5
+ *) echo "" ;;
6
+ esac
@@ -274,6 +274,13 @@ const kChannelDefs = {
274
274
  telegram: { envKey: "TELEGRAM_BOT_TOKEN" },
275
275
  discord: { envKey: "DISCORD_BOT_TOKEN" },
276
276
  };
277
+ const kLockedBrowsePaths = new Set([
278
+ "hooks/bootstrap/agents.md",
279
+ "hooks/bootstrap/tools.md",
280
+ "skills/control-ui/skill.md",
281
+ ".alphaclaw/hourly-git-sync.sh",
282
+ ".alphaclaw/.cli-device-auto-approved",
283
+ ]);
277
284
 
278
285
  const SETUP_API_PREFIXES = [
279
286
  "/api/status",
@@ -343,6 +350,7 @@ module.exports = {
343
350
  kSystemVars,
344
351
  kKnownVars,
345
352
  kKnownKeys,
353
+ kLockedBrowsePaths,
346
354
  SCOPE_MAP,
347
355
  REVERSE_SCOPE_MAP,
348
356
  BASE_SCOPES,
@@ -0,0 +1,51 @@
1
+ const kDefaultTreeDepth = 10;
2
+ const kIgnoredDirectoryNames = new Set([
3
+ ".git",
4
+ ".alphaclaw",
5
+ "node_modules",
6
+ ".cache",
7
+ "dist",
8
+ "build",
9
+ ]);
10
+ const kImageMimeTypeByExtension = new Map([
11
+ [".png", "image/png"],
12
+ [".jpg", "image/jpeg"],
13
+ [".jpeg", "image/jpeg"],
14
+ [".gif", "image/gif"],
15
+ [".webp", "image/webp"],
16
+ [".svg", "image/svg+xml"],
17
+ [".bmp", "image/bmp"],
18
+ [".ico", "image/x-icon"],
19
+ [".avif", "image/avif"],
20
+ ]);
21
+ const kCommitHistoryLimit = 12;
22
+ const kAudioMimeTypeByExtension = new Map([
23
+ [".mp3", "audio/mpeg"],
24
+ [".wav", "audio/wav"],
25
+ [".ogg", "audio/ogg"],
26
+ [".oga", "audio/ogg"],
27
+ [".m4a", "audio/mp4"],
28
+ [".aac", "audio/aac"],
29
+ [".flac", "audio/flac"],
30
+ [".opus", "audio/opus"],
31
+ [".weba", "audio/webm"],
32
+ ]);
33
+ const kSqliteFileExtensions = new Set([
34
+ ".sqlite",
35
+ ".sqlite3",
36
+ ".db",
37
+ ".db3",
38
+ ".sdb",
39
+ ".sqlitedb",
40
+ ]);
41
+ const kSqliteTablePageSize = 50;
42
+
43
+ module.exports = {
44
+ kDefaultTreeDepth,
45
+ kIgnoredDirectoryNames,
46
+ kImageMimeTypeByExtension,
47
+ kCommitHistoryLimit,
48
+ kAudioMimeTypeByExtension,
49
+ kSqliteFileExtensions,
50
+ kSqliteTablePageSize,
51
+ };
@@ -0,0 +1,43 @@
1
+ const path = require("path");
2
+ const {
3
+ kImageMimeTypeByExtension,
4
+ kAudioMimeTypeByExtension,
5
+ kSqliteFileExtensions,
6
+ } = require("./constants");
7
+
8
+ const isLikelyBinaryFile = (fs, targetPath) => {
9
+ let fileHandle = null;
10
+ try {
11
+ fileHandle = fs.openSync(targetPath, "r");
12
+ const sample = Buffer.alloc(512);
13
+ const bytesRead = fs.readSync(fileHandle, sample, 0, sample.length, 0);
14
+ for (let index = 0; index < bytesRead; index += 1) {
15
+ if (sample[index] === 0) return true;
16
+ }
17
+ return false;
18
+ } finally {
19
+ if (fileHandle !== null) fs.closeSync(fileHandle);
20
+ }
21
+ };
22
+
23
+ const getImageMimeType = (targetPath) => {
24
+ const extension = String(path.extname(targetPath || "") || "").toLowerCase();
25
+ return kImageMimeTypeByExtension.get(extension) || "";
26
+ };
27
+
28
+ const getAudioMimeType = (targetPath) => {
29
+ const extension = String(path.extname(targetPath || "") || "").toLowerCase();
30
+ return kAudioMimeTypeByExtension.get(extension) || "";
31
+ };
32
+
33
+ const isSqliteFilePath = (targetPath) => {
34
+ const extension = String(path.extname(targetPath || "") || "").toLowerCase();
35
+ return kSqliteFileExtensions.has(extension);
36
+ };
37
+
38
+ module.exports = {
39
+ isLikelyBinaryFile,
40
+ getImageMimeType,
41
+ getAudioMimeType,
42
+ isSqliteFilePath,
43
+ };
@@ -0,0 +1,131 @@
1
+ const { execFile } = require("child_process");
2
+
3
+ const runGitCommand = (args, kRootResolved) =>
4
+ new Promise((resolve) => {
5
+ execFile(
6
+ "git",
7
+ args,
8
+ { timeout: 10000, cwd: kRootResolved },
9
+ (error, stdout, stderr) => {
10
+ if (error) {
11
+ return resolve({
12
+ ok: false,
13
+ error: String(
14
+ stderr || stdout || error.message || "git command failed",
15
+ ).trim(),
16
+ });
17
+ }
18
+ return resolve({ ok: true, stdout: String(stdout || "") });
19
+ },
20
+ );
21
+ });
22
+
23
+ const runGitCommandWithExitCode = (args, kRootResolved) =>
24
+ new Promise((resolve) => {
25
+ execFile(
26
+ "git",
27
+ args,
28
+ { timeout: 10000, cwd: kRootResolved },
29
+ (error, stdout, stderr) => {
30
+ const safeStdout = String(stdout || "");
31
+ const safeStderr = String(stderr || "");
32
+ if (!error) {
33
+ return resolve({
34
+ ok: true,
35
+ stdout: safeStdout,
36
+ stderr: safeStderr,
37
+ exitCode: 0,
38
+ });
39
+ }
40
+ return resolve({
41
+ ok: false,
42
+ stdout: safeStdout,
43
+ stderr: safeStderr,
44
+ exitCode: Number.isInteger(error.code) ? error.code : 1,
45
+ error: String(error.message || "git command failed"),
46
+ });
47
+ },
48
+ );
49
+ });
50
+
51
+ const parseGithubRepoSlug = (value) => {
52
+ const raw = String(value || "").trim();
53
+ if (!raw) return "";
54
+ return raw
55
+ .replace(/^git@github\.com:/i, "")
56
+ .replace(/^https:\/\/github\.com\//i, "")
57
+ .replace(/\.git$/i, "")
58
+ .trim();
59
+ };
60
+
61
+ const normalizeChangedPath = (rawPath) => {
62
+ const value = String(rawPath || "").trim();
63
+ if (!value) return "";
64
+ if (value.includes(" -> ")) {
65
+ const segments = value.split(" -> ");
66
+ return String(segments[segments.length - 1] || "").trim();
67
+ }
68
+ return value;
69
+ };
70
+
71
+ const parseBranchTracking = (branchLine) => {
72
+ const safeBranchLine = String(branchLine || "").trim();
73
+ const withoutPrefix = safeBranchLine.replace(/^##\s*/, "");
74
+ const [localBranchRaw = "", trackingRaw = ""] = withoutPrefix.split("...");
75
+ const localBranch = localBranchRaw || "unknown";
76
+ const trackingSegment = String(trackingRaw || "").trim();
77
+ const upstreamBranch = trackingSegment.split(" [")[0]?.trim() || "";
78
+ const hasUpstream = upstreamBranch.length > 0;
79
+ const countsMatch = trackingSegment.match(/\[([^\]]+)\]/);
80
+ const countsRaw = countsMatch?.[1] || "";
81
+ const countParts = countsRaw
82
+ .split(",")
83
+ .map((part) => String(part || "").trim())
84
+ .filter(Boolean);
85
+ let aheadCount = 0;
86
+ let behindCount = 0;
87
+ let upstreamGone = false;
88
+ countParts.forEach((part) => {
89
+ const aheadMatch = part.match(/^ahead\s+(\d+)$/i);
90
+ if (aheadMatch?.[1]) {
91
+ aheadCount = Number.parseInt(aheadMatch[1], 10) || 0;
92
+ return;
93
+ }
94
+ const behindMatch = part.match(/^behind\s+(\d+)$/i);
95
+ if (behindMatch?.[1]) {
96
+ behindCount = Number.parseInt(behindMatch[1], 10) || 0;
97
+ return;
98
+ }
99
+ if (part.toLowerCase() === "gone") {
100
+ upstreamGone = true;
101
+ }
102
+ });
103
+ const syncState = !hasUpstream
104
+ ? "no-upstream"
105
+ : upstreamGone
106
+ ? "upstream-gone"
107
+ : aheadCount > 0 && behindCount > 0
108
+ ? "diverged"
109
+ : aheadCount > 0
110
+ ? "ahead"
111
+ : behindCount > 0
112
+ ? "behind"
113
+ : "up-to-date";
114
+ return {
115
+ branch: localBranch,
116
+ upstreamBranch,
117
+ hasUpstream,
118
+ upstreamGone,
119
+ aheadCount,
120
+ behindCount,
121
+ syncState,
122
+ };
123
+ };
124
+
125
+ module.exports = {
126
+ runGitCommand,
127
+ runGitCommandWithExitCode,
128
+ parseGithubRepoSlug,
129
+ normalizeChangedPath,
130
+ parseBranchTracking,
131
+ };