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 +113 -50
- package/bin/github-update-submodule.js +314 -61
- package/package.json +1 -1
- package/submodule.config.example.json +10 -0
package/README.md
CHANGED
|
@@ -6,21 +6,23 @@
|
|
|
6
6
|
|
|
7
7
|
## The Problem
|
|
8
8
|
|
|
9
|
-
Git submodules
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
60
|
+
## Options
|
|
49
61
|
|
|
50
62
|
| Flag | Description |
|
|
51
63
|
|---|---|
|
|
52
|
-
| `--no-push` | Pull locally only,
|
|
53
|
-
| `--
|
|
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
|
-
|
|
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
|
-
|
|
76
|
-
github-update-submodule /path/to/your/repo
|
|
78
|
+
## Config File
|
|
77
79
|
|
|
78
|
-
|
|
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
|
-
|
|
82
|
-
|
|
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.
|
|
95
|
-
3. Resolves the correct branch
|
|
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
|
|
98
|
-
6.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 :
|
|
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
|
|
162
|
-
-
|
|
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
|
-
*
|
|
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
|
|
14
|
-
* --
|
|
15
|
-
* --
|
|
16
|
-
* --
|
|
17
|
-
* --
|
|
18
|
-
* --
|
|
19
|
-
* --
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
|
31
|
-
const 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
|
|
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,
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
if
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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",
|
|
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
|
|
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,
|
|
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 —
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
480
|
+
// ─── Phase 2 — commit + push, innermost first ────────────────────────────────
|
|
269
481
|
|
|
270
|
-
function commitAndPush(repoDir, label, depth = 0) {
|
|
271
|
-
// Children first
|
|
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
|
|
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
|
|
335
|
-
|
|
336
|
-
|
|
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
|
|
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
|
|
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.
|
|
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": {
|