github-update-submodule 1.0.0 → 1.1.0

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 CHANGED
@@ -6,21 +6,23 @@
6
6
 
7
7
  ## The Problem
8
8
 
9
- Git submodules work by storing a **commit pointer** (a hash) in the parent repo. When a submodule gets new commits, the parent repo's pointer goes stale — GitHub still shows the old commit until someone manually updates and pushes it. With deeply nested submodules this becomes a nightmare to manage by hand.
9
+ Git submodules store a **commit pointer** (a hash) in the parent repo. When a submodule gets new commits, the parent's pointer goes stale — GitHub still shows the old commit until someone manually updates and pushes it. With deeply nested submodules this becomes a nightmare to manage by hand.
10
10
 
11
11
  ```
12
- GitHub (parent repo) ──pins──▶ old commit
12
+ GitHub (parent repo) ──pins──▶ old commit
13
13
  Your local submodule latest commit ✅
14
14
  ```
15
15
 
16
16
  ## The Solution
17
17
 
18
- One command. Run it in any repo with submodules and every parent on GitHub will point to the latest commit — automatically, recursively, all the way down the tree.
18
+ One command from any repo with submodules:
19
19
 
20
20
  ```bash
21
21
  github-update-submodule
22
22
  ```
23
23
 
24
+ Everything is handled automatically — pull, commit, push — all the way down the tree and back up again.
25
+
24
26
  ---
25
27
 
26
28
  ## Installation
@@ -33,79 +35,118 @@ npm install -g github-update-submodule
33
35
 
34
36
  ## Usage
35
37
 
36
- Navigate to your root repo in the terminal and run:
37
-
38
38
  ```bash
39
+ # Run from your root repo — pulls + commits + pushes everything
39
40
  github-update-submodule
40
- ```
41
41
 
42
- That's it. The tool will:
42
+ # Preview what would change without touching anything
43
+ github-update-submodule --dry-run
44
+
45
+ # Confirm each repo before pushing
46
+ github-update-submodule --interactive
47
+
48
+ # Fetch all submodules at the same time (much faster on large trees)
49
+ github-update-submodule --parallel
50
+
51
+ # Skip specific submodules
52
+ github-update-submodule --ignore frontend --ignore legacy-lib
53
+
54
+ # Local update only, no push
55
+ github-update-submodule --no-push
56
+ ```
43
57
 
44
- 1. **Pull** — fetch every submodule (at any nesting depth) and reset it to the latest commit on its remote branch
45
- 2. **Commit** — stage and commit the updated submodule pointers in each parent repo
46
- 3. **Push** — push from the innermost repos outward to the root, so GitHub is fully up to date at every level
58
+ ---
47
59
 
48
- ### Options
60
+ ## Options
49
61
 
50
62
  | Flag | Description |
51
63
  |---|---|
52
- | `--no-push` | Pull locally only, do not commit or push |
53
- | `--dry-run` | Preview what would change without touching anything |
64
+ | `--no-push` | Pull locally only, skip commit and push |
65
+ | `--interactive` | Show a diff and ask yes/no before pushing each repo |
66
+ | `--ignore <name>` | Skip a submodule by name. Repeatable: `--ignore a --ignore b` |
67
+ | `--parallel` | Fetch all submodules concurrently (huge speedup on large trees) |
68
+ | `--dry-run` | Preview all changes — nothing is modified |
54
69
  | `--message <m>` | Custom commit message (default: `chore: update submodule refs`) |
55
70
  | `--branch <b>` | Default branch if not declared in `.gitmodules` (default: `main`) |
56
71
  | `--depth <n>` | Limit recursion depth |
57
72
  | `--verbose` | Show full git output for every operation |
58
73
  | `--no-color` | Disable colored output |
74
+ | `--no-progress` | Disable the progress bar |
59
75
 
60
- ### Examples
61
-
62
- ```bash
63
- # Standard usage — pull + commit + push everything
64
- github-update-submodule
65
-
66
- # Preview changes without modifying anything
67
- github-update-submodule --dry-run
68
-
69
- # Pull locally only, skip the push
70
- github-update-submodule --no-push
71
-
72
- # Custom commit message
73
- github-update-submodule --message "ci: bump all submodule refs to latest"
76
+ ---
74
77
 
75
- # Run on a specific repo path
76
- github-update-submodule /path/to/your/repo
78
+ ## Config File
77
79
 
78
- # Use master as the default branch
79
- github-update-submodule --branch master
80
+ Place a `.submodulerc` or `submodule.config.json` file in your repo root to set persistent defaults. CLI flags always override the config file.
80
81
 
81
- # Limit to 2 levels of nesting
82
- github-update-submodule --depth 2
82
+ **`.submodulerc`** (JSON):
83
+ ```json
84
+ {
85
+ "defaultBranch": "main",
86
+ "parallel": true,
87
+ "ignore": ["legacy-lib", "vendor"],
88
+ "commitMessage": "ci: bump submodule refs",
89
+ "interactive": false,
90
+ "verbose": false
91
+ }
83
92
  ```
84
93
 
94
+ All config keys match the CLI flag names (camelCase, without `--`):
95
+
96
+ | Key | Type | Default |
97
+ |---|---|---|
98
+ | `push` | boolean | `true` |
99
+ | `interactive` | boolean | `false` |
100
+ | `ignore` | string or string[] | `[]` |
101
+ | `parallel` | boolean | `false` |
102
+ | `commitMessage` | string | `"chore: update submodule refs"` |
103
+ | `defaultBranch` | string | `"main"` |
104
+ | `maxDepth` | number | unlimited |
105
+ | `verbose` | boolean | `false` |
106
+ | `color` | boolean | `true` |
107
+ | `progress` | boolean | `true` |
108
+
85
109
  ---
86
110
 
87
111
  ## How It Works
88
112
 
89
113
  ### Phase 1 — Pull
90
114
 
91
- For each submodule (recursively):
115
+ For each submodule (recursively, depth-first):
92
116
 
93
117
  1. Initialises any submodule that hasn't been cloned yet
94
- 2. Runs `git fetch --prune origin`
95
- 3. Resolves the correct branch (from `.gitmodules`, then remote HEAD, then `--branch` flag)
118
+ 2. Fetches from `origin` (in parallel if `--parallel` is set)
119
+ 3. Resolves the correct branch: `.gitmodules` declaration remote HEAD `--branch` flag
96
120
  4. Runs `git checkout -B <branch> origin/<branch>` to hard-move to the remote tip
97
- 5. Stages the updated pointer in the parent repo with `git add <path>`
98
- 6. Recurses into the submodule's own submodules
121
+ 5. Stages the updated pointer in the parent with `git add <path>`
122
+ 6. Prints a clickable **GitHub compare URL** for every submodule that changed:
123
+ ```
124
+ ⎘ https://github.com/org/repo/compare/abc12345...def67890
125
+ ```
126
+ 7. Recurses into the submodule's own submodules
99
127
 
100
128
  ### Phase 2 — Commit & Push
101
129
 
102
- Walks the repo tree **innermost → outermost**:
130
+ Walks the tree **innermost → outermost**:
131
+
132
+ 1. For each repo with staged changes, optionally shows a `--interactive` diff prompt
133
+ 2. Commits with the configured message
134
+ 3. Pushes to `origin/<branch>`
135
+ 4. Moves up to the parent and repeats
136
+
137
+ The innermost-first order guarantees that by the time GitHub receives a pointer update from a parent, the commit it points to already exists on the remote.
138
+
139
+ ---
140
+
141
+ ## Progress Bar
103
142
 
104
- 1. For each repo that has staged changes, commits with the configured message
105
- 2. Pushes to `origin/<branch>`
106
- 3. Moves up to the parent and repeats
143
+ In sequential mode (default) a live progress bar tracks the fetch phase:
107
144
 
108
- The innermost-first order ensures that by the time GitHub receives a pointer update from a parent repo, the commit it points to already exists on the remote.
145
+ ```
146
+ [████████████░░░░░░░░░░░░░░░░] 43% (6/13) frontend
147
+ ```
148
+
149
+ In `--parallel` mode the bar advances as each concurrent fetch completes.
109
150
 
110
151
  ---
111
152
 
@@ -113,27 +154,33 @@ The innermost-first order ensures that by the time GitHub receives a pointer upd
113
154
 
114
155
  ```
115
156
  ╔══════════════════════════════════════════╗
116
- ║ github-update-submodule
157
+ ║ github-update-submodule v2.0.0
117
158
  ╚══════════════════════════════════════════╝
118
159
 
119
160
  › Repository : /projects/my-app
120
161
  › Default branch : main
121
162
  › Push mode : ON
163
+ › Interactive : OFF
164
+ › Parallel fetch : ON
165
+ › Ignoring : legacy-lib
122
166
 
123
167
  Phase 1 — Pull all submodules to latest remote commit
124
168
 
169
+ Parallel fetching 12 submodules…
170
+ [████████████████████████████] 100% (12/12)
171
+
125
172
  ▸ QuantumDocsSyncer (docs/QuantumDocsSyncer)
126
- › Fetching from origin…
127
173
  › Branch: main
128
174
  ✔ Updated d11a9fce → 4a82bc91
175
+ ⎘ https://github.com/org/QuantumDocsSyncer/compare/d11a9fce...4a82bc91
129
176
  ▸ frontend (frontend)
130
- › Fetching from origin…
131
177
  › Branch: main
132
178
  ✔ Updated fe03e5be → 9c14d7aa
179
+ ⎘ https://github.com/org/frontend/compare/fe03e5be...9c14d7aa
133
180
  ▸ backend (backend)
134
- › Fetching from origin…
135
181
  › Branch: main
136
182
  ✔ Already up to date (b6732bc5)
183
+ ⊘ legacy-lib (ignored)
137
184
 
138
185
  Phase 2 — Commit & push updated refs (innermost → root)
139
186
 
@@ -148,18 +195,34 @@ Summary
148
195
  · Up to date : 1
149
196
  ↑ Committed : 2
150
197
  ↑ Pushed : 2
198
+ ⊘ Ignored : 1
151
199
  ⚠ Skipped : 0
152
200
  ✘ Failed : 0
153
- Total : 3 (18.42s)
201
+ Total : 4 (8.31s)
154
202
  ```
155
203
 
156
204
  ---
157
205
 
206
+ ## Interactive Mode
207
+
208
+ With `--interactive`, the tool pauses before pushing each parent repo and shows a staged diff summary:
209
+
210
+ ```
211
+ docs/QuantumDocsSyncer | 2 +-
212
+ 1 file changed, 1 insertion(+), 1 deletion(-)
213
+
214
+ Push 'my-app' → origin/main? [y/N]
215
+ ```
216
+
217
+ Type `y` to push or anything else to skip that repo.
218
+
219
+ ---
220
+
158
221
  ## Requirements
159
222
 
160
223
  - **Node.js** >= 14
161
- - **Git** installed and available in your PATH
162
- - Your git remotes must be authenticated (SSH keys or credential manager) so pushes can succeed without a password prompt
224
+ - **Git** installed and in your PATH
225
+ - Remote authentication set up (SSH keys or credential manager) so pushes don't require a password prompt
163
226
 
164
227
  ---
165
228
 
@@ -1,69 +1,121 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  /**
4
- * github-update-submodule
4
+ * github-update-submodule v2.0.0
5
+ *
5
6
  * Recursively pulls all Git submodules to their latest remote commit,
6
- * then commits and pushes the updated refs up every parent repo
7
- * so GitHub always points to the latest commit in every submodule.
7
+ * then commits and pushes the updated refs up every parent repo.
8
+ *
9
+ * New in v2: --interactive, --ignore, --parallel, progress bar,
10
+ * GitHub compare links, .submodulerc / submodule.config.json
8
11
  *
9
12
  * Usage:
10
13
  * github-update-submodule [repo-path] [options]
11
14
  *
12
15
  * Options:
13
- * --no-push Skip committing and pushing (local update only)
14
- * --message <m> Commit message (default: "chore: update submodule refs")
15
- * --dry-run Show what would happen without making any changes
16
- * --branch <b> Default branch when none is declared in .gitmodules (default: main)
17
- * --depth <n> Max recursion depth (default: unlimited)
18
- * --verbose Show full git output
19
- * --no-color Disable colored output
20
- *
21
- * Examples:
22
- * github-update-submodule
23
- * github-update-submodule --dry-run
24
- * github-update-submodule --no-push
25
- * github-update-submodule --message "ci: bump submodules"
26
- * github-update-submodule /path/to/repo --branch master
16
+ * --no-push Skip committing and pushing (local update only)
17
+ * --interactive Prompt before pushing each parent repo
18
+ * --ignore <n> Submodule name to skip (repeatable)
19
+ * --parallel Fetch all submodules concurrently
20
+ * --message <m> Commit message (default: "chore: update submodule refs")
21
+ * --dry-run Preview changes without modifying anything
22
+ * --branch <b> Default branch when not in .gitmodules (default: main)
23
+ * --depth <n> Max recursion depth (default: unlimited)
24
+ * --verbose Show full git output
25
+ * --no-color Disable colored output
26
+ * --no-progress Disable the progress bar
27
27
  */
28
28
 
29
- const { spawnSync } = require("child_process");
30
- const path = require("path");
31
- const fs = require("fs");
29
+ const { spawnSync, spawn } = require("child_process");
30
+ const path = require("path");
31
+ const fs = require("fs");
32
+ const readline = require("readline");
33
+
34
+ // ─── Config file loader ───────────────────────────────────────────────────────
35
+ // Reads .submodulerc or submodule.config.json from cwd.
36
+ // CLI flags always override config values.
37
+
38
+ function loadConfig(repoPath) {
39
+ const candidates = [
40
+ path.join(repoPath, ".submodulerc"),
41
+ path.join(repoPath, "submodule.config.json"),
42
+ ];
43
+ for (const f of candidates) {
44
+ if (fs.existsSync(f)) {
45
+ try {
46
+ const raw = fs.readFileSync(f, "utf8").trim();
47
+ const cfg = JSON.parse(raw);
48
+ return cfg;
49
+ } catch (e) {
50
+ console.warn(`⚠ Could not parse config file ${f}: ${e.message}`);
51
+ }
52
+ }
53
+ }
54
+ return {};
55
+ }
32
56
 
33
57
  // ─── CLI argument parsing ────────────────────────────────────────────────────
34
58
 
35
- const args = process.argv.slice(2);
59
+ const cliArgs = process.argv.slice(2);
36
60
 
61
+ // Defaults (lowest priority)
37
62
  const options = {
38
63
  repoPath: process.cwd(),
39
- push: true, // ON by default
64
+ push: true,
65
+ interactive: false,
66
+ ignore: [], // array of submodule names to skip
67
+ parallel: false,
40
68
  commitMessage: "chore: update submodule refs",
41
69
  dryRun: false,
42
70
  defaultBranch: "main",
43
71
  maxDepth: Infinity,
44
72
  verbose: false,
45
73
  color: true,
74
+ progress: true,
46
75
  };
47
76
 
48
- for (let i = 0; i < args.length; i++) {
49
- const arg = args[i];
50
- if (arg === "--no-push") options.push = false;
51
- else if (arg === "--dry-run") options.dryRun = true;
52
- else if (arg === "--verbose") options.verbose = true;
53
- else if (arg === "--no-color") options.color = false;
54
- else if (arg === "--branch") options.defaultBranch = args[++i];
55
- else if (arg === "--message") options.commitMessage = args[++i];
56
- else if (arg === "--depth") options.maxDepth = parseInt(args[++i], 10);
57
- else if (!arg.startsWith("--")) options.repoPath = path.resolve(arg);
77
+ // Collect positional repo path first so config is loaded from correct dir
78
+ for (let i = 0; i < cliArgs.length; i++) {
79
+ if (!cliArgs[i].startsWith("--")) options.repoPath = path.resolve(cliArgs[i]);
80
+ }
81
+
82
+ // Merge config file (overrides defaults, CLI will override config)
83
+ const cfg = loadConfig(options.repoPath);
84
+ if (cfg.push !== undefined) options.push = cfg.push;
85
+ if (cfg.interactive !== undefined) options.interactive = cfg.interactive;
86
+ if (cfg.ignore !== undefined) options.ignore = [].concat(cfg.ignore);
87
+ if (cfg.parallel !== undefined) options.parallel = cfg.parallel;
88
+ if (cfg.commitMessage !== undefined) options.commitMessage = cfg.commitMessage;
89
+ if (cfg.defaultBranch !== undefined) options.defaultBranch = cfg.defaultBranch;
90
+ if (cfg.maxDepth !== undefined) options.maxDepth = cfg.maxDepth;
91
+ if (cfg.verbose !== undefined) options.verbose = cfg.verbose;
92
+ if (cfg.color !== undefined) options.color = cfg.color;
93
+ if (cfg.progress !== undefined) options.progress = cfg.progress;
94
+
95
+ // CLI flags (highest priority)
96
+ for (let i = 0; i < cliArgs.length; i++) {
97
+ const a = cliArgs[i];
98
+ if (a === "--no-push") options.push = false;
99
+ else if (a === "--interactive") options.interactive = true;
100
+ else if (a === "--parallel") options.parallel = true;
101
+ else if (a === "--dry-run") options.dryRun = true;
102
+ else if (a === "--verbose") options.verbose = true;
103
+ else if (a === "--no-color") options.color = false;
104
+ else if (a === "--no-progress") options.progress = false;
105
+ else if (a === "--branch") options.defaultBranch = cliArgs[++i];
106
+ else if (a === "--message") options.commitMessage = cliArgs[++i];
107
+ else if (a === "--depth") options.maxDepth = parseInt(cliArgs[++i], 10);
108
+ else if (a === "--ignore") options.ignore.push(cliArgs[++i]);
58
109
  }
59
110
 
60
111
  // ─── Colour helpers ──────────────────────────────────────────────────────────
61
112
 
62
113
  const C = options.color
63
114
  ? { reset:"\x1b[0m", bold:"\x1b[1m", dim:"\x1b[2m", green:"\x1b[32m",
64
- yellow:"\x1b[33m", cyan:"\x1b[36m", red:"\x1b[31m", magenta:"\x1b[35m", blue:"\x1b[34m" }
115
+ yellow:"\x1b[33m", cyan:"\x1b[36m", red:"\x1b[31m", magenta:"\x1b[35m",
116
+ blue:"\x1b[34m", white:"\x1b[37m" }
65
117
  : Object.fromEntries(
66
- ["reset","bold","dim","green","yellow","cyan","red","magenta","blue"].map(k => [k, ""])
118
+ ["reset","bold","dim","green","yellow","cyan","red","magenta","blue","white"].map(k=>[k,""])
67
119
  );
68
120
 
69
121
  // ─── Logging ─────────────────────────────────────────────────────────────────
@@ -76,8 +128,74 @@ const warn = (d, m) => log(d, "⚠", C.yellow, m);
76
128
  const error = (d, m) => log(d, "✘", C.red, m);
77
129
  const header = (d, m) => log(d, "▸", C.bold + C.magenta, m);
78
130
  const pushLog = (d, m) => log(d, "↑", C.bold + C.green, m);
131
+ const linkLog = (d, m) => log(d, "⎘", C.bold + C.blue, m);
79
132
  const verbose = (d, m) => { if (options.verbose) log(d, " ", C.dim, m); };
80
133
 
134
+ // ─── Progress bar ─────────────────────────────────────────────────────────────
135
+
136
+ const progress = {
137
+ total: 0,
138
+ current: 0,
139
+ active: false,
140
+
141
+ init(total) {
142
+ if (!options.progress || !process.stdout.isTTY) return;
143
+ this.total = total;
144
+ this.current = 0;
145
+ this.active = true;
146
+ this._render();
147
+ },
148
+
149
+ tick(label = "") {
150
+ if (!this.active) return;
151
+ this.current++;
152
+ this._render(label);
153
+ if (this.current >= this.total) this.done();
154
+ },
155
+
156
+ done() {
157
+ if (!this.active) return;
158
+ this.active = false;
159
+ process.stdout.write("\r\x1b[K"); // clear line
160
+ },
161
+
162
+ _render(label = "") {
163
+ const W = 28;
164
+ const filled = Math.round((this.current / this.total) * W);
165
+ const empty = W - filled;
166
+ const bar = C.green + "█".repeat(filled) + C.dim + "░".repeat(empty) + C.reset;
167
+ const pct = String(Math.round((this.current / this.total) * 100)).padStart(3);
168
+ const counter = `${this.current}/${this.total}`;
169
+ const lbl = label ? ` ${C.dim}${label.slice(0, 24)}${C.reset}` : "";
170
+ process.stdout.write(`\r${C.bold}[${bar}${C.bold}] ${pct}% (${counter})${lbl}\x1b[K`);
171
+ },
172
+ };
173
+
174
+ // ─── GitHub compare URL helper ────────────────────────────────────────────────
175
+
176
+ function getRemoteUrl(dir) {
177
+ const r = git(dir, "remote", "get-url", "origin");
178
+ return r.ok ? r.stdout : null;
179
+ }
180
+
181
+ function buildCompareUrl(remoteUrl, oldHash, newHash) {
182
+ if (!remoteUrl) return null;
183
+
184
+ let url = remoteUrl.trim();
185
+
186
+ // SSH → HTTPS: git@github.com:org/repo.git → https://github.com/org/repo
187
+ if (url.startsWith("git@github.com:")) {
188
+ url = url.replace("git@github.com:", "https://github.com/");
189
+ }
190
+ // Strip .git suffix
191
+ url = url.replace(/\.git$/, "");
192
+
193
+ // Only emit links for github.com repos
194
+ if (!url.includes("github.com")) return null;
195
+
196
+ return `${url}/compare/${oldHash.slice(0, 8)}...${newHash.slice(0, 8)}`;
197
+ }
198
+
81
199
  // ─── Git helpers ─────────────────────────────────────────────────────────────
82
200
 
83
201
  function git(cwd, ...gitArgs) {
@@ -93,6 +211,24 @@ function git(cwd, ...gitArgs) {
93
211
  };
94
212
  }
95
213
 
214
+ // Async version used by parallel fetch
215
+ function gitAsync(cwd, ...gitArgs) {
216
+ return new Promise((resolve) => {
217
+ let stdout = "", stderr = "";
218
+ const proc = spawn("git", gitArgs, {
219
+ cwd,
220
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
221
+ });
222
+ proc.stdout.on("data", d => { stdout += d; });
223
+ proc.stderr.on("data", d => { stderr += d; });
224
+ proc.on("close", status => resolve({
225
+ stdout: stdout.trim(),
226
+ stderr: stderr.trim(),
227
+ ok: status === 0,
228
+ }));
229
+ });
230
+ }
231
+
96
232
  function isGitRepo(dir) {
97
233
  return fs.existsSync(path.join(dir, ".git"));
98
234
  }
@@ -126,6 +262,19 @@ function parseGitmodules(repoDir) {
126
262
  return submodules.filter(s => s.path);
127
263
  }
128
264
 
265
+ /** Flatten the full submodule tree for progress bar counting */
266
+ function countAllSubmodules(repoDir, depth = 0) {
267
+ if (depth > options.maxDepth) return 0;
268
+ let n = 0;
269
+ for (const sub of parseGitmodules(repoDir)) {
270
+ if (options.ignore.includes(sub.name)) continue;
271
+ n++;
272
+ const subDir = path.resolve(repoDir, sub.path);
273
+ if (fs.existsSync(subDir)) n += countAllSubmodules(subDir, depth + 1);
274
+ }
275
+ return n;
276
+ }
277
+
129
278
  function resolveBranch(dir, declared) {
130
279
  if (declared) return declared;
131
280
  const r = git(dir, "remote", "show", "origin");
@@ -140,14 +289,58 @@ function hasStagedChanges(dir) {
140
289
  return !git(dir, "diff", "--cached", "--quiet").ok;
141
290
  }
142
291
 
292
+ // ─── Interactive prompt ───────────────────────────────────────────────────────
293
+
294
+ function askUser(question) {
295
+ return new Promise((resolve) => {
296
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
297
+ rl.question(question, (answer) => { rl.close(); resolve(answer.trim().toLowerCase()); });
298
+ });
299
+ }
300
+
143
301
  // ─── Statistics ───────────────────────────────────────────────────────────────
144
302
 
145
303
  const stats = {
146
- updated: 0, upToDate: 0, skipped: 0, failed: 0,
147
- committed: 0, pushed: 0, total: 0,
304
+ updated: 0, upToDate: 0, skipped: 0, ignored: 0,
305
+ failed: 0, committed: 0, pushed: 0, total: 0,
148
306
  };
149
307
 
150
- // ─── Phase 1 — pull every submodule to the remote tip ────────────────────────
308
+ // ─── Phase 1 — parallel fetch pass ───────────────────────────────────────────
309
+
310
+ /**
311
+ * Collect every submodule directory in the tree (flat list) so we can
312
+ * fire all fetches concurrently in --parallel mode.
313
+ */
314
+ function collectSubmoduleDirs(repoDir, depth = 0, out = []) {
315
+ if (depth > options.maxDepth) return out;
316
+ for (const sub of parseGitmodules(repoDir)) {
317
+ if (options.ignore.includes(sub.name)) continue;
318
+ const subDir = path.resolve(repoDir, sub.path);
319
+ if (fs.existsSync(subDir) && isGitRepo(subDir)) {
320
+ out.push(subDir);
321
+ collectSubmoduleDirs(subDir, depth + 1, out);
322
+ }
323
+ }
324
+ return out;
325
+ }
326
+
327
+ async function parallelFetchAll(repoDir) {
328
+ const dirs = collectSubmoduleDirs(repoDir);
329
+ if (!dirs.length) return;
330
+
331
+ info(0, `Parallel fetching ${C.bold}${dirs.length}${C.reset} submodules…`);
332
+ progress.init(dirs.length);
333
+
334
+ await Promise.all(dirs.map(async (dir) => {
335
+ await gitAsync(dir, "fetch", "--prune", "origin");
336
+ progress.tick(path.basename(dir));
337
+ }));
338
+
339
+ progress.done();
340
+ console.log();
341
+ }
342
+
343
+ // ─── Phase 1 — sequential update pass ────────────────────────────────────────
151
344
 
152
345
  function pullSubmodules(repoDir, depth = 0) {
153
346
  if (depth > options.maxDepth) { warn(depth, "Max depth reached."); return false; }
@@ -161,9 +354,16 @@ function pullSubmodules(repoDir, depth = 0) {
161
354
  const subDir = path.resolve(repoDir, sub.path);
162
355
  stats.total++;
163
356
 
357
+ // ── Ignore list ───────────────────────────────────────────────────────
358
+ if (options.ignore.includes(sub.name)) {
359
+ log(depth, "⊘", C.dim, `${sub.name} ${C.dim}(ignored)${C.reset}`);
360
+ stats.ignored++;
361
+ continue;
362
+ }
363
+
164
364
  header(depth, `${sub.name} ${C.dim}(${sub.path})${C.reset}`);
165
365
 
166
- // Init if missing
366
+ // ── Init if missing ───────────────────────────────────────────────────
167
367
  if (!fs.existsSync(subDir) || !isGitRepo(subDir)) {
168
368
  info(depth + 1, "Not initialised — running git submodule update --init");
169
369
  if (!options.dryRun) {
@@ -182,15 +382,19 @@ function pullSubmodules(repoDir, depth = 0) {
182
382
  continue;
183
383
  }
184
384
 
185
- // Fetch
186
- info(depth + 1, "Fetching from origin…");
187
- if (!options.dryRun) {
188
- const f = git(subDir, "fetch", "--prune", "origin");
189
- if (!f.ok) warn(depth + 1, `Fetch warning: ${f.stderr}`);
190
- else verbose(depth + 1, f.stderr || "fetch ok");
385
+ // ── Fetch (sequential mode; parallel mode already fetched above) ──────
386
+ if (!options.parallel) {
387
+ info(depth + 1, "Fetching from origin…");
388
+ if (!options.dryRun) {
389
+ const f = git(subDir, "fetch", "--prune", "origin");
390
+ if (!f.ok) warn(depth + 1, `Fetch warning: ${f.stderr}`);
391
+ else verbose(depth + 1, f.stderr || "fetch ok");
392
+ }
393
+ // Update progress bar in sequential mode
394
+ progress.tick(sub.name);
191
395
  }
192
396
 
193
- // Resolve branch + remote tip
397
+ // ── Resolve branch + remote tip ───────────────────────────────────────
194
398
  const branch = resolveBranch(subDir, sub.branch);
195
399
  const remoteRef = `origin/${branch}`;
196
400
  const remoteTip = git(subDir, "rev-parse", remoteRef).stdout;
@@ -204,8 +408,9 @@ function pullSubmodules(repoDir, depth = 0) {
204
408
  }
205
409
 
206
410
  const beforeHash = git(subDir, "rev-parse", "HEAD").stdout;
411
+ const remoteUrl = getRemoteUrl(subDir);
207
412
 
208
- // Dry-run path
413
+ // ── Dry-run ───────────────────────────────────────────────────────────
209
414
  if (options.dryRun) {
210
415
  if (beforeHash === remoteTip) {
211
416
  success(depth + 1, `Up to date (${remoteTip.slice(0, 8)})`);
@@ -213,6 +418,8 @@ function pullSubmodules(repoDir, depth = 0) {
213
418
  } else {
214
419
  success(depth + 1,
215
420
  `Would update ${C.dim}${beforeHash.slice(0, 8)}${C.reset} → ${C.bold}${C.green}${remoteTip.slice(0, 8)}${C.reset} (dry-run)`);
421
+ const url = buildCompareUrl(remoteUrl, beforeHash, remoteTip);
422
+ if (url) linkLog(depth + 1, url);
216
423
  stats.updated++;
217
424
  anyChanged = true;
218
425
  }
@@ -220,7 +427,7 @@ function pullSubmodules(repoDir, depth = 0) {
220
427
  continue;
221
428
  }
222
429
 
223
- // Checkout -B <branch> <remoteRef> — moves branch pointer to remote tip
430
+ // ── Checkout + reset to remote tip ────────────────────────────────────
224
431
  const co = git(subDir, "checkout", "-B", branch, remoteRef);
225
432
  if (!co.ok) {
226
433
  const co2 = git(subDir, "checkout", branch);
@@ -251,11 +458,16 @@ function pullSubmodules(repoDir, depth = 0) {
251
458
  } else {
252
459
  success(depth + 1,
253
460
  `Updated ${C.dim}${beforeHash.slice(0, 8)}${C.reset} → ${C.bold}${C.green}${afterHash.slice(0, 8)}${C.reset}`);
461
+
462
+ // ── GitHub compare link ───────────────────────────────────────────
463
+ const url = buildCompareUrl(remoteUrl, beforeHash, afterHash);
464
+ if (url) linkLog(depth + 1, `${C.cyan}${url}${C.reset}`);
465
+
254
466
  stats.updated++;
255
467
  anyChanged = true;
256
468
  }
257
469
 
258
- // Recurse — if a nested pointer changed, re-stage this submodule in parent
470
+ // ── Recurse ───────────────────────────────────────────────────────────
259
471
  if (pullSubmodules(subDir, depth + 1)) {
260
472
  git(repoDir, "add", sub.path);
261
473
  anyChanged = true;
@@ -265,14 +477,15 @@ function pullSubmodules(repoDir, depth = 0) {
265
477
  return anyChanged;
266
478
  }
267
479
 
268
- // ─── Phase 2 — commit + push updated refs, innermost repos first ─────────────
480
+ // ─── Phase 2 — commit + push, innermost first ────────────────────────────────
269
481
 
270
- function commitAndPush(repoDir, label, depth = 0) {
271
- // Children first so innermost repos are pushed before outer ones
482
+ async function commitAndPush(repoDir, label, depth = 0) {
483
+ // Children first
272
484
  for (const sub of parseGitmodules(repoDir)) {
485
+ if (options.ignore.includes(sub.name)) continue;
273
486
  const subDir = path.resolve(repoDir, sub.path);
274
487
  if (fs.existsSync(subDir) && isGitRepo(subDir)) {
275
- commitAndPush(subDir, sub.name, depth + 1);
488
+ await commitAndPush(subDir, sub.name, depth + 1);
276
489
  }
277
490
  }
278
491
 
@@ -284,6 +497,24 @@ function commitAndPush(repoDir, label, depth = 0) {
284
497
  const branch = resolveBranch(repoDir, null);
285
498
  info(depth, `${C.bold}${label}${C.reset} — committing on ${C.bold}${branch}${C.reset}…`);
286
499
 
500
+ // ── Interactive prompt ────────────────────────────────────────────────
501
+ if (options.interactive && !options.dryRun) {
502
+ // Show what's staged
503
+ const diff = git(repoDir, "diff", "--cached", "--stat");
504
+ console.log();
505
+ console.log(`${C.dim}${diff.stdout}${C.reset}`);
506
+ console.log();
507
+ const answer = await askUser(
508
+ `${C.bold}${C.yellow} Push '${label}' → origin/${branch}? [y/N] ${C.reset}`
509
+ );
510
+ console.log();
511
+ if (answer !== "y" && answer !== "yes") {
512
+ warn(depth, `Skipped '${label}' (user declined)`);
513
+ stats.skipped++;
514
+ return;
515
+ }
516
+ }
517
+
287
518
  if (options.dryRun) {
288
519
  warn(depth, `Would commit + push '${label}' → origin/${branch} (dry-run)`);
289
520
  stats.committed++;
@@ -317,10 +548,10 @@ function commitAndPush(repoDir, label, depth = 0) {
317
548
 
318
549
  // ─── Entry point ─────────────────────────────────────────────────────────────
319
550
 
320
- function main() {
551
+ async function main() {
321
552
  console.log();
322
553
  console.log(`${C.bold}${C.blue}╔══════════════════════════════════════════╗${C.reset}`);
323
- console.log(`${C.bold}${C.blue}║ github-update-submodule ║${C.reset}`);
554
+ console.log(`${C.bold}${C.blue}║ github-update-submodule v2.0.0 ║${C.reset}`);
324
555
  console.log(`${C.bold}${C.blue}╚══════════════════════════════════════════╝${C.reset}`);
325
556
  console.log();
326
557
 
@@ -329,26 +560,44 @@ function main() {
329
560
  process.exit(1);
330
561
  }
331
562
 
563
+ // Print active config
332
564
  info(0, `Repository : ${C.bold}${options.repoPath}${C.reset}`);
333
565
  info(0, `Default branch : ${C.bold}${options.defaultBranch}${C.reset}`);
334
- info(0, `Push mode : ${options.push ? C.bold + C.green + "ON" : C.dim + "OFF"}${C.reset}`);
335
- if (options.dryRun) warn(0, "DRY RUN no changes will be made");
336
- if (options.maxDepth !== Infinity) info(0, `Max depth : ${options.maxDepth}`);
566
+ info(0, `Push mode : ${options.push ? C.bold+C.green+"ON" : C.dim+"OFF"}${C.reset}`);
567
+ info(0, `Interactive : ${options.interactive ? C.bold+C.yellow+"ON" : C.dim+"OFF"}${C.reset}`);
568
+ info(0, `Parallel fetch : ${options.parallel ? C.bold+C.cyan+"ON" : C.dim+"OFF"}${C.reset}`);
569
+ if (options.ignore.length)
570
+ info(0, `Ignoring : ${C.bold}${C.yellow}${options.ignore.join(", ")}${C.reset}`);
571
+ if (options.dryRun)
572
+ warn(0, "DRY RUN — no changes will be made");
573
+ if (options.maxDepth !== Infinity)
574
+ info(0, `Max depth : ${options.maxDepth}`);
337
575
  console.log();
338
576
 
339
577
  const t0 = Date.now();
340
578
 
341
- // Phase 1 — pull
579
+ // ── Phase 1 ───────────────────────────────────────────────────────────────
342
580
  console.log(`${C.bold}${C.cyan}Phase 1 — Pull all submodules to latest remote commit${C.reset}`);
343
581
  console.log();
582
+
583
+ if (options.parallel && !options.dryRun) {
584
+ // Fire all fetches at once, then do the sequential update pass
585
+ await parallelFetchAll(options.repoPath);
586
+ } else if (!options.parallel) {
587
+ // Sequential mode: init progress bar based on tree size
588
+ const total = countAllSubmodules(options.repoPath);
589
+ progress.init(total);
590
+ }
591
+
344
592
  pullSubmodules(options.repoPath, 0);
593
+ progress.done(); // ensure bar is cleared if sequential
345
594
 
346
- // Phase 2 — commit + push
595
+ // ── Phase 2 ───────────────────────────────────────────────────────────────
347
596
  if (options.push) {
348
597
  console.log();
349
598
  console.log(`${C.bold}${C.cyan}Phase 2 — Commit & push updated refs (innermost → root)${C.reset}`);
350
599
  console.log();
351
- commitAndPush(options.repoPath, path.basename(options.repoPath), 0);
600
+ await commitAndPush(options.repoPath, path.basename(options.repoPath), 0);
352
601
  } else {
353
602
  console.log();
354
603
  warn(0, `Refs staged locally but NOT pushed (--no-push mode).`);
@@ -365,6 +614,7 @@ function main() {
365
614
  console.log(` ${C.green}↑ Committed : ${stats.committed}${C.reset}`);
366
615
  console.log(` ${C.green}↑ Pushed : ${stats.pushed}${C.reset}`);
367
616
  }
617
+ console.log(` ${C.yellow}⊘ Ignored : ${stats.ignored}${C.reset}`);
368
618
  console.log(` ${C.yellow}⚠ Skipped : ${stats.skipped}${C.reset}`);
369
619
  console.log(` ${C.red}✘ Failed : ${stats.failed}${C.reset}`);
370
620
  console.log(` ${C.dim} Total : ${stats.total} (${elapsed}s)${C.reset}`);
@@ -373,4 +623,7 @@ function main() {
373
623
  if (stats.failed > 0) process.exit(1);
374
624
  }
375
625
 
376
- main();
626
+ main().catch(err => {
627
+ console.error(`\n${C.red}Fatal error: ${err.message}${C.reset}`);
628
+ process.exit(1);
629
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "github-update-submodule",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Recursively pull all Git submodules to their latest remote commit and push updated refs up every parent repo — so GitHub always points to the latest.",
5
5
  "main": "bin/github-update-submodule.js",
6
6
  "bin": {
@@ -0,0 +1,10 @@
1
+ {
2
+ "defaultBranch": "main",
3
+ "parallel": false,
4
+ "ignore": [],
5
+ "commitMessage": "chore: update submodule refs",
6
+ "interactive": false,
7
+ "verbose": false,
8
+ "color": true,
9
+ "progress": true
10
+ }