pan-stack-cli 0.1.4 → 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.
Files changed (64) hide show
  1. package/README.md +572 -23
  2. package/bin/pan.js +206 -20
  3. package/lib/ai-skills.js +829 -0
  4. package/lib/create.js +22 -4
  5. package/lib/discover.js +69 -2
  6. package/lib/git/git-checkout.js +395 -0
  7. package/lib/git/git-clone.js +163 -28
  8. package/lib/git/git-fetch-all.js +100 -33
  9. package/lib/git/git-grv.js +68 -37
  10. package/lib/git/git-gst.js +97 -26
  11. package/lib/git/git-mr.js +546 -0
  12. package/lib/git/git-pull.js +104 -43
  13. package/lib/git/git-push.js +150 -41
  14. package/lib/git/git-sync-main.js +334 -0
  15. package/lib/git/helpers/bulk-report.js +202 -0
  16. package/lib/git/helpers/cli.js +41 -5
  17. package/lib/git/helpers/colors.js +19 -0
  18. package/lib/git/helpers/git.js +74 -0
  19. package/lib/git/helpers/manifest.js +93 -0
  20. package/lib/git/helpers/repo-select.js +416 -0
  21. package/lib/git/helpers/repo-summary.js +161 -0
  22. package/lib/i18n.js +61 -0
  23. package/lib/inquirer-theme.js +20 -0
  24. package/lib/language.js +121 -0
  25. package/lib/languages.js +14 -0
  26. package/lib/manifest.js +115 -14
  27. package/lib/menu-prompt.js +201 -0
  28. package/lib/menu.js +134 -0
  29. package/lib/messages.js +647 -0
  30. package/lib/run-command.js +173 -52
  31. package/lib/select.js +0 -0
  32. package/lib/serve.js +75 -0
  33. package/lib/skill-registry-client.js +173 -0
  34. package/lib/stats/aggregate.js +51 -0
  35. package/lib/stats/identity.js +20 -0
  36. package/lib/stats/index.js +189 -0
  37. package/lib/stats/parse.js +46 -0
  38. package/lib/stats/range.js +38 -0
  39. package/lib/stats/report.js +264 -0
  40. package/lib/stats/review.js +157 -0
  41. package/lib/stats/scan.js +92 -0
  42. package/lib/stats/serve.js +25 -0
  43. package/lib/ui/api.js +202 -0
  44. package/lib/ui/clone.js +115 -0
  45. package/lib/ui/config.js +182 -0
  46. package/lib/ui/diff.js +128 -0
  47. package/lib/ui/export-gate.js +79 -0
  48. package/lib/ui/gen-react-mobile-code.js +6 -0
  49. package/lib/ui/gen-react-web-code.js +75 -0
  50. package/lib/ui/index.js +89 -0
  51. package/lib/ui/init.js +252 -0
  52. package/lib/ui/login.js +128 -0
  53. package/lib/ui/logout.js +22 -0
  54. package/lib/ui/palettes.js +103 -0
  55. package/lib/ui/published-html.js +24 -0
  56. package/lib/ui/sync.js +137 -0
  57. package/lib/ui/workspace.js +213 -0
  58. package/package.json +10 -3
  59. package/templates/workspace/.claude/skills/writing-guidelines/SKILL.md +0 -48
  60. package/templates/workspace/.codex/skills/writing-guidelines/SKILL.md +0 -48
  61. package/templates/workspace/.cursor/skills/writing-guidelines/SKILL.md +0 -48
  62. package/templates/workspace/.pan.yaml +0 -25
  63. package/templates/workspace/.project.yaml +0 -15
  64. package/templates/workspace/README.md +0 -35
@@ -0,0 +1,334 @@
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 { t } = require("../i18n");
17
+ const {
18
+ ensureRepoExists,
19
+ runGit,
20
+ runGitCapture,
21
+ getCurrentBranch,
22
+ refExists,
23
+ listConflictedFiles,
24
+ isMergeOrRebaseInProgress,
25
+ countDirtyFiles,
26
+ } = require("./helpers/git");
27
+
28
+ const ICON = {
29
+ ok: "✅",
30
+ warn: "⚠️",
31
+ conflict: "⛔",
32
+ skip: "⏭️",
33
+ info: "ℹ️",
34
+ };
35
+
36
+ function printHelp() {
37
+ console.log(`usage: pan sync-main [repo|all] [options] (alias: pan sm)
38
+
39
+ Fetch remote main (or --ref) and merge into the current branch for workspace and/or child repos.
40
+
41
+ positional:
42
+ repo|all Repo name from .project.yaml, or "all". Interactive picker if omitted (TTY).
43
+
44
+ ${printCommonOptions()}
45
+ --ref REF Remote-tracking ref to merge (default: origin/main)
46
+ --rebase Use git rebase instead of merge
47
+ --no-workspace Only manifest child repos (skip workspace stack root)`);
48
+ }
49
+
50
+ function parseSyncMainArgs(argv) {
51
+ const args = {
52
+ help: false,
53
+ manifest: "",
54
+ baseDir: ".",
55
+ dryRun: false,
56
+ ref: "origin/main",
57
+ rebase: false,
58
+ noWorkspace: false,
59
+ selection: "",
60
+ };
61
+
62
+ for (let index = 0; index < argv.length; index += 1) {
63
+ const arg = argv[index];
64
+ if (arg === "-h" || arg === "--help") {
65
+ args.help = true;
66
+ } else if (arg === "--base-dir") {
67
+ args.baseDir = argv[++index] || ".";
68
+ } else if (arg === "--dry-run") {
69
+ args.dryRun = true;
70
+ } else if (arg === "--ref") {
71
+ args.ref = argv[++index] || "origin/main";
72
+ } else if (arg === "--rebase") {
73
+ args.rebase = true;
74
+ } else if (arg === "--no-workspace") {
75
+ args.noWorkspace = true;
76
+ } else if (!arg.startsWith("-")) {
77
+ if (looksLikeManifest(arg) && !args.manifest) {
78
+ args.manifest = arg;
79
+ } else if (!args.selection) {
80
+ args.selection = arg;
81
+ } else {
82
+ throw new Error(`Unexpected argument: ${arg}`);
83
+ }
84
+ } else {
85
+ throw new Error(`Unexpected argument: ${arg}`);
86
+ }
87
+ }
88
+
89
+ return args;
90
+ }
91
+
92
+ function printEarlyConflict({ name, branch, ref, files, mode }) {
93
+ const headline = colors.bold(
94
+ colors.red(`${ICON.conflict} [conflict] ${name} (${branch})`)
95
+ );
96
+ const detail = colors.yellow(
97
+ t("SYNCMAIN_CONFLICT_SHORT__MODE_REF_N", { mode, ref, n: files.length })
98
+ );
99
+ console.log(`${headline} — ${detail}`);
100
+ for (const file of files.slice(0, 8)) {
101
+ console.log(colors.dim(` • ${file}`));
102
+ }
103
+ if (files.length > 8) {
104
+ console.log(
105
+ colors.dim(` ${t("SYNCMAIN_MORE_FILES__N", { n: files.length - 8 })}`)
106
+ );
107
+ }
108
+ }
109
+
110
+ function syncMainRepo({
111
+ name,
112
+ destination,
113
+ dryRun,
114
+ ref,
115
+ rebase,
116
+ }) {
117
+ const result = {
118
+ name,
119
+ destination,
120
+ branch: "",
121
+ status: "skipped",
122
+ message: "",
123
+ conflictFiles: [],
124
+ mode: rebase ? "rebase" : "merge",
125
+ };
126
+
127
+ if (!ensureRepoExists({ name, destination })) {
128
+ result.message = t("SYNCMAIN_MSG_NOT_CLONED");
129
+ return result;
130
+ }
131
+
132
+ const branch = getCurrentBranch(destination);
133
+ result.branch = branch || t("SYNCMAIN_DETACHED_LABEL");
134
+ if (!branch) {
135
+ result.message = t("SYNCMAIN_MSG_DETACHED");
136
+ console.log(`${ICON.skip} [skip] ${name}: ${result.message}`);
137
+ return result;
138
+ }
139
+
140
+ const inProgress = isMergeOrRebaseInProgress(destination);
141
+ if (inProgress) {
142
+ const files = listConflictedFiles(destination);
143
+ result.status = "conflict";
144
+ result.conflictFiles = files;
145
+ result.message = t("SYNCMAIN_MSG_IN_PROGRESS__MODE", { mode: inProgress });
146
+ printEarlyConflict({ name, branch, ref, files, mode: inProgress });
147
+ return result;
148
+ }
149
+
150
+ const dirtyCount = countDirtyFiles(destination);
151
+ if (dirtyCount > 0) {
152
+ console.log(
153
+ `${ICON.warn} ${colors.yellow(t("SYNCMAIN_MSG_DIRTY__NAME_N", { name, n: dirtyCount }))}`
154
+ );
155
+ }
156
+
157
+ if (dryRun) {
158
+ console.log(
159
+ `[dry-run] (in ${destination}) git fetch origin && git ${rebase ? "rebase" : "merge"} ${ref}`
160
+ );
161
+ result.status = "dry-run";
162
+ result.message = "dry-run";
163
+ return result;
164
+ }
165
+
166
+ console.log(`[fetch] ${name}: ${destination}`);
167
+ const fetch = runGitCapture(["fetch", "origin"], { cwd: destination });
168
+ if (fetch.status !== 0) {
169
+ result.status = "error";
170
+ result.message = fetch.stderr || t("SYNCMAIN_MSG_FETCH_FAILED");
171
+ console.log(`${ICON.warn} ${colors.yellow(`[warn] ${name}: ${result.message}`)}`);
172
+ return result;
173
+ }
174
+
175
+ if (!refExists(destination, ref)) {
176
+ result.status = "error";
177
+ result.message = t("SYNCMAIN_MSG_REF_MISSING__REF", { ref });
178
+ console.log(`${ICON.warn} ${colors.yellow(`[warn] ${name}: ${result.message}`)}`);
179
+ return result;
180
+ }
181
+
182
+ const gitArgs = rebase ? ["rebase", ref] : ["merge", "--no-edit", ref];
183
+ console.log(`[${result.mode}] ${name} (${branch}) ← ${ref}`);
184
+ const action = runGit(gitArgs, {
185
+ cwd: destination,
186
+ dryRun: false,
187
+ action: result.mode,
188
+ name,
189
+ });
190
+
191
+ const conflictFiles = listConflictedFiles(destination);
192
+ if (conflictFiles.length > 0 || action !== 0) {
193
+ result.status = "conflict";
194
+ result.conflictFiles =
195
+ conflictFiles.length > 0 ? conflictFiles : [t("SYNCMAIN_MSG_SEE_STATUS")];
196
+ result.message = t("SYNCMAIN_MSG_CONFLICT__MODE", { mode: result.mode });
197
+ printEarlyConflict({
198
+ name,
199
+ branch,
200
+ ref,
201
+ files: result.conflictFiles,
202
+ mode: result.mode,
203
+ });
204
+ return result;
205
+ }
206
+
207
+ result.status = "ok";
208
+ result.message = t("SYNCMAIN_MSG_UPDATED");
209
+ console.log(
210
+ `${ICON.ok} ${colors.green(t("SYNCMAIN_MSG_SYNCED__NAME_BRANCH_REF", { name, branch, ref }))}`
211
+ );
212
+ return result;
213
+ }
214
+
215
+ function printSummary(results, { ref, rebase }) {
216
+ const ok = results.filter((item) => item.status === "ok");
217
+ const conflicts = results.filter((item) => item.status === "conflict");
218
+ const errors = results.filter((item) => item.status === "error");
219
+ const skipped = results.filter(
220
+ (item) => item.status === "skipped" || item.status === "dry-run"
221
+ );
222
+
223
+ console.log("");
224
+ console.log(colors.bold(t("SYNCMAIN_HEADER")));
225
+ console.log(
226
+ `${ICON.info} ${t("SYNCMAIN_INFO__REF_MODE_REPOS", {
227
+ ref, mode: rebase ? "rebase" : "merge", repos: results.length,
228
+ })}`
229
+ );
230
+ console.log(
231
+ t("SYNCMAIN_COUNTS__OK_CONFLICT_ERR_SKIP", {
232
+ ok: ok.length, conflict: conflicts.length, err: errors.length, skip: skipped.length,
233
+ })
234
+ );
235
+
236
+ if (conflicts.length > 0) {
237
+ console.log("");
238
+ console.log(
239
+ colors.bold(colors.red(`${ICON.conflict} ${t("SYNCMAIN_CONFLICT_HEADER")}`))
240
+ );
241
+ for (const item of conflicts) {
242
+ console.log(colors.yellow(`\n ${item.name} (${item.branch})`));
243
+ console.log(colors.dim(` ${item.destination}`));
244
+ for (const file of item.conflictFiles) {
245
+ console.log(` • ${file}`);
246
+ }
247
+ const hint = item.mode === "rebase"
248
+ ? t("SYNCMAIN_HINT_REBASE")
249
+ : t("SYNCMAIN_HINT_MERGE");
250
+ console.log(colors.dim(` → ${hint}`));
251
+ }
252
+ }
253
+
254
+ if (errors.length > 0) {
255
+ console.log("");
256
+ console.log(colors.bold(colors.yellow(`${ICON.warn} ${t("SYNCMAIN_ERRORS_HEADER")}`)));
257
+ for (const item of errors) {
258
+ console.log(` • ${item.name}: ${item.message}`);
259
+ }
260
+ }
261
+
262
+ if (conflicts.length > 0) {
263
+ console.log("");
264
+ console.log(
265
+ colors.bold(colors.red(`${ICON.conflict} ${t("SYNCMAIN_END_CONFLICT")}`))
266
+ );
267
+ } else if (errors.length > 0) {
268
+ console.log("");
269
+ console.log(
270
+ colors.bold(colors.yellow(`${ICON.warn} ${t("SYNCMAIN_END_ERROR")}`))
271
+ );
272
+ } else if (ok.length > 0) {
273
+ console.log("");
274
+ console.log(
275
+ colors.green(`${ICON.ok} ${t("SYNCMAIN_END_ALL_SYNCED__REF", { ref })}`)
276
+ );
277
+ }
278
+ }
279
+
280
+ async function main() {
281
+ const argv = process.argv.slice(2);
282
+ const args = parseSyncMainArgs(argv);
283
+ if (args.help) {
284
+ printHelp();
285
+ return;
286
+ }
287
+
288
+ const { repos, workspaceRoot } = loadManifestRepos({
289
+ manifestPath: args.manifest,
290
+ baseDir: args.baseDir,
291
+ });
292
+
293
+ const targets = await resolveTargets({
294
+ repos,
295
+ workspaceRoot,
296
+ includeWorkspace: !args.noWorkspace,
297
+ selection: args.selection,
298
+ });
299
+
300
+ console.log(
301
+ `${ICON.info} ${t("SYNCMAIN_INTRO__COUNT_MODE_REF", {
302
+ count: targets.length,
303
+ mode: args.rebase ? "rebase" : "merge",
304
+ ref: args.ref,
305
+ })}`
306
+ );
307
+
308
+ const results = [];
309
+ for (const target of targets) {
310
+ results.push(
311
+ syncMainRepo({
312
+ name: target.name,
313
+ destination: target.destination,
314
+ dryRun: args.dryRun,
315
+ ref: args.ref,
316
+ rebase: args.rebase,
317
+ })
318
+ );
319
+ console.log("");
320
+ }
321
+
322
+ printSummary(results, { ref: args.ref, rebase: args.rebase });
323
+
324
+ const hasConflict = results.some((item) => item.status === "conflict");
325
+ const hasError = results.some((item) => item.status === "error");
326
+ if (hasConflict || hasError) {
327
+ process.exit(1);
328
+ }
329
+ }
330
+
331
+ main().catch((error) => {
332
+ console.error(error.message);
333
+ process.exit(1);
334
+ });
@@ -0,0 +1,202 @@
1
+ "use strict";
2
+
3
+ const { spawnSync } = require("child_process");
4
+ const { spawnOptions } = require("../../platform");
5
+ const colors = require("./colors");
6
+ const { runGitCapture, getCurrentBranch, listConflictedFiles } = require("./git");
7
+ const { t } = require("../../i18n");
8
+
9
+ const ICON = { ok: "✅", fail: "❌", warn: "⚠️", skip: "⏭️", info: "ℹ️", up: "↑", down: "↓" };
10
+
11
+ // ───────── shared helpers ─────────
12
+
13
+ /** Chạy git im lặng, capture stdout/stderr; trả { status, stdout, stderr }. */
14
+ function runGitSilent(gitArgs, cwd) {
15
+ const result = spawnSync("git", gitArgs, spawnOptions({ cwd, encoding: "utf8" }));
16
+ return {
17
+ status: result.status ?? 1,
18
+ stdout: (result.stdout || "").trim(),
19
+ stderr: (result.stderr || "").trim(),
20
+ };
21
+ }
22
+
23
+ /** ahead/behind so với upstream. Trả null nếu không có upstream. */
24
+ function aheadBehind(cwd) {
25
+ const upstream = runGitCapture(["rev-parse", "--abbrev-ref", "@{u}"], { cwd });
26
+ if (upstream.status !== 0 || !upstream.stdout) return null;
27
+ const rl = runGitCapture(["rev-list", "--left-right", "--count", "HEAD...@{u}"], { cwd });
28
+ if (rl.status !== 0) return null;
29
+ const [ahead, behind] = rl.stdout.split(/\s+/).map((n) => parseInt(n, 10) || 0);
30
+ return { ahead, behind, upstream: upstream.stdout };
31
+ }
32
+
33
+ /**
34
+ * Tổng line change của các commit sắp push (@{u}..HEAD).
35
+ * Cộng theo từng commit (`git log --numstat`), không dùng net-diff.
36
+ * Trả { insertions, deletions, filesChanged } — filesChanged = số path unique.
37
+ */
38
+ function aheadLineStats(cwd, { range } = {}) {
39
+ const empty = { insertions: 0, deletions: 0, filesChanged: 0 };
40
+ let revRange = range;
41
+ if (!revRange) {
42
+ const ab = aheadBehind(cwd);
43
+ if (!ab || ab.ahead === 0) return empty;
44
+ revRange = "@{u}..HEAD";
45
+ }
46
+ const res = runGitCapture(
47
+ ["log", "--numstat", "--pretty=tformat:", "--no-merges", revRange],
48
+ { cwd }
49
+ );
50
+ if (res.status !== 0) return empty;
51
+ let insertions = 0;
52
+ let deletions = 0;
53
+ const files = new Set();
54
+ for (const line of (res.stdout || "").split(/\r?\n/)) {
55
+ if (!line) continue;
56
+ // numstat: "<add>\t<del>\t<path>" — binary: "-\t-\t<path>"
57
+ const parts = line.split("\t");
58
+ if (parts.length < 3) continue;
59
+ const add = parts[0] === "-" ? 0 : parseInt(parts[0], 10) || 0;
60
+ const del = parts[1] === "-" ? 0 : parseInt(parts[1], 10) || 0;
61
+ insertions += add;
62
+ deletions += del;
63
+ files.add(parts.slice(2).join("\t"));
64
+ }
65
+ return { insertions, deletions, filesChanged: files.size };
66
+ }
67
+
68
+ // ───────── output parsers ─────────
69
+
70
+ /** Parse `git pull` output. */
71
+ function parsePullOutput(stdout, stderr) {
72
+ const text = `${stdout}\n${stderr}`;
73
+ const out = { mode: "unknown", filesChanged: 0, insertions: 0, deletions: 0, conflict: false, upToDate: false };
74
+ if (/Already up to date|Already up-to-date/i.test(text)) out.upToDate = true;
75
+ if (/Fast-forward/i.test(text)) out.mode = "fast-forward";
76
+ else if (/Merge made by/i.test(text)) out.mode = "merge";
77
+ else if (/Successfully rebased/i.test(text)) out.mode = "rebase";
78
+ else if (/CONFLICT/i.test(text)) { out.mode = "conflict"; out.conflict = true; }
79
+ // shortstat line: " 12 files changed, 234 insertions(+), 56 deletions(-)"
80
+ const stat = text.match(/(\d+)\s+files?\s+changed(?:,\s*(\d+)\s+insertions?\(\+\))?(?:,\s*(\d+)\s+deletions?\(-\))?/);
81
+ if (stat) {
82
+ out.filesChanged = parseInt(stat[1], 10);
83
+ out.insertions = parseInt(stat[2] || "0", 10);
84
+ out.deletions = parseInt(stat[3] || "0", 10);
85
+ }
86
+ return out;
87
+ }
88
+
89
+ /** Parse `git push` output. GitLab/GitHub in gợi ý URL MR/PR ở stderr. */
90
+ function parsePushOutput(stdout, stderr) {
91
+ const text = `${stdout}\n${stderr}`;
92
+ const out = { pushed: false, upToDate: false, rejected: false, mrLink: "", refs: [] };
93
+ if (/Everything up-to-date|up to date/i.test(text)) out.upToDate = true;
94
+ if (/\[rejected\]|! \[remote rejected\]|failed to push some refs/i.test(text)) out.rejected = true;
95
+ // URL merge_requests hoặc pull/new
96
+ const url =
97
+ text.match(/https?:\/\/\S*merge_requests\/new\S*/) ||
98
+ text.match(/https?:\/\/\S*\/merge_requests\/\d+/) ||
99
+ text.match(/https?:\/\/\S*\/-\/merge_requests\S*/) ||
100
+ text.match(/https?:\/\/\S*\/pull\/new\/\S+/) ||
101
+ text.match(/https?:\/\/\S*\/pull\/\d+/);
102
+ if (url) out.mrLink = url[0].replace(/[.,)]+$/, "");
103
+ // refs pushed: "abcdef..123456 main -> main"
104
+ const refRe = /([0-9a-f]{7,}\.\.[0-9a-f]{7,})\s+(\S+)\s+->\s+(\S+)/g;
105
+ let m;
106
+ while ((m = refRe.exec(text)) !== null) {
107
+ out.refs.push({ range: m[1], local: m[2], remote: m[3] });
108
+ out.pushed = true;
109
+ }
110
+ // "* [new branch]"
111
+ if (/\*\s+\[new branch\]/.test(text)) out.pushed = true;
112
+ return out;
113
+ }
114
+
115
+ /** Parse `git fetch --all` output — số dòng update ref. */
116
+ function parseFetchOutput(stdout, stderr) {
117
+ const text = `${stdout}\n${stderr}`;
118
+ const out = { newBranches: 0, updatedBranches: 0, newTags: 0, prunedRefs: 0 };
119
+ out.newBranches = (text.match(/\*\s+\[new branch\]/g) || []).length;
120
+ out.updatedBranches = (text.match(/^\s*[0-9a-f]{6,}\.\.[0-9a-f]{6,}\s+/gm) || []).length;
121
+ out.newTags = (text.match(/\*\s+\[new tag\]/g) || []).length;
122
+ out.prunedRefs = (text.match(/\[deleted\]/g) || []).length;
123
+ return out;
124
+ }
125
+
126
+ // ───────── table renderer ─────────
127
+
128
+ /**
129
+ * @param {Array<{key:string,label:string,align?:'l'|'r'}>} columns
130
+ * @param {Array<Object>} rows
131
+ */
132
+ function renderTable(columns, rows) {
133
+ // strip ANSI để đo width thật
134
+ const strip = (s) => String(s ?? "").replace(/\x1b\[[0-9;]*m/g, "");
135
+ const visLen = (s) => strip(s).length;
136
+ const widths = columns.map((c) => Math.max(c.label.length, ...rows.map((r) => visLen(r[c.key]))));
137
+
138
+ const pad = (s, w, align) => {
139
+ const len = visLen(s);
140
+ const spaces = " ".repeat(Math.max(0, w - len));
141
+ return align === "r" ? spaces + s : s + spaces;
142
+ };
143
+
144
+ const header = columns.map((c, i) => colors.bold(pad(c.label, widths[i], c.align))).join(" ");
145
+ const sep = columns.map((_, i) => colors.dim("─".repeat(widths[i]))).join(" ");
146
+ const body = rows.map((r) =>
147
+ columns.map((c, i) => pad(String(r[c.key] ?? ""), widths[i], c.align)).join(" ")
148
+ );
149
+ return [header, sep, ...body].join("\n");
150
+ }
151
+
152
+ function statusBadge(kind, text) {
153
+ if (kind === "ok") return colors.green(`${ICON.ok} ${text}`);
154
+ if (kind === "warn") return colors.yellow(`${ICON.warn} ${text}`);
155
+ if (kind === "fail") return colors.red(`${ICON.fail} ${text}`);
156
+ if (kind === "skip") return colors.dim(`${ICON.skip} ${text}`);
157
+ return text;
158
+ }
159
+
160
+ function printSummary({ title, rows, columns, footerLines = [] }) {
161
+ console.log("");
162
+ console.log(colors.bold(`─── ${title} ───`));
163
+ if (rows.length === 0) {
164
+ console.log(colors.dim("(no data)"));
165
+ return;
166
+ }
167
+ console.log(renderTable(columns, rows));
168
+ for (const line of footerLines) console.log(line);
169
+ }
170
+
171
+ /** Helper: cột bảng có label theo i18n. */
172
+ function bulkColumns(specs) {
173
+ return specs.map((s) => ({ key: s.key, label: t(s.labelKey), align: s.align }));
174
+ }
175
+
176
+ /** Dịch mode pull (fast-forward/merge/rebase/conflict) qua i18n. */
177
+ function pullModeLabel(mode) {
178
+ switch (mode) {
179
+ case "fast-forward": return t("BULK_MODE_FAST_FORWARD");
180
+ case "merge": return t("BULK_MODE_MERGE");
181
+ case "rebase": return t("BULK_MODE_REBASE");
182
+ case "conflict": return t("BULK_STATUS_CONFLICT");
183
+ default: return mode;
184
+ }
185
+ }
186
+
187
+ module.exports = {
188
+ ICON,
189
+ runGitSilent,
190
+ aheadBehind,
191
+ aheadLineStats,
192
+ parsePullOutput,
193
+ parsePushOutput,
194
+ parseFetchOutput,
195
+ renderTable,
196
+ statusBadge,
197
+ printSummary,
198
+ bulkColumns,
199
+ pullModeLabel,
200
+ listConflictedFiles,
201
+ getCurrentBranch,
202
+ };
@@ -1,7 +1,8 @@
1
1
  const path = require("path");
2
2
  const fs = require("fs");
3
- const { findNearestProjectManifest } = require("../../discover");
4
- const { parseManifest, manifestRepos, validateRepo } = require("./manifest");
3
+ const { findNearestProjectManifest, discoverGitRepos } = require("../../discover");
4
+ const { parseManifestWithLocal, manifestRepos, validateRepo } = require("./manifest");
5
+ const { t } = require("../../i18n");
5
6
 
6
7
  function parseCommonArgs(argv, extraFlags = {}) {
7
8
  const args = {
@@ -96,12 +97,46 @@ function resolveProjectWorkspace({
96
97
  };
97
98
  }
98
99
 
99
- function loadManifestRepos({ manifestPath, baseDir, startDir = process.cwd() }) {
100
- const workspace = resolveProjectWorkspace({ manifestPath, baseDir, startDir });
100
+ /**
101
+ * Fallback khi không có `.project.yaml`: coi `startDir` (nơi gõ lệnh) là gốc dự
102
+ * án, quét git repo con bên trong (giống `pan stats`) — dùng cho các lệnh git
103
+ * thuần (pull/push/fetch/checkout/sync-main/mr/grv/gst), KHÔNG áp dụng cho
104
+ * `clone` (cần url khai báo trong manifest, không tự suy ra được).
105
+ */
106
+ function fallbackReposFromScan(startDir) {
107
+ const found = discoverGitRepos(startDir);
108
+ if (!found.length) {
109
+ throw new Error(
110
+ t("MANIFEST_NOT_FOUND_NO_GIT_FALLBACK__DIR", { dir: startDir })
111
+ );
112
+ }
113
+ console.log(t("MANIFEST_NOT_FOUND_FALLBACK_SCAN__DIR", { dir: startDir }));
114
+ return found.map((repo) => ({
115
+ name: repo.name,
116
+ url: null,
117
+ repoPath: path.relative(startDir, repo.dir) || ".",
118
+ destination: repo.dir,
119
+ }));
120
+ }
121
+
122
+ function loadManifestRepos({ manifestPath, baseDir, startDir = process.cwd(), allowFallback = true }) {
123
+ let workspace;
124
+ try {
125
+ workspace = resolveProjectWorkspace({ manifestPath, baseDir, startDir });
126
+ } catch (error) {
127
+ if (!allowFallback) throw error;
128
+ return {
129
+ repos: fallbackReposFromScan(startDir),
130
+ manifestPath: "",
131
+ baseDir: startDir,
132
+ workspaceRoot: startDir,
133
+ };
134
+ }
135
+
101
136
  const { manifestPath: resolvedManifest, baseDir: resolvedBaseDir, workspaceRoot } =
102
137
  workspace;
103
138
 
104
- const data = parseManifest(resolvedManifest);
139
+ const data = parseManifestWithLocal(resolvedManifest);
105
140
  const repos = manifestRepos(data);
106
141
  if (repos.length === 0) {
107
142
  throw new Error("No repositories found in manifest.");
@@ -146,6 +181,7 @@ options:
146
181
 
147
182
  module.exports = {
148
183
  parseCommonArgs,
184
+ looksLikeManifest,
149
185
  resolveProjectWorkspace,
150
186
  loadManifestRepos,
151
187
  printCommonOptions,
@@ -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
+ };
@@ -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,