github-update-submodule 1.0.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/LICENSE +21 -0
- package/README.md +168 -0
- package/bin/github-update-submodule.js +376 -0
- package/package.json +33 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 [Muhammad Saad Amin] @SENODROOM
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# github-update-submodule
|
|
2
|
+
|
|
3
|
+
> Recursively pull all Git submodules to their latest remote commit and push the updated refs up every parent repo — so **GitHub always points to the latest commit** in every submodule, no matter how deeply nested.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## The Problem
|
|
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.
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
GitHub (parent repo) ──pins──▶ old commit ❌
|
|
13
|
+
Your local submodule latest commit ✅
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## The Solution
|
|
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.
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
github-update-submodule
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## Installation
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
npm install -g github-update-submodule
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## Usage
|
|
35
|
+
|
|
36
|
+
Navigate to your root repo in the terminal and run:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
github-update-submodule
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
That's it. The tool will:
|
|
43
|
+
|
|
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
|
|
47
|
+
|
|
48
|
+
### Options
|
|
49
|
+
|
|
50
|
+
| Flag | Description |
|
|
51
|
+
|---|---|
|
|
52
|
+
| `--no-push` | Pull locally only, do not commit or push |
|
|
53
|
+
| `--dry-run` | Preview what would change without touching anything |
|
|
54
|
+
| `--message <m>` | Custom commit message (default: `chore: update submodule refs`) |
|
|
55
|
+
| `--branch <b>` | Default branch if not declared in `.gitmodules` (default: `main`) |
|
|
56
|
+
| `--depth <n>` | Limit recursion depth |
|
|
57
|
+
| `--verbose` | Show full git output for every operation |
|
|
58
|
+
| `--no-color` | Disable colored output |
|
|
59
|
+
|
|
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"
|
|
74
|
+
|
|
75
|
+
# Run on a specific repo path
|
|
76
|
+
github-update-submodule /path/to/your/repo
|
|
77
|
+
|
|
78
|
+
# Use master as the default branch
|
|
79
|
+
github-update-submodule --branch master
|
|
80
|
+
|
|
81
|
+
# Limit to 2 levels of nesting
|
|
82
|
+
github-update-submodule --depth 2
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## How It Works
|
|
88
|
+
|
|
89
|
+
### Phase 1 — Pull
|
|
90
|
+
|
|
91
|
+
For each submodule (recursively):
|
|
92
|
+
|
|
93
|
+
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)
|
|
96
|
+
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
|
|
99
|
+
|
|
100
|
+
### Phase 2 — Commit & Push
|
|
101
|
+
|
|
102
|
+
Walks the repo tree **innermost → outermost**:
|
|
103
|
+
|
|
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
|
|
107
|
+
|
|
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.
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## Example Output
|
|
113
|
+
|
|
114
|
+
```
|
|
115
|
+
╔══════════════════════════════════════════╗
|
|
116
|
+
║ github-update-submodule ║
|
|
117
|
+
╚══════════════════════════════════════════╝
|
|
118
|
+
|
|
119
|
+
› Repository : /projects/my-app
|
|
120
|
+
› Default branch : main
|
|
121
|
+
› Push mode : ON
|
|
122
|
+
|
|
123
|
+
Phase 1 — Pull all submodules to latest remote commit
|
|
124
|
+
|
|
125
|
+
▸ QuantumDocsSyncer (docs/QuantumDocsSyncer)
|
|
126
|
+
› Fetching from origin…
|
|
127
|
+
› Branch: main
|
|
128
|
+
✔ Updated d11a9fce → 4a82bc91
|
|
129
|
+
▸ frontend (frontend)
|
|
130
|
+
› Fetching from origin…
|
|
131
|
+
› Branch: main
|
|
132
|
+
✔ Updated fe03e5be → 9c14d7aa
|
|
133
|
+
▸ backend (backend)
|
|
134
|
+
› Fetching from origin…
|
|
135
|
+
› Branch: main
|
|
136
|
+
✔ Already up to date (b6732bc5)
|
|
137
|
+
|
|
138
|
+
Phase 2 — Commit & push updated refs (innermost → root)
|
|
139
|
+
|
|
140
|
+
› QuantumDocsSyncer — committing on main…
|
|
141
|
+
↑ QuantumDocsSyncer → origin/main
|
|
142
|
+
› my-app — committing on main…
|
|
143
|
+
↑ my-app → origin/main
|
|
144
|
+
|
|
145
|
+
─────────────────────────────────────────
|
|
146
|
+
Summary
|
|
147
|
+
✔ Updated : 2
|
|
148
|
+
· Up to date : 1
|
|
149
|
+
↑ Committed : 2
|
|
150
|
+
↑ Pushed : 2
|
|
151
|
+
⚠ Skipped : 0
|
|
152
|
+
✘ Failed : 0
|
|
153
|
+
Total : 3 (18.42s)
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
|
|
158
|
+
## Requirements
|
|
159
|
+
|
|
160
|
+
- **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
|
|
163
|
+
|
|
164
|
+
---
|
|
165
|
+
|
|
166
|
+
## License
|
|
167
|
+
|
|
168
|
+
MIT
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* github-update-submodule
|
|
5
|
+
* 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.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* github-update-submodule [repo-path] [options]
|
|
11
|
+
*
|
|
12
|
+
* 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
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const { spawnSync } = require("child_process");
|
|
30
|
+
const path = require("path");
|
|
31
|
+
const fs = require("fs");
|
|
32
|
+
|
|
33
|
+
// ─── CLI argument parsing ────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
const args = process.argv.slice(2);
|
|
36
|
+
|
|
37
|
+
const options = {
|
|
38
|
+
repoPath: process.cwd(),
|
|
39
|
+
push: true, // ON by default
|
|
40
|
+
commitMessage: "chore: update submodule refs",
|
|
41
|
+
dryRun: false,
|
|
42
|
+
defaultBranch: "main",
|
|
43
|
+
maxDepth: Infinity,
|
|
44
|
+
verbose: false,
|
|
45
|
+
color: true,
|
|
46
|
+
};
|
|
47
|
+
|
|
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);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ─── Colour helpers ──────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
const C = options.color
|
|
63
|
+
? { 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" }
|
|
65
|
+
: Object.fromEntries(
|
|
66
|
+
["reset","bold","dim","green","yellow","cyan","red","magenta","blue"].map(k => [k, ""])
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
// ─── Logging ─────────────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
const indent = (d) => " ".repeat(d);
|
|
72
|
+
const log = (d, sym, col, msg) => console.log(`${indent(d)}${col}${sym} ${msg}${C.reset}`);
|
|
73
|
+
const info = (d, m) => log(d, "›", C.cyan, m);
|
|
74
|
+
const success = (d, m) => log(d, "✔", C.green, m);
|
|
75
|
+
const warn = (d, m) => log(d, "⚠", C.yellow, m);
|
|
76
|
+
const error = (d, m) => log(d, "✘", C.red, m);
|
|
77
|
+
const header = (d, m) => log(d, "▸", C.bold + C.magenta, m);
|
|
78
|
+
const pushLog = (d, m) => log(d, "↑", C.bold + C.green, m);
|
|
79
|
+
const verbose = (d, m) => { if (options.verbose) log(d, " ", C.dim, m); };
|
|
80
|
+
|
|
81
|
+
// ─── Git helpers ─────────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
function git(cwd, ...gitArgs) {
|
|
84
|
+
const r = spawnSync("git", gitArgs, {
|
|
85
|
+
cwd,
|
|
86
|
+
encoding: "utf8",
|
|
87
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
|
|
88
|
+
});
|
|
89
|
+
return {
|
|
90
|
+
stdout: (r.stdout || "").trim(),
|
|
91
|
+
stderr: (r.stderr || "").trim(),
|
|
92
|
+
ok: r.status === 0,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function isGitRepo(dir) {
|
|
97
|
+
return fs.existsSync(path.join(dir, ".git"));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function parseGitmodules(repoDir) {
|
|
101
|
+
const gmPath = path.join(repoDir, ".gitmodules");
|
|
102
|
+
if (!fs.existsSync(gmPath)) return [];
|
|
103
|
+
|
|
104
|
+
const submodules = [];
|
|
105
|
+
let cur = null;
|
|
106
|
+
|
|
107
|
+
for (const rawLine of fs.readFileSync(gmPath, "utf8").split("\n")) {
|
|
108
|
+
const line = rawLine.trim();
|
|
109
|
+
if (!line || line.startsWith("#")) continue;
|
|
110
|
+
|
|
111
|
+
const sec = line.match(/^\[submodule\s+"(.+)"\]$/);
|
|
112
|
+
if (sec) {
|
|
113
|
+
cur = { name: sec[1], path: null, url: null, branch: null };
|
|
114
|
+
submodules.push(cur);
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (!cur) continue;
|
|
118
|
+
|
|
119
|
+
const kv = line.match(/^(\w+)\s*=\s*(.+)$/);
|
|
120
|
+
if (kv) {
|
|
121
|
+
if (kv[1] === "path") cur.path = kv[2];
|
|
122
|
+
if (kv[1] === "url") cur.url = kv[2];
|
|
123
|
+
if (kv[1] === "branch") cur.branch = kv[2];
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return submodules.filter(s => s.path);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function resolveBranch(dir, declared) {
|
|
130
|
+
if (declared) return declared;
|
|
131
|
+
const r = git(dir, "remote", "show", "origin");
|
|
132
|
+
if (r.ok) {
|
|
133
|
+
const m = r.stdout.match(/HEAD branch:\s+(\S+)/);
|
|
134
|
+
if (m) return m[1];
|
|
135
|
+
}
|
|
136
|
+
return options.defaultBranch;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function hasStagedChanges(dir) {
|
|
140
|
+
return !git(dir, "diff", "--cached", "--quiet").ok;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ─── Statistics ───────────────────────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
const stats = {
|
|
146
|
+
updated: 0, upToDate: 0, skipped: 0, failed: 0,
|
|
147
|
+
committed: 0, pushed: 0, total: 0,
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
// ─── Phase 1 — pull every submodule to the remote tip ────────────────────────
|
|
151
|
+
|
|
152
|
+
function pullSubmodules(repoDir, depth = 0) {
|
|
153
|
+
if (depth > options.maxDepth) { warn(depth, "Max depth reached."); return false; }
|
|
154
|
+
|
|
155
|
+
const submodules = parseGitmodules(repoDir);
|
|
156
|
+
if (!submodules.length) { verbose(depth, "No submodules."); return false; }
|
|
157
|
+
|
|
158
|
+
let anyChanged = false;
|
|
159
|
+
|
|
160
|
+
for (const sub of submodules) {
|
|
161
|
+
const subDir = path.resolve(repoDir, sub.path);
|
|
162
|
+
stats.total++;
|
|
163
|
+
|
|
164
|
+
header(depth, `${sub.name} ${C.dim}(${sub.path})${C.reset}`);
|
|
165
|
+
|
|
166
|
+
// Init if missing
|
|
167
|
+
if (!fs.existsSync(subDir) || !isGitRepo(subDir)) {
|
|
168
|
+
info(depth + 1, "Not initialised — running git submodule update --init");
|
|
169
|
+
if (!options.dryRun) {
|
|
170
|
+
const init = git(repoDir, "submodule", "update", "--init", "--", sub.path);
|
|
171
|
+
if (!init.ok) {
|
|
172
|
+
error(depth + 1, `Init failed: ${init.stderr}`);
|
|
173
|
+
stats.failed++;
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (!fs.existsSync(subDir)) {
|
|
180
|
+
warn(depth + 1, "Directory missing after init — skipping.");
|
|
181
|
+
stats.skipped++;
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
|
|
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");
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Resolve branch + remote tip
|
|
194
|
+
const branch = resolveBranch(subDir, sub.branch);
|
|
195
|
+
const remoteRef = `origin/${branch}`;
|
|
196
|
+
const remoteTip = git(subDir, "rev-parse", remoteRef).stdout;
|
|
197
|
+
|
|
198
|
+
info(depth + 1, `Branch: ${C.bold}${branch}${C.reset}`);
|
|
199
|
+
|
|
200
|
+
if (!remoteTip) {
|
|
201
|
+
warn(depth + 1, `Cannot resolve ${remoteRef} — skipping.`);
|
|
202
|
+
stats.skipped++;
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const beforeHash = git(subDir, "rev-parse", "HEAD").stdout;
|
|
207
|
+
|
|
208
|
+
// Dry-run path
|
|
209
|
+
if (options.dryRun) {
|
|
210
|
+
if (beforeHash === remoteTip) {
|
|
211
|
+
success(depth + 1, `Up to date (${remoteTip.slice(0, 8)})`);
|
|
212
|
+
stats.upToDate++;
|
|
213
|
+
} else {
|
|
214
|
+
success(depth + 1,
|
|
215
|
+
`Would update ${C.dim}${beforeHash.slice(0, 8)}${C.reset} → ${C.bold}${C.green}${remoteTip.slice(0, 8)}${C.reset} (dry-run)`);
|
|
216
|
+
stats.updated++;
|
|
217
|
+
anyChanged = true;
|
|
218
|
+
}
|
|
219
|
+
pullSubmodules(subDir, depth + 1);
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Checkout -B <branch> <remoteRef> — moves branch pointer to remote tip
|
|
224
|
+
const co = git(subDir, "checkout", "-B", branch, remoteRef);
|
|
225
|
+
if (!co.ok) {
|
|
226
|
+
const co2 = git(subDir, "checkout", branch);
|
|
227
|
+
if (!co2.ok) {
|
|
228
|
+
error(depth + 1, `Cannot checkout '${branch}': ${co2.stderr}`);
|
|
229
|
+
stats.failed++;
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
const rs = git(subDir, "reset", "--hard", remoteRef);
|
|
233
|
+
if (!rs.ok) {
|
|
234
|
+
error(depth + 1, `reset --hard failed: ${rs.stderr}`);
|
|
235
|
+
stats.failed++;
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
verbose(depth + 1, rs.stdout);
|
|
239
|
+
} else {
|
|
240
|
+
verbose(depth + 1, co.stdout || co.stderr);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const afterHash = git(subDir, "rev-parse", "HEAD").stdout;
|
|
244
|
+
|
|
245
|
+
// Stage updated pointer in parent
|
|
246
|
+
git(repoDir, "add", sub.path);
|
|
247
|
+
|
|
248
|
+
if (beforeHash === afterHash) {
|
|
249
|
+
success(depth + 1, `Already up to date (${afterHash.slice(0, 8)})`);
|
|
250
|
+
stats.upToDate++;
|
|
251
|
+
} else {
|
|
252
|
+
success(depth + 1,
|
|
253
|
+
`Updated ${C.dim}${beforeHash.slice(0, 8)}${C.reset} → ${C.bold}${C.green}${afterHash.slice(0, 8)}${C.reset}`);
|
|
254
|
+
stats.updated++;
|
|
255
|
+
anyChanged = true;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Recurse — if a nested pointer changed, re-stage this submodule in parent
|
|
259
|
+
if (pullSubmodules(subDir, depth + 1)) {
|
|
260
|
+
git(repoDir, "add", sub.path);
|
|
261
|
+
anyChanged = true;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return anyChanged;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ─── Phase 2 — commit + push updated refs, innermost repos first ─────────────
|
|
269
|
+
|
|
270
|
+
function commitAndPush(repoDir, label, depth = 0) {
|
|
271
|
+
// Children first so innermost repos are pushed before outer ones
|
|
272
|
+
for (const sub of parseGitmodules(repoDir)) {
|
|
273
|
+
const subDir = path.resolve(repoDir, sub.path);
|
|
274
|
+
if (fs.existsSync(subDir) && isGitRepo(subDir)) {
|
|
275
|
+
commitAndPush(subDir, sub.name, depth + 1);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (!hasStagedChanges(repoDir)) {
|
|
280
|
+
verbose(depth, `${label}: nothing staged — skipping`);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const branch = resolveBranch(repoDir, null);
|
|
285
|
+
info(depth, `${C.bold}${label}${C.reset} — committing on ${C.bold}${branch}${C.reset}…`);
|
|
286
|
+
|
|
287
|
+
if (options.dryRun) {
|
|
288
|
+
warn(depth, `Would commit + push '${label}' → origin/${branch} (dry-run)`);
|
|
289
|
+
stats.committed++;
|
|
290
|
+
stats.pushed++;
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const commit = git(repoDir, "commit", "-m", options.commitMessage);
|
|
295
|
+
if (!commit.ok) {
|
|
296
|
+
if (/nothing to commit/.test(commit.stdout + commit.stderr)) {
|
|
297
|
+
verbose(depth, `${label}: nothing to commit`);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
error(depth, `Commit failed in '${label}': ${commit.stderr}`);
|
|
301
|
+
stats.failed++;
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
verbose(depth, commit.stdout);
|
|
305
|
+
stats.committed++;
|
|
306
|
+
|
|
307
|
+
info(depth, `Pushing ${C.bold}${label}${C.reset} → origin/${branch}…`);
|
|
308
|
+
const pushR = git(repoDir, "push", "origin", branch);
|
|
309
|
+
if (!pushR.ok) {
|
|
310
|
+
error(depth, `Push failed in '${label}': ${pushR.stderr}`);
|
|
311
|
+
stats.failed++;
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
pushLog(depth, `${C.bold}${label}${C.reset} → origin/${branch}`);
|
|
315
|
+
stats.pushed++;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ─── Entry point ─────────────────────────────────────────────────────────────
|
|
319
|
+
|
|
320
|
+
function main() {
|
|
321
|
+
console.log();
|
|
322
|
+
console.log(`${C.bold}${C.blue}╔══════════════════════════════════════════╗${C.reset}`);
|
|
323
|
+
console.log(`${C.bold}${C.blue}║ github-update-submodule ║${C.reset}`);
|
|
324
|
+
console.log(`${C.bold}${C.blue}╚══════════════════════════════════════════╝${C.reset}`);
|
|
325
|
+
console.log();
|
|
326
|
+
|
|
327
|
+
if (!isGitRepo(options.repoPath)) {
|
|
328
|
+
error(0, `Not a git repository: ${options.repoPath}`);
|
|
329
|
+
process.exit(1);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
info(0, `Repository : ${C.bold}${options.repoPath}${C.reset}`);
|
|
333
|
+
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}`);
|
|
337
|
+
console.log();
|
|
338
|
+
|
|
339
|
+
const t0 = Date.now();
|
|
340
|
+
|
|
341
|
+
// Phase 1 — pull
|
|
342
|
+
console.log(`${C.bold}${C.cyan}Phase 1 — Pull all submodules to latest remote commit${C.reset}`);
|
|
343
|
+
console.log();
|
|
344
|
+
pullSubmodules(options.repoPath, 0);
|
|
345
|
+
|
|
346
|
+
// Phase 2 — commit + push
|
|
347
|
+
if (options.push) {
|
|
348
|
+
console.log();
|
|
349
|
+
console.log(`${C.bold}${C.cyan}Phase 2 — Commit & push updated refs (innermost → root)${C.reset}`);
|
|
350
|
+
console.log();
|
|
351
|
+
commitAndPush(options.repoPath, path.basename(options.repoPath), 0);
|
|
352
|
+
} else {
|
|
353
|
+
console.log();
|
|
354
|
+
warn(0, `Refs staged locally but NOT pushed (--no-push mode).`);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const elapsed = ((Date.now() - t0) / 1000).toFixed(2);
|
|
358
|
+
|
|
359
|
+
console.log();
|
|
360
|
+
console.log(`${C.bold}${C.blue}─────────────────────────────────────────${C.reset}`);
|
|
361
|
+
console.log(`${C.bold}Summary${C.reset}`);
|
|
362
|
+
console.log(` ${C.green}✔ Updated : ${stats.updated}${C.reset}`);
|
|
363
|
+
console.log(` ${C.cyan}· Up to date : ${stats.upToDate}${C.reset}`);
|
|
364
|
+
if (options.push) {
|
|
365
|
+
console.log(` ${C.green}↑ Committed : ${stats.committed}${C.reset}`);
|
|
366
|
+
console.log(` ${C.green}↑ Pushed : ${stats.pushed}${C.reset}`);
|
|
367
|
+
}
|
|
368
|
+
console.log(` ${C.yellow}⚠ Skipped : ${stats.skipped}${C.reset}`);
|
|
369
|
+
console.log(` ${C.red}✘ Failed : ${stats.failed}${C.reset}`);
|
|
370
|
+
console.log(` ${C.dim} Total : ${stats.total} (${elapsed}s)${C.reset}`);
|
|
371
|
+
console.log();
|
|
372
|
+
|
|
373
|
+
if (stats.failed > 0) process.exit(1);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
main();
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "github-update-submodule",
|
|
3
|
+
"version": "1.0.0",
|
|
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
|
+
"main": "bin/github-update-submodule.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"github-update-submodule": "bin/github-update-submodule.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"test": "echo \"No tests yet\" && exit 0"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"git",
|
|
14
|
+
"submodule",
|
|
15
|
+
"github",
|
|
16
|
+
"recursive",
|
|
17
|
+
"update",
|
|
18
|
+
"cli"
|
|
19
|
+
],
|
|
20
|
+
"author": "Muhammad Saad Amin",
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=14.0.0"
|
|
24
|
+
},
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/SENODROOM/github-update-submodule.git"
|
|
28
|
+
},
|
|
29
|
+
"bugs": {
|
|
30
|
+
"url": "https://github.com/SENODROOM/github-update-submodule/issues"
|
|
31
|
+
},
|
|
32
|
+
"homepage": "https://github.com/SENODROOM/github-update-submodule#readme"
|
|
33
|
+
}
|