@errhythm/gitmux 1.6.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/README.md +219 -0
- package/bin/gitmux.js +10 -0
- package/package.json +43 -0
- package/src/commands/fetch.js +76 -0
- package/src/commands/mr.js +484 -0
- package/src/commands/portal.js +1711 -0
- package/src/commands/settings.js +490 -0
- package/src/commands/status.js +86 -0
- package/src/commands/switch.js +296 -0
- package/src/config/index.js +22 -0
- package/src/constants.js +15 -0
- package/src/git/branches.js +68 -0
- package/src/git/core.js +67 -0
- package/src/git/templates.js +51 -0
- package/src/gitlab/api.js +46 -0
- package/src/gitlab/helpers.js +73 -0
- package/src/main.js +418 -0
- package/src/ui/colors.js +54 -0
- package/src/ui/print.js +177 -0
- package/src/ui/theme.js +26 -0
- package/src/utils/exec.js +12 -0
package/src/main.js
ADDED
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
import { basename } from "path";
|
|
2
|
+
import { execSync } from "child_process";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
import boxen from "boxen";
|
|
5
|
+
import { input, confirm, select } from "@inquirer/prompts";
|
|
6
|
+
|
|
7
|
+
import { VERSION, DEFAULT_DEPTH, SUBCOMMANDS } from "./constants.js";
|
|
8
|
+
import { findRepos } from "./git/core.js";
|
|
9
|
+
import { getSwitchBranchSuggestions } from "./git/templates.js";
|
|
10
|
+
import { loadConfig, saveConfig } from "./config/index.js";
|
|
11
|
+
import { printLogo, printHelp, printAbout } from "./ui/print.js";
|
|
12
|
+
import { p, THEME } from "./ui/theme.js";
|
|
13
|
+
import { colorBranch } from "./ui/colors.js";
|
|
14
|
+
import { cmdStatus } from "./commands/status.js";
|
|
15
|
+
import { cmdFetch } from "./commands/fetch.js";
|
|
16
|
+
import { cmdSwitch } from "./commands/switch.js";
|
|
17
|
+
import { cmdMr } from "./commands/mr.js";
|
|
18
|
+
import { cmdPortal } from "./commands/portal.js";
|
|
19
|
+
import { cmdSettings } from "./commands/settings.js";
|
|
20
|
+
|
|
21
|
+
// ── Dependency checks ─────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
function checkRequiredDeps() {
|
|
24
|
+
// git — required by every command
|
|
25
|
+
try { execSync("git --version", { stdio: "pipe" }); } catch {
|
|
26
|
+
const isMac = process.platform === "darwin";
|
|
27
|
+
const isWin = process.platform === "win32";
|
|
28
|
+
const isLinux = process.platform === "linux";
|
|
29
|
+
|
|
30
|
+
const installLines = isMac
|
|
31
|
+
? p.muted(" brew ") + p.cyan("brew install git") + "\n" +
|
|
32
|
+
p.muted(" direct ") + p.cyan("https://git-scm.com/download/mac")
|
|
33
|
+
: isWin
|
|
34
|
+
? p.muted(" winget ") + p.cyan("winget install --id Git.Git") + "\n" +
|
|
35
|
+
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
|
+
|
|
42
|
+
console.log(
|
|
43
|
+
boxen(
|
|
44
|
+
chalk.bold(p.red("git not found")) + "\n\n" +
|
|
45
|
+
p.white("gitmux requires git. Install it:\n\n") +
|
|
46
|
+
installLines,
|
|
47
|
+
{
|
|
48
|
+
padding: { top: 1, bottom: 1, left: 3, right: 3 },
|
|
49
|
+
borderStyle: "round",
|
|
50
|
+
borderColor: "#f87171",
|
|
51
|
+
title: p.red(" missing dependency: git "),
|
|
52
|
+
titleAlignment: "center",
|
|
53
|
+
},
|
|
54
|
+
),
|
|
55
|
+
);
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ── Argument parser ───────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
// Options that take a value argument
|
|
63
|
+
const VALUE_OPTIONS = new Set([
|
|
64
|
+
"--depth", "--exclude", "--filter",
|
|
65
|
+
// mr
|
|
66
|
+
"--target", "-t", "--repo", "--title", "--description", "--labels",
|
|
67
|
+
// portal
|
|
68
|
+
"--epic", "--issue-project", "--issue-title", "--issue-description",
|
|
69
|
+
"--issue-labels", "--branch-name", "--base-branch",
|
|
70
|
+
]);
|
|
71
|
+
|
|
72
|
+
function parseArgs(rawArgs) {
|
|
73
|
+
const flags = new Set();
|
|
74
|
+
const options = {};
|
|
75
|
+
const positional = [];
|
|
76
|
+
// Multi-value: --repo can appear multiple times
|
|
77
|
+
const repos = [];
|
|
78
|
+
|
|
79
|
+
for (let i = 0; i < rawArgs.length; i++) {
|
|
80
|
+
const arg = rawArgs[i];
|
|
81
|
+
|
|
82
|
+
if (VALUE_OPTIONS.has(arg) && rawArgs[i + 1] !== undefined) {
|
|
83
|
+
const key = arg.replace(/^-+/, "").replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
84
|
+
if (arg === "--repo") {
|
|
85
|
+
repos.push(rawArgs[++i]);
|
|
86
|
+
} else {
|
|
87
|
+
options[key] = rawArgs[++i];
|
|
88
|
+
}
|
|
89
|
+
} else if (arg.includes("=") && arg.startsWith("--")) {
|
|
90
|
+
const eqIdx = arg.indexOf("=");
|
|
91
|
+
const key = arg.slice(2, eqIdx).replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
92
|
+
options[key] = arg.slice(eqIdx + 1);
|
|
93
|
+
} else if (!arg.startsWith("--") && arg.startsWith("-") && arg.length > 2 && arg !== "-t") {
|
|
94
|
+
for (const c of arg.slice(1)) flags.add(`-${c}`);
|
|
95
|
+
} else if (arg.startsWith("-")) {
|
|
96
|
+
flags.add(arg);
|
|
97
|
+
} else {
|
|
98
|
+
positional.push(arg);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const first = positional[0] ?? "";
|
|
103
|
+
const isSubcmd = SUBCOMMANDS.has(first);
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
// ── Core ────────────────────────────────────────────────────────────────
|
|
107
|
+
subcommand: isSubcmd ? first : null,
|
|
108
|
+
branch: isSubcmd ? (positional[1] ?? "") : first,
|
|
109
|
+
|
|
110
|
+
// ── 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"),
|
|
117
|
+
|
|
118
|
+
// ── Global ──────────────────────────────────────────────────────────────
|
|
119
|
+
version: flags.has("--version") || flags.has("-v"),
|
|
120
|
+
help: flags.has("--help") || flags.has("-h"),
|
|
121
|
+
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
|
+
|
|
128
|
+
// ── MR flags ─────────────────────────────────────────────────────────────
|
|
129
|
+
// --target / -t <branch> — MR target branch
|
|
130
|
+
target: options.target ?? options.t ?? null,
|
|
131
|
+
// --repo <name> (repeatable) — restrict MR to these repos
|
|
132
|
+
mrRepos: repos.length > 0 ? repos : null,
|
|
133
|
+
// --title / --description / --labels — MR or issue metadata
|
|
134
|
+
title: options.title ?? null,
|
|
135
|
+
description: options.description ?? null,
|
|
136
|
+
labels: options.labels ?? null,
|
|
137
|
+
// --draft — mark MR as draft
|
|
138
|
+
draft: flags.has("--draft"),
|
|
139
|
+
// --no-push — skip git push before MR
|
|
140
|
+
noPush: flags.has("--no-push"),
|
|
141
|
+
|
|
142
|
+
// ── Portal flags ──────────────────────────────────────────────────────────
|
|
143
|
+
// --epic <iid> — select epic by IID non-interactively
|
|
144
|
+
epic: options.epic ?? null,
|
|
145
|
+
// --checkout — run epic checkout without menus
|
|
146
|
+
checkout: flags.has("--checkout"),
|
|
147
|
+
// --create-mr — create bulk MRs for epic without menus
|
|
148
|
+
createMr: flags.has("--create-mr"),
|
|
149
|
+
// --create-issue — go straight to issue creation
|
|
150
|
+
createIssue: flags.has("--create-issue"),
|
|
151
|
+
// --review — run cr review on all primary branches of the epic
|
|
152
|
+
review: flags.has("--review"),
|
|
153
|
+
// Issue creation params
|
|
154
|
+
issueProject: options.issueProject ?? null,
|
|
155
|
+
issueTitle: options.issueTitle ?? null,
|
|
156
|
+
issueDescription: options.issueDescription ?? null,
|
|
157
|
+
issueLabels: options.issueLabels ?? null,
|
|
158
|
+
branchName: options.branchName ?? null,
|
|
159
|
+
baseBranch: options.baseBranch ?? null,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ── Entry point ───────────────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
export async function main() {
|
|
166
|
+
process.on("SIGINT", () => { console.log("\n\n" + p.muted(" Interrupted.") + "\n"); process.exit(0); });
|
|
167
|
+
process.on("SIGTERM", () => { console.log("\n\n" + p.muted(" Terminated.") + "\n"); process.exit(0); });
|
|
168
|
+
|
|
169
|
+
const opts = parseArgs(process.argv.slice(2));
|
|
170
|
+
if (opts.debug) process.env.GITMUX_DEBUG = "1"; // set before any module uses it
|
|
171
|
+
const config = loadConfig();
|
|
172
|
+
|
|
173
|
+
if (opts.version) {
|
|
174
|
+
console.log(`gitmux v${VERSION}`);
|
|
175
|
+
return 0;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (opts.help) {
|
|
179
|
+
printHelp();
|
|
180
|
+
return 0;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (opts.subcommand === "about") {
|
|
184
|
+
printAbout();
|
|
185
|
+
return 0;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
process.stdout.write("\x1Bc");
|
|
189
|
+
printLogo();
|
|
190
|
+
checkRequiredDeps();
|
|
191
|
+
|
|
192
|
+
const cwd = process.cwd();
|
|
193
|
+
let repos = findRepos(cwd, opts.depth);
|
|
194
|
+
|
|
195
|
+
if (repos.length === 0) {
|
|
196
|
+
console.log(
|
|
197
|
+
boxen(p.red("No git repositories found in the current directory."), {
|
|
198
|
+
padding: { top: 0, bottom: 0, left: 2, right: 2 },
|
|
199
|
+
borderStyle: "round",
|
|
200
|
+
borderColor: "red",
|
|
201
|
+
}),
|
|
202
|
+
);
|
|
203
|
+
return 1;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (opts.filter) {
|
|
207
|
+
const pat = opts.filter.toLowerCase();
|
|
208
|
+
const before = repos.length;
|
|
209
|
+
repos = repos.filter((r) => basename(r).toLowerCase().includes(pat));
|
|
210
|
+
if (repos.length === 0) {
|
|
211
|
+
console.log(p.yellow(` No repos matched --filter "${opts.filter}". Nothing to do.\n`));
|
|
212
|
+
return 0;
|
|
213
|
+
}
|
|
214
|
+
console.log(
|
|
215
|
+
p.muted(` ${repos.length} repos matched --filter "${opts.filter}" (${before - repos.length} hidden)\n`),
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (opts.exclude) {
|
|
220
|
+
const pat = opts.exclude.toLowerCase();
|
|
221
|
+
const before = repos.length;
|
|
222
|
+
repos = repos.filter((r) => !basename(r).toLowerCase().includes(pat));
|
|
223
|
+
if (repos.length === 0) {
|
|
224
|
+
console.log(
|
|
225
|
+
p.yellow(` All ${before} repos matched --exclude "${opts.exclude}". Nothing to do.\n`),
|
|
226
|
+
);
|
|
227
|
+
return 0;
|
|
228
|
+
}
|
|
229
|
+
console.log(
|
|
230
|
+
p.muted(` ${repos.length} repos · ${before - repos.length} excluded via "${opts.exclude}"\n`),
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ── Subcommands ───────────────────────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
if (opts.subcommand === "status") {
|
|
237
|
+
await cmdStatus(repos);
|
|
238
|
+
return 0;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (opts.subcommand === "fetch") {
|
|
242
|
+
console.log();
|
|
243
|
+
await cmdFetch(repos);
|
|
244
|
+
return 0;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (opts.subcommand === "mr") {
|
|
248
|
+
return await cmdMr(repos, {
|
|
249
|
+
target: opts.target,
|
|
250
|
+
mrRepos: opts.mrRepos,
|
|
251
|
+
title: opts.title,
|
|
252
|
+
description: opts.description,
|
|
253
|
+
labels: opts.labels,
|
|
254
|
+
draft: opts.draft,
|
|
255
|
+
noPush: opts.noPush,
|
|
256
|
+
yes: opts.yes,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (opts.subcommand === "portal") {
|
|
261
|
+
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,
|
|
276
|
+
issueDescription: opts.issueDescription,
|
|
277
|
+
issueLabels: opts.issueLabels,
|
|
278
|
+
branchName: opts.branchName,
|
|
279
|
+
baseBranch: opts.baseBranch,
|
|
280
|
+
yes: opts.yes,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (opts.subcommand === "settings") {
|
|
285
|
+
return await cmdSettings(repos);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ── Interactive switch mode ────────────────────────────────────────────────
|
|
289
|
+
|
|
290
|
+
let {
|
|
291
|
+
branch: targetBranch,
|
|
292
|
+
pull: pullChanges,
|
|
293
|
+
fuzzy: fuzzyMode,
|
|
294
|
+
create: createBranch,
|
|
295
|
+
stash: autoStash,
|
|
296
|
+
fetch: doFetch,
|
|
297
|
+
dryRun,
|
|
298
|
+
} = opts;
|
|
299
|
+
|
|
300
|
+
if (!targetBranch) {
|
|
301
|
+
console.log(
|
|
302
|
+
" " + p.muted("Found ") + p.white(String(repos.length)) +
|
|
303
|
+
p.muted(` ${repos.length === 1 ? "repo" : "repos"} in scope\n`),
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
// Mode selector — loop so "go back" from sub-commands returns here
|
|
307
|
+
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
|
+
});
|
|
322
|
+
|
|
323
|
+
console.log();
|
|
324
|
+
|
|
325
|
+
if (mode === "portal") {
|
|
326
|
+
const result = await cmdPortal(repos);
|
|
327
|
+
if (result !== "__back__") return result;
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// ── Switch branch flow (with back support) ─────────────────────────────
|
|
332
|
+
const branchSuggestions = getSwitchBranchSuggestions(config);
|
|
333
|
+
|
|
334
|
+
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__") {
|
|
358
|
+
targetBranch = await input({
|
|
359
|
+
message: p.white("Branch name") + p.muted(" (or partial):"),
|
|
360
|
+
theme: THEME,
|
|
361
|
+
validate: (v) => v.trim() !== "" || "Branch name cannot be empty",
|
|
362
|
+
});
|
|
363
|
+
} else {
|
|
364
|
+
targetBranch = branchChoice;
|
|
365
|
+
}
|
|
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
|
+
}
|
|
373
|
+
|
|
374
|
+
break modeLoop; // branch was chosen — proceed to confirm prompts
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const switchDefaults = config.switch?.lastOptions ?? {};
|
|
378
|
+
|
|
379
|
+
pullChanges = await confirm({
|
|
380
|
+
message: p.white("Pull") + p.muted(" after switching?"),
|
|
381
|
+
default: switchDefaults.pull ?? false,
|
|
382
|
+
theme: THEME,
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
fuzzyMode = await confirm({
|
|
386
|
+
message: p.white("Fuzzy") + p.muted(" / partial branch matching?"),
|
|
387
|
+
default: switchDefaults.fuzzy ?? false,
|
|
388
|
+
theme: THEME,
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
autoStash = await confirm({
|
|
392
|
+
message: p.white("Auto-stash") + p.muted(" dirty repos before switching?"),
|
|
393
|
+
default: switchDefaults.stash ?? false,
|
|
394
|
+
theme: THEME,
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
saveConfig({
|
|
398
|
+
...config,
|
|
399
|
+
switch: {
|
|
400
|
+
...config.switch,
|
|
401
|
+
lastOptions: { pull: pullChanges, fuzzy: fuzzyMode, stash: autoStash },
|
|
402
|
+
},
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
console.log();
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
return await cmdSwitch(repos, {
|
|
410
|
+
targetBranch,
|
|
411
|
+
pullChanges,
|
|
412
|
+
fuzzyMode,
|
|
413
|
+
createBranch,
|
|
414
|
+
autoStash,
|
|
415
|
+
doFetch,
|
|
416
|
+
dryRun,
|
|
417
|
+
});
|
|
418
|
+
}
|
package/src/ui/colors.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { p } from "./theme.js";
|
|
3
|
+
|
|
4
|
+
// Convert HSL (h: 0-360, s: 0-100, l: 0-100) to a hex colour string
|
|
5
|
+
function hslToHex(h, s, l) {
|
|
6
|
+
s /= 100;
|
|
7
|
+
l /= 100;
|
|
8
|
+
const k = (n) => (n + h / 30) % 12;
|
|
9
|
+
const a = s * Math.min(l, 1 - l);
|
|
10
|
+
const f = (n) =>
|
|
11
|
+
l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
|
|
12
|
+
const hex = (x) =>
|
|
13
|
+
Math.round(x * 255)
|
|
14
|
+
.toString(16)
|
|
15
|
+
.padStart(2, "0");
|
|
16
|
+
return `#${hex(f(0))}${hex(f(8))}${hex(f(4))}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Deterministically map a prefix string to a hue (0–359)
|
|
20
|
+
function prefixHue(str) {
|
|
21
|
+
let h = 0;
|
|
22
|
+
for (let i = 0; i < str.length; i++) h = (h * 31 + str.charCodeAt(i)) >>> 0;
|
|
23
|
+
return h % 360;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Colour a GitLab label by hashing its namespace prefix (e.g. "STA", "PRI", "TECH")
|
|
27
|
+
// Works for any prefix — same prefix always gets the same colour.
|
|
28
|
+
export function colorLabel(label) {
|
|
29
|
+
const prefix = label.split("::")[0].trim();
|
|
30
|
+
const hex = hslToHex(prefixHue(prefix.toUpperCase()), 65, 72);
|
|
31
|
+
return chalk.hex(hex)(label);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function colorBranch(name) {
|
|
35
|
+
const n = name.trimEnd();
|
|
36
|
+
const pad = name.slice(n.length);
|
|
37
|
+
const color =
|
|
38
|
+
n === "main" || n === "master"
|
|
39
|
+
? p.red
|
|
40
|
+
: /^(feature|feat)/.test(n)
|
|
41
|
+
? p.cyan
|
|
42
|
+
: /^(hotfix|fix|bugfix)/.test(n)
|
|
43
|
+
? p.orange
|
|
44
|
+
: /^(release|rel)/.test(n)
|
|
45
|
+
? p.teal
|
|
46
|
+
: /^(develop|dev)$/.test(n)
|
|
47
|
+
? p.purple
|
|
48
|
+
: /^sprint/.test(n)
|
|
49
|
+
? p.yellow
|
|
50
|
+
: /^(chore|refactor|docs|test)/.test(n)
|
|
51
|
+
? p.muted
|
|
52
|
+
: p.white;
|
|
53
|
+
return color(n) + pad;
|
|
54
|
+
}
|
package/src/ui/print.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import figlet from "figlet";
|
|
2
|
+
import boxen from "boxen";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
|
|
5
|
+
import { p } from "./theme.js";
|
|
6
|
+
import { VERSION } from "../constants.js";
|
|
7
|
+
|
|
8
|
+
export function printLogo() {
|
|
9
|
+
const art = figlet.textSync("gitmux", { font: "ANSI Shadow" });
|
|
10
|
+
console.log();
|
|
11
|
+
console.log(chalk.white(art));
|
|
12
|
+
console.log(
|
|
13
|
+
" " + p.slate("─".repeat(3)) +
|
|
14
|
+
" " + p.muted(`v${VERSION}`) +
|
|
15
|
+
" " + p.slate("─".repeat(3)),
|
|
16
|
+
);
|
|
17
|
+
console.log();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function printAbout() {
|
|
21
|
+
printLogo();
|
|
22
|
+
|
|
23
|
+
const hr = " " + p.dim("─".repeat(58));
|
|
24
|
+
const gap = "";
|
|
25
|
+
|
|
26
|
+
const features = [
|
|
27
|
+
[p.cyan("⇌ Branch switching"), "checkout any branch across all repos instantly"],
|
|
28
|
+
[p.teal("↓ Pull & stash"), "pull latest or auto-stash dirty repos on switch"],
|
|
29
|
+
[p.purple("~ Fuzzy matching"), "partial branch name matching across all repos"],
|
|
30
|
+
[p.cyan("↺ Parallel fetch"), "fetch all remotes concurrently"],
|
|
31
|
+
[chalk.hex("#FC6D26")("◈ GitLab portal"), "browse epics, issues & primary branches"],
|
|
32
|
+
[p.purple("⎇ Merge requests"), "open MRs straight from the CLI via glab"],
|
|
33
|
+
[p.green("✚ Issue creation"), "create GitLab issues linked to epics"],
|
|
34
|
+
[p.teal("★ Primary branches"), "pin a branch per issue for fast checkout"],
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
const lines = [
|
|
38
|
+
// About
|
|
39
|
+
" " + chalk.bold(p.white("ABOUT")),
|
|
40
|
+
gap,
|
|
41
|
+
" " + p.muted("gitmux is a multi-repo Git workflow CLI."),
|
|
42
|
+
" " + p.muted("Run it from any directory to operate across all git"),
|
|
43
|
+
" " + p.muted("repositories found within the configured search depth."),
|
|
44
|
+
gap,
|
|
45
|
+
hr,
|
|
46
|
+
gap,
|
|
47
|
+
|
|
48
|
+
// Features
|
|
49
|
+
" " + chalk.bold(p.white("FEATURES")),
|
|
50
|
+
gap,
|
|
51
|
+
...features.map(([label, desc]) =>
|
|
52
|
+
" " + label + " " + p.muted(desc),
|
|
53
|
+
),
|
|
54
|
+
gap,
|
|
55
|
+
hr,
|
|
56
|
+
gap,
|
|
57
|
+
|
|
58
|
+
// Commands
|
|
59
|
+
" " + chalk.bold(p.white("COMMANDS")),
|
|
60
|
+
gap,
|
|
61
|
+
` ${p.cyan("gitmux <branch>")} ${p.muted("Switch branch across all repos")}`,
|
|
62
|
+
` ${p.cyan("gitmux status")} ${p.muted("Show branch & dirty state for all repos")}`,
|
|
63
|
+
` ${p.cyan("gitmux fetch")} ${p.muted("Fetch all remotes in parallel")}`,
|
|
64
|
+
` ${p.cyan("gitmux mr")} ${p.muted("Create merge requests via glab")}`,
|
|
65
|
+
` ${p.cyan("gitmux portal")} ${p.muted("GitLab development portal")}`,
|
|
66
|
+
` ${p.cyan("gitmux settings")} ${p.muted("Configure portal & workflow defaults")}`,
|
|
67
|
+
` ${p.cyan("gitmux about")} ${p.muted("This page")}`,
|
|
68
|
+
gap,
|
|
69
|
+
hr,
|
|
70
|
+
gap,
|
|
71
|
+
|
|
72
|
+
// Meta
|
|
73
|
+
" " + chalk.bold(p.white("VERSION")) + " " + p.white(`v${VERSION}`),
|
|
74
|
+
gap,
|
|
75
|
+
" " + chalk.bold(p.white("AUTHOR")) + " " + p.white("E.R.Rhythm"),
|
|
76
|
+
" " + chalk.bold(p.white("EMAIL")) + " " + p.muted("errhythm.me@gmail.com"),
|
|
77
|
+
" " + chalk.bold(p.white("GITHUB")) + " " + p.muted("github.com/") + p.cyan("e.r.rhythm"),
|
|
78
|
+
gap,
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
for (const line of lines) console.log(line);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
export function printHelp() {
|
|
86
|
+
printLogo();
|
|
87
|
+
const hr = p.dim("─".repeat(52));
|
|
88
|
+
console.log(
|
|
89
|
+
boxen(
|
|
90
|
+
[
|
|
91
|
+
chalk.bold(p.white("USAGE")),
|
|
92
|
+
` ${p.cyan("gitmux")} ${p.muted("[branch] [options]")}`,
|
|
93
|
+
` ${p.cyan("gitmux status")}`,
|
|
94
|
+
` ${p.cyan("gitmux fetch")}`,
|
|
95
|
+
` ${p.cyan("gitmux mr [options]")}`,
|
|
96
|
+
` ${p.cyan("gitmux portal [options]")}`,
|
|
97
|
+
` ${p.cyan("gitmux settings")}`,
|
|
98
|
+
` ${p.cyan("gitmux about")}`,
|
|
99
|
+
"",
|
|
100
|
+
hr,
|
|
101
|
+
"",
|
|
102
|
+
chalk.bold(p.white("COMMANDS")),
|
|
103
|
+
` ${p.cyan("status")} ${p.muted("Show branch & dirty status for all repos")}`,
|
|
104
|
+
` ${p.cyan("fetch")} ${p.muted("Fetch all remotes across repos in parallel")}`,
|
|
105
|
+
` ${p.cyan("mr")} ${p.muted("Create GitLab merge requests directly (via glab)")}`,
|
|
106
|
+
` ${p.cyan("portal")} ${p.muted("GitLab Portal — epics, MRs, issues & branches")}`,
|
|
107
|
+
` ${p.cyan("settings")} ${p.muted("Configure portal, switch & MR defaults")}`,
|
|
108
|
+
` ${p.cyan("about")} ${p.muted("Show version, features & author info")}`,
|
|
109
|
+
"",
|
|
110
|
+
hr,
|
|
111
|
+
"",
|
|
112
|
+
chalk.bold(p.white("SWITCH OPTIONS")),
|
|
113
|
+
` ${p.purple("-p, --pull")} ${p.muted("Pull latest on the target branch after switching")}`,
|
|
114
|
+
` ${p.purple("-f, --fuzzy")} ${p.muted("Fuzzy / partial branch name matching")}`,
|
|
115
|
+
` ${p.purple("-c, --create")} ${p.muted("Create branch if it doesn't exist")}`,
|
|
116
|
+
` ${p.purple("-s, --stash")} ${p.muted("Auto-stash dirty repos before switching")}`,
|
|
117
|
+
` ${p.purple(" --fetch")} ${p.muted("Fetch all remotes before switching")}`,
|
|
118
|
+
` ${p.purple(" --dry-run")} ${p.muted("Preview what would happen, no changes made")}`,
|
|
119
|
+
"",
|
|
120
|
+
chalk.bold(p.white("MR OPTIONS")),
|
|
121
|
+
` ${p.purple("-t, --target <b>")} ${p.muted("Target branch to merge into (skips scope picker)")}`,
|
|
122
|
+
` ${p.purple(" --repo <name>")} ${p.muted("Restrict to this repo (repeatable)")}`,
|
|
123
|
+
` ${p.purple(" --title <s>")} ${p.muted("MR title (skips prompt)")}`,
|
|
124
|
+
` ${p.purple(" --description")} ${p.muted("MR description (skips prompt)")}`,
|
|
125
|
+
` ${p.purple(" --labels <csv>")}${p.muted("Comma-separated labels")}`,
|
|
126
|
+
` ${p.purple(" --draft")} ${p.muted("Mark MR as draft")}`,
|
|
127
|
+
` ${p.purple(" --no-push")} ${p.muted("Skip git push before creating MR")}`,
|
|
128
|
+
"",
|
|
129
|
+
chalk.bold(p.white("PORTAL OPTIONS")),
|
|
130
|
+
` ${p.purple(" --epic <iid>")} ${p.muted("Select epic by IID (skips epic picker)")}`,
|
|
131
|
+
` ${p.purple(" --checkout")} ${p.muted("Checkout primary branches for epic's repos")}`,
|
|
132
|
+
` ${p.purple(" --create-mr")} ${p.muted("Bulk-create MRs for epic issues")}`,
|
|
133
|
+
` ${p.purple(" --create-issue")} ${p.muted("Create a new issue in the epic")}`,
|
|
134
|
+
` ${p.purple(" --issue-project <path>")} ${p.muted("Project name or path for issue creation")}`,
|
|
135
|
+
` ${p.purple(" --issue-title <text>")} ${p.muted("Issue title")}`,
|
|
136
|
+
` ${p.purple(" --issue-description")} ${p.muted("Issue description")}`,
|
|
137
|
+
` ${p.purple(" --issue-labels <csv>")} ${p.muted("Issue labels")}`,
|
|
138
|
+
` ${p.purple(" --branch-name <name>")} ${p.muted("Create this branch for the new issue")}`,
|
|
139
|
+
` ${p.purple(" --base-branch <name>")} ${p.muted("Base branch for branch/MR creation")}`,
|
|
140
|
+
"",
|
|
141
|
+
chalk.bold(p.white("GLOBAL OPTIONS")),
|
|
142
|
+
` ${p.purple("-y, --yes")} ${p.muted("Auto-confirm all prompts (non-interactive)")}`,
|
|
143
|
+
` ${p.purple(" --depth n")} ${p.muted("Repo search depth (default: 4)")}`,
|
|
144
|
+
` ${p.purple(" --filter p")} ${p.muted("Only include repos whose name contains pattern")}`,
|
|
145
|
+
` ${p.purple(" --exclude p")} ${p.muted("Exclude repos whose name contains pattern")}`,
|
|
146
|
+
` ${p.purple(" --settings")} ${p.muted("Open portal settings (group, milestone, iteration…)")}`,
|
|
147
|
+
` ${p.purple("-v, --version")} ${p.muted("Show version number")}`,
|
|
148
|
+
` ${p.purple("-h, --help")} ${p.muted("Show this help")}`,
|
|
149
|
+
"",
|
|
150
|
+
hr,
|
|
151
|
+
"",
|
|
152
|
+
chalk.bold(p.white("EXAMPLES")),
|
|
153
|
+
` ${p.muted("$")} ${p.cyan("gitmux develop")}`,
|
|
154
|
+
` ${p.muted("$")} ${p.cyan("gitmux main --pull")}`,
|
|
155
|
+
` ${p.muted("$")} ${p.cyan("gitmux feature/auth --stash --pull")}`,
|
|
156
|
+
` ${p.muted("$")} ${p.cyan("gitmux feat --fuzzy")}`,
|
|
157
|
+
` ${p.muted("$")} ${p.cyan("gitmux main --create")}`,
|
|
158
|
+
` ${p.muted("$")} ${p.cyan("gitmux --dry-run develop")}`,
|
|
159
|
+
` ${p.muted("$")} ${p.cyan("gitmux --exclude legacy develop")}`,
|
|
160
|
+
` ${p.muted("$")} ${p.cyan("gitmux status")}`,
|
|
161
|
+
` ${p.muted("$")} ${p.cyan("gitmux fetch")}`,
|
|
162
|
+
` ${p.muted("$")} ${p.cyan("gitmux mr --target develop --title \"Fix auth\" --labels \"bug\" --yes")}`,
|
|
163
|
+
` ${p.muted("$")} ${p.cyan("gitmux portal --epic 42 --checkout --yes")}`,
|
|
164
|
+
` ${p.muted("$")} ${p.cyan("gitmux portal --epic 42 --create-mr --target develop --yes")}`,
|
|
165
|
+
` ${p.muted("$")} ${p.cyan("gitmux portal --epic 42 --create-issue --issue-project group/repo --issue-title \"Task\" --yes")}`,
|
|
166
|
+
].join("\n"),
|
|
167
|
+
{
|
|
168
|
+
padding: { top: 1, bottom: 1, left: 3, right: 3 },
|
|
169
|
+
borderStyle: "round",
|
|
170
|
+
borderColor: "#334155",
|
|
171
|
+
title: p.muted(" help "),
|
|
172
|
+
titleAlignment: "right",
|
|
173
|
+
},
|
|
174
|
+
),
|
|
175
|
+
);
|
|
176
|
+
console.log();
|
|
177
|
+
}
|
package/src/ui/theme.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
|
|
3
|
+
export const p = {
|
|
4
|
+
cyan: chalk.hex("#67e8f9"),
|
|
5
|
+
teal: chalk.hex("#4ecdc4"),
|
|
6
|
+
purple: chalk.hex("#c084fc"),
|
|
7
|
+
green: chalk.hex("#4ade80"),
|
|
8
|
+
yellow: chalk.hex("#fbbf24"),
|
|
9
|
+
red: chalk.hex("#f87171"),
|
|
10
|
+
orange: chalk.hex("#fb923c"),
|
|
11
|
+
muted: chalk.hex("#64748b"),
|
|
12
|
+
slate: chalk.hex("#475569"),
|
|
13
|
+
dim: chalk.hex("#1e293b"),
|
|
14
|
+
white: chalk.hex("#f1f5f9"),
|
|
15
|
+
bold: chalk.bold,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const THEME = {
|
|
19
|
+
prefix: p.purple("◆"),
|
|
20
|
+
icon: { cursor: " " },
|
|
21
|
+
style: {
|
|
22
|
+
highlight: (s) => chalk.bgHex("#5b21b6").white.bold(s.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, "")),
|
|
23
|
+
answer: (s) => p.cyan(s),
|
|
24
|
+
defaultAnswer: (s) => p.muted(s),
|
|
25
|
+
},
|
|
26
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { exec, execFile } from "child_process";
|
|
2
|
+
import { promisify } from "util";
|
|
3
|
+
|
|
4
|
+
export const execAsync = promisify(exec);
|
|
5
|
+
export const execFileAsync = promisify(execFile);
|
|
6
|
+
|
|
7
|
+
export function extractMsg(e) {
|
|
8
|
+
return (e.stderr || e.stdout || e.message || "")
|
|
9
|
+
.toString()
|
|
10
|
+
.replace(/\n/g, " ")
|
|
11
|
+
.trim();
|
|
12
|
+
}
|