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