@forceuser/git-profile-switcher 0.1.4
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 +96 -0
- package/bin/gip.mjs +25 -0
- package/dist/runtime/app/index.js +677 -0
- package/dist/runtime/cli/completion.js +142 -0
- package/dist/runtime/cli/help.js +288 -0
- package/dist/runtime/cli/parse.js +41 -0
- package/dist/runtime/cli/prompts.js +389 -0
- package/dist/runtime/config/paths.js +39 -0
- package/dist/runtime/git/config.js +188 -0
- package/dist/runtime/profiles/repository.js +183 -0
- package/dist/runtime/profiles/transfer.js +111 -0
- package/dist/runtime/profiles/types.js +23 -0
- package/dist/runtime/prompt/status.js +103 -0
- package/dist/runtime/shell/integration.js +254 -0
- package/dist/runtime/tui/index.js +604 -0
- package/package.json +105 -0
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
import { createInterface as createLineInterface } from "node:readline";
|
|
2
|
+
import { createInterface as createQuestionInterface } from "node:readline/promises";
|
|
3
|
+
const CLEAR_SCREEN = "\x1b[2J";
|
|
4
|
+
const CLEAR_LINE = "\x1b[2K";
|
|
5
|
+
const CURSOR_HOME = "\x1b[H";
|
|
6
|
+
const HIDE_CURSOR = "\x1b[?25l";
|
|
7
|
+
const SHOW_CURSOR = "\x1b[?25h";
|
|
8
|
+
const DEFAULT_TERMINAL_ROWS = 24;
|
|
9
|
+
const PRODUCT_TITLE = "Git Profile Switcher";
|
|
10
|
+
const COLORS = {
|
|
11
|
+
accent: "\x1b[36m",
|
|
12
|
+
dim: "\x1b[2m",
|
|
13
|
+
title: "\x1b[1;36m",
|
|
14
|
+
};
|
|
15
|
+
const RESET = "\x1b[0m";
|
|
16
|
+
export class PromptCancelError extends Error {
|
|
17
|
+
constructor() {
|
|
18
|
+
super("Prompt cancelled.");
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export class PromptInterruptError extends Error {
|
|
22
|
+
constructor() {
|
|
23
|
+
super("Prompt interrupted.");
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export function isPromptCancelError(error) {
|
|
27
|
+
return error instanceof PromptCancelError;
|
|
28
|
+
}
|
|
29
|
+
export function isPromptInterruptError(error) {
|
|
30
|
+
return error instanceof PromptInterruptError;
|
|
31
|
+
}
|
|
32
|
+
export function createPromptSession(input = process.stdin, output = process.stdout) {
|
|
33
|
+
const lineRl = input.isTTY ? null : createLineInterface({ input, terminal: false });
|
|
34
|
+
const lines = [];
|
|
35
|
+
let isClosed = false;
|
|
36
|
+
let waiting = null;
|
|
37
|
+
lineRl?.on("line", (line) => {
|
|
38
|
+
if (waiting) {
|
|
39
|
+
const current = waiting;
|
|
40
|
+
waiting = null;
|
|
41
|
+
current.resolve(line);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
lines.push(line);
|
|
45
|
+
});
|
|
46
|
+
lineRl?.on("close", () => {
|
|
47
|
+
isClosed = true;
|
|
48
|
+
if (waiting) {
|
|
49
|
+
const current = waiting;
|
|
50
|
+
waiting = null;
|
|
51
|
+
current.reject(new Error("Input ended before a value was provided."));
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
return {
|
|
55
|
+
async ask(prompt) {
|
|
56
|
+
return (await readLine(input, output, lines, prompt, (pending) => {
|
|
57
|
+
waiting = pending;
|
|
58
|
+
}, isClosed)).trim();
|
|
59
|
+
},
|
|
60
|
+
async askRequired(prompt) {
|
|
61
|
+
for (;;) {
|
|
62
|
+
const value = (await readLine(input, output, lines, prompt, (pending) => {
|
|
63
|
+
waiting = pending;
|
|
64
|
+
}, isClosed)).trim();
|
|
65
|
+
if (value) {
|
|
66
|
+
return value;
|
|
67
|
+
}
|
|
68
|
+
output.write("Value is required.\n");
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
async selectOne(selection) {
|
|
72
|
+
if (selection.options.length === 0) {
|
|
73
|
+
throw new Error(selection.emptyMessage);
|
|
74
|
+
}
|
|
75
|
+
const interaction = { input, output };
|
|
76
|
+
if (canUseArrowKeys(interaction)) {
|
|
77
|
+
const choice = await promptMenu(interaction, selection.prompt.trimEnd(), selection.options.map((option) => selection.renderOption(option)), (selection.defaultIndex ?? 0) + 1);
|
|
78
|
+
if (choice === null) {
|
|
79
|
+
throw new PromptCancelError();
|
|
80
|
+
}
|
|
81
|
+
return selection.options[choice - 1];
|
|
82
|
+
}
|
|
83
|
+
for (const [index, option] of selection.options.entries()) {
|
|
84
|
+
output.write(`${index + 1}. ${selection.renderOption(option)}\n`);
|
|
85
|
+
}
|
|
86
|
+
for (;;) {
|
|
87
|
+
const answer = (await readLine(input, output, lines, selection.prompt, (pending) => {
|
|
88
|
+
waiting = pending;
|
|
89
|
+
}, isClosed)).trim();
|
|
90
|
+
if (answer === "" && selection.defaultIndex !== undefined) {
|
|
91
|
+
const defaultOption = selection.options[selection.defaultIndex];
|
|
92
|
+
if (defaultOption) {
|
|
93
|
+
return defaultOption;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const selectedByNumber = Number.parseInt(answer, 10);
|
|
97
|
+
if (Number.isInteger(selectedByNumber) &&
|
|
98
|
+
selectedByNumber >= 1 &&
|
|
99
|
+
selectedByNumber <= selection.options.length) {
|
|
100
|
+
return selection.options[selectedByNumber - 1];
|
|
101
|
+
}
|
|
102
|
+
const selectedByValue = selection.options.find((option) => selection.getValue(option) === answer);
|
|
103
|
+
if (selectedByValue) {
|
|
104
|
+
return selectedByValue;
|
|
105
|
+
}
|
|
106
|
+
output.write("Choose one of the listed options.\n");
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
close() {
|
|
110
|
+
lineRl?.close();
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
async function promptMenu(interaction, title, options, initialChoice = 1) {
|
|
115
|
+
if (!interaction.askLine && canUseArrowKeys(interaction)) {
|
|
116
|
+
return promptArrowMenu(interaction, title, options, initialChoice);
|
|
117
|
+
}
|
|
118
|
+
renderScreen(interaction, title);
|
|
119
|
+
for (const [index, option] of options.entries()) {
|
|
120
|
+
interaction.output.write(` ${index + 1}. ${option}\n`);
|
|
121
|
+
}
|
|
122
|
+
const answer = interaction.askLine
|
|
123
|
+
? await interaction.askLine("Choose number: ")
|
|
124
|
+
: await askLine(interaction, "Choose number: ");
|
|
125
|
+
if (!answer) {
|
|
126
|
+
clearScreenMessage(interaction);
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
const choice = Number.parseInt(answer, 10);
|
|
130
|
+
if (!Number.isInteger(choice) ||
|
|
131
|
+
String(choice) !== answer ||
|
|
132
|
+
choice < 1 ||
|
|
133
|
+
choice > options.length) {
|
|
134
|
+
clearScreenMessage(interaction);
|
|
135
|
+
interaction.output.write("Invalid choice.\n");
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
clearScreenMessage(interaction);
|
|
139
|
+
return choice;
|
|
140
|
+
}
|
|
141
|
+
async function promptArrowMenu(interaction, title, options, initialChoice) {
|
|
142
|
+
let selectedIndex = normalizeInitialChoice(initialChoice, options.length) - 1;
|
|
143
|
+
let firstVisibleIndex = 0;
|
|
144
|
+
const input = interaction.input;
|
|
145
|
+
const previousRawMode = input.isRaw;
|
|
146
|
+
input.setRawMode?.(true);
|
|
147
|
+
input.resume();
|
|
148
|
+
interaction.output.write(HIDE_CURSOR);
|
|
149
|
+
const render = () => {
|
|
150
|
+
const headerLines = [
|
|
151
|
+
color(interaction, "title", PRODUCT_TITLE),
|
|
152
|
+
"",
|
|
153
|
+
color(interaction, "accent", title),
|
|
154
|
+
color(interaction, "dim", "Use Up/Down and Enter. Press q or Esc to go back. Ctrl+C quits."),
|
|
155
|
+
...renderScreenMessages(interaction),
|
|
156
|
+
];
|
|
157
|
+
const menuRows = getMenuRowCapacity(interaction, headerLines.length);
|
|
158
|
+
const visibleMenu = getVisibleMenu(options, selectedIndex, firstVisibleIndex, menuRows);
|
|
159
|
+
firstVisibleIndex = visibleMenu.firstIndex;
|
|
160
|
+
const lines = [
|
|
161
|
+
...headerLines,
|
|
162
|
+
...visibleMenu.lines.map(({ option, index }) => renderMenuOption(interaction, option, index === selectedIndex)),
|
|
163
|
+
];
|
|
164
|
+
interaction.output.write(`${CLEAR_SCREEN}${CURSOR_HOME}`);
|
|
165
|
+
interaction.output.write(lines.map((line) => `${CLEAR_LINE}${line}`).join("\n"));
|
|
166
|
+
interaction.output.write("\n");
|
|
167
|
+
};
|
|
168
|
+
render();
|
|
169
|
+
try {
|
|
170
|
+
return await new Promise((resolve, reject) => {
|
|
171
|
+
let finished = false;
|
|
172
|
+
const cleanup = () => {
|
|
173
|
+
input.off("data", onData);
|
|
174
|
+
interaction.output.off("resize", onResize);
|
|
175
|
+
input.setRawMode?.(previousRawMode ?? false);
|
|
176
|
+
input.pause();
|
|
177
|
+
interaction.output.write(SHOW_CURSOR);
|
|
178
|
+
};
|
|
179
|
+
const finish = (choice, error) => {
|
|
180
|
+
if (finished) {
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
finished = true;
|
|
184
|
+
cleanup();
|
|
185
|
+
clearScreenMessage(interaction);
|
|
186
|
+
if (error) {
|
|
187
|
+
reject(error);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
resolve(choice);
|
|
191
|
+
};
|
|
192
|
+
const onData = (chunk) => {
|
|
193
|
+
const key = String(chunk);
|
|
194
|
+
if (key === "\u0003") {
|
|
195
|
+
finish(null, new PromptInterruptError());
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (key === "\x1b" || key === "q") {
|
|
199
|
+
finish(null);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if (key === "\x1b[A") {
|
|
203
|
+
selectedIndex = selectedIndex === 0 ? options.length - 1 : selectedIndex - 1;
|
|
204
|
+
render();
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (key === "\x1b[B") {
|
|
208
|
+
selectedIndex = selectedIndex === options.length - 1 ? 0 : selectedIndex + 1;
|
|
209
|
+
render();
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (key === "\r" || key === "\n") {
|
|
213
|
+
finish(selectedIndex + 1);
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
const onResize = () => {
|
|
217
|
+
if (!finished) {
|
|
218
|
+
render();
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
input.on("data", onData);
|
|
222
|
+
interaction.output.on("resize", onResize);
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
finally {
|
|
226
|
+
interaction.output.write("\n");
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
async function askLine(interaction, prompt) {
|
|
230
|
+
if (interaction.askLine) {
|
|
231
|
+
interaction.output.write(prompt);
|
|
232
|
+
return (await interaction.askLine(prompt)).trim();
|
|
233
|
+
}
|
|
234
|
+
return await readTtyQuestion(interaction.input, interaction.output, prompt).then((answer) => answer.trim());
|
|
235
|
+
}
|
|
236
|
+
function canUseArrowKeys(interaction) {
|
|
237
|
+
return (interaction.input.isTTY &&
|
|
238
|
+
interaction.output.isTTY &&
|
|
239
|
+
typeof interaction.input.setRawMode === "function");
|
|
240
|
+
}
|
|
241
|
+
function getMenuRowCapacity(interaction, headerLineCount) {
|
|
242
|
+
const terminalRows = getTerminalRows(interaction);
|
|
243
|
+
return Math.max(1, terminalRows - headerLineCount - 2);
|
|
244
|
+
}
|
|
245
|
+
function getTerminalRows(interaction) {
|
|
246
|
+
const rows = interaction.output.rows ?? process.stdout.rows;
|
|
247
|
+
if (Number.isInteger(rows) && rows > 0) {
|
|
248
|
+
return rows;
|
|
249
|
+
}
|
|
250
|
+
return DEFAULT_TERMINAL_ROWS;
|
|
251
|
+
}
|
|
252
|
+
function getVisibleMenu(options, selectedIndex, firstVisibleIndex, rowCapacity) {
|
|
253
|
+
if (options.length <= rowCapacity) {
|
|
254
|
+
return {
|
|
255
|
+
firstIndex: 0,
|
|
256
|
+
lines: options.map((option, index) => ({ option, index })),
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
const canShowScrollHints = rowCapacity >= 3;
|
|
260
|
+
const visibleOptionCount = canShowScrollHints ? rowCapacity - 2 : rowCapacity;
|
|
261
|
+
let nextFirstVisibleIndex = firstVisibleIndex;
|
|
262
|
+
if (selectedIndex < nextFirstVisibleIndex) {
|
|
263
|
+
nextFirstVisibleIndex = selectedIndex;
|
|
264
|
+
}
|
|
265
|
+
if (selectedIndex >= nextFirstVisibleIndex + visibleOptionCount) {
|
|
266
|
+
nextFirstVisibleIndex = selectedIndex - visibleOptionCount + 1;
|
|
267
|
+
}
|
|
268
|
+
nextFirstVisibleIndex = clamp(nextFirstVisibleIndex, 0, Math.max(0, options.length - visibleOptionCount));
|
|
269
|
+
const visibleOptions = options
|
|
270
|
+
.slice(nextFirstVisibleIndex, nextFirstVisibleIndex + visibleOptionCount)
|
|
271
|
+
.map((option, offset) => ({
|
|
272
|
+
option,
|
|
273
|
+
index: nextFirstVisibleIndex + offset,
|
|
274
|
+
}));
|
|
275
|
+
const hasPrevious = nextFirstVisibleIndex > 0;
|
|
276
|
+
const hasNext = nextFirstVisibleIndex + visibleOptionCount < options.length;
|
|
277
|
+
return {
|
|
278
|
+
firstIndex: nextFirstVisibleIndex,
|
|
279
|
+
lines: canShowScrollHints
|
|
280
|
+
? [
|
|
281
|
+
{
|
|
282
|
+
option: hasPrevious ? "↑" : "",
|
|
283
|
+
index: -1,
|
|
284
|
+
},
|
|
285
|
+
...visibleOptions,
|
|
286
|
+
{
|
|
287
|
+
option: hasNext ? "↓" : "",
|
|
288
|
+
index: -1,
|
|
289
|
+
},
|
|
290
|
+
]
|
|
291
|
+
: visibleOptions,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
function clamp(value, min, max) {
|
|
295
|
+
return Math.min(Math.max(value, min), max);
|
|
296
|
+
}
|
|
297
|
+
function renderScreen(interaction, title) {
|
|
298
|
+
interaction.output.write(`${CLEAR_SCREEN}${CURSOR_HOME}`);
|
|
299
|
+
interaction.output.write(`${color(interaction, "title", PRODUCT_TITLE)}\n\n`);
|
|
300
|
+
interaction.output.write(`${color(interaction, "accent", title)}\n`);
|
|
301
|
+
for (const message of renderScreenMessages(interaction)) {
|
|
302
|
+
interaction.output.write(`${message}\n`);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
function renderScreenMessages(interaction) {
|
|
306
|
+
const messages = interaction.screen?.messages ?? [];
|
|
307
|
+
if (messages.length === 0) {
|
|
308
|
+
return [""];
|
|
309
|
+
}
|
|
310
|
+
return ["", ...messages, ""];
|
|
311
|
+
}
|
|
312
|
+
function clearScreenMessage(interaction) {
|
|
313
|
+
if (interaction.screen) {
|
|
314
|
+
interaction.screen.messages = [];
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
function renderMenuOption(interaction, option, selected) {
|
|
318
|
+
if (!selected) {
|
|
319
|
+
return ` ${option}`;
|
|
320
|
+
}
|
|
321
|
+
return color(interaction, "accent", `› ${option}`);
|
|
322
|
+
}
|
|
323
|
+
function normalizeInitialChoice(initialChoice, optionCount) {
|
|
324
|
+
if (!Number.isInteger(initialChoice) || initialChoice < 1 || initialChoice > optionCount) {
|
|
325
|
+
return 1;
|
|
326
|
+
}
|
|
327
|
+
return initialChoice;
|
|
328
|
+
}
|
|
329
|
+
function color(interaction, colorName, value) {
|
|
330
|
+
if (!interaction.output.isTTY || process.env.NO_COLOR) {
|
|
331
|
+
return value;
|
|
332
|
+
}
|
|
333
|
+
return `${COLORS[colorName]}${value}${RESET}`;
|
|
334
|
+
}
|
|
335
|
+
function readLine(input, output, lines, prompt, setWaiting, isClosed) {
|
|
336
|
+
if (input.isTTY) {
|
|
337
|
+
return readTtyQuestion(input, output, prompt);
|
|
338
|
+
}
|
|
339
|
+
output.write(prompt);
|
|
340
|
+
const existing = lines.shift();
|
|
341
|
+
if (existing !== undefined) {
|
|
342
|
+
return Promise.resolve(existing);
|
|
343
|
+
}
|
|
344
|
+
if (isClosed) {
|
|
345
|
+
return Promise.reject(new Error("Input ended before a value was provided."));
|
|
346
|
+
}
|
|
347
|
+
return new Promise((resolve, reject) => {
|
|
348
|
+
setWaiting({ resolve, reject });
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
function readTtyQuestion(input, output, prompt) {
|
|
352
|
+
const questionRl = createQuestionInterface({ input, output });
|
|
353
|
+
input.resume();
|
|
354
|
+
return new Promise((resolve, reject) => {
|
|
355
|
+
let finished = false;
|
|
356
|
+
const cleanup = () => {
|
|
357
|
+
questionRl.off("SIGINT", onSigint);
|
|
358
|
+
input.off("data", onData);
|
|
359
|
+
questionRl.close();
|
|
360
|
+
};
|
|
361
|
+
const finish = (answer, error) => {
|
|
362
|
+
if (finished) {
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
finished = true;
|
|
366
|
+
cleanup();
|
|
367
|
+
if (error) {
|
|
368
|
+
reject(error);
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
resolve(answer ?? "");
|
|
372
|
+
};
|
|
373
|
+
const onSigint = () => {
|
|
374
|
+
finish(null, new PromptInterruptError());
|
|
375
|
+
};
|
|
376
|
+
const onData = (chunk) => {
|
|
377
|
+
if (String(chunk) === "\x1b") {
|
|
378
|
+
finish(null, new PromptCancelError());
|
|
379
|
+
}
|
|
380
|
+
};
|
|
381
|
+
questionRl.once("SIGINT", onSigint);
|
|
382
|
+
input.on("data", onData);
|
|
383
|
+
questionRl.question(prompt).then((answer) => {
|
|
384
|
+
finish(answer);
|
|
385
|
+
}, (error) => {
|
|
386
|
+
finish(null, error instanceof Error ? error : new Error(String(error)));
|
|
387
|
+
});
|
|
388
|
+
});
|
|
389
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { realpathSync } from "node:fs";
|
|
4
|
+
export function getAppPaths(env = process.env) {
|
|
5
|
+
const homeDir = env.HOME ? resolve(env.HOME) : homedir();
|
|
6
|
+
const baseConfigDir = env.GIP_APP_DATA_DIR ?? env.XDG_CONFIG_HOME;
|
|
7
|
+
const appDataDir = resolve(baseConfigDir
|
|
8
|
+
? join(baseConfigDir, "git-profile-switcher")
|
|
9
|
+
: join(homeDir, ".config", "git-profile-switcher"));
|
|
10
|
+
return {
|
|
11
|
+
homeDir,
|
|
12
|
+
appDataDir,
|
|
13
|
+
profilesPath: join(appDataDir, "profiles.json"),
|
|
14
|
+
generatedGitConfigDir: join(appDataDir, "gitconfigs"),
|
|
15
|
+
globalGitConfigPath: env.GIP_GLOBAL_GITCONFIG ?? join(homeDir, ".gitconfig"),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
export function expandHomePath(path, homeDir = homedir()) {
|
|
19
|
+
if (path === "~") {
|
|
20
|
+
return homeDir;
|
|
21
|
+
}
|
|
22
|
+
if (path.startsWith("~/")) {
|
|
23
|
+
return join(homeDir, path.slice(2));
|
|
24
|
+
}
|
|
25
|
+
return path;
|
|
26
|
+
}
|
|
27
|
+
export function normalizeDirectoryPath(path, homeDir = homedir()) {
|
|
28
|
+
let normalized = resolve(expandHomePath(path, homeDir));
|
|
29
|
+
try {
|
|
30
|
+
normalized = realpathSync.native(normalized);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// Directory rules may be created before the directory exists.
|
|
34
|
+
}
|
|
35
|
+
if (!normalized.endsWith("/")) {
|
|
36
|
+
normalized += "/";
|
|
37
|
+
}
|
|
38
|
+
return normalized;
|
|
39
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { execFile, execFileSync } from "node:child_process";
|
|
2
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { basename, dirname, join } from "node:path";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
export const GIT_INCLUDE_BLOCK_START = "# >>> gip includeIf >>>";
|
|
6
|
+
export const GIT_INCLUDE_BLOCK_END = "# <<< gip includeIf <<<";
|
|
7
|
+
const GIT_INCLUDE_BLOCK_PATTERN = new RegExp(`${escapeRegExp(GIT_INCLUDE_BLOCK_START)}\\n[\\s\\S]*?\\n${escapeRegExp(GIT_INCLUDE_BLOCK_END)}\\n?`, "g");
|
|
8
|
+
const execFileAsync = promisify(execFile);
|
|
9
|
+
export async function applyGitProfileConfig(input) {
|
|
10
|
+
await mkdir(input.generatedGitConfigDir, { recursive: true });
|
|
11
|
+
const generatedFiles = [];
|
|
12
|
+
for (const profile of input.data.profiles) {
|
|
13
|
+
const path = getGeneratedProfileConfigPath(input.generatedGitConfigDir, profile.name);
|
|
14
|
+
await writeFile(path, renderProfileGitConfig(profile), { mode: 0o600 });
|
|
15
|
+
generatedFiles.push(path);
|
|
16
|
+
}
|
|
17
|
+
const current = await readOptionalText(input.globalGitConfigPath);
|
|
18
|
+
const next = appendManagedBlock(removeManagedIncludeBlock(current), renderIncludeBlock(input.data.rules, input.generatedGitConfigDir));
|
|
19
|
+
if (next === current) {
|
|
20
|
+
return {
|
|
21
|
+
globalGitConfigPath: input.globalGitConfigPath,
|
|
22
|
+
generatedFiles,
|
|
23
|
+
changed: false,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
await mkdir(dirname(input.globalGitConfigPath), { recursive: true });
|
|
27
|
+
await writeFile(input.globalGitConfigPath, next, { mode: 0o600 });
|
|
28
|
+
return {
|
|
29
|
+
globalGitConfigPath: input.globalGitConfigPath,
|
|
30
|
+
generatedFiles,
|
|
31
|
+
changed: true,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
export async function setGlobalGitIdentity(input) {
|
|
35
|
+
const currentName = await readGitConfigFileValue(input.globalGitConfigPath, "user.name");
|
|
36
|
+
const currentEmail = await readGitConfigFileValue(input.globalGitConfigPath, "user.email");
|
|
37
|
+
if (currentName === input.profile.userName && currentEmail === input.profile.userEmail) {
|
|
38
|
+
return {
|
|
39
|
+
globalGitConfigPath: input.globalGitConfigPath,
|
|
40
|
+
changed: false,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
const before = await readOptionalText(input.globalGitConfigPath);
|
|
44
|
+
await mkdir(dirname(input.globalGitConfigPath), { recursive: true });
|
|
45
|
+
await writeGitConfigValue(input.globalGitConfigPath, "user.name", input.profile.userName);
|
|
46
|
+
await writeGitConfigValue(input.globalGitConfigPath, "user.email", input.profile.userEmail);
|
|
47
|
+
const after = await readOptionalText(input.globalGitConfigPath);
|
|
48
|
+
return {
|
|
49
|
+
globalGitConfigPath: input.globalGitConfigPath,
|
|
50
|
+
changed: before !== after,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
export async function clearGlobalGitIdentity(globalGitConfigPath) {
|
|
54
|
+
const currentName = await readGitConfigFileValue(globalGitConfigPath, "user.name");
|
|
55
|
+
const currentEmail = await readGitConfigFileValue(globalGitConfigPath, "user.email");
|
|
56
|
+
if (!currentName && !currentEmail) {
|
|
57
|
+
return {
|
|
58
|
+
globalGitConfigPath,
|
|
59
|
+
changed: false,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
const before = await readOptionalText(globalGitConfigPath);
|
|
63
|
+
if (!before) {
|
|
64
|
+
return {
|
|
65
|
+
globalGitConfigPath,
|
|
66
|
+
changed: false,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
await unsetGitConfigValue(globalGitConfigPath, "user.name");
|
|
70
|
+
await unsetGitConfigValue(globalGitConfigPath, "user.email");
|
|
71
|
+
const after = await readOptionalText(globalGitConfigPath);
|
|
72
|
+
return {
|
|
73
|
+
globalGitConfigPath,
|
|
74
|
+
changed: before !== after,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
export function findMatchingRule(data, cwd) {
|
|
78
|
+
const candidates = getDirectoryMatchCandidates(cwd);
|
|
79
|
+
return (data.rules
|
|
80
|
+
.filter((rule) => getDirectoryMatchCandidates(rule.directory).some((ruleDirectory) => candidates.some((candidate) => candidate.startsWith(ruleDirectory))))
|
|
81
|
+
.sort((left, right) => right.directory.length - left.directory.length)[0] ?? null);
|
|
82
|
+
}
|
|
83
|
+
export function renderProfileGitConfig(profile) {
|
|
84
|
+
return `[user]\n\tname = ${escapeGitConfigValue(profile.userName)}\n\temail = ${escapeGitConfigValue(profile.userEmail)}\n`;
|
|
85
|
+
}
|
|
86
|
+
export function renderIncludeBlock(rules, generatedGitConfigDir) {
|
|
87
|
+
const body = rules
|
|
88
|
+
.map((rule) => {
|
|
89
|
+
const path = getGeneratedProfileConfigPath(generatedGitConfigDir, rule.profileName);
|
|
90
|
+
return `[includeIf "gitdir:${rule.directory}"]\n\tpath = ${path}`;
|
|
91
|
+
})
|
|
92
|
+
.join("\n\n");
|
|
93
|
+
return `${GIT_INCLUDE_BLOCK_START}\n${body}\n${GIT_INCLUDE_BLOCK_END}\n`;
|
|
94
|
+
}
|
|
95
|
+
export function removeManagedIncludeBlock(text) {
|
|
96
|
+
return text
|
|
97
|
+
.replace(GIT_INCLUDE_BLOCK_PATTERN, "")
|
|
98
|
+
.replaceAll(/\n{3,}/g, "\n\n")
|
|
99
|
+
.trimEnd();
|
|
100
|
+
}
|
|
101
|
+
export function appendManagedBlock(text, block) {
|
|
102
|
+
const trimmed = text.trimEnd();
|
|
103
|
+
if (!trimmed) {
|
|
104
|
+
return block;
|
|
105
|
+
}
|
|
106
|
+
return `${trimmed}\n\n${block}`;
|
|
107
|
+
}
|
|
108
|
+
export function readActiveGitIdentity(cwd = process.cwd()) {
|
|
109
|
+
return {
|
|
110
|
+
userName: readGitConfigValue("user.name", cwd),
|
|
111
|
+
userEmail: readGitConfigValue("user.email", cwd),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
export function getGeneratedProfileConfigPath(dir, profileName) {
|
|
115
|
+
return join(dir, `${safeFileName(profileName)}.gitconfig`);
|
|
116
|
+
}
|
|
117
|
+
async function readOptionalText(path) {
|
|
118
|
+
try {
|
|
119
|
+
return await readFile(path, "utf8");
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
|
123
|
+
return "";
|
|
124
|
+
}
|
|
125
|
+
throw error;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
function readGitConfigValue(key, cwd) {
|
|
129
|
+
try {
|
|
130
|
+
const value = execFileSync("git", ["config", key], {
|
|
131
|
+
cwd,
|
|
132
|
+
encoding: "utf8",
|
|
133
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
134
|
+
}).trim();
|
|
135
|
+
return value || null;
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
async function writeGitConfigValue(path, key, value) {
|
|
142
|
+
await execFileAsync("git", ["config", "--file", path, key, value]);
|
|
143
|
+
}
|
|
144
|
+
async function readGitConfigFileValue(path, key) {
|
|
145
|
+
try {
|
|
146
|
+
const { stdout } = await execFileAsync("git", ["config", "--file", path, key], {
|
|
147
|
+
encoding: "utf8",
|
|
148
|
+
});
|
|
149
|
+
return stdout.trim() || null;
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
async function unsetGitConfigValue(path, key) {
|
|
156
|
+
try {
|
|
157
|
+
await execFileAsync("git", ["config", "--file", path, "--unset", key]);
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
if (isExpectedGitUnsetMiss(error)) {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
throw error;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
function isExpectedGitUnsetMiss(error) {
|
|
167
|
+
return error instanceof Error && "code" in error && (error.code === 5 || error.code === 1);
|
|
168
|
+
}
|
|
169
|
+
function safeFileName(value) {
|
|
170
|
+
return basename(value).replaceAll(/[^a-zA-Z0-9._-]/g, "_");
|
|
171
|
+
}
|
|
172
|
+
function escapeGitConfigValue(value) {
|
|
173
|
+
return value.replaceAll("\n", " ").trim();
|
|
174
|
+
}
|
|
175
|
+
function escapeRegExp(value) {
|
|
176
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
177
|
+
}
|
|
178
|
+
function getDirectoryMatchCandidates(path) {
|
|
179
|
+
const withSlash = path.endsWith("/") ? path : `${path}/`;
|
|
180
|
+
const candidates = new Set([withSlash]);
|
|
181
|
+
if (withSlash.startsWith("/private/var/")) {
|
|
182
|
+
candidates.add(withSlash.replace("/private/var/", "/var/"));
|
|
183
|
+
}
|
|
184
|
+
else if (withSlash.startsWith("/var/")) {
|
|
185
|
+
candidates.add(withSlash.replace("/var/", "/private/var/"));
|
|
186
|
+
}
|
|
187
|
+
return [...candidates];
|
|
188
|
+
}
|