pan-stack-cli 0.1.5 → 0.2.1
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 +572 -24
- package/bin/pan.js +204 -21
- package/lib/ai-skills.js +829 -0
- package/lib/create.js +22 -4
- package/lib/discover.js +59 -1
- package/lib/git/git-checkout.js +395 -0
- package/lib/git/git-clone.js +162 -27
- package/lib/git/git-fetch-all.js +100 -33
- package/lib/git/git-grv.js +68 -37
- package/lib/git/git-gst.js +97 -26
- package/lib/git/git-mr.js +546 -0
- package/lib/git/git-pull.js +104 -43
- package/lib/git/git-push.js +150 -41
- package/lib/git/git-sync-main.js +42 -28
- package/lib/git/helpers/bulk-report.js +202 -0
- package/lib/git/helpers/cli.js +40 -5
- package/lib/git/helpers/manifest.js +93 -0
- package/lib/git/helpers/repo-select.js +328 -9
- package/lib/git/helpers/repo-summary.js +161 -0
- package/lib/i18n.js +61 -0
- package/lib/inquirer-theme.js +20 -0
- package/lib/language.js +121 -0
- package/lib/languages.js +14 -0
- package/lib/manifest.js +115 -14
- package/lib/menu-prompt.js +201 -0
- package/lib/menu.js +134 -0
- package/lib/messages.js +647 -0
- package/lib/run-command.js +173 -52
- package/lib/select.js +0 -0
- package/lib/serve.js +75 -0
- package/lib/skill-registry-client.js +173 -0
- package/lib/stats/aggregate.js +51 -0
- package/lib/stats/identity.js +20 -0
- package/lib/stats/index.js +189 -0
- package/lib/stats/parse.js +46 -0
- package/lib/stats/range.js +38 -0
- package/lib/stats/report.js +264 -0
- package/lib/stats/review.js +157 -0
- package/lib/stats/scan.js +92 -0
- package/lib/stats/serve.js +25 -0
- package/lib/ui/api.js +202 -0
- package/lib/ui/clone.js +115 -0
- package/lib/ui/config.js +182 -0
- package/lib/ui/diff.js +128 -0
- package/lib/ui/export-gate.js +79 -0
- package/lib/ui/gen-react-mobile-code.js +6 -0
- package/lib/ui/gen-react-web-code.js +75 -0
- package/lib/ui/index.js +89 -0
- package/lib/ui/init.js +252 -0
- package/lib/ui/login.js +128 -0
- package/lib/ui/logout.js +22 -0
- package/lib/ui/palettes.js +103 -0
- package/lib/ui/published-html.js +24 -0
- package/lib/ui/sync.js +137 -0
- package/lib/ui/workspace.js +213 -0
- package/package.json +10 -3
- package/templates/workspace/.claude/skills/writing-guidelines/SKILL.md +0 -48
- package/templates/workspace/.codex/skills/writing-guidelines/SKILL.md +0 -48
- package/templates/workspace/.cursor/skills/writing-guidelines/SKILL.md +0 -48
- package/templates/workspace/.pan.yaml +0 -25
- package/templates/workspace/.project.yaml +0 -15
- package/templates/workspace/README.md +0 -35
package/lib/git/git-clone.js
CHANGED
|
@@ -1,66 +1,201 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/*
|
|
3
3
|
* Clone PAN Stack project repositories from a small clone manifest.
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* Node.js and Git are available.
|
|
4
|
+
* Compact summary table (giống pull/push/fetch): repo · result · note — KHÔNG in
|
|
5
|
+
* [warn]/lỗi rời rạc theo từng repo, chỉ tổng hợp cuối (cloned · đã có sẵn · không
|
|
6
|
+
* có quyền · không tìm thấy · lỗi khác).
|
|
8
7
|
*/
|
|
9
8
|
|
|
10
9
|
const fs = require("fs");
|
|
11
10
|
const path = require("path");
|
|
12
11
|
const { spawnSync } = require("child_process");
|
|
13
12
|
const { spawnOptions } = require("../platform");
|
|
14
|
-
const { parseCommonArgs, loadManifestRepos, printCommonOptions } = require("./helpers/cli");
|
|
13
|
+
const { parseCommonArgs, loadManifestRepos, resolveProjectWorkspace, printCommonOptions } = require("./helpers/cli");
|
|
14
|
+
const { looksLikeGitUrl, repoNameFromUrl, appendRepository } = require("./helpers/manifest");
|
|
15
|
+
const { printSummary, statusBadge, bulkColumns } = require("./helpers/bulk-report");
|
|
16
|
+
const colors = require("./helpers/colors");
|
|
17
|
+
const { t } = require("../i18n");
|
|
18
|
+
|
|
19
|
+
// Dòng lỗi ngắn gọn cho cột "note" — bỏ qua dòng tiến trình "Cloning into '<path dài>'..." (luôn
|
|
20
|
+
// là dòng đầu của git clone, không có ý nghĩa), ưu tiên dòng thật (fatal:/remote:).
|
|
21
|
+
function shortCloneError(stderr) {
|
|
22
|
+
const lines = stderr.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
23
|
+
const meaningful = lines.find((l) => /^(fatal:|remote:|error:)/i.test(l)) || lines[lines.length - 1] || lines[0] || "";
|
|
24
|
+
return meaningful.slice(0, 100);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Phân loại lỗi clone từ stderr — KHÔNG in message thô cho user, chỉ dùng để chọn nhãn tổng hợp.
|
|
28
|
+
function classifyCloneError(stderr) {
|
|
29
|
+
if (/permission denied|authentication failed|access denied|403|could not read username|invalid username or password/i.test(stderr)) {
|
|
30
|
+
return "no-access";
|
|
31
|
+
}
|
|
32
|
+
if (/repository .* not found|not found|does not exist|404/i.test(stderr)) {
|
|
33
|
+
return "not-found";
|
|
34
|
+
}
|
|
35
|
+
return "failed";
|
|
36
|
+
}
|
|
15
37
|
|
|
16
38
|
function cloneRepo({ name, url, destination, dryRun }) {
|
|
17
39
|
if (fs.existsSync(destination)) {
|
|
18
|
-
|
|
19
|
-
return;
|
|
40
|
+
return { name, exists: true };
|
|
20
41
|
}
|
|
21
42
|
|
|
22
|
-
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
|
23
|
-
const command = ["git", "clone", url, destination];
|
|
24
43
|
if (dryRun) {
|
|
25
|
-
console.log(`[dry-run] ${
|
|
26
|
-
return;
|
|
44
|
+
console.log(colors.dim(`[dry-run] git clone ${url} ${destination}`));
|
|
45
|
+
return { name, dryRun: true };
|
|
27
46
|
}
|
|
28
47
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
48
|
+
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
|
49
|
+
process.stdout.write(colors.dim(` ⇩ ${name} ... `));
|
|
50
|
+
const result = spawnSync("git", ["clone", url, destination], spawnOptions({ encoding: "utf8" }));
|
|
51
|
+
const ok = result.status === 0;
|
|
52
|
+
const stderr = (result.stderr || "").trim();
|
|
53
|
+
const category = ok ? "ok" : classifyCloneError(stderr);
|
|
54
|
+
const shortMsg = ok ? t("CLONE_STATUS_CLONED")
|
|
55
|
+
: category === "no-access" ? t("CLONE_STATUS_NO_ACCESS")
|
|
56
|
+
: category === "not-found" ? t("CLONE_STATUS_NOT_FOUND")
|
|
57
|
+
: t("BULK_STATUS_FAILED");
|
|
58
|
+
console.log((ok ? colors.green : category === "no-access" ? colors.yellow : colors.red)(shortMsg));
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
name,
|
|
62
|
+
status: result.status,
|
|
63
|
+
category,
|
|
64
|
+
error: !ok ? shortCloneError(stderr) : "",
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function row(r) {
|
|
69
|
+
if (r.dryRun) return { repo: r.name, result: colors.dim(t("BULK_STATUS_DRY_RUN")), note: "" };
|
|
70
|
+
if (r.exists) return { repo: r.name, result: statusBadge("skip", t("CLONE_STATUS_EXISTS")), note: "" };
|
|
71
|
+
if (!r.error) return { repo: r.name, result: statusBadge("ok", t("CLONE_STATUS_CLONED")), note: "" };
|
|
72
|
+
const label =
|
|
73
|
+
r.category === "no-access" ? t("CLONE_STATUS_NO_ACCESS")
|
|
74
|
+
: r.category === "not-found" ? t("CLONE_STATUS_NOT_FOUND")
|
|
75
|
+
: t("BULK_STATUS_FAILED");
|
|
76
|
+
const kind = r.category === "no-access" ? "warn" : "fail";
|
|
77
|
+
return { repo: r.name, result: statusBadge(kind, label), note: colors.dim(r.error) };
|
|
34
78
|
}
|
|
35
79
|
|
|
36
80
|
function printHelp() {
|
|
37
81
|
console.log(`usage: git-clone.js [--base-dir BASE_DIR] [--dry-run] [manifest]
|
|
82
|
+
git-clone.js <git-url> [--dry-run]
|
|
83
|
+
|
|
84
|
+
Không tham số (hoặc [manifest]) → clone TẤT CẢ repo trong manifest (clone all).
|
|
85
|
+
Có <git-url> (https://…, git@host:…, hoặc kết thúc .git) → tự tách tên dự án từ
|
|
86
|
+
URL, THÊM 1 entry mới vào cuối 'repositories:' trong .project.yaml (path mặc
|
|
87
|
+
định 'apps/<tên>'), rồi clone NGAY repo đó — tương tự 'git clone <url>' nhưng
|
|
88
|
+
tiện hơn: repo được ghi lại vào manifest luôn, lần sau 'pan clone'/'pan pull'... tự
|
|
89
|
+
thấy nó. Đã có tên đó trong manifest rồi → KHÔNG thêm trùng, dùng lại entry cũ.
|
|
38
90
|
|
|
39
|
-
|
|
91
|
+
Prints a compact summary table (repo · result · note) — KHÔNG dừng ở lỗi đầu
|
|
92
|
+
tiên, clone hết các repo rồi tổng hợp (đã có sẵn / không có quyền / không tìm
|
|
93
|
+
thấy / lỗi khác) ở cuối.
|
|
40
94
|
|
|
41
95
|
${printCommonOptions()}`);
|
|
42
96
|
}
|
|
43
97
|
|
|
98
|
+
function printCloneSummary(results, dryRun) {
|
|
99
|
+
const cloned = results.filter((r) => !r.dryRun && !r.exists && !r.error);
|
|
100
|
+
const exists = results.filter((r) => r.exists);
|
|
101
|
+
const noAccess = results.filter((r) => r.category === "no-access");
|
|
102
|
+
const notFound = results.filter((r) => r.category === "not-found");
|
|
103
|
+
const failed = results.filter((r) => r.error && r.category === "failed");
|
|
104
|
+
const rows = results.map(row);
|
|
105
|
+
|
|
106
|
+
const footer = [];
|
|
107
|
+
footer.push(colors.dim(t("CLONE_FOOTER_COUNTS__TOTAL_CLONED_EXISTS_NOACCESS_NOTFOUND_FAILED", {
|
|
108
|
+
total: results.length, cloned: cloned.length, exists: exists.length,
|
|
109
|
+
noAccess: noAccess.length, notFound: notFound.length, failed: failed.length,
|
|
110
|
+
})));
|
|
111
|
+
if (noAccess.length) {
|
|
112
|
+
footer.push("");
|
|
113
|
+
footer.push(colors.yellow(t("CLONE_NO_ACCESS_HEADER")));
|
|
114
|
+
for (const r of noAccess) footer.push(` ${colors.cyan(r.name)}`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (!dryRun && results.every((r) => r.exists)) {
|
|
118
|
+
console.log("");
|
|
119
|
+
console.log(colors.bold(`─── ${t("CLONE_TITLE")} ───`));
|
|
120
|
+
console.log(colors.green(t("CLONE_ALL_EXIST__N", { n: results.length })));
|
|
121
|
+
for (const line of footer) console.log(line);
|
|
122
|
+
} else {
|
|
123
|
+
printSummary({
|
|
124
|
+
title: t("CLONE_TITLE"),
|
|
125
|
+
rows,
|
|
126
|
+
columns: bulkColumns([
|
|
127
|
+
{ key: "repo", labelKey: "BULK_COL_REPO" },
|
|
128
|
+
{ key: "result", labelKey: "BULK_COL_RESULT" },
|
|
129
|
+
{ key: "note", labelKey: "BULK_COL_NOTE" },
|
|
130
|
+
]),
|
|
131
|
+
footerLines: footer,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (noAccess.length || notFound.length || failed.length) process.exitCode = 1;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// `pan clone <url>` — thêm 1 repo mới vào .project.yaml (KHÔNG phải .project.local.yaml — repo
|
|
139
|
+
// thêm qua đường này coi như chính thức, muốn giữ riêng thì tự thêm tay vào .project.local.yaml)
|
|
140
|
+
// rồi clone ngay repo đó. Đã có tên trùng trong manifest (kể cả từ .project.local.yaml) → dùng
|
|
141
|
+
// lại entry cũ, không thêm trùng.
|
|
142
|
+
function runAddAndCloneOne(urlArg, { baseDirArg, dryRun }) {
|
|
143
|
+
const name = repoNameFromUrl(urlArg);
|
|
144
|
+
if (!name) {
|
|
145
|
+
throw new Error(`Không tách được tên dự án từ URL: ${urlArg}`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const { manifestPath, baseDir } = resolveProjectWorkspace({ baseDir: baseDirArg || "." });
|
|
149
|
+
const { repos: existingRepos } = loadManifestRepos({ baseDir: baseDirArg, allowFallback: false });
|
|
150
|
+
const already = existingRepos.find((r) => r.name === name);
|
|
151
|
+
|
|
152
|
+
let url = urlArg;
|
|
153
|
+
let destination = path.resolve(baseDir, "apps", name);
|
|
154
|
+
if (already) {
|
|
155
|
+
console.log(colors.dim(`ℹ️ '${name}' đã có trong manifest — dùng entry hiện có (${already.url}).`));
|
|
156
|
+
url = already.url;
|
|
157
|
+
destination = already.destination;
|
|
158
|
+
} else {
|
|
159
|
+
const repoPath = `apps/${name}`;
|
|
160
|
+
if (dryRun) {
|
|
161
|
+
console.log(colors.dim(`[dry-run] + thêm '${name}' vào ${manifestPath} (url: ${urlArg}, path: ${repoPath})`));
|
|
162
|
+
} else {
|
|
163
|
+
appendRepository(manifestPath, { name, url: urlArg, path: repoPath });
|
|
164
|
+
console.log(colors.green(`✓ Đã thêm '${name}' vào ${manifestPath} (path: ${repoPath})`));
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const result = cloneRepo({ name, url, destination, dryRun });
|
|
169
|
+
printCloneSummary([result], dryRun);
|
|
170
|
+
}
|
|
171
|
+
|
|
44
172
|
function main() {
|
|
45
|
-
const
|
|
46
|
-
if (
|
|
173
|
+
const rawArgs = process.argv.slice(2);
|
|
174
|
+
if (rawArgs.includes("-h") || rawArgs.includes("--help")) {
|
|
47
175
|
printHelp();
|
|
48
176
|
return;
|
|
49
177
|
}
|
|
50
178
|
|
|
179
|
+
const dryRun = rawArgs.includes("--dry-run");
|
|
180
|
+
const baseDirIdx = rawArgs.indexOf("--base-dir");
|
|
181
|
+
const baseDirArg = baseDirIdx >= 0 ? rawArgs[baseDirIdx + 1] : "";
|
|
182
|
+
const urlArg = rawArgs.find((a, i) => !a.startsWith("--") && rawArgs[i - 1] !== "--base-dir" && looksLikeGitUrl(a));
|
|
183
|
+
|
|
184
|
+
if (urlArg) {
|
|
185
|
+
runAddAndCloneOne(urlArg, { baseDirArg, dryRun });
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const args = parseCommonArgs(rawArgs);
|
|
190
|
+
|
|
51
191
|
const { repos } = loadManifestRepos({
|
|
52
192
|
manifestPath: args.manifest,
|
|
53
193
|
baseDir: args.baseDir,
|
|
194
|
+
allowFallback: false,
|
|
54
195
|
});
|
|
55
196
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
name: repo.name,
|
|
59
|
-
url: repo.url,
|
|
60
|
-
destination: repo.destination,
|
|
61
|
-
dryRun: args.dryRun,
|
|
62
|
-
});
|
|
63
|
-
}
|
|
197
|
+
const results = repos.map((r) => cloneRepo({ name: r.name, url: r.url, destination: r.destination, dryRun: args.dryRun }));
|
|
198
|
+
printCloneSummary(results, args.dryRun);
|
|
64
199
|
}
|
|
65
200
|
|
|
66
201
|
try {
|
package/lib/git/git-fetch-all.js
CHANGED
|
@@ -1,62 +1,129 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/*
|
|
3
|
-
* Fetch all remotes for PAN Stack project repositories
|
|
4
|
-
*
|
|
5
|
-
* Skips repositories that do not exist or are not git checkouts. Failures warn
|
|
6
|
-
* and continue so one broken repo does not block the rest.
|
|
3
|
+
* Fetch all remotes for PAN Stack project repositories.
|
|
4
|
+
* Compact summary table: repo · branch · fetched · behind/ahead · new refs.
|
|
7
5
|
*/
|
|
8
6
|
|
|
9
7
|
const { parseCommonArgs, loadManifestRepos, printCommonOptions } = require("./helpers/cli");
|
|
10
|
-
const { ensureRepoExists
|
|
8
|
+
const { ensureRepoExists } = require("./helpers/git");
|
|
9
|
+
const {
|
|
10
|
+
runGitSilent, parseFetchOutput, printSummary, statusBadge, bulkColumns,
|
|
11
|
+
aheadBehind, getCurrentBranch, ICON,
|
|
12
|
+
} = require("./helpers/bulk-report");
|
|
13
|
+
const colors = require("./helpers/colors");
|
|
14
|
+
const { t } = require("../i18n");
|
|
11
15
|
|
|
12
16
|
function fetchRepo({ name, destination, dryRun }) {
|
|
13
17
|
if (!ensureRepoExists({ name, destination })) {
|
|
14
|
-
return;
|
|
18
|
+
return { name, skipped: true };
|
|
15
19
|
}
|
|
20
|
+
const branch = getCurrentBranch(destination) || t("GST_DETACHED");
|
|
16
21
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
dryRun
|
|
20
|
-
action: "fetch",
|
|
21
|
-
name,
|
|
22
|
-
});
|
|
23
|
-
if (status !== 0) {
|
|
24
|
-
console.log(`[warn] ${name}: fetch failed, check remote access`);
|
|
22
|
+
if (dryRun) {
|
|
23
|
+
console.log(colors.dim(`[dry-run] (in ${destination}) git fetch --all`));
|
|
24
|
+
return { name, branch, dryRun: true };
|
|
25
25
|
}
|
|
26
|
+
|
|
27
|
+
process.stdout.write(colors.dim(` ⇣ ${name} (${branch}) ... `));
|
|
28
|
+
const res = runGitSilent(["fetch", "--all", "--prune"], destination);
|
|
29
|
+
const parsed = parseFetchOutput(res.stdout, res.stderr);
|
|
30
|
+
const ab = aheadBehind(destination);
|
|
31
|
+
const ok = res.status === 0;
|
|
32
|
+
const shortMsg = !ok ? t("BULK_STATUS_FAILED")
|
|
33
|
+
: (parsed.newBranches || parsed.updatedBranches || parsed.newTags) ? t("BULK_STATUS_UPDATED")
|
|
34
|
+
: t("BULK_STATUS_OK");
|
|
35
|
+
console.log((ok ? colors.green : colors.red)(shortMsg));
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
name, branch,
|
|
39
|
+
status: res.status,
|
|
40
|
+
...parsed,
|
|
41
|
+
ahead: ab ? ab.ahead : 0,
|
|
42
|
+
behind: ab ? ab.behind : 0,
|
|
43
|
+
hasUpstream: Boolean(ab),
|
|
44
|
+
error: !ok ? (res.stderr.split("\n")[0] || "").slice(0, 80) : "",
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function row(r) {
|
|
49
|
+
if (r.skipped) return { repo: colors.dim(r.name), branch: "", result: colors.dim(t("BULK_STATUS_SKIPPED")), sync: "", refs: "" };
|
|
50
|
+
if (r.dryRun) return { repo: r.name, branch: r.branch, result: colors.dim(t("BULK_STATUS_DRY_RUN")), sync: "", refs: "" };
|
|
51
|
+
const result = r.error ? statusBadge("fail", t("BULK_STATUS_FAILED")) : statusBadge("ok", t("BULK_STATUS_FETCHED"));
|
|
52
|
+
let sync;
|
|
53
|
+
if (!r.hasUpstream) sync = colors.dim(t("BULK_STATUS_NO_UPSTREAM"));
|
|
54
|
+
else if (r.behind === 0 && r.ahead === 0) sync = colors.green(t("BULK_STATUS_IN_SYNC"));
|
|
55
|
+
else {
|
|
56
|
+
const parts = [];
|
|
57
|
+
if (r.behind) parts.push(colors.yellow(`${ICON.down} ${r.behind}`));
|
|
58
|
+
if (r.ahead) parts.push(colors.cyan(`${ICON.up} ${r.ahead}`));
|
|
59
|
+
sync = parts.join(" ");
|
|
60
|
+
}
|
|
61
|
+
const refBits = [];
|
|
62
|
+
if (r.newBranches) refBits.push(t("BULK_REFS_NEW__N", { n: r.newBranches }));
|
|
63
|
+
if (r.updatedBranches) refBits.push(t("BULK_REFS_UPD__N", { n: r.updatedBranches }));
|
|
64
|
+
if (r.newTags) refBits.push(t("BULK_REFS_TAGS__N", { n: r.newTags }));
|
|
65
|
+
if (r.prunedRefs) refBits.push(t("BULK_REFS_PRUNED__N", { n: r.prunedRefs }));
|
|
66
|
+
const refs = refBits.length ? refBits.join(" · ") : colors.dim("—");
|
|
67
|
+
return { repo: r.name, branch: r.branch, result, sync, refs };
|
|
26
68
|
}
|
|
27
69
|
|
|
28
70
|
function printHelp() {
|
|
29
71
|
console.log(`usage: git-fetch-all.js [--base-dir BASE_DIR] [--dry-run] [manifest]
|
|
30
72
|
|
|
31
73
|
Fetch all remotes for repositories listed in a PAN Stack clone manifest.
|
|
74
|
+
Prints a compact summary table (repo · branch · result · behind/ahead · new refs).
|
|
32
75
|
|
|
33
76
|
${printCommonOptions()}`);
|
|
34
77
|
}
|
|
35
78
|
|
|
36
79
|
function main() {
|
|
37
80
|
const args = parseCommonArgs(process.argv.slice(2));
|
|
38
|
-
if (args.help) {
|
|
39
|
-
printHelp();
|
|
40
|
-
return;
|
|
41
|
-
}
|
|
81
|
+
if (args.help) { printHelp(); return; }
|
|
42
82
|
|
|
43
|
-
const { repos } = loadManifestRepos({
|
|
44
|
-
|
|
45
|
-
baseDir: args.baseDir,
|
|
46
|
-
});
|
|
83
|
+
const { repos } = loadManifestRepos({ manifestPath: args.manifest, baseDir: args.baseDir });
|
|
84
|
+
const results = repos.map((r) => fetchRepo({ name: r.name, destination: r.destination, dryRun: args.dryRun }));
|
|
47
85
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
86
|
+
const behind = results.filter((r) => r.behind > 0);
|
|
87
|
+
const failed = results.filter((r) => r.error);
|
|
88
|
+
const hasNewRefs = (r) => (r.newBranches || r.updatedBranches || r.newTags || r.prunedRefs) > 0;
|
|
89
|
+
const changed = results.filter((r) => !r.skipped && (hasNewRefs(r) || r.behind > 0 || r.ahead > 0 || r.error));
|
|
90
|
+
const inSyncCount = results.length - changed.length - results.filter((r) => r.skipped).length;
|
|
91
|
+
// Chỉ đưa vào bảng: có ref mới / behind-ahead / lỗi / dry-run.
|
|
92
|
+
const shown = results.filter((r) => r.dryRun || (!r.skipped && (hasNewRefs(r) || r.behind > 0 || r.ahead > 0 || r.error)));
|
|
93
|
+
const rows = shown.map(row);
|
|
94
|
+
|
|
95
|
+
const footer = [];
|
|
96
|
+
footer.push(colors.dim(t("FETCH_FOOTER_COUNTS__TOTAL_CHANGED_INSYNC_BEHIND_FAILED", {
|
|
97
|
+
total: results.length, changed: changed.length, inSync: inSyncCount,
|
|
98
|
+
behind: behind.length, failed: failed.length,
|
|
99
|
+
})));
|
|
100
|
+
if (behind.length) {
|
|
101
|
+
footer.push("");
|
|
102
|
+
footer.push(colors.yellow(t("FETCH_BEHIND_HEADER")));
|
|
103
|
+
for (const r of behind) footer.push(` ${colors.cyan(r.name)} (${r.branch}): ${ICON.down} ${r.behind}`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (rows.length === 0) {
|
|
107
|
+
console.log("");
|
|
108
|
+
console.log(colors.bold(`─── ${t("FETCH_TITLE")} ───`));
|
|
109
|
+
console.log(colors.green(t("FETCH_ALL_IN_SYNC__N", { n: results.length })));
|
|
110
|
+
for (const line of footer) console.log(line);
|
|
111
|
+
} else {
|
|
112
|
+
printSummary({
|
|
113
|
+
title: t("FETCH_TITLE"),
|
|
114
|
+
rows,
|
|
115
|
+
columns: bulkColumns([
|
|
116
|
+
{ key: "repo", labelKey: "BULK_COL_REPO" },
|
|
117
|
+
{ key: "branch", labelKey: "BULK_COL_BRANCH" },
|
|
118
|
+
{ key: "result", labelKey: "BULK_COL_RESULT" },
|
|
119
|
+
{ key: "sync", labelKey: "BULK_COL_SYNC" },
|
|
120
|
+
{ key: "refs", labelKey: "BULK_COL_NEW_REFS" },
|
|
121
|
+
]),
|
|
122
|
+
footerLines: footer,
|
|
53
123
|
});
|
|
54
124
|
}
|
|
55
|
-
}
|
|
56
125
|
|
|
57
|
-
|
|
58
|
-
main();
|
|
59
|
-
} catch (error) {
|
|
60
|
-
console.error(error.message);
|
|
61
|
-
process.exit(1);
|
|
126
|
+
if (failed.length) process.exitCode = 1;
|
|
62
127
|
}
|
|
128
|
+
|
|
129
|
+
try { main(); } catch (error) { console.error(error.message); process.exit(1); }
|
package/lib/git/git-grv.js
CHANGED
|
@@ -1,48 +1,58 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/*
|
|
3
3
|
* List git remotes (git remote -v) for PAN Stack project repositories.
|
|
4
|
-
*
|
|
5
|
-
* oh-my-zsh-style alias for grv — full remote URLs (fetch/push).
|
|
4
|
+
* Compact summary table (giống pull/push/fetch): repo · branch · remote · fetch · push.
|
|
6
5
|
*/
|
|
7
6
|
|
|
8
7
|
const { parseCommonArgs, loadManifestRepos, printCommonOptions } = require("./helpers/cli");
|
|
9
|
-
const { ensureRepoExists, isGitRepo, listRemotesVerbose } = require("./helpers/git");
|
|
8
|
+
const { ensureRepoExists, isGitRepo, listRemotesVerbose, getCurrentBranch } = require("./helpers/git");
|
|
9
|
+
const { printSummary, bulkColumns } = require("./helpers/bulk-report");
|
|
10
|
+
const colors = require("./helpers/colors");
|
|
11
|
+
const { t } = require("../i18n");
|
|
10
12
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
// `git remote -v` in 2 dòng/remote ("origin\tURL (fetch)" / "origin\tURL (push)") — gộp lại
|
|
14
|
+
// thành { name: { fetch, push } } theo tên remote.
|
|
15
|
+
function parseRemotes(lines) {
|
|
16
|
+
const byName = new Map();
|
|
17
|
+
for (const line of lines) {
|
|
18
|
+
const m = line.match(/^(\S+)\s+(\S+)\s+\((fetch|push)\)$/);
|
|
19
|
+
if (!m) continue;
|
|
20
|
+
const [, name, url, kind] = m;
|
|
21
|
+
if (!byName.has(name)) byName.set(name, { name, fetch: "", push: "" });
|
|
22
|
+
byName.get(name)[kind] = url;
|
|
15
23
|
}
|
|
24
|
+
return [...byName.values()];
|
|
25
|
+
}
|
|
16
26
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
console.log("(no remotes)");
|
|
22
|
-
} else {
|
|
23
|
-
for (const line of lines) {
|
|
24
|
-
console.log(line);
|
|
25
|
-
}
|
|
27
|
+
function repoRow({ label, destination, dryRun }) {
|
|
28
|
+
if (dryRun) {
|
|
29
|
+
console.log(colors.dim(`[dry-run] (in ${destination}) git remote -v`));
|
|
30
|
+
return null;
|
|
26
31
|
}
|
|
27
|
-
|
|
32
|
+
const branch = getCurrentBranch(destination) || t("GST_DETACHED");
|
|
33
|
+
const remotes = parseRemotes(listRemotesVerbose(destination));
|
|
34
|
+
return { name: label, branch, remotes };
|
|
28
35
|
}
|
|
29
36
|
|
|
30
|
-
function
|
|
31
|
-
if (
|
|
32
|
-
return;
|
|
37
|
+
function row(r) {
|
|
38
|
+
if (r.skipped) {
|
|
39
|
+
return { repo: colors.dim(r.name), branch: "", remote: colors.dim(t("BULK_STATUS_SKIPPED")), fetch: "", push: "" };
|
|
33
40
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
});
|
|
41
|
+
if (r.remotes.length === 0) {
|
|
42
|
+
return { repo: r.name, branch: r.branch, remote: colors.dim(t("GRV_NO_REMOTES")), fetch: "", push: "" };
|
|
43
|
+
}
|
|
44
|
+
// Ưu tiên hiện 'origin' trên dòng đầu; nếu có remote khác, gộp tên (vd "origin (+1)").
|
|
45
|
+
const primary = r.remotes.find((rm) => rm.name === "origin") || r.remotes[0];
|
|
46
|
+
const remoteLabel = r.remotes.length > 1 ? `${primary.name} (+${r.remotes.length - 1})` : primary.name;
|
|
47
|
+
const push = primary.push === primary.fetch ? colors.dim(t("GRV_PUSH_SAME_AS_FETCH")) : primary.push;
|
|
48
|
+
return { repo: r.name, branch: r.branch, remote: remoteLabel, fetch: primary.fetch, push };
|
|
40
49
|
}
|
|
41
50
|
|
|
42
51
|
function printHelp() {
|
|
43
52
|
console.log(`usage: git-grv.js [--base-dir BASE_DIR] [--dry-run] [--no-workspace] [manifest]
|
|
44
53
|
|
|
45
|
-
List git remotes
|
|
54
|
+
List git remotes (git remote -v) for each repository — compact summary table
|
|
55
|
+
(repo · branch · remote · fetch · push; push = '=' khi giống fetch).
|
|
46
56
|
|
|
47
57
|
${printCommonOptions()}
|
|
48
58
|
--no-workspace Skip workspace root (manifest repos only)`);
|
|
@@ -67,23 +77,44 @@ function main() {
|
|
|
67
77
|
baseDir: args.baseDir,
|
|
68
78
|
});
|
|
69
79
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
});
|
|
80
|
+
const results = [];
|
|
81
|
+
if (!args.noWorkspace && isGitRepo(workspaceRoot)) {
|
|
82
|
+
const r = repoRow({ label: "workspace", destination: workspaceRoot, dryRun: args.dryRun });
|
|
83
|
+
if (r) results.push(r);
|
|
75
84
|
}
|
|
76
85
|
|
|
77
86
|
for (const repo of repos) {
|
|
78
87
|
if (!ensureRepoExists({ name: repo.name, destination: repo.destination })) {
|
|
88
|
+
results.push({ name: repo.name, skipped: true });
|
|
79
89
|
continue;
|
|
80
90
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
destination: repo.destination,
|
|
84
|
-
dryRun: args.dryRun,
|
|
85
|
-
});
|
|
91
|
+
const r = repoRow({ label: repo.name, destination: repo.destination, dryRun: args.dryRun });
|
|
92
|
+
if (r) results.push(r);
|
|
86
93
|
}
|
|
94
|
+
|
|
95
|
+
if (args.dryRun) return;
|
|
96
|
+
|
|
97
|
+
const noRemotes = results.filter((r) => !r.skipped && r.remotes.length === 0);
|
|
98
|
+
const skipped = results.filter((r) => r.skipped);
|
|
99
|
+
const rows = results.map(row);
|
|
100
|
+
|
|
101
|
+
const footer = [];
|
|
102
|
+
footer.push(colors.dim(
|
|
103
|
+
`Total ${results.length} repos · ${results.length - noRemotes.length - skipped.length} with remotes · ${noRemotes.length} no remote · ${skipped.length} skipped`
|
|
104
|
+
));
|
|
105
|
+
|
|
106
|
+
printSummary({
|
|
107
|
+
title: t("GRV_TITLE"),
|
|
108
|
+
rows,
|
|
109
|
+
columns: bulkColumns([
|
|
110
|
+
{ key: "repo", labelKey: "BULK_COL_REPO" },
|
|
111
|
+
{ key: "branch", labelKey: "BULK_COL_BRANCH" },
|
|
112
|
+
{ key: "remote", labelKey: "GRV_COL_REMOTE" },
|
|
113
|
+
{ key: "fetch", labelKey: "GRV_COL_FETCH" },
|
|
114
|
+
{ key: "push", labelKey: "GRV_COL_PUSH" },
|
|
115
|
+
]),
|
|
116
|
+
footerLines: footer,
|
|
117
|
+
});
|
|
87
118
|
}
|
|
88
119
|
|
|
89
120
|
try {
|