@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
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { basename } from "path";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import boxen from "boxen";
|
|
4
|
+
import { select } from "@inquirer/prompts";
|
|
5
|
+
import { Listr } from "listr2";
|
|
6
|
+
|
|
7
|
+
import { getCurrentBranch, getRepoStatus } from "../git/core.js";
|
|
8
|
+
import { listMatchingBranches } from "../git/branches.js";
|
|
9
|
+
import { execAsync, extractMsg } from "../utils/exec.js";
|
|
10
|
+
import { MAX_JOBS } from "../constants.js";
|
|
11
|
+
import { p, THEME } from "../ui/theme.js";
|
|
12
|
+
import { colorBranch } from "../ui/colors.js";
|
|
13
|
+
|
|
14
|
+
export async function cmdSwitch(repos, {
|
|
15
|
+
targetBranch, pullChanges, fuzzyMode, createBranch, autoStash, doFetch, dryRun,
|
|
16
|
+
}) {
|
|
17
|
+
const badges = [];
|
|
18
|
+
if (pullChanges) badges.push(p.teal("↓ pull"));
|
|
19
|
+
if (fuzzyMode) badges.push(p.purple("~ fuzzy"));
|
|
20
|
+
if (createBranch) badges.push(p.cyan("+ create"));
|
|
21
|
+
if (autoStash) badges.push(p.yellow("⊙ stash"));
|
|
22
|
+
if (doFetch) badges.push(p.cyan("↺ fetch"));
|
|
23
|
+
if (dryRun) badges.push(p.orange("◎ dry-run"));
|
|
24
|
+
|
|
25
|
+
console.log(
|
|
26
|
+
boxen(
|
|
27
|
+
p.muted("branch") + " " + chalk.bold(p.cyan(targetBranch)) +
|
|
28
|
+
" " + p.muted("repos") + " " + chalk.bold(p.white(String(repos.length))) +
|
|
29
|
+
(badges.length ? " " + p.slate("·") + " " + badges.join(" ") : ""),
|
|
30
|
+
{
|
|
31
|
+
padding: { top: 0, bottom: 0, left: 2, right: 2 },
|
|
32
|
+
borderStyle: "round",
|
|
33
|
+
borderColor: "#334155",
|
|
34
|
+
},
|
|
35
|
+
),
|
|
36
|
+
);
|
|
37
|
+
console.log();
|
|
38
|
+
|
|
39
|
+
let resolvedBranches = repos.map(() => targetBranch);
|
|
40
|
+
|
|
41
|
+
if (fuzzyMode) {
|
|
42
|
+
console.log(
|
|
43
|
+
p.muted(` Resolving "${targetBranch}" across ${repos.length} repo${repos.length !== 1 ? "s" : ""}…\n`),
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
for (let i = 0; i < repos.length; i++) {
|
|
47
|
+
const name = basename(repos[i]);
|
|
48
|
+
const candidates = listMatchingBranches(repos[i], targetBranch);
|
|
49
|
+
|
|
50
|
+
if (candidates.length === 0) {
|
|
51
|
+
resolvedBranches[i] = null;
|
|
52
|
+
} else if (candidates.length === 1) {
|
|
53
|
+
resolvedBranches[i] = candidates[0];
|
|
54
|
+
} else {
|
|
55
|
+
resolvedBranches[i] = await select({
|
|
56
|
+
message: chalk.bold(p.white(name)) + p.muted(` — pick branch for "${targetBranch}":`),
|
|
57
|
+
choices: candidates.map((c) => ({ value: c, name: p.cyan(c) })),
|
|
58
|
+
theme: THEME,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
console.log();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const stats = { ok: 0, pulled: 0, skip: 0, fail: 0, stashed: 0, alreadyOn: 0 };
|
|
66
|
+
const failedRepos = [];
|
|
67
|
+
const timings = [];
|
|
68
|
+
const startAll = Date.now();
|
|
69
|
+
const numWidth = String(repos.length).length;
|
|
70
|
+
|
|
71
|
+
// ── Phase 1: parallel pre-fetch ───────────────────────────────────────────
|
|
72
|
+
// Fire all fetches concurrently so network latency is paid once (max 1 fetch
|
|
73
|
+
// time) rather than once per Listr slot. Silent — no Listr overhead.
|
|
74
|
+
if (!dryRun) {
|
|
75
|
+
console.log(" " + p.muted(`Pre-fetching ${repos.length} repos in parallel…`));
|
|
76
|
+
// Track which repos fetched successfully so phase 2 knows
|
|
77
|
+
const fetchedSet = new Set();
|
|
78
|
+
await Promise.all(
|
|
79
|
+
repos.map(async (repo, i) => {
|
|
80
|
+
const branch = resolvedBranches[i];
|
|
81
|
+
if (!branch) return;
|
|
82
|
+
try {
|
|
83
|
+
await execAsync(`git fetch origin "${branch}"`, { cwd: repo });
|
|
84
|
+
fetchedSet.add(repo);
|
|
85
|
+
} catch {
|
|
86
|
+
// Branch may not exist on remote — phase 2 will handle it gracefully
|
|
87
|
+
}
|
|
88
|
+
}),
|
|
89
|
+
);
|
|
90
|
+
// Overwrite the line once done
|
|
91
|
+
process.stdout.write("\x1b[1A\x1b[2K");
|
|
92
|
+
|
|
93
|
+
// ── Phase 2: switch + pull ────────────────────────────────────────────────
|
|
94
|
+
const tasks = new Listr(
|
|
95
|
+
repos.map((repo, i) => {
|
|
96
|
+
const name = basename(repo);
|
|
97
|
+
const branch = resolvedBranches[i];
|
|
98
|
+
const idx = p.muted(`[${String(i + 1).padStart(numWidth)}/${repos.length}]`);
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
title: idx + " " + chalk.bold(p.white(name)) + p.muted(" waiting…"),
|
|
102
|
+
skip: () => (branch === null ? "no matching branch" : false),
|
|
103
|
+
task: async (_, task) => {
|
|
104
|
+
const t0 = Date.now();
|
|
105
|
+
const elapsed = () => p.muted(" " + ((Date.now() - t0) / 1000).toFixed(1) + "s");
|
|
106
|
+
const current = await getCurrentBranch(repo);
|
|
107
|
+
|
|
108
|
+
task.title =
|
|
109
|
+
idx + " " + chalk.bold(p.white(name)) + " " +
|
|
110
|
+
p.muted(current + " → ") + p.cyan(branch) + p.muted(" …");
|
|
111
|
+
|
|
112
|
+
if (current === branch && !pullChanges) {
|
|
113
|
+
stats.ok++;
|
|
114
|
+
stats.alreadyOn++;
|
|
115
|
+
timings.push((Date.now() - t0) / 1000);
|
|
116
|
+
task.title =
|
|
117
|
+
idx + " " + chalk.bold(p.white(name)) +
|
|
118
|
+
" " + p.teal("◉") + " " + colorBranch(branch) +
|
|
119
|
+
p.muted(" already on branch") + elapsed();
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
let stashed = false;
|
|
124
|
+
const { dirty } = autoStash ? await getRepoStatus(repo) : { dirty: false };
|
|
125
|
+
if (dirty) {
|
|
126
|
+
try {
|
|
127
|
+
await execAsync("git stash push --include-untracked -m 'gitmux auto-stash'", { cwd: repo });
|
|
128
|
+
stashed = true;
|
|
129
|
+
stats.stashed++;
|
|
130
|
+
} catch {}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (doFetch) {
|
|
134
|
+
try { await execAsync("git fetch --all --prune", { cwd: repo }); } catch {}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (current !== branch) {
|
|
138
|
+
try {
|
|
139
|
+
if (createBranch) {
|
|
140
|
+
try {
|
|
141
|
+
await execAsync(`git switch "${branch}"`, { cwd: repo });
|
|
142
|
+
} catch {
|
|
143
|
+
await execAsync(`git switch -c "${branch}"`, { cwd: repo });
|
|
144
|
+
}
|
|
145
|
+
} else {
|
|
146
|
+
await execAsync(`git switch "${branch}"`, { cwd: repo });
|
|
147
|
+
}
|
|
148
|
+
} catch (e) {
|
|
149
|
+
const msg = extractMsg(e);
|
|
150
|
+
if (stashed) {
|
|
151
|
+
try { await execAsync("git stash pop", { cwd: repo }); } catch {}
|
|
152
|
+
}
|
|
153
|
+
if (/did not match|pathspec|not found|invalid reference/i.test(msg)) {
|
|
154
|
+
stats.skip++;
|
|
155
|
+
timings.push((Date.now() - t0) / 1000);
|
|
156
|
+
task.title =
|
|
157
|
+
idx + " " + chalk.bold(p.white(name)) +
|
|
158
|
+
" " + p.yellow("⊘") + " " + p.muted("branch not found") + elapsed();
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
stats.fail++;
|
|
162
|
+
failedRepos.push({ name, msg });
|
|
163
|
+
timings.push((Date.now() - t0) / 1000);
|
|
164
|
+
task.title =
|
|
165
|
+
idx + " " + chalk.bold(p.white(name)) +
|
|
166
|
+
" " + p.red("✘") + " " + p.muted(msg.slice(0, 60)) + elapsed();
|
|
167
|
+
throw new Error(msg);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
let pulled = false;
|
|
172
|
+
if (pullChanges) {
|
|
173
|
+
try {
|
|
174
|
+
await execAsync("git pull", { cwd: repo });
|
|
175
|
+
pulled = true;
|
|
176
|
+
} catch (e) {
|
|
177
|
+
failedRepos.push({ name, msg: "pull failed: " + extractMsg(e) });
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (stashed) {
|
|
182
|
+
try { await execAsync("git stash pop", { cwd: repo }); } catch {}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
stats.ok++;
|
|
186
|
+
if (pulled) stats.pulled++;
|
|
187
|
+
timings.push((Date.now() - t0) / 1000);
|
|
188
|
+
|
|
189
|
+
const tags = [];
|
|
190
|
+
if (pulled) tags.push(p.teal("↓ pulled"));
|
|
191
|
+
if (stashed) tags.push(p.yellow("⊙ stashed"));
|
|
192
|
+
if (dryRun) tags.push(p.orange("◎ dry"));
|
|
193
|
+
|
|
194
|
+
const transitionLabel = current === branch
|
|
195
|
+
? colorBranch(branch)
|
|
196
|
+
: p.muted(current + " → ") + colorBranch(branch);
|
|
197
|
+
|
|
198
|
+
task.title =
|
|
199
|
+
idx + " " + chalk.bold(p.white(name)) +
|
|
200
|
+
" " + p.green("✔") + " " + transitionLabel +
|
|
201
|
+
(tags.length ? " " + tags.join(" ") : "") +
|
|
202
|
+
elapsed();
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
}),
|
|
206
|
+
{
|
|
207
|
+
concurrent: MAX_JOBS,
|
|
208
|
+
exitOnError: false,
|
|
209
|
+
rendererOptions: { collapseSkips: false, collapseErrors: false },
|
|
210
|
+
},
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
await tasks.run().catch(() => {});
|
|
214
|
+
} else {
|
|
215
|
+
// dry-run: skip fetch phase, just run the Listr switch tasks
|
|
216
|
+
const tasks = new Listr(
|
|
217
|
+
repos.map((repo, i) => {
|
|
218
|
+
const name = basename(repo);
|
|
219
|
+
const branch = resolvedBranches[i];
|
|
220
|
+
const idx = p.muted(`[${String(i + 1).padStart(numWidth)}/${repos.length}]`);
|
|
221
|
+
return {
|
|
222
|
+
title: idx + " " + chalk.bold(p.white(name)) + p.muted(" waiting…"),
|
|
223
|
+
skip: () => (branch === null ? "no matching branch" : false),
|
|
224
|
+
task: async (_, task) => {
|
|
225
|
+
const t0 = Date.now();
|
|
226
|
+
const elapsed = () => p.muted(" " + ((Date.now() - t0) / 1000).toFixed(1) + "s");
|
|
227
|
+
const current = await getCurrentBranch(repo);
|
|
228
|
+
stats.ok++;
|
|
229
|
+
timings.push((Date.now() - t0) / 1000);
|
|
230
|
+
task.title =
|
|
231
|
+
idx + " " + chalk.bold(p.white(name)) +
|
|
232
|
+
" " + p.orange("◎") + " " + p.muted(current + " → ") + colorBranch(branch) +
|
|
233
|
+
p.muted(" dry-run") + elapsed();
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
}),
|
|
237
|
+
{
|
|
238
|
+
concurrent: MAX_JOBS,
|
|
239
|
+
exitOnError: false,
|
|
240
|
+
rendererOptions: { collapseSkips: false, collapseErrors: false },
|
|
241
|
+
},
|
|
242
|
+
);
|
|
243
|
+
await tasks.run().catch(() => {});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
for (const b of resolvedBranches) {
|
|
247
|
+
if (b === null) stats.skip++;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
console.log();
|
|
251
|
+
|
|
252
|
+
const totalElapsed = ((Date.now() - startAll) / 1000).toFixed(1);
|
|
253
|
+
const avgTime = timings.length
|
|
254
|
+
? (timings.reduce((s, t) => s + t, 0) / timings.length).toFixed(1)
|
|
255
|
+
: "0.0";
|
|
256
|
+
|
|
257
|
+
const sep = p.slate(" · ");
|
|
258
|
+
const summaryParts = [];
|
|
259
|
+
if (stats.ok > 0) summaryParts.push(chalk.bold(p.green(`✔ ${stats.ok} switched`)));
|
|
260
|
+
if (stats.pulled > 0) summaryParts.push(p.teal(`↓ ${stats.pulled} pulled`));
|
|
261
|
+
if (stats.stashed > 0) summaryParts.push(p.yellow(`⊙ ${stats.stashed} stashed`));
|
|
262
|
+
if (stats.skip > 0) summaryParts.push(p.yellow(`⊘ ${stats.skip} skipped`));
|
|
263
|
+
if (stats.fail > 0) summaryParts.push(p.red(`✘ ${stats.fail} failed`));
|
|
264
|
+
if (dryRun) summaryParts.push(p.orange("◎ dry-run · no changes made"));
|
|
265
|
+
|
|
266
|
+
console.log(
|
|
267
|
+
boxen(
|
|
268
|
+
summaryParts.join(sep) +
|
|
269
|
+
"\n" + p.muted(`${totalElapsed}s total · avg ${avgTime}s/repo · ${repos.length} repos`),
|
|
270
|
+
{
|
|
271
|
+
padding: { top: 0, bottom: 0, left: 2, right: 2 },
|
|
272
|
+
borderStyle: "round",
|
|
273
|
+
borderColor:
|
|
274
|
+
stats.fail > 0 ? "#f87171" :
|
|
275
|
+
stats.ok === 0 ? "#fbbf24" :
|
|
276
|
+
"#4ade80",
|
|
277
|
+
},
|
|
278
|
+
),
|
|
279
|
+
);
|
|
280
|
+
console.log();
|
|
281
|
+
|
|
282
|
+
if (failedRepos.length > 0) {
|
|
283
|
+
console.log(" " + chalk.bold(p.white("Errors")));
|
|
284
|
+
console.log(" " + p.dim("─".repeat(50)));
|
|
285
|
+
for (const { name, msg } of failedRepos) {
|
|
286
|
+
console.log(
|
|
287
|
+
" " + p.red("✘") + " " +
|
|
288
|
+
chalk.bold(p.white(name)) + " " +
|
|
289
|
+
p.muted(msg.slice(0, 90)),
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
console.log();
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return stats.fail > 0 ? 1 : 0;
|
|
296
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
2
|
+
import { homedir } from "os";
|
|
3
|
+
import { join, dirname } from "path";
|
|
4
|
+
|
|
5
|
+
export const CONFIG_PATH = join(homedir(), ".config", "gitmux", "gitmux.json");
|
|
6
|
+
|
|
7
|
+
export function loadConfig() {
|
|
8
|
+
try {
|
|
9
|
+
return JSON.parse(readFileSync(CONFIG_PATH, "utf8"));
|
|
10
|
+
} catch {
|
|
11
|
+
return {};
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function saveConfig(config) {
|
|
16
|
+
try {
|
|
17
|
+
mkdirSync(dirname(CONFIG_PATH), { recursive: true });
|
|
18
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + "\n", "utf8");
|
|
19
|
+
} catch (e) {
|
|
20
|
+
process.stderr.write(`gitmux: warning: could not save config: ${e.message}\n`);
|
|
21
|
+
}
|
|
22
|
+
}
|
package/src/constants.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { cpus } from "os";
|
|
2
|
+
import { readFileSync } from "fs";
|
|
3
|
+
|
|
4
|
+
const pkgPath = new URL("../package.json", import.meta.url).pathname;
|
|
5
|
+
let VERSION = "1.6.0";
|
|
6
|
+
try {
|
|
7
|
+
VERSION = JSON.parse(readFileSync(pkgPath, "utf8")).version;
|
|
8
|
+
} catch (e) {
|
|
9
|
+
console.error("Failed to read version:", e);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export { VERSION };
|
|
13
|
+
export const MAX_JOBS = cpus().length;
|
|
14
|
+
export const DEFAULT_DEPTH = 4;
|
|
15
|
+
export const SUBCOMMANDS = new Set(["status", "fetch", "mr", "portal", "settings", "about"]);
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { execSync } from "child_process";
|
|
2
|
+
|
|
3
|
+
export function listMatchingBranches(repoPath, partial) {
|
|
4
|
+
try {
|
|
5
|
+
const out = execSync(`git branch -a --list "*${partial}*"`, {
|
|
6
|
+
cwd: repoPath,
|
|
7
|
+
encoding: "utf8",
|
|
8
|
+
});
|
|
9
|
+
return [
|
|
10
|
+
...new Set(
|
|
11
|
+
out
|
|
12
|
+
.trim()
|
|
13
|
+
.split("\n")
|
|
14
|
+
.map((b) =>
|
|
15
|
+
b
|
|
16
|
+
.trim()
|
|
17
|
+
.replace(/^\*\s*/, "")
|
|
18
|
+
.replace(/^remotes\/[^/]+\//, ""),
|
|
19
|
+
)
|
|
20
|
+
.filter(Boolean)
|
|
21
|
+
.filter((b) => !b.includes("HEAD")),
|
|
22
|
+
),
|
|
23
|
+
];
|
|
24
|
+
} catch {
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function listRecentBranches(repoPath) {
|
|
30
|
+
try {
|
|
31
|
+
const out = execSync(
|
|
32
|
+
'git for-each-ref --sort=-committerdate --format="%(refname:short)\t%(committerdate:unix)" refs/heads refs/remotes/origin',
|
|
33
|
+
{ cwd: repoPath, encoding: "utf8" },
|
|
34
|
+
);
|
|
35
|
+
return out
|
|
36
|
+
.trim()
|
|
37
|
+
.split("\n")
|
|
38
|
+
.filter(Boolean)
|
|
39
|
+
.map((line) => {
|
|
40
|
+
const [rawName, rawTs] = line.split("\t");
|
|
41
|
+
const name = rawName.replace(/^origin\//, "");
|
|
42
|
+
return { name, ts: Number(rawTs) || 0 };
|
|
43
|
+
})
|
|
44
|
+
.filter((branch) => branch.name && branch.name !== "HEAD");
|
|
45
|
+
} catch {
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function getBranchChoices(repos) {
|
|
51
|
+
const branchMap = new Map();
|
|
52
|
+
|
|
53
|
+
for (const repo of repos) {
|
|
54
|
+
for (const branch of listRecentBranches(repo.repo)) {
|
|
55
|
+
const existing = branchMap.get(branch.name);
|
|
56
|
+
if (!existing) {
|
|
57
|
+
branchMap.set(branch.name, { ...branch, repos: [repo.name] });
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
existing.ts = Math.max(existing.ts, branch.ts);
|
|
61
|
+
if (!existing.repos.includes(repo.name)) {
|
|
62
|
+
existing.repos.push(repo.name);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return [...branchMap.values()].sort((a, b) => b.ts - a.ts);
|
|
68
|
+
}
|
package/src/git/core.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { execSync } from "child_process";
|
|
2
|
+
import { join, dirname } from "path";
|
|
3
|
+
|
|
4
|
+
import { execAsync } from "../utils/exec.js";
|
|
5
|
+
|
|
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)));
|
|
17
|
+
|
|
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 [];
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function getCurrentBranch(repoPath) {
|
|
31
|
+
try {
|
|
32
|
+
const { stdout } = await execAsync("git rev-parse --abbrev-ref HEAD", {
|
|
33
|
+
cwd: repoPath,
|
|
34
|
+
encoding: "utf8",
|
|
35
|
+
});
|
|
36
|
+
return stdout.trim();
|
|
37
|
+
} catch {
|
|
38
|
+
return "unknown";
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function getRepoStatus(repoPath) {
|
|
43
|
+
try {
|
|
44
|
+
const { stdout } = await execAsync("git status --porcelain", {
|
|
45
|
+
cwd: repoPath,
|
|
46
|
+
encoding: "utf8",
|
|
47
|
+
});
|
|
48
|
+
const lines = stdout.trim().split("\n").filter(Boolean);
|
|
49
|
+
return { dirty: lines.length > 0, count: lines.length };
|
|
50
|
+
} catch {
|
|
51
|
+
return { dirty: false, count: 0 };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function getAheadBehind(repoPath) {
|
|
56
|
+
try {
|
|
57
|
+
const { stdout } = await execAsync(
|
|
58
|
+
"git rev-list --left-right --count @{upstream}...HEAD 2>/dev/null",
|
|
59
|
+
{ cwd: repoPath, encoding: "utf8" },
|
|
60
|
+
);
|
|
61
|
+
if (!stdout.trim()) return { ahead: 0, behind: 0 };
|
|
62
|
+
const [behind, ahead] = stdout.trim().split("\t").map(Number);
|
|
63
|
+
return { ahead: ahead || 0, behind: behind || 0 };
|
|
64
|
+
} catch {
|
|
65
|
+
return { ahead: 0, behind: 0 };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
function pad2(value) {
|
|
2
|
+
return String(value).padStart(2, "0");
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
function getWeekOfMonth(date) {
|
|
6
|
+
return Math.floor((date.getDate() - 1) / 7) + 1;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function getIsoWeek(date) {
|
|
10
|
+
const copy = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
|
|
11
|
+
const day = copy.getUTCDay() || 7;
|
|
12
|
+
copy.setUTCDate(copy.getUTCDate() + 4 - day);
|
|
13
|
+
const yearStart = new Date(Date.UTC(copy.getUTCFullYear(), 0, 1));
|
|
14
|
+
return Math.ceil((((copy - yearStart) / 86400000) + 1) / 7);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function expandBranchTemplate(template, date = new Date()) {
|
|
18
|
+
const month = date.getMonth() + 1;
|
|
19
|
+
const day = date.getDate();
|
|
20
|
+
const values = {
|
|
21
|
+
yyyy: String(date.getFullYear()),
|
|
22
|
+
yy: String(date.getFullYear()).slice(-2),
|
|
23
|
+
mm: pad2(month),
|
|
24
|
+
m: String(month),
|
|
25
|
+
dd: pad2(day),
|
|
26
|
+
d: String(day),
|
|
27
|
+
q: String(Math.floor((month - 1) / 3) + 1),
|
|
28
|
+
w: String(getWeekOfMonth(date)),
|
|
29
|
+
ww: pad2(getIsoWeek(date)),
|
|
30
|
+
};
|
|
31
|
+
return template.replace(/\{([a-z]+)\}/gi, (match, token) => values[token] ?? match);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function getSwitchBranchSuggestions(config, date = new Date()) {
|
|
35
|
+
const templates = config.switch?.branchSuggestions;
|
|
36
|
+
if (!Array.isArray(templates)) return [];
|
|
37
|
+
|
|
38
|
+
const seen = new Set();
|
|
39
|
+
|
|
40
|
+
return templates
|
|
41
|
+
.filter((t) => typeof t === "string")
|
|
42
|
+
.map((t) => t.trim())
|
|
43
|
+
.filter(Boolean)
|
|
44
|
+
.map((t) => ({ template: t, value: expandBranchTemplate(t, date).trim() }))
|
|
45
|
+
.filter((item) => item.value !== "")
|
|
46
|
+
.filter((item) => {
|
|
47
|
+
if (seen.has(item.value)) return false;
|
|
48
|
+
seen.add(item.value);
|
|
49
|
+
return true;
|
|
50
|
+
});
|
|
51
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { execFileAsync, extractMsg } from "../utils/exec.js";
|
|
2
|
+
|
|
3
|
+
const DEBUG = () => process.env.GITMUX_DEBUG === "1";
|
|
4
|
+
|
|
5
|
+
function dbg(label, value) {
|
|
6
|
+
if (!DEBUG()) return;
|
|
7
|
+
process.stderr.write(
|
|
8
|
+
`\n\x1b[35m[gitmux:debug] ${label}\x1b[0m\n${typeof value === "string" ? value : JSON.stringify(value, null, 2)
|
|
9
|
+
}\n`,
|
|
10
|
+
);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function glabApi(apiPath, { method = "GET", fields = {} } = {}) {
|
|
14
|
+
const args = ["api", apiPath, "-X", method];
|
|
15
|
+
for (const [k, v] of Object.entries(fields)) args.push("-F", `${k}=${v}`);
|
|
16
|
+
dbg("glab " + args.join(" "), "");
|
|
17
|
+
try {
|
|
18
|
+
const { stdout } = await execFileAsync("glab", args);
|
|
19
|
+
const parsed = JSON.parse(stdout);
|
|
20
|
+
dbg("response", parsed);
|
|
21
|
+
return parsed;
|
|
22
|
+
} catch (e) {
|
|
23
|
+
dbg("ERROR stdout", e.stdout ?? "");
|
|
24
|
+
dbg("ERROR stderr", e.stderr ?? "");
|
|
25
|
+
dbg("ERROR message", e.message ?? "");
|
|
26
|
+
throw new Error(extractMsg(e));
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function glabGraphQL(query) {
|
|
31
|
+
dbg("graphql query", query);
|
|
32
|
+
try {
|
|
33
|
+
const { stdout } = await execFileAsync("glab", ["api", "graphql", "-f", `query=${query}`]);
|
|
34
|
+
const result = JSON.parse(stdout);
|
|
35
|
+
dbg("graphql response", result);
|
|
36
|
+
if (result.errors?.length) {
|
|
37
|
+
throw new Error(result.errors.map((e) => e.message).join("; "));
|
|
38
|
+
}
|
|
39
|
+
return result.data;
|
|
40
|
+
} catch (e) {
|
|
41
|
+
dbg("ERROR stdout", e.stdout ?? "");
|
|
42
|
+
dbg("ERROR stderr", e.stderr ?? "");
|
|
43
|
+
dbg("ERROR message", e.message ?? "");
|
|
44
|
+
throw new Error(extractMsg(e));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { execSync } from "child_process";
|
|
2
|
+
|
|
3
|
+
export function getRemoteUrl(repoPath) {
|
|
4
|
+
try {
|
|
5
|
+
return execSync("git remote get-url origin", {
|
|
6
|
+
cwd: repoPath,
|
|
7
|
+
encoding: "utf8",
|
|
8
|
+
}).trim();
|
|
9
|
+
} catch {
|
|
10
|
+
return "";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function branchToTitle(branch) {
|
|
15
|
+
const segment = branch.split("/").pop() ?? branch;
|
|
16
|
+
return segment
|
|
17
|
+
.replace(/[-_]+/g, " ")
|
|
18
|
+
.replace(/^\w/, (c) => c.toUpperCase())
|
|
19
|
+
.trim();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function isGitLabRemote(url) {
|
|
23
|
+
return /gitlab/i.test(url);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function getDefaultBranch(repoPath) {
|
|
27
|
+
try {
|
|
28
|
+
const out = execSync("git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null", {
|
|
29
|
+
cwd: repoPath,
|
|
30
|
+
encoding: "utf8",
|
|
31
|
+
}).trim();
|
|
32
|
+
return out.replace(/^refs\/remotes\/origin\//, "") || "main";
|
|
33
|
+
} catch {
|
|
34
|
+
return "main";
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function extractGroupFromUrl(url) {
|
|
39
|
+
const path = url
|
|
40
|
+
.replace(/^git@[^:]+:/, "")
|
|
41
|
+
.replace(/^https?:\/\/[^/]+\//, "")
|
|
42
|
+
.replace(/\.git$/, "");
|
|
43
|
+
const parts = path.split("/");
|
|
44
|
+
if (parts.length < 2) return null;
|
|
45
|
+
return parts.slice(0, -1).join("/");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function detectGroupFromRepos(repos) {
|
|
49
|
+
const freq = new Map();
|
|
50
|
+
for (const repo of repos) {
|
|
51
|
+
const remote = getRemoteUrl(repo);
|
|
52
|
+
if (!isGitLabRemote(remote)) continue;
|
|
53
|
+
const group = extractGroupFromUrl(remote);
|
|
54
|
+
if (group) freq.set(group, (freq.get(group) ?? 0) + 1);
|
|
55
|
+
}
|
|
56
|
+
if (freq.size === 0) return null;
|
|
57
|
+
return [...freq.entries()].sort((a, b) => b[1] - a[1])[0][0];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function getProjectPath(url) {
|
|
61
|
+
return url
|
|
62
|
+
.replace(/^git@[^:]+:/, "")
|
|
63
|
+
.replace(/^https?:\/\/[^/]+\//, "")
|
|
64
|
+
.replace(/\.git$/, "");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function slugify(str) {
|
|
68
|
+
return str
|
|
69
|
+
.toLowerCase()
|
|
70
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
71
|
+
.replace(/^-|-$/g, "")
|
|
72
|
+
.slice(0, 60);
|
|
73
|
+
}
|