@errhythm/gitmux 1.6.5 → 1.6.11

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@errhythm/gitmux",
3
- "version": "1.6.5",
3
+ "version": "1.6.11",
4
4
  "description": "Multi-repo Git & GitLab workflow CLI — switch branches, manage epics & issues, create MRs, all from the terminal",
5
5
  "type": "module",
6
6
  "bin": {
@@ -11,6 +11,10 @@
11
11
  "bin",
12
12
  "src"
13
13
  ],
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "https://github.com/errhythm/gitmux.git"
17
+ },
14
18
  "engines": {
15
19
  "node": ">=22.13.0"
16
20
  },
@@ -30,7 +34,11 @@
30
34
  "license": "MIT",
31
35
  "main": "src/main.js",
32
36
  "scripts": {
33
- "test": "echo \"Error: no test specified\" && exit 1"
37
+ "test": "echo \"No tests specified yet\""
38
+ },
39
+ "publishConfig": {
40
+ "access": "public",
41
+ "provenance": true
34
42
  },
35
43
  "dependencies": {
36
44
  "@inquirer/prompts": "^8.3.0",
@@ -17,6 +17,7 @@ import {
17
17
  isGitLabRemote,
18
18
  detectGroupFromRepos,
19
19
  getProjectPath,
20
+ getDefaultBranch,
20
21
  slugify,
21
22
  } from "../gitlab/helpers.js";
22
23
  import { loadConfig, saveConfig } from "../config/index.js";
@@ -263,6 +264,11 @@ async function cmdIssueView(issue, glabRepos = [], portalConfig = {}) {
263
264
  name: p.purple("⊞ Create merge request"),
264
265
  description: p.muted("MR from ") + colorBranch(currentPrimary) + p.muted(" → default base branch"),
265
266
  }] : []),
267
+ {
268
+ value: "createBranch",
269
+ name: p.green("+ Create branch"),
270
+ description: p.muted("create a new branch for this issue"),
271
+ },
266
272
  {
267
273
  value: "open",
268
274
  name: p.teal("⊕ View in GitLab"),
@@ -286,6 +292,63 @@ async function cmdIssueView(issue, glabRepos = [], portalConfig = {}) {
286
292
  return;
287
293
  }
288
294
 
295
+ // Create a new branch for this issue
296
+ if (issueAction === "createBranch") {
297
+ const localRepo = findLocalRepo(glabRepos, projectPath);
298
+ const projectDefaultBranch = localRepo ? getDefaultBranch(localRepo.repo) : null;
299
+ const defaultBranchName = `feature/${localIid}-${slugify(issue.title)}`;
300
+
301
+ const { branchName } = await enquirer.prompt({
302
+ type: "input",
303
+ name: "branchName",
304
+ message: p.white("Branch name:"),
305
+ initial: defaultBranchName,
306
+ validate: (v) => v.trim() !== "" || "Branch name cannot be empty",
307
+ });
308
+
309
+ const { baseBranchName } = await enquirer.prompt({
310
+ type: "input",
311
+ name: "baseBranchName",
312
+ message: p.white("Base branch:"),
313
+ initial: projectDefaultBranch || portalConfig.defaultBaseBranch || "develop",
314
+ validate: (v) => v.trim() !== "" || "Base branch cannot be empty",
315
+ });
316
+
317
+ const enc = encodeURIComponent(projectPath);
318
+ process.stdout.write(" " + p.muted("Creating branch…\r"));
319
+ try {
320
+ await glabApi(`projects/${enc}/repository/branches`, {
321
+ method: "POST",
322
+ fields: { branch: branchName.trim(), ref: baseBranchName.trim() },
323
+ });
324
+ process.stdout.write(" ".repeat(40) + "\r");
325
+ console.log(
326
+ boxen(
327
+ chalk.bold(p.green("✔ Branch created")) + " " + colorBranch(branchName.trim()),
328
+ { padding: { top: 0, bottom: 0, left: 2, right: 2 }, borderStyle: "round", borderColor: "#4ade80" },
329
+ ),
330
+ );
331
+ // Auto-set as primary if none is set yet
332
+ const cfgNow = loadConfig();
333
+ const pb = cfgNow.portal?.primaryBranches ?? {};
334
+ if (!pb[configKey]) {
335
+ pb[configKey] = branchName.trim();
336
+ saveConfig({ ...cfgNow, portal: { ...cfgNow.portal, primaryBranches: pb } });
337
+ console.log(" " + p.teal("★") + " " + p.muted("Set as primary branch"));
338
+ }
339
+ } catch (e) {
340
+ process.stdout.write(" ".repeat(40) + "\r");
341
+ console.log(
342
+ boxen(
343
+ chalk.bold(p.red("Branch creation failed")) + "\n\n" + p.muted(e.message.slice(0, 80)),
344
+ { padding: { top: 0, bottom: 0, left: 2, right: 2 }, borderStyle: "round", borderColor: "#f87171" },
345
+ ),
346
+ );
347
+ }
348
+ console.log();
349
+ return;
350
+ }
351
+
289
352
  // Set primary branch
290
353
  const primaryChoice = await search({
291
354
  message: p.white("Set primary branch:"),
@@ -477,62 +540,62 @@ async function cmdEpicMr(epicIssues, glabRepos, portalConfig, { autoConfirm = fa
477
540
  console.log();
478
541
  }
479
542
  } else {
480
- // Multi-select: all ready repos pre-selected, space to toggle, Esc to go back
481
- const choices = ready.map((c) => ({
482
- name: c.localRepo.name,
483
- value: c.localRepo.name,
484
- message: chalk.bold(p.white(c.localRepo.name)) + " " + colorBranch(c.primary),
485
- hint: "",
486
- enabled: true, // pre-select all
487
- }));
488
-
489
- const selectedNames = await enquirer.autocomplete({
490
- name: "repos",
491
- message: "Select repos to create MRs for:",
492
- multiple: true,
493
- initial: 0,
494
- limit: 12,
495
- choices,
496
- symbols: { indicator: { on: "◉", off: "◯" } },
497
- footer() { return p.muted("space to toggle · type to filter · enter to confirm · esc to go back"); },
498
- suggest(input = "", allChoices = []) {
499
- const term = (input ?? "").toLowerCase().trim();
500
- const selected = allChoices.filter((c) => c.enabled);
501
- const unselected = allChoices.filter((c) => !c.enabled);
502
- const filtered = term ? unselected.filter((c) => c.value.toLowerCase().includes(term)) : unselected;
503
- return [...selected, ...filtered];
504
- },
505
- }).catch(() => "__back__");
543
+ // Multi-select: all ready repos pre-selected, space to toggle, Esc to go back
544
+ const choices = ready.map((c) => ({
545
+ name: c.localRepo.name,
546
+ value: c.localRepo.name,
547
+ message: chalk.bold(p.white(c.localRepo.name)) + " " + colorBranch(c.primary),
548
+ hint: "",
549
+ enabled: true, // pre-select all
550
+ }));
551
+
552
+ const selectedNames = await enquirer.autocomplete({
553
+ name: "repos",
554
+ message: "Select repos to create MRs for:",
555
+ multiple: true,
556
+ initial: 0,
557
+ limit: 12,
558
+ choices,
559
+ symbols: { indicator: { on: "◉", off: "◯" } },
560
+ footer() { return p.muted("space to toggle · type to filter · enter to confirm · esc to go back"); },
561
+ suggest(input = "", allChoices = []) {
562
+ const term = (input ?? "").toLowerCase().trim();
563
+ const selected = allChoices.filter((c) => c.enabled);
564
+ const unselected = allChoices.filter((c) => !c.enabled);
565
+ const filtered = term ? unselected.filter((c) => c.value.toLowerCase().includes(term)) : unselected;
566
+ return [...selected, ...filtered];
567
+ },
568
+ }).catch(() => "__back__");
506
569
 
507
- console.log();
508
- if (selectedNames === "__back__" || !Array.isArray(selectedNames) || selectedNames.length === 0) {
509
- if (selectedNames !== "__back__") console.log(" " + p.muted("Nothing selected.\n"));
510
- return;
511
- }
570
+ console.log();
571
+ if (selectedNames === "__back__" || !Array.isArray(selectedNames) || selectedNames.length === 0) {
572
+ if (selectedNames !== "__back__") console.log(" " + p.muted("Nothing selected.\n"));
573
+ return;
574
+ }
512
575
 
513
- selectedReady = selectedNames
514
- .map((name) => ready.find((c) => c.localRepo.name === name))
515
- .filter(Boolean);
576
+ selectedReady = selectedNames
577
+ .map((name) => ready.find((c) => c.localRepo.name === name))
578
+ .filter(Boolean);
516
579
  } // end autoConfirm / interactive split
517
580
 
518
581
  // Shared options — prompt once for all (skip prompts when auto-confirming)
519
582
  const targetBranch = autoConfirm
520
583
  ? (portalConfig.defaultBaseBranch ?? "develop")
521
584
  : (await enquirer.prompt({
522
- type: "input",
523
- name: "targetBranch",
524
- message: p.white("Target branch") + p.muted(" (for all):"),
525
- initial: portalConfig.defaultBaseBranch ?? "develop",
526
- validate: (v) => v.trim() !== "" || "Required",
527
- })).targetBranch;
585
+ type: "input",
586
+ name: "targetBranch",
587
+ message: p.white("Target branch") + p.muted(" (for all):"),
588
+ initial: portalConfig.defaultBaseBranch ?? "develop",
589
+ validate: (v) => v.trim() !== "" || "Required",
590
+ })).targetBranch;
528
591
 
529
592
  const labels = autoConfirm
530
593
  ? (portalConfig.defaultLabels ?? "")
531
594
  : await input({
532
- message: p.white("Labels") + p.muted(" (optional, applied to all):"),
533
- default: portalConfig.defaultLabels ?? "",
534
- theme: { ...THEME, style: { ...THEME.style, answer: (s) => p.purple(s) } },
535
- });
595
+ message: p.white("Labels") + p.muted(" (optional, applied to all):"),
596
+ default: portalConfig.defaultLabels ?? "",
597
+ theme: { ...THEME, style: { ...THEME.style, answer: (s) => p.purple(s) } },
598
+ });
536
599
 
537
600
  const isDraft = autoConfirm
538
601
  ? false
@@ -547,9 +610,9 @@ async function cmdEpicMr(epicIssues, glabRepos, portalConfig, { autoConfirm = fa
547
610
  const confirmed = autoConfirm
548
611
  ? true
549
612
  : await confirm({
550
- message: p.white("Create ") + p.cyan(String(selectedReady.length)) + p.white(` MR${selectedReady.length !== 1 ? "s" : ""}?`),
551
- default: true, theme: THEME,
552
- });
613
+ message: p.white("Create ") + p.cyan(String(selectedReady.length)) + p.white(` MR${selectedReady.length !== 1 ? "s" : ""}?`),
614
+ default: true, theme: THEME,
615
+ });
553
616
  console.log();
554
617
  if (!confirmed) return;
555
618
 
@@ -753,10 +816,10 @@ async function cmdEpicCheckout(epicIssues, glabRepos, { autoConfirm = false } =
753
816
  const confirmed = autoConfirm
754
817
  ? true
755
818
  : await confirm({
756
- message: p.white("Checkout ") + p.cyan(String(checkouts.length)) + p.white(" repo" + (checkouts.length !== 1 ? "s" : "") + "?"),
757
- default: true,
758
- theme: THEME,
759
- });
819
+ message: p.white("Checkout ") + p.cyan(String(checkouts.length)) + p.white(" repo" + (checkouts.length !== 1 ? "s" : "") + "?"),
820
+ default: true,
821
+ theme: THEME,
822
+ });
760
823
  console.log();
761
824
  if (!confirmed) return;
762
825
 
@@ -901,29 +964,29 @@ async function cmdEpicCrReview(epicIssues, glabRepos, portalConfig = {}) {
901
964
  // ── Main portal command ────────────────────────────────────────────────────────
902
965
 
903
966
  export async function cmdPortal(repos, {
904
- settings = false,
967
+ settings = false,
905
968
  // ── non-interactive opts ────────────────────────────────────────────────────────────
906
- epic: cliEpic = null, // --epic <iid>
907
- checkout: cliCheckout = false, // --checkout
908
- createMr: cliCreateMr = false, // --create-mr
909
- createIssue: cliCreateIssue = false, // --create-issue
910
- review: cliReview = false, // --review
969
+ epic: cliEpic = null, // --epic <iid>
970
+ checkout: cliCheckout = false, // --checkout
971
+ createMr: cliCreateMr = false, // --create-mr
972
+ createIssue: cliCreateIssue = false, // --create-issue
973
+ review: cliReview = false, // --review
911
974
  // shared
912
- target: cliTarget = null, // --target
913
- title: cliTitle = null, // --title (MR)
914
- description: cliDescription = null,
915
- labels: cliLabels = null,
916
- draft: cliDraft = false,
917
- noPush: cliNoPush = false,
975
+ target: cliTarget = null, // --target
976
+ title: cliTitle = null, // --title (MR)
977
+ description: cliDescription = null,
978
+ labels: cliLabels = null,
979
+ draft: cliDraft = false,
980
+ noPush: cliNoPush = false,
918
981
  // issue creation
919
- issueProject: cliIssueProject = null,
920
- issueTitle: cliIssueTitle = null,
982
+ issueProject: cliIssueProject = null,
983
+ issueTitle: cliIssueTitle = null,
921
984
  issueDescription: cliIssueDescription = null,
922
- issueLabels: cliIssueLabels = null,
923
- branchName: cliBranchName = null,
924
- baseBranch: cliBaseBranch = null,
985
+ issueLabels: cliIssueLabels = null,
986
+ branchName: cliBranchName = null,
987
+ baseBranch: cliBaseBranch = null,
925
988
  // auto-confirm
926
- yes: autoConfirm = false,
989
+ yes: autoConfirm = false,
927
990
  } = {}) {
928
991
  // Detect CodeRabbit CLI once up front
929
992
  const crAvailable = isCrAvailable();
@@ -933,28 +996,28 @@ export async function cmdPortal(repos, {
933
996
  try {
934
997
  execSync("glab version", { encoding: "utf8", stdio: "pipe" });
935
998
  } catch {
936
- const isMac = process.platform === "darwin";
937
- const isWin = process.platform === "win32";
999
+ const isMac = process.platform === "darwin";
1000
+ const isWin = process.platform === "win32";
938
1001
  const isLinux = process.platform === "linux";
939
1002
 
940
1003
  const platform = isMac ? "macOS" : isWin ? "Windows" : isLinux ? "Linux" : null;
941
1004
 
942
1005
  const installLines = isMac
943
1006
  ? p.muted(" brew ") + p.cyan("brew install glab") + "\n" +
944
- p.muted(" MacPorts ") + p.cyan("sudo port install glab") + "\n" +
945
- p.muted(" asdf ") + p.cyan("asdf plugin add glab && asdf install glab latest")
1007
+ p.muted(" MacPorts ") + p.cyan("sudo port install glab") + "\n" +
1008
+ p.muted(" asdf ") + p.cyan("asdf plugin add glab && asdf install glab latest")
946
1009
  : isWin
947
- ? p.muted(" winget ") + p.cyan("winget install glab.glab") + "\n" +
1010
+ ? p.muted(" winget ") + p.cyan("winget install glab.glab") + "\n" +
948
1011
  p.muted(" choco ") + p.cyan("choco install glab") + "\n" +
949
1012
  p.muted(" scoop ") + p.cyan("scoop install glab") + "\n" +
950
1013
  p.muted(" brew ") + p.cyan("brew install glab") + p.muted(" (via WSL)")
951
- : isLinux
952
- ? p.muted(" brew ") + p.cyan("brew install glab") + "\n" +
953
- p.muted(" snap ") + p.cyan("sudo snap install glab && sudo snap connect glab:ssh-keys") + "\n" +
954
- p.muted(" apt ") + p.cyan("sudo apt install glab") + p.muted(" (WakeMeOps repo)") + "\n" +
955
- p.muted(" pacman ") + p.cyan("pacman -S glab") + "\n" +
956
- p.muted(" dnf ") + p.cyan("dnf install glab")
957
- : p.muted(" ") + p.cyan("https://gitlab.com/gitlab-org/cli#installation");
1014
+ : isLinux
1015
+ ? p.muted(" brew ") + p.cyan("brew install glab") + "\n" +
1016
+ p.muted(" snap ") + p.cyan("sudo snap install glab && sudo snap connect glab:ssh-keys") + "\n" +
1017
+ p.muted(" apt ") + p.cyan("sudo apt install glab") + p.muted(" (WakeMeOps repo)") + "\n" +
1018
+ p.muted(" pacman ") + p.cyan("pacman -S glab") + "\n" +
1019
+ p.muted(" dnf ") + p.cyan("dnf install glab")
1020
+ : p.muted(" ") + p.cyan("https://gitlab.com/gitlab-org/cli#installation");
958
1021
 
959
1022
  console.log(
960
1023
  boxen(
@@ -1110,13 +1173,13 @@ export async function cmdPortal(repos, {
1110
1173
  // --create-mr via portal (non-interactive MR command)
1111
1174
  if (cliCreateMr && !cliEpic) {
1112
1175
  return await cmdMr(repos, {
1113
- target: cliTarget,
1114
- title: cliTitle,
1176
+ target: cliTarget,
1177
+ title: cliTitle,
1115
1178
  description: cliDescription,
1116
- labels: cliLabels,
1117
- draft: cliDraft,
1118
- noPush: cliNoPush,
1119
- yes: autoConfirm,
1179
+ labels: cliLabels,
1180
+ draft: cliDraft,
1181
+ noPush: cliNoPush,
1182
+ yes: autoConfirm,
1120
1183
  });
1121
1184
  }
1122
1185
 
@@ -1155,8 +1218,8 @@ export async function cmdPortal(repos, {
1155
1218
  // Bulk epic MR — override shared options in portalConfig for non-interactive
1156
1219
  const mergedPortalConfig = {
1157
1220
  ...portalConfig,
1158
- ...(cliTarget ? { defaultBaseBranch: cliTarget } : {}),
1159
- ...(cliLabels ? { defaultLabels: cliLabels } : {}),
1221
+ ...(cliTarget ? { defaultBaseBranch: cliTarget } : {}),
1222
+ ...(cliLabels ? { defaultLabels: cliLabels } : {}),
1160
1223
  };
1161
1224
  return await cmdEpicMr(epicIssues, glabRepos, mergedPortalConfig, { autoConfirm });
1162
1225
  }
@@ -1297,13 +1360,13 @@ export async function cmdPortal(repos, {
1297
1360
 
1298
1361
  if (section === "mr") {
1299
1362
  const r = await cmdMr(repos, {
1300
- target: cliTarget,
1301
- title: cliTitle,
1363
+ target: cliTarget,
1364
+ title: cliTitle,
1302
1365
  description: cliDescription,
1303
- labels: cliLabels,
1304
- draft: cliDraft,
1305
- noPush: cliNoPush,
1306
- yes: autoConfirm,
1366
+ labels: cliLabels,
1367
+ draft: cliDraft,
1368
+ noPush: cliNoPush,
1369
+ yes: autoConfirm,
1307
1370
  });
1308
1371
  if (r !== "__back__") return r;
1309
1372
  continue portalHome;
@@ -1657,11 +1720,12 @@ export async function cmdPortal(repos, {
1657
1720
  validate: (v) => v.trim() !== "" || "Branch name cannot be empty",
1658
1721
  });
1659
1722
 
1723
+ const projectDefaultBranch = getDefaultBranch(projectChoice.repo);
1660
1724
  const { baseBranchName } = await enquirer.prompt({
1661
1725
  type: "input",
1662
1726
  name: "baseBranchName",
1663
1727
  message: p.white("Base branch:"),
1664
- initial: portalConfig.defaultBaseBranch ?? "develop",
1728
+ initial: projectDefaultBranch || portalConfig.defaultBaseBranch || "develop",
1665
1729
  validate: (v) => v.trim() !== "" || "Base branch cannot be empty",
1666
1730
  });
1667
1731
 
package/src/git/core.js CHANGED
@@ -1,30 +1,40 @@
1
- import { execSync } from "child_process";
2
- import { join, dirname } from "path";
1
+ import { readdirSync, statSync } from "fs";
2
+ import { join, dirname, sep } from "path";
3
3
 
4
4
  import { execAsync } from "../utils/exec.js";
5
5
 
6
6
  export function findRepos(cwd, depth) {
7
- try {
8
- const out = execSync(
9
- `find . -mindepth 1 -maxdepth ${depth} -type d -name '.git' 2>/dev/null`,
10
- { cwd, encoding: "utf8" },
11
- );
12
- const repos = out
13
- .trim()
14
- .split("\n")
15
- .filter(Boolean)
16
- .map((g) => join(cwd, dirname(g)));
7
+ const repos = [];
17
8
 
18
- // Filter out repos nested inside another found repo (submodule / monorepo protection).
19
- // A repo is nested if any other repo path is a strict path prefix of it.
20
- return repos.filter(
21
- (repo) => !repos.some(
22
- (other) => other !== repo && repo.startsWith(other + "/"),
23
- ),
24
- );
25
- } catch {
26
- return [];
9
+ function walk(dir, currentDepth) {
10
+ if (currentDepth > depth) return;
11
+ let entries;
12
+ try {
13
+ entries = readdirSync(dir, { withFileTypes: true });
14
+ } catch {
15
+ return; // permission denied or unreadable — skip
16
+ }
17
+ for (const entry of entries) {
18
+ if (!entry.isDirectory()) continue;
19
+ const full = join(dir, entry.name);
20
+ if (entry.name === ".git") {
21
+ repos.push(dirname(full));
22
+ return; // don't recurse into .git itself
23
+ }
24
+ // Skip hidden dirs (other than .git above) and node_modules
25
+ if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
26
+ walk(full, currentDepth + 1);
27
+ }
27
28
  }
29
+
30
+ walk(cwd, 1);
31
+
32
+ // Filter out repos nested inside another found repo (submodule / monorepo protection).
33
+ return repos.filter(
34
+ (repo) => !repos.some(
35
+ (other) => other !== repo && repo.startsWith(other + sep),
36
+ ),
37
+ );
28
38
  }
29
39
 
30
40
  export async function getCurrentBranch(repoPath) {
@@ -55,7 +65,7 @@ export async function getRepoStatus(repoPath) {
55
65
  export async function getAheadBehind(repoPath) {
56
66
  try {
57
67
  const { stdout } = await execAsync(
58
- "git rev-list --left-right --count @{upstream}...HEAD 2>/dev/null",
68
+ "git rev-list --left-right --count @{upstream}...HEAD",
59
69
  { cwd: repoPath, encoding: "utf8" },
60
70
  );
61
71
  if (!stdout.trim()) return { ahead: 0, behind: 0 };
@@ -65,3 +75,4 @@ export async function getAheadBehind(repoPath) {
65
75
  return { ahead: 0, behind: 0 };
66
76
  }
67
77
  }
78
+
@@ -25,7 +25,7 @@ export function isGitLabRemote(url) {
25
25
 
26
26
  export function getDefaultBranch(repoPath) {
27
27
  try {
28
- const out = execSync("git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null", {
28
+ const out = execSync("git symbolic-ref refs/remotes/origin/HEAD", {
29
29
  cwd: repoPath,
30
30
  encoding: "utf8",
31
31
  }).trim();
package/src/main.js CHANGED
@@ -17,27 +17,32 @@ import { cmdSwitch } from "./commands/switch.js";
17
17
  import { cmdMr } from "./commands/mr.js";
18
18
  import { cmdPortal } from "./commands/portal.js";
19
19
  import { cmdSettings } from "./commands/settings.js";
20
+ import { checkForUpdate, awaitUpdateCheck } from "./utils/update-check.js";
21
+
22
+ // Kick off the update check in the background immediately so the network
23
+ // request runs while the rest of startup happens.
24
+ checkForUpdate();
20
25
 
21
26
  // ── Dependency checks ─────────────────────────────────────────────────────────
22
27
 
23
28
  function checkRequiredDeps() {
24
29
  // git — required by every command
25
30
  try { execSync("git --version", { stdio: "pipe" }); } catch {
26
- const isMac = process.platform === "darwin";
27
- const isWin = process.platform === "win32";
31
+ const isMac = process.platform === "darwin";
32
+ const isWin = process.platform === "win32";
28
33
  const isLinux = process.platform === "linux";
29
34
 
30
35
  const installLines = isMac
31
36
  ? p.muted(" brew ") + p.cyan("brew install git") + "\n" +
32
- p.muted(" direct ") + p.cyan("https://git-scm.com/download/mac")
37
+ p.muted(" direct ") + p.cyan("https://git-scm.com/download/mac")
33
38
  : isWin
34
- ? p.muted(" winget ") + p.cyan("winget install --id Git.Git") + "\n" +
39
+ ? p.muted(" winget ") + p.cyan("winget install --id Git.Git") + "\n" +
35
40
  p.muted(" direct ") + p.cyan("https://git-scm.com/download/win")
36
- : isLinux
37
- ? p.muted(" apt ") + p.cyan("sudo apt install git") + "\n" +
38
- p.muted(" dnf ") + p.cyan("sudo dnf install git") + "\n" +
39
- p.muted(" pacman ") + p.cyan("sudo pacman -S git")
40
- : p.muted(" ") + p.cyan("https://git-scm.com/downloads");
41
+ : isLinux
42
+ ? p.muted(" apt ") + p.cyan("sudo apt install git") + "\n" +
43
+ p.muted(" dnf ") + p.cyan("sudo dnf install git") + "\n" +
44
+ p.muted(" pacman ") + p.cyan("sudo pacman -S git")
45
+ : p.muted(" ") + p.cyan("https://git-scm.com/downloads");
41
46
 
42
47
  console.log(
43
48
  boxen(
@@ -45,10 +50,10 @@ function checkRequiredDeps() {
45
50
  p.white("gitmux requires git. Install it:\n\n") +
46
51
  installLines,
47
52
  {
48
- padding: { top: 1, bottom: 1, left: 3, right: 3 },
49
- borderStyle: "round",
50
- borderColor: "#f87171",
51
- title: p.red(" missing dependency: git "),
53
+ padding: { top: 1, bottom: 1, left: 3, right: 3 },
54
+ borderStyle: "round",
55
+ borderColor: "#f87171",
56
+ title: p.red(" missing dependency: git "),
52
57
  titleAlignment: "center",
53
58
  },
54
59
  ),
@@ -108,55 +113,55 @@ function parseArgs(rawArgs) {
108
113
  branch: isSubcmd ? (positional[1] ?? "") : first,
109
114
 
110
115
  // ── Switch flags ────────────────────────────────────────────────────────
111
- pull: flags.has("--pull") || flags.has("-p"),
112
- fuzzy: flags.has("--fuzzy") || flags.has("-f"),
113
- create: flags.has("--create") || flags.has("-c"),
114
- stash: flags.has("--stash") || flags.has("-s"),
115
- fetch: flags.has("--fetch"),
116
- dryRun: flags.has("--dry-run"),
116
+ pull: flags.has("--pull") || flags.has("-p"),
117
+ fuzzy: flags.has("--fuzzy") || flags.has("-f"),
118
+ create: flags.has("--create") || flags.has("-c"),
119
+ stash: flags.has("--stash") || flags.has("-s"),
120
+ fetch: flags.has("--fetch"),
121
+ dryRun: flags.has("--dry-run"),
117
122
 
118
123
  // ── Global ──────────────────────────────────────────────────────────────
119
- version: flags.has("--version") || flags.has("-v"),
120
- help: flags.has("--help") || flags.has("-h"),
124
+ version: flags.has("--version") || flags.has("-v"),
125
+ help: flags.has("--help") || flags.has("-h"),
121
126
  settings: flags.has("--settings"),
122
- debug: flags.has("--debug"),
123
- yes: flags.has("--yes") || flags.has("-y"),
124
- depth: parseInt(options.depth ?? String(DEFAULT_DEPTH), 10) || DEFAULT_DEPTH,
125
- exclude: options.exclude ?? null,
126
- filter: options.filter ?? null,
127
+ debug: flags.has("--debug"),
128
+ yes: flags.has("--yes") || flags.has("-y"),
129
+ depth: parseInt(options.depth ?? String(DEFAULT_DEPTH), 10) || DEFAULT_DEPTH,
130
+ exclude: options.exclude ?? null,
131
+ filter: options.filter ?? null,
127
132
 
128
133
  // ── MR flags ─────────────────────────────────────────────────────────────
129
134
  // --target / -t <branch> — MR target branch
130
- target: options.target ?? options.t ?? null,
135
+ target: options.target ?? options.t ?? null,
131
136
  // --repo <name> (repeatable) — restrict MR to these repos
132
- mrRepos: repos.length > 0 ? repos : null,
137
+ mrRepos: repos.length > 0 ? repos : null,
133
138
  // --title / --description / --labels — MR or issue metadata
134
- title: options.title ?? null,
139
+ title: options.title ?? null,
135
140
  description: options.description ?? null,
136
- labels: options.labels ?? null,
141
+ labels: options.labels ?? null,
137
142
  // --draft — mark MR as draft
138
- draft: flags.has("--draft"),
143
+ draft: flags.has("--draft"),
139
144
  // --no-push — skip git push before MR
140
- noPush: flags.has("--no-push"),
145
+ noPush: flags.has("--no-push"),
141
146
 
142
147
  // ── Portal flags ──────────────────────────────────────────────────────────
143
148
  // --epic <iid> — select epic by IID non-interactively
144
- epic: options.epic ?? null,
149
+ epic: options.epic ?? null,
145
150
  // --checkout — run epic checkout without menus
146
- checkout: flags.has("--checkout"),
151
+ checkout: flags.has("--checkout"),
147
152
  // --create-mr — create bulk MRs for epic without menus
148
- createMr: flags.has("--create-mr"),
153
+ createMr: flags.has("--create-mr"),
149
154
  // --create-issue — go straight to issue creation
150
- createIssue: flags.has("--create-issue"),
155
+ createIssue: flags.has("--create-issue"),
151
156
  // --review — run cr review on all primary branches of the epic
152
- review: flags.has("--review"),
157
+ review: flags.has("--review"),
153
158
  // Issue creation params
154
- issueProject: options.issueProject ?? null,
155
- issueTitle: options.issueTitle ?? null,
159
+ issueProject: options.issueProject ?? null,
160
+ issueTitle: options.issueTitle ?? null,
156
161
  issueDescription: options.issueDescription ?? null,
157
- issueLabels: options.issueLabels ?? null,
158
- branchName: options.branchName ?? null,
159
- baseBranch: options.baseBranch ?? null,
162
+ issueLabels: options.issueLabels ?? null,
163
+ branchName: options.branchName ?? null,
164
+ baseBranch: options.baseBranch ?? null,
160
165
  };
161
166
  }
162
167
 
@@ -189,6 +194,27 @@ export async function main() {
189
194
  printLogo();
190
195
  checkRequiredDeps();
191
196
 
197
+ // Show update notice if available (result already fetched in background)
198
+ const updateInfo = await awaitUpdateCheck();
199
+ if (updateInfo) {
200
+ const notice =
201
+ chalk.green("◆") + " " +
202
+ chalk.bold(p.white("update available")) + " " +
203
+ p.muted("·") + " " +
204
+ p.muted(updateInfo.current) + p.muted(" → ") + chalk.bold(p.green(updateInfo.latest)) + " " +
205
+ p.muted("·") + " " +
206
+ p.cyan(`npm i -g @errhythm/gitmux@${updateInfo.latest}`);
207
+
208
+ console.log(
209
+ boxen(notice, {
210
+ padding: { top: 0, bottom: 0, left: 2, right: 2 },
211
+ borderStyle: "round",
212
+ borderColor: "#22c55e",
213
+ }),
214
+ );
215
+ console.log();
216
+ }
217
+
192
218
  const cwd = process.cwd();
193
219
  let repos = findRepos(cwd, opts.depth);
194
220
 
@@ -246,38 +272,38 @@ export async function main() {
246
272
 
247
273
  if (opts.subcommand === "mr") {
248
274
  return await cmdMr(repos, {
249
- target: opts.target,
250
- mrRepos: opts.mrRepos,
251
- title: opts.title,
275
+ target: opts.target,
276
+ mrRepos: opts.mrRepos,
277
+ title: opts.title,
252
278
  description: opts.description,
253
- labels: opts.labels,
254
- draft: opts.draft,
255
- noPush: opts.noPush,
256
- yes: opts.yes,
279
+ labels: opts.labels,
280
+ draft: opts.draft,
281
+ noPush: opts.noPush,
282
+ yes: opts.yes,
257
283
  });
258
284
  }
259
285
 
260
286
  if (opts.subcommand === "portal") {
261
287
  return await cmdPortal(repos, {
262
- settings: opts.settings,
263
- epic: opts.epic,
264
- checkout: opts.checkout,
265
- createMr: opts.createMr,
266
- createIssue: opts.createIssue,
267
- review: opts.review,
268
- target: opts.target,
269
- title: opts.title,
270
- description: opts.description,
271
- labels: opts.labels,
272
- draft: opts.draft,
273
- noPush: opts.noPush,
274
- issueProject: opts.issueProject,
275
- issueTitle: opts.issueTitle,
288
+ settings: opts.settings,
289
+ epic: opts.epic,
290
+ checkout: opts.checkout,
291
+ createMr: opts.createMr,
292
+ createIssue: opts.createIssue,
293
+ review: opts.review,
294
+ target: opts.target,
295
+ title: opts.title,
296
+ description: opts.description,
297
+ labels: opts.labels,
298
+ draft: opts.draft,
299
+ noPush: opts.noPush,
300
+ issueProject: opts.issueProject,
301
+ issueTitle: opts.issueTitle,
276
302
  issueDescription: opts.issueDescription,
277
- issueLabels: opts.issueLabels,
278
- branchName: opts.branchName,
279
- baseBranch: opts.baseBranch,
280
- yes: opts.yes,
303
+ issueLabels: opts.issueLabels,
304
+ branchName: opts.branchName,
305
+ baseBranch: opts.baseBranch,
306
+ yes: opts.yes,
281
307
  });
282
308
  }
283
309
 
@@ -303,22 +329,31 @@ export async function main() {
303
329
  p.muted(` ${repos.length === 1 ? "repo" : "repos"} in scope\n`),
304
330
  );
305
331
 
306
- // Mode selector loop so "go back" from sub-commands returns here
332
+ // Helper to detect ESC / ExitPromptError from @inquirer/prompts
333
+ const isEsc = (e) => e?.name === "ExitPromptError";
334
+
335
+ // Mode selector — loop so ESC / "go back" from sub-commands returns here
307
336
  modeLoop: while (true) {
308
- const mode = await select({
309
- message: p.white("What do you want to do?"),
310
- choices: [
311
- {
312
- value: "switch",
313
- name: p.cyan("⇌ Switch branches") + p.muted(" checkout a branch across all repos"),
314
- },
315
- {
316
- value: "portal",
317
- name: chalk.hex("#FC6D26")("◈ GitLab") + p.muted(" development portal — epics, MRs & branches"),
318
- },
319
- ],
320
- theme: THEME,
321
- });
337
+ let mode;
338
+ try {
339
+ mode = await select({
340
+ message: p.white("What do you want to do?"),
341
+ choices: [
342
+ {
343
+ value: "switch",
344
+ name: p.cyan("⇌ Switch branches") + p.muted(" checkout a branch across all repos"),
345
+ },
346
+ {
347
+ value: "portal",
348
+ name: chalk.hex("#FC6D26")("◈ GitLab") + p.muted(" development portal — epics, MRs & branches"),
349
+ },
350
+ ],
351
+ theme: THEME,
352
+ });
353
+ } catch (e) {
354
+ if (isEsc(e)) { console.log("\n" + p.muted(" Exited.\n")); return 0; }
355
+ throw e;
356
+ }
322
357
 
323
358
  console.log();
324
359
 
@@ -328,47 +363,66 @@ export async function main() {
328
363
  continue;
329
364
  }
330
365
 
331
- // ── Switch branch flow (with back support) ─────────────────────────────
366
+ // ── Switch branch flow ─────────────────────────────────────────────────
367
+ // ESC at any prompt navigates back one level; no explicit "← Go back" needed.
332
368
  const branchSuggestions = getSwitchBranchSuggestions(config);
333
369
 
334
370
  if (branchSuggestions.length > 0) {
335
- const branchChoice = await select({
336
- message: p.white("Suggested branch:"),
337
- choices: [
338
- { value: "__back__", name: p.yellow("← Go back"), description: p.muted("return to main menu") },
339
- ...branchSuggestions.map((item) => ({
340
- value: item.value,
341
- name: colorBranch(item.value),
342
- description: p.muted(item.template),
343
- })),
344
- {
345
- value: "__custom__",
346
- name: p.white("Custom branch..."),
347
- description: p.muted("type any branch name or partial"),
348
- },
349
- ],
350
- theme: THEME,
351
- });
352
-
353
- console.log();
354
-
355
- if (branchChoice === "__back__") continue modeLoop;
356
-
357
- if (branchChoice === "__custom__") {
371
+ // Inner loop so ESC on the custom-branch input returns to suggestions.
372
+ suggestionLoop: while (true) {
373
+ let branchChoice;
374
+ try {
375
+ branchChoice = await select({
376
+ message: p.white("Suggested branch:"),
377
+ choices: [
378
+ ...branchSuggestions.map((item) => ({
379
+ value: item.value,
380
+ name: colorBranch(item.value),
381
+ description: p.muted(item.template),
382
+ })),
383
+ {
384
+ value: "__custom__",
385
+ name: p.white("Custom branch..."),
386
+ description: p.muted("type any branch name or partial"),
387
+ },
388
+ ],
389
+ theme: THEME,
390
+ });
391
+ } catch (e) {
392
+ if (isEsc(e)) { console.log(); continue modeLoop; } // ESC → mode selector
393
+ throw e;
394
+ }
395
+
396
+ console.log();
397
+
398
+ if (branchChoice === "__custom__") {
399
+ try {
400
+ targetBranch = await input({
401
+ message: p.white("Branch name") + p.muted(" (or partial):"),
402
+ theme: THEME,
403
+ validate: (v) => v.trim() !== "" || "Branch name cannot be empty",
404
+ });
405
+ } catch (e) {
406
+ if (isEsc(e)) { console.log(); continue suggestionLoop; } // ESC → suggestions
407
+ throw e;
408
+ }
409
+ } else {
410
+ targetBranch = branchChoice;
411
+ }
412
+
413
+ break suggestionLoop;
414
+ }
415
+ } else {
416
+ try {
358
417
  targetBranch = await input({
359
418
  message: p.white("Branch name") + p.muted(" (or partial):"),
360
419
  theme: THEME,
361
420
  validate: (v) => v.trim() !== "" || "Branch name cannot be empty",
362
421
  });
363
- } else {
364
- targetBranch = branchChoice;
422
+ } catch (e) {
423
+ if (isEsc(e)) { console.log(); continue modeLoop; } // ESC → mode selector
424
+ throw e;
365
425
  }
366
- } else {
367
- targetBranch = await input({
368
- message: p.white("Branch name") + p.muted(" (or partial):"),
369
- theme: THEME,
370
- validate: (v) => v.trim() !== "" || "Branch name cannot be empty",
371
- });
372
426
  }
373
427
 
374
428
  break modeLoop; // branch was chosen — proceed to confirm prompts
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Non-blocking npm update checker.
3
+ *
4
+ * - Checks the npm registry for a newer version of the package.
5
+ * - Throttled to at most once per day using a cache entry in the gitmux config.
6
+ * - Run checkForUpdate() early (don't await) to fetch in the background, then
7
+ * call awaitUpdateCheck() before blocking on user input to show the notice.
8
+ */
9
+
10
+ import { createRequire } from "module";
11
+ import { loadConfig, saveConfig } from "../config/index.js";
12
+
13
+ const require = createRequire(import.meta.url);
14
+
15
+ // Read own package.json without dynamic import (avoids assertion syntax issues)
16
+ let PKG_NAME = "@errhythm/gitmux";
17
+ let PKG_VERSION = "0.0.0";
18
+ try {
19
+ // Walk up from this file: src/utils/ → src/ → root
20
+ const pkg = require("../../package.json");
21
+ PKG_NAME = pkg.name;
22
+ PKG_VERSION = pkg.version;
23
+ } catch { /* ignore */ }
24
+
25
+ /** ms in one day */
26
+ const ONE_DAY_MS = 24 * 60 * 60 * 1000;
27
+
28
+ /** Registry URL — use the unscoped form to avoid encoding issues */
29
+ function registryUrl(name) {
30
+ return `https://registry.npmjs.org/${encodeURIComponent(name).replace("%40", "@")}`;
31
+ }
32
+
33
+ // Internal promise handle — resolved by checkForUpdate()
34
+ let _resolve;
35
+ let _updatePromise = new Promise((r) => { _resolve = r; });
36
+
37
+ /**
38
+ * Kick off the update check in the background.
39
+ * Call this EARLY in startup (do NOT await).
40
+ */
41
+ export function checkForUpdate() {
42
+ (async () => {
43
+ try {
44
+ const cfg = loadConfig();
45
+ const cache = cfg._updateCheck ?? {};
46
+ const now = Date.now();
47
+
48
+ // Only hit the network once per day
49
+ if (cache.lastChecked && (now - cache.lastChecked) < ONE_DAY_MS) {
50
+ _resolve({ latest: cache.latest ?? null, current: PKG_VERSION });
51
+ return;
52
+ }
53
+
54
+ const controller = new AbortController();
55
+ const timeout = setTimeout(() => controller.abort(), 4000);
56
+
57
+ let latest = null;
58
+ try {
59
+ const res = await fetch(registryUrl(PKG_NAME), { signal: controller.signal });
60
+ const data = await res.json();
61
+ latest = data["dist-tags"]?.latest ?? null;
62
+ } catch {
63
+ // Network error / timeout — silently skip
64
+ } finally {
65
+ clearTimeout(timeout);
66
+ }
67
+
68
+ // Persist the result so we don't hammer npm
69
+ if (latest) {
70
+ saveConfig({
71
+ ...loadConfig(),
72
+ _updateCheck: { lastChecked: now, latest },
73
+ });
74
+ }
75
+
76
+ _resolve({ latest, current: PKG_VERSION });
77
+ } catch {
78
+ _resolve({ latest: null, current: PKG_VERSION });
79
+ }
80
+ })();
81
+ }
82
+
83
+ /**
84
+ * Await the background check and return a boxen-ready notice string,
85
+ * or null if the user is already on the latest version.
86
+ */
87
+ export async function awaitUpdateCheck() {
88
+ const { latest, current } = await _updatePromise;
89
+ if (!latest) return null;
90
+
91
+ // Simple semver comparison (major.minor.patch — no pre-release needed)
92
+ if (!isNewer(latest, current)) return null;
93
+
94
+ return { latest, current };
95
+ }
96
+
97
+ /** Returns true if `candidate` is strictly newer than `baseline`. */
98
+ function isNewer(candidate, baseline) {
99
+ const toNum = (v) => v.split(".").map(Number);
100
+ const [ma, mi, pa] = toNum(candidate);
101
+ const [mb, mib, pb] = toNum(baseline);
102
+ if (ma !== mb) return ma > mb;
103
+ if (mi !== mib) return mi > mib;
104
+ return pa > pb;
105
+ }