pan-stack-cli 0.1.4 → 0.1.5
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 +4 -3
- package/bin/pan.js +3 -0
- package/lib/discover.js +10 -1
- package/lib/git/git-clone.js +1 -1
- package/lib/git/git-sync-main.js +320 -0
- package/lib/git/helpers/cli.js +1 -0
- package/lib/git/helpers/colors.js +19 -0
- package/lib/git/helpers/git.js +74 -0
- package/lib/git/helpers/repo-select.js +97 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -40,11 +40,11 @@ skill pre-installed for all three agent folders.
|
|
|
40
40
|
## Requirements
|
|
41
41
|
|
|
42
42
|
- Node.js 18+
|
|
43
|
-
- Git (for `pan clone`, `pull`, `fetch`, `push`, `grv`, `gst`)
|
|
43
|
+
- Git (for `pan clone`, `pull`, `sync-main`, `fetch`, `push`, `grv`, `gst`)
|
|
44
44
|
|
|
45
45
|
## Windows support
|
|
46
46
|
|
|
47
|
-
Built-in commands (`create`, `clone`, `pull`, `push`, `fetch`, `grv`, `gst`) run on
|
|
47
|
+
Built-in commands (`create`, `clone`, `pull`, `sync-main`, `push`, `fetch`, `grv`, `gst`) run on
|
|
48
48
|
Windows, macOS, and Linux. Notes for Windows:
|
|
49
49
|
|
|
50
50
|
- `shell:` blocks in `.pan.yaml` run through **bash** on all platforms (Git Bash on
|
|
@@ -158,6 +158,7 @@ Read `repositories` from the nearest `.project.yaml` (walk-up from cwd):
|
|
|
158
158
|
|---------|--------|
|
|
159
159
|
| `pan clone` | Clone all repositories in the manifest |
|
|
160
160
|
| `pan pull` | Pull all cloned repositories |
|
|
161
|
+
| `pan sync-main [repo\|all]` (`pan sm`) | Fetch + merge `origin/main` into current branch (interactive picker; conflict summary) |
|
|
161
162
|
| `pan fetch` | Fetch all remotes |
|
|
162
163
|
| `pan push` | Push all repos (`-u origin HEAD` by default) |
|
|
163
164
|
| `pan grv` | `git remote -v` for workspace + each child repo |
|
|
@@ -202,7 +203,7 @@ pan-stack-cli/
|
|
|
202
203
|
platform.js # Windows/cross-platform helpers
|
|
203
204
|
manifest.js # YAML parsing
|
|
204
205
|
run-command.js # command execution
|
|
205
|
-
git/ # clone, pull, push, fetch, grv, gst
|
|
206
|
+
git/ # clone, pull, sync-main, push, fetch, grv, gst
|
|
206
207
|
templates/workspace/ # pan create template
|
|
207
208
|
references/ # npm publish docs
|
|
208
209
|
```
|
package/bin/pan.js
CHANGED
|
@@ -19,6 +19,8 @@ const GIT_SCRIPT_MAP = {
|
|
|
19
19
|
push: "git-push.js",
|
|
20
20
|
grv: "git-grv.js",
|
|
21
21
|
gst: "git-gst.js",
|
|
22
|
+
"sync-main": "git-sync-main.js",
|
|
23
|
+
sm: "git-sync-main.js",
|
|
22
24
|
};
|
|
23
25
|
|
|
24
26
|
function parseGlobalArgs(argv) {
|
|
@@ -54,6 +56,7 @@ workspace commands (built-in):
|
|
|
54
56
|
create Scaffold a new PAN Stack workspace (prompts for folder name)
|
|
55
57
|
clone Clone all repositories from .project.yaml
|
|
56
58
|
pull Pull latest changes for all repositories
|
|
59
|
+
sync-main (sm) Fetch origin/main and merge into current branch (pick repo or all)
|
|
57
60
|
fetch Fetch all remotes for all repositories
|
|
58
61
|
push Push all repositories and set upstream
|
|
59
62
|
grv List git remotes -v for workspace + child repos
|
package/lib/discover.js
CHANGED
|
@@ -12,7 +12,16 @@ const {
|
|
|
12
12
|
cleanValue,
|
|
13
13
|
} = require("./manifest");
|
|
14
14
|
|
|
15
|
-
const BUILTIN_GIT_COMMANDS = new Set([
|
|
15
|
+
const BUILTIN_GIT_COMMANDS = new Set([
|
|
16
|
+
"clone",
|
|
17
|
+
"pull",
|
|
18
|
+
"sync-main",
|
|
19
|
+
"sm",
|
|
20
|
+
"fetch",
|
|
21
|
+
"push",
|
|
22
|
+
"grv",
|
|
23
|
+
"gst",
|
|
24
|
+
]);
|
|
16
25
|
const BUILTIN_WORKSPACE_COMMANDS = new Set(["create", ...BUILTIN_GIT_COMMANDS]);
|
|
17
26
|
|
|
18
27
|
function readProjectRoots() {
|
package/lib/git/git-clone.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
const fs = require("fs");
|
|
11
11
|
const path = require("path");
|
|
12
12
|
const { spawnSync } = require("child_process");
|
|
13
|
-
const { spawnOptions } = require("
|
|
13
|
+
const { spawnOptions } = require("../platform");
|
|
14
14
|
const { parseCommonArgs, loadManifestRepos, printCommonOptions } = require("./helpers/cli");
|
|
15
15
|
|
|
16
16
|
function cloneRepo({ name, url, destination, dryRun }) {
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/*
|
|
3
|
+
* Sync origin/main (or --ref) into the current branch for selected PAN Stack repos.
|
|
4
|
+
*
|
|
5
|
+
* Fetches first, then merge (default) or rebase (--rebase). Interactive repo picker
|
|
6
|
+
* when TTY; pass repo name or "all" as first positional argument.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const {
|
|
10
|
+
looksLikeManifest,
|
|
11
|
+
loadManifestRepos,
|
|
12
|
+
printCommonOptions,
|
|
13
|
+
} = require("./helpers/cli");
|
|
14
|
+
const colors = require("./helpers/colors");
|
|
15
|
+
const { resolveTargets } = require("./helpers/repo-select");
|
|
16
|
+
const {
|
|
17
|
+
ensureRepoExists,
|
|
18
|
+
runGit,
|
|
19
|
+
runGitCapture,
|
|
20
|
+
getCurrentBranch,
|
|
21
|
+
refExists,
|
|
22
|
+
listConflictedFiles,
|
|
23
|
+
isMergeOrRebaseInProgress,
|
|
24
|
+
countDirtyFiles,
|
|
25
|
+
} = require("./helpers/git");
|
|
26
|
+
|
|
27
|
+
const ICON = {
|
|
28
|
+
ok: "✅",
|
|
29
|
+
warn: "⚠️",
|
|
30
|
+
conflict: "⛔",
|
|
31
|
+
skip: "⏭️",
|
|
32
|
+
info: "ℹ️",
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function printHelp() {
|
|
36
|
+
console.log(`usage: pan sync-main [repo|all] [options] (alias: pan sm)
|
|
37
|
+
|
|
38
|
+
Fetch remote main (or --ref) and merge into the current branch for workspace and/or child repos.
|
|
39
|
+
|
|
40
|
+
positional:
|
|
41
|
+
repo|all Repo name from .project.yaml, or "all". Interactive picker if omitted (TTY).
|
|
42
|
+
|
|
43
|
+
${printCommonOptions()}
|
|
44
|
+
--ref REF Remote-tracking ref to merge (default: origin/main)
|
|
45
|
+
--rebase Use git rebase instead of merge
|
|
46
|
+
--no-workspace Only manifest child repos (skip workspace stack root)`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function parseSyncMainArgs(argv) {
|
|
50
|
+
const args = {
|
|
51
|
+
help: false,
|
|
52
|
+
manifest: "",
|
|
53
|
+
baseDir: ".",
|
|
54
|
+
dryRun: false,
|
|
55
|
+
ref: "origin/main",
|
|
56
|
+
rebase: false,
|
|
57
|
+
noWorkspace: false,
|
|
58
|
+
selection: "",
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
62
|
+
const arg = argv[index];
|
|
63
|
+
if (arg === "-h" || arg === "--help") {
|
|
64
|
+
args.help = true;
|
|
65
|
+
} else if (arg === "--base-dir") {
|
|
66
|
+
args.baseDir = argv[++index] || ".";
|
|
67
|
+
} else if (arg === "--dry-run") {
|
|
68
|
+
args.dryRun = true;
|
|
69
|
+
} else if (arg === "--ref") {
|
|
70
|
+
args.ref = argv[++index] || "origin/main";
|
|
71
|
+
} else if (arg === "--rebase") {
|
|
72
|
+
args.rebase = true;
|
|
73
|
+
} else if (arg === "--no-workspace") {
|
|
74
|
+
args.noWorkspace = true;
|
|
75
|
+
} else if (!arg.startsWith("-")) {
|
|
76
|
+
if (looksLikeManifest(arg) && !args.manifest) {
|
|
77
|
+
args.manifest = arg;
|
|
78
|
+
} else if (!args.selection) {
|
|
79
|
+
args.selection = arg;
|
|
80
|
+
} else {
|
|
81
|
+
throw new Error(`Unexpected argument: ${arg}`);
|
|
82
|
+
}
|
|
83
|
+
} else {
|
|
84
|
+
throw new Error(`Unexpected argument: ${arg}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return args;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function printEarlyConflict({ name, branch, ref, files, mode }) {
|
|
92
|
+
const headline = colors.bold(
|
|
93
|
+
colors.red(`${ICON.conflict} [conflict] ${name} (${branch})`)
|
|
94
|
+
);
|
|
95
|
+
const detail = colors.yellow(
|
|
96
|
+
`${mode} ${ref} thất bại — ${files.length} file conflict`
|
|
97
|
+
);
|
|
98
|
+
console.log(`${headline} — ${detail}`);
|
|
99
|
+
for (const file of files.slice(0, 8)) {
|
|
100
|
+
console.log(colors.dim(` • ${file}`));
|
|
101
|
+
}
|
|
102
|
+
if (files.length > 8) {
|
|
103
|
+
console.log(colors.dim(` … và ${files.length - 8} file khác`));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function syncMainRepo({
|
|
108
|
+
name,
|
|
109
|
+
destination,
|
|
110
|
+
dryRun,
|
|
111
|
+
ref,
|
|
112
|
+
rebase,
|
|
113
|
+
}) {
|
|
114
|
+
const result = {
|
|
115
|
+
name,
|
|
116
|
+
destination,
|
|
117
|
+
branch: "",
|
|
118
|
+
status: "skipped",
|
|
119
|
+
message: "",
|
|
120
|
+
conflictFiles: [],
|
|
121
|
+
mode: rebase ? "rebase" : "merge",
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
if (!ensureRepoExists({ name, destination })) {
|
|
125
|
+
result.message = "repo không tồn tại hoặc chưa clone";
|
|
126
|
+
return result;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const branch = getCurrentBranch(destination);
|
|
130
|
+
result.branch = branch || "(detached HEAD)";
|
|
131
|
+
if (!branch) {
|
|
132
|
+
result.message = "đang detached HEAD — bỏ qua";
|
|
133
|
+
console.log(`${ICON.skip} [skip] ${name}: ${result.message}`);
|
|
134
|
+
return result;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const inProgress = isMergeOrRebaseInProgress(destination);
|
|
138
|
+
if (inProgress) {
|
|
139
|
+
const files = listConflictedFiles(destination);
|
|
140
|
+
result.status = "conflict";
|
|
141
|
+
result.conflictFiles = files;
|
|
142
|
+
result.message = `đang dở ${inProgress} — cần resolve trước`;
|
|
143
|
+
printEarlyConflict({ name, branch, ref, files, mode: inProgress });
|
|
144
|
+
return result;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const dirtyCount = countDirtyFiles(destination);
|
|
148
|
+
if (dirtyCount > 0) {
|
|
149
|
+
console.log(
|
|
150
|
+
`${ICON.warn} ${colors.yellow(`[warn] ${name}: working tree có ${dirtyCount} thay đổi chưa commit`)}`
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (dryRun) {
|
|
155
|
+
console.log(
|
|
156
|
+
`[dry-run] (in ${destination}) git fetch origin && git ${rebase ? "rebase" : "merge"} ${ref}`
|
|
157
|
+
);
|
|
158
|
+
result.status = "dry-run";
|
|
159
|
+
result.message = "dry-run";
|
|
160
|
+
return result;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
console.log(`[fetch] ${name}: ${destination}`);
|
|
164
|
+
const fetch = runGitCapture(["fetch", "origin"], { cwd: destination });
|
|
165
|
+
if (fetch.status !== 0) {
|
|
166
|
+
result.status = "error";
|
|
167
|
+
result.message = fetch.stderr || "git fetch origin thất bại";
|
|
168
|
+
console.log(`${ICON.warn} ${colors.yellow(`[warn] ${name}: ${result.message}`)}`);
|
|
169
|
+
return result;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (!refExists(destination, ref)) {
|
|
173
|
+
result.status = "error";
|
|
174
|
+
result.message = `không tìm thấy ref ${ref} (chạy fetch trước?)`;
|
|
175
|
+
console.log(`${ICON.warn} ${colors.yellow(`[warn] ${name}: ${result.message}`)}`);
|
|
176
|
+
return result;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const gitArgs = rebase ? ["rebase", ref] : ["merge", "--no-edit", ref];
|
|
180
|
+
console.log(`[${result.mode}] ${name} (${branch}) ← ${ref}`);
|
|
181
|
+
const action = runGit(gitArgs, {
|
|
182
|
+
cwd: destination,
|
|
183
|
+
dryRun: false,
|
|
184
|
+
action: result.mode,
|
|
185
|
+
name,
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
const conflictFiles = listConflictedFiles(destination);
|
|
189
|
+
if (conflictFiles.length > 0 || action !== 0) {
|
|
190
|
+
result.status = "conflict";
|
|
191
|
+
result.conflictFiles = conflictFiles.length > 0 ? conflictFiles : ["(xem git status)"];
|
|
192
|
+
result.message = `${result.mode} conflict`;
|
|
193
|
+
printEarlyConflict({
|
|
194
|
+
name,
|
|
195
|
+
branch,
|
|
196
|
+
ref,
|
|
197
|
+
files: result.conflictFiles,
|
|
198
|
+
mode: result.mode,
|
|
199
|
+
});
|
|
200
|
+
return result;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
result.status = "ok";
|
|
204
|
+
result.message = "đã cập nhật";
|
|
205
|
+
console.log(`${ICON.ok} ${colors.green(`[ok] ${name}: ${branch} đã sync ${ref}`)}`);
|
|
206
|
+
return result;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function printSummary(results, { ref, rebase }) {
|
|
210
|
+
const ok = results.filter((item) => item.status === "ok");
|
|
211
|
+
const conflicts = results.filter((item) => item.status === "conflict");
|
|
212
|
+
const errors = results.filter((item) => item.status === "error");
|
|
213
|
+
const skipped = results.filter(
|
|
214
|
+
(item) => item.status === "skipped" || item.status === "dry-run"
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
console.log("");
|
|
218
|
+
console.log(colors.bold("─── sync-main summary ───"));
|
|
219
|
+
console.log(
|
|
220
|
+
`${ICON.info} ref: ${ref} · mode: ${rebase ? "rebase" : "merge"} · repos: ${results.length}`
|
|
221
|
+
);
|
|
222
|
+
console.log(
|
|
223
|
+
` ${ICON.ok} ok: ${ok.length} · ${ICON.conflict} conflict: ${conflicts.length} · ${ICON.warn} error: ${errors.length} · ${ICON.skip} skip: ${skipped.length}`
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
if (conflicts.length > 0) {
|
|
227
|
+
console.log("");
|
|
228
|
+
console.log(colors.bold(colors.red(`${ICON.conflict} CONFLICT — cần xử lý thủ công`)));
|
|
229
|
+
for (const item of conflicts) {
|
|
230
|
+
console.log(colors.yellow(`\n ${item.name} (${item.branch})`));
|
|
231
|
+
console.log(colors.dim(` ${item.destination}`));
|
|
232
|
+
for (const file of item.conflictFiles) {
|
|
233
|
+
console.log(` • ${file}`);
|
|
234
|
+
}
|
|
235
|
+
const hint = item.mode === "rebase"
|
|
236
|
+
? "git add <file>… && git rebase --continue (hoặc git rebase --abort)"
|
|
237
|
+
: "git add <file>… && git commit";
|
|
238
|
+
console.log(colors.dim(` → ${hint}`));
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (errors.length > 0) {
|
|
243
|
+
console.log("");
|
|
244
|
+
console.log(colors.bold(colors.yellow(`${ICON.warn} LỖI`)));
|
|
245
|
+
for (const item of errors) {
|
|
246
|
+
console.log(` • ${item.name}: ${item.message}`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (conflicts.length > 0) {
|
|
251
|
+
console.log("");
|
|
252
|
+
console.log(
|
|
253
|
+
colors.bold(
|
|
254
|
+
colors.red(
|
|
255
|
+
`${ICON.conflict} Kết thúc với conflict — resolve từng repo ở trên rồi commit/rebase --continue`
|
|
256
|
+
)
|
|
257
|
+
)
|
|
258
|
+
);
|
|
259
|
+
} else if (errors.length > 0) {
|
|
260
|
+
console.log("");
|
|
261
|
+
console.log(
|
|
262
|
+
colors.bold(colors.yellow(`${ICON.warn} Kết thúc với lỗi — xem chi tiết ở trên`))
|
|
263
|
+
);
|
|
264
|
+
} else if (ok.length > 0) {
|
|
265
|
+
console.log("");
|
|
266
|
+
console.log(colors.green(`${ICON.ok} Tất cả repo đã chọn đã sync ${ref}`));
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function main() {
|
|
271
|
+
const argv = process.argv.slice(2);
|
|
272
|
+
const args = parseSyncMainArgs(argv);
|
|
273
|
+
if (args.help) {
|
|
274
|
+
printHelp();
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const { repos, workspaceRoot } = loadManifestRepos({
|
|
279
|
+
manifestPath: args.manifest,
|
|
280
|
+
baseDir: args.baseDir,
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
const targets = await resolveTargets({
|
|
284
|
+
repos,
|
|
285
|
+
workspaceRoot,
|
|
286
|
+
includeWorkspace: !args.noWorkspace,
|
|
287
|
+
selection: args.selection,
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
console.log(
|
|
291
|
+
`${ICON.info} sync-main: ${targets.length} repo · ${args.rebase ? "rebase" : "merge"} ← ${args.ref}`
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
const results = [];
|
|
295
|
+
for (const target of targets) {
|
|
296
|
+
results.push(
|
|
297
|
+
syncMainRepo({
|
|
298
|
+
name: target.name,
|
|
299
|
+
destination: target.destination,
|
|
300
|
+
dryRun: args.dryRun,
|
|
301
|
+
ref: args.ref,
|
|
302
|
+
rebase: args.rebase,
|
|
303
|
+
})
|
|
304
|
+
);
|
|
305
|
+
console.log("");
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
printSummary(results, { ref: args.ref, rebase: args.rebase });
|
|
309
|
+
|
|
310
|
+
const hasConflict = results.some((item) => item.status === "conflict");
|
|
311
|
+
const hasError = results.some((item) => item.status === "error");
|
|
312
|
+
if (hasConflict || hasError) {
|
|
313
|
+
process.exit(1);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
main().catch((error) => {
|
|
318
|
+
console.error(error.message);
|
|
319
|
+
process.exit(1);
|
|
320
|
+
});
|
package/lib/git/helpers/cli.js
CHANGED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
const supportsColor =
|
|
2
|
+
Boolean(process.stdout.isTTY) && !process.env.NO_COLOR && process.env.FORCE_COLOR !== "0";
|
|
3
|
+
|
|
4
|
+
function wrap(code, text) {
|
|
5
|
+
if (!supportsColor || text == null) {
|
|
6
|
+
return String(text ?? "");
|
|
7
|
+
}
|
|
8
|
+
return `\u001b[${code}m${text}\u001b[0m`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
module.exports = {
|
|
12
|
+
supportsColor,
|
|
13
|
+
bold: (text) => wrap("1", text),
|
|
14
|
+
red: (text) => wrap("31", text),
|
|
15
|
+
green: (text) => wrap("32", text),
|
|
16
|
+
yellow: (text) => wrap("33", text),
|
|
17
|
+
cyan: (text) => wrap("36", text),
|
|
18
|
+
dim: (text) => wrap("2", text),
|
|
19
|
+
};
|
package/lib/git/helpers/git.js
CHANGED
|
@@ -53,9 +53,83 @@ function runGitStatus(directory, gitArgs = []) {
|
|
|
53
53
|
return result.status ?? 1;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
function runGitCapture(gitArgs, { cwd } = {}) {
|
|
57
|
+
const result = spawnSync("git", gitArgs, spawnOptions({
|
|
58
|
+
cwd,
|
|
59
|
+
encoding: "utf8",
|
|
60
|
+
}));
|
|
61
|
+
return {
|
|
62
|
+
status: result.status ?? 1,
|
|
63
|
+
stdout: (result.stdout || "").trim(),
|
|
64
|
+
stderr: (result.stderr || "").trim(),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function getCurrentBranch(directory) {
|
|
69
|
+
const branch = runGitCapture(["rev-parse", "--abbrev-ref", "HEAD"], { cwd: directory });
|
|
70
|
+
if (branch.status !== 0) {
|
|
71
|
+
return "";
|
|
72
|
+
}
|
|
73
|
+
if (branch.stdout === "HEAD") {
|
|
74
|
+
return "";
|
|
75
|
+
}
|
|
76
|
+
return branch.stdout;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function refExists(directory, ref) {
|
|
80
|
+
const result = runGitCapture(["rev-parse", "--verify", ref], { cwd: directory });
|
|
81
|
+
return result.status === 0;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function listConflictedFiles(directory) {
|
|
85
|
+
const result = runGitCapture(
|
|
86
|
+
["diff", "--name-only", "--diff-filter=U"],
|
|
87
|
+
{ cwd: directory }
|
|
88
|
+
);
|
|
89
|
+
if (result.status !== 0) {
|
|
90
|
+
return [];
|
|
91
|
+
}
|
|
92
|
+
return result.stdout
|
|
93
|
+
.split(/\r?\n/)
|
|
94
|
+
.map((line) => line.trim())
|
|
95
|
+
.filter(Boolean);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function isMergeOrRebaseInProgress(directory) {
|
|
99
|
+
const merge = runGitCapture(["rev-parse", "-q", "--verify", "MERGE_HEAD"], {
|
|
100
|
+
cwd: directory,
|
|
101
|
+
});
|
|
102
|
+
if (merge.status === 0) {
|
|
103
|
+
return "merge";
|
|
104
|
+
}
|
|
105
|
+
const rebaseMerge = fs.existsSync(path.join(directory, ".git", "rebase-merge"));
|
|
106
|
+
const rebaseApply = fs.existsSync(path.join(directory, ".git", "rebase-apply"));
|
|
107
|
+
if (rebaseMerge || rebaseApply) {
|
|
108
|
+
return "rebase";
|
|
109
|
+
}
|
|
110
|
+
return "";
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function countDirtyFiles(directory) {
|
|
114
|
+
const result = runGitCapture(["status", "--porcelain"], { cwd: directory });
|
|
115
|
+
if (result.status !== 0) {
|
|
116
|
+
return 0;
|
|
117
|
+
}
|
|
118
|
+
return result.stdout
|
|
119
|
+
.split(/\r?\n/)
|
|
120
|
+
.map((line) => line.trim())
|
|
121
|
+
.filter(Boolean).length;
|
|
122
|
+
}
|
|
123
|
+
|
|
56
124
|
module.exports = {
|
|
57
125
|
isGitRepo,
|
|
58
126
|
runGit,
|
|
127
|
+
runGitCapture,
|
|
128
|
+
getCurrentBranch,
|
|
129
|
+
refExists,
|
|
130
|
+
listConflictedFiles,
|
|
131
|
+
isMergeOrRebaseInProgress,
|
|
132
|
+
countDirtyFiles,
|
|
59
133
|
ensureRepoExists,
|
|
60
134
|
listRemotesVerbose,
|
|
61
135
|
runGitStatus,
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
const readline = require("readline");
|
|
2
|
+
const { isGitRepo } = require("./git");
|
|
3
|
+
|
|
4
|
+
function buildTargets({ repos, workspaceRoot, includeWorkspace }) {
|
|
5
|
+
const targets = [];
|
|
6
|
+
if (includeWorkspace && isGitRepo(workspaceRoot)) {
|
|
7
|
+
targets.push({
|
|
8
|
+
name: "workspace",
|
|
9
|
+
destination: workspaceRoot,
|
|
10
|
+
label: "workspace (stack root)",
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
for (const repo of repos) {
|
|
14
|
+
targets.push({
|
|
15
|
+
name: repo.name,
|
|
16
|
+
destination: repo.destination,
|
|
17
|
+
label: repo.name,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
return targets;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function findTargetByName(targets, name) {
|
|
24
|
+
const needle = String(name || "").trim();
|
|
25
|
+
if (!needle) {
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
return targets.filter((target) => target.name === needle);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function promptRepoChoice(targets) {
|
|
32
|
+
return new Promise((resolve, reject) => {
|
|
33
|
+
const rl = readline.createInterface({
|
|
34
|
+
input: process.stdin,
|
|
35
|
+
output: process.stdout,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
console.log("");
|
|
39
|
+
console.log("Chọn repo để sync-main:");
|
|
40
|
+
console.log(` 1. all (${targets.length} repo)`);
|
|
41
|
+
targets.forEach((target, index) => {
|
|
42
|
+
console.log(` ${index + 2}. ${target.label}`);
|
|
43
|
+
console.log(` ${target.destination}`);
|
|
44
|
+
});
|
|
45
|
+
console.log("");
|
|
46
|
+
|
|
47
|
+
rl.question("Nhập số (1 = all): ", (answer) => {
|
|
48
|
+
rl.close();
|
|
49
|
+
const choice = Number.parseInt(String(answer).trim(), 10);
|
|
50
|
+
if (!Number.isFinite(choice) || choice < 1 || choice > targets.length + 1) {
|
|
51
|
+
reject(new Error("Lựa chọn không hợp lệ."));
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (choice === 1) {
|
|
55
|
+
resolve(targets);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
resolve([targets[choice - 2]]);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function resolveTargets({
|
|
64
|
+
repos,
|
|
65
|
+
workspaceRoot,
|
|
66
|
+
includeWorkspace,
|
|
67
|
+
selection,
|
|
68
|
+
}) {
|
|
69
|
+
const targets = buildTargets({ repos, workspaceRoot, includeWorkspace });
|
|
70
|
+
if (targets.length === 0) {
|
|
71
|
+
throw new Error("Không có repo git nào để sync-main.");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const picked = String(selection || "").trim();
|
|
75
|
+
if (picked === "all") {
|
|
76
|
+
return targets;
|
|
77
|
+
}
|
|
78
|
+
if (picked) {
|
|
79
|
+
const matches = findTargetByName(targets, picked);
|
|
80
|
+
if (matches.length === 0) {
|
|
81
|
+
const names = targets.map((target) => target.name).join(", ");
|
|
82
|
+
throw new Error(`Repo '${picked}' không có trong manifest. Có: ${names}`);
|
|
83
|
+
}
|
|
84
|
+
return matches;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (process.stdin.isTTY) {
|
|
88
|
+
return promptRepoChoice(targets);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return targets;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
module.exports = {
|
|
95
|
+
buildTargets,
|
|
96
|
+
resolveTargets,
|
|
97
|
+
};
|