oh-my-space 0.6.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -10
- package/dist/build-info.json +1 -0
- package/dist/oms.js +213 -57
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -49,10 +49,13 @@ Run `oms init` to scaffold a starter `oms.yaml`, or create one by hand in your p
|
|
|
49
49
|
# yaml-language-server: $schema=https://raw.githubusercontent.com/divlook/oh-my-space/main/oms.schema.json
|
|
50
50
|
repos:
|
|
51
51
|
- alias: api
|
|
52
|
-
|
|
52
|
+
remotes:
|
|
53
|
+
origin: git@github.com:example/api.git
|
|
53
54
|
branch: main
|
|
54
55
|
- alias: web
|
|
55
|
-
|
|
56
|
+
remotes:
|
|
57
|
+
origin: git@github.com:example/web.git
|
|
58
|
+
upstream: git@github.com:upstream/web.git
|
|
56
59
|
```
|
|
57
60
|
|
|
58
61
|
Sync and work:
|
|
@@ -66,6 +69,7 @@ oms sync --all # sync every registered source repo
|
|
|
66
69
|
oms status # branch / pointer / dirty / ahead-behind per submodule
|
|
67
70
|
oms checkout <alias> <branch> # switch (or create a brand-new local branch)
|
|
68
71
|
oms push <alias>... # push the branch (lazy remote) + stage the pointer
|
|
72
|
+
oms push <alias> --remote upstream # target a specific remote (repeatable; defaults to origin)
|
|
69
73
|
oms unsync <alias>... # deinitialize and remove a submodule
|
|
70
74
|
```
|
|
71
75
|
|
|
@@ -83,7 +87,7 @@ git add oms/api && git commit # record the pointer in your project history
|
|
|
83
87
|
For an alias `api` checked out on `main`:
|
|
84
88
|
|
|
85
89
|
```
|
|
86
|
-
.gitmodules # registers oms/api -> url, branch
|
|
90
|
+
.gitmodules # registers oms/api -> origin url, branch
|
|
87
91
|
oms/
|
|
88
92
|
└── api/ # git submodule (a normal working tree, on a branch)
|
|
89
93
|
```
|
|
@@ -92,7 +96,7 @@ oms/
|
|
|
92
96
|
|
|
93
97
|
## Managing source repositories
|
|
94
98
|
|
|
95
|
-
`oms.yaml` declares each source repo with `alias`, `
|
|
99
|
+
`oms.yaml` declares each source repo with `alias`, a `remotes` mapping (which must include `origin`), and optional `branch` (the baseline).
|
|
96
100
|
|
|
97
101
|
| Command | Runs in | Does | Notes |
|
|
98
102
|
| --- | --- | --- | --- |
|
|
@@ -101,9 +105,9 @@ oms/
|
|
|
101
105
|
| `oms sync <alias>` / `--all` | workspace root | Registers missing repos with `git submodule add`, initializes registered-but-uninitialized ones, fetches, and attaches the baseline branch. | Reproduces the recorded pointer on a fresh clone. |
|
|
102
106
|
| `oms status [alias...]` / `--all` | anywhere under root | Prints branch, pointer state (`ok`/`moved`/`uninit`), dirtiness, and ahead/behind for each submodule. | `moved` means the working commit differs from the recorded pointer — stage/commit it. |
|
|
103
107
|
| `oms checkout <alias> <branch>` | workspace root | `git switch` to the branch; creates it locally if it exists nowhere yet (no remote required). | `--from <ref>` sets the start point for a new branch. |
|
|
104
|
-
| `oms fetch ...` | workspace root | `git fetch
|
|
105
|
-
| `oms pull ...` | workspace root | `git pull --ff-only
|
|
106
|
-
| `oms push <alias>...` | workspace root | `git push
|
|
108
|
+
| `oms fetch ...` | workspace root | `git fetch <remote> --prune` in each submodule. | `--remote <name>` (repeatable) picks the remote(s); omit to choose interactively, defaults to `origin`. |
|
|
109
|
+
| `oms pull ...` | workspace root | `git pull --ff-only <remote>` on each submodule's current branch, then stages the moved pointer. | Requires the submodule to be on a branch. `--remote <name>` selects a single remote (defaults to `origin`). |
|
|
110
|
+
| `oms push <alias>...` | workspace root | `git push <remote> <branch>` (creating the remote branch on first push), then stages the moved pointer. | `--remote <name>` (repeatable) picks the remote(s), defaults to `origin`; upstream is set only for `origin`. `--commit` also commits the pointer update in the parent. |
|
|
107
111
|
| `oms unsync <alias>` / `--all` | workspace root | `git submodule deinit` + `git rm` for the alias; drops an empty `.gitmodules`. | Keeps the `oms.yaml` entry. Use `--force` to discard uncommitted changes. |
|
|
108
112
|
|
|
109
113
|
## `oms.yaml` format
|
|
@@ -112,17 +116,20 @@ oms/
|
|
|
112
116
|
# yaml-language-server: $schema=https://raw.githubusercontent.com/divlook/oh-my-space/main/oms.schema.json
|
|
113
117
|
repos:
|
|
114
118
|
- alias: service-a
|
|
115
|
-
|
|
119
|
+
remotes:
|
|
120
|
+
origin: git@github.com:example/service-a.git
|
|
116
121
|
branch: main
|
|
117
122
|
- alias: docs
|
|
118
|
-
|
|
123
|
+
remotes:
|
|
124
|
+
origin: https://github.com/example/docs.git
|
|
125
|
+
upstream: https://github.com/upstream/docs.git
|
|
119
126
|
```
|
|
120
127
|
|
|
121
128
|
Rules:
|
|
122
129
|
|
|
123
130
|
- `repos` must be a non-empty array.
|
|
124
131
|
- `alias` must be unique and match `/^[a-z0-9][a-z0-9-]*$/`.
|
|
125
|
-
- `
|
|
132
|
+
- `remotes` is required and must include an `origin` entry; each value is a clonable git URL. `origin` becomes the submodule's primary remote, and additional remotes are configured on `oms sync`.
|
|
126
133
|
- `branch` is optional; when omitted the remote's default branch is used as the baseline.
|
|
127
134
|
|
|
128
135
|
JSON schema: [`oms.schema.json`](./oms.schema.json) (also reachable at `https://raw.githubusercontent.com/divlook/oh-my-space/main/oms.schema.json` for YAML LSPs).
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"commit":"b3b6890088ebe3eb5dd965d2fdd730a60b5a0a01"}
|
package/dist/oms.js
CHANGED
|
@@ -4,11 +4,12 @@ import { existsSync, readFileSync, readdirSync, rmSync, rmdirSync, writeFileSync
|
|
|
4
4
|
import { dirname, join, resolve } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { Command } from "commander";
|
|
7
|
-
import { cancel, isCancel, log, multiselect } from "@clack/prompts";
|
|
7
|
+
import { cancel, isCancel, log, multiselect, select } from "@clack/prompts";
|
|
8
8
|
import { parse as parseYaml } from "yaml";
|
|
9
9
|
const ALIAS_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
|
10
|
+
const REMOTE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/;
|
|
10
11
|
const ALLOWED_TOP_KEYS = new Set(["repos"]);
|
|
11
|
-
const ALLOWED_ITEM_KEYS = new Set(["alias", "
|
|
12
|
+
const ALLOWED_ITEM_KEYS = new Set(["alias", "remotes", "branch"]);
|
|
12
13
|
const MANIFEST_FILENAME = "oms.yaml";
|
|
13
14
|
const DATA_DIRNAME = "oms";
|
|
14
15
|
const GITIGNORE_ENTRY = `${DATA_DIRNAME}/`;
|
|
@@ -17,6 +18,9 @@ const LEGACY_MANIFEST = "sources.yaml";
|
|
|
17
18
|
const LEGACY_DATA_DIRNAME = "sources";
|
|
18
19
|
const RENAME_MIGRATION_DOC = "docs/migrations/0.3.x-to-0.4.0.md";
|
|
19
20
|
const WORKTREE_MIGRATION_DOC = "docs/migrations/0.5.x-to-0.6.0.md";
|
|
21
|
+
const REMOTES_MIGRATION_DOC = "docs/migrations/0.6.x-to-0.7.0.md";
|
|
22
|
+
/** GitHub blob base for the clickable doc permalinks shown in CLI messages. */
|
|
23
|
+
const DOCS_REPO_BLOB_BASE = "https://github.com/divlook/oh-my-space/blob";
|
|
20
24
|
const MIN_GIT_MAJOR = 2;
|
|
21
25
|
const MIN_GIT_MINOR = 40;
|
|
22
26
|
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
@@ -47,6 +51,10 @@ function validateSources(data) {
|
|
|
47
51
|
throw new Error(`${MANIFEST_FILENAME}: ${where} must be a mapping`);
|
|
48
52
|
}
|
|
49
53
|
const r = item;
|
|
54
|
+
// Friendlier than "unknown key url": the 0.7.0 manifest replaced url with a remotes mapping.
|
|
55
|
+
if ("url" in r && !("remotes" in r)) {
|
|
56
|
+
throw new Error(`${MANIFEST_FILENAME}: ${where}.url is no longer supported; use a "remotes" mapping with an "origin" entry. See ${docUrl(REMOTES_MIGRATION_DOC)}`);
|
|
57
|
+
}
|
|
50
58
|
for (const key of Object.keys(r)) {
|
|
51
59
|
if (!ALLOWED_ITEM_KEYS.has(key)) {
|
|
52
60
|
throw new Error(`${MANIFEST_FILENAME}: ${where} has unknown key "${key}"`);
|
|
@@ -62,8 +70,25 @@ function validateSources(data) {
|
|
|
62
70
|
throw new Error(`${MANIFEST_FILENAME}: duplicate alias "${r.alias}"`);
|
|
63
71
|
}
|
|
64
72
|
seen.add(r.alias);
|
|
65
|
-
if (typeof r.
|
|
66
|
-
throw new Error(`${MANIFEST_FILENAME}: ${where} missing required "
|
|
73
|
+
if (!r.remotes || typeof r.remotes !== "object" || Array.isArray(r.remotes)) {
|
|
74
|
+
throw new Error(`${MANIFEST_FILENAME}: ${where} missing required "remotes" mapping`);
|
|
75
|
+
}
|
|
76
|
+
const remoteEntries = Object.entries(r.remotes);
|
|
77
|
+
if (remoteEntries.length === 0) {
|
|
78
|
+
throw new Error(`${MANIFEST_FILENAME}: ${where}.remotes must have at least one remote`);
|
|
79
|
+
}
|
|
80
|
+
const remotes = {};
|
|
81
|
+
for (const [name, url] of remoteEntries) {
|
|
82
|
+
if (!REMOTE_NAME_PATTERN.test(name)) {
|
|
83
|
+
throw new Error(`${MANIFEST_FILENAME}: ${where}.remotes name "${name}" must match ${REMOTE_NAME_PATTERN}`);
|
|
84
|
+
}
|
|
85
|
+
if (typeof url !== "string" || url.length === 0) {
|
|
86
|
+
throw new Error(`${MANIFEST_FILENAME}: ${where}.remotes.${name} must be a non-empty string URL`);
|
|
87
|
+
}
|
|
88
|
+
remotes[name] = url;
|
|
89
|
+
}
|
|
90
|
+
if (!remotes.origin) {
|
|
91
|
+
throw new Error(`${MANIFEST_FILENAME}: ${where}.remotes must include an "origin" entry`);
|
|
67
92
|
}
|
|
68
93
|
let branch;
|
|
69
94
|
if (r.branch !== undefined) {
|
|
@@ -72,19 +97,27 @@ function validateSources(data) {
|
|
|
72
97
|
}
|
|
73
98
|
branch = r.branch;
|
|
74
99
|
}
|
|
75
|
-
validated.push({ alias: r.alias,
|
|
100
|
+
validated.push({ alias: r.alias, remotes, branch });
|
|
76
101
|
});
|
|
77
102
|
return validated;
|
|
78
103
|
}
|
|
79
104
|
function pad(s, width) {
|
|
80
105
|
return s + " ".repeat(Math.max(0, width - s.length));
|
|
81
106
|
}
|
|
107
|
+
/** Names of a repo's non-origin remotes, in declared order (origin is shown via its URL column). */
|
|
108
|
+
function extraRemoteNames(repo) {
|
|
109
|
+
return Object.keys(repo.remotes).filter((name) => name !== "origin");
|
|
110
|
+
}
|
|
82
111
|
function printList(repos) {
|
|
112
|
+
const extras = (r) => {
|
|
113
|
+
const names = extraRemoteNames(r);
|
|
114
|
+
return names.length > 0 ? ` (+${names.join(",")})` : "";
|
|
115
|
+
};
|
|
83
116
|
const aliasW = Math.max("ALIAS".length, ...repos.map((r) => r.alias.length));
|
|
84
|
-
const urlW = Math.max("
|
|
85
|
-
console.log(dim(`${pad("ALIAS", aliasW)} ${pad("
|
|
117
|
+
const urlW = Math.max("ORIGIN".length, ...repos.map((r) => (r.remotes.origin + extras(r)).length));
|
|
118
|
+
console.log(dim(`${pad("ALIAS", aliasW)} ${pad("ORIGIN", urlW)} BRANCH`));
|
|
86
119
|
for (const r of repos) {
|
|
87
|
-
console.log(`${pad(r.alias, aliasW)} ${pad(r.
|
|
120
|
+
console.log(`${pad(r.alias, aliasW)} ${pad(r.remotes.origin + extras(r), urlW)} ${r.branch ?? ""}`);
|
|
88
121
|
}
|
|
89
122
|
}
|
|
90
123
|
async function selectInteractive(repos, actionLabel) {
|
|
@@ -105,6 +138,56 @@ async function selectInteractive(repos, actionLabel) {
|
|
|
105
138
|
.map((alias) => repos.find((r) => r.alias === alias))
|
|
106
139
|
.filter((r) => r !== undefined);
|
|
107
140
|
}
|
|
141
|
+
/**
|
|
142
|
+
* Decide which remote(s) a fetch/pull/push targets for one repo. Honors an explicit --remote list,
|
|
143
|
+
* otherwise prompts interactively on a TTY (origin preselected) and falls back to origin off-TTY.
|
|
144
|
+
* pull is restricted to a single remote since --ff-only can advance to at most one. Returns the
|
|
145
|
+
* resolved remote names, or null when the request is invalid or the prompt was cancelled.
|
|
146
|
+
*/
|
|
147
|
+
async function resolveRemotes(repo, requested, command) {
|
|
148
|
+
const declared = Object.keys(repo.remotes);
|
|
149
|
+
if (requested && requested.length > 0) {
|
|
150
|
+
const unique = uniqueAliases(requested);
|
|
151
|
+
const unknown = unique.filter((name) => !declared.includes(name));
|
|
152
|
+
if (unknown.length > 0) {
|
|
153
|
+
log.error(`${repo.alias}: unknown remote(s): ${unknown.join(", ")}. Declared: ${declared.join(", ")}.`);
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
if (command === "pull" && unique.length > 1) {
|
|
157
|
+
log.error(`${repo.alias}: pull targets a single remote (git pull --ff-only can advance only one).`);
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
return unique;
|
|
161
|
+
}
|
|
162
|
+
// No explicit remote: a lone origin needs no prompt, and a non-interactive shell defaults to origin.
|
|
163
|
+
if (declared.length === 1)
|
|
164
|
+
return declared;
|
|
165
|
+
if (!process.stdin.isTTY)
|
|
166
|
+
return ["origin"];
|
|
167
|
+
if (command === "pull") {
|
|
168
|
+
const choice = await select({
|
|
169
|
+
message: `${repo.alias}: select a remote to ${command}`,
|
|
170
|
+
options: declared.map((name) => ({ value: name, label: name, hint: repo.remotes[name] })),
|
|
171
|
+
initialValue: "origin",
|
|
172
|
+
});
|
|
173
|
+
if (isCancel(choice)) {
|
|
174
|
+
cancel("Cancelled.");
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
return [choice];
|
|
178
|
+
}
|
|
179
|
+
const choice = await multiselect({
|
|
180
|
+
message: `${repo.alias}: select remote(s) to ${command} (space to toggle, enter to confirm)`,
|
|
181
|
+
options: declared.map((name) => ({ value: name, label: name, hint: repo.remotes[name] })),
|
|
182
|
+
initialValues: ["origin"],
|
|
183
|
+
required: true,
|
|
184
|
+
});
|
|
185
|
+
if (isCancel(choice)) {
|
|
186
|
+
cancel("Cancelled.");
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
return choice;
|
|
190
|
+
}
|
|
108
191
|
function runGit(cwd, args, inheritOutput = false) {
|
|
109
192
|
const result = spawnSync("git", args, {
|
|
110
193
|
cwd,
|
|
@@ -155,10 +238,10 @@ function submodulePath(alias) {
|
|
|
155
238
|
function isGitRepo(repoRoot) {
|
|
156
239
|
return runGit(repoRoot, ["rev-parse", "--is-inside-work-tree"]).stdout.trim() === "true";
|
|
157
240
|
}
|
|
158
|
-
/**
|
|
159
|
-
function
|
|
241
|
+
/** Every submodule path registered in .gitmodules (empty when the file is absent or registers none). */
|
|
242
|
+
function registeredSubmodulePaths(repoRoot) {
|
|
160
243
|
if (!existsSync(join(repoRoot, ".gitmodules")))
|
|
161
|
-
return
|
|
244
|
+
return [];
|
|
162
245
|
const result = runGit(repoRoot, [
|
|
163
246
|
"config",
|
|
164
247
|
"--file",
|
|
@@ -167,10 +250,19 @@ function isRegisteredSubmodule(repoRoot, sourcePath) {
|
|
|
167
250
|
"^submodule\\..*\\.path$",
|
|
168
251
|
]);
|
|
169
252
|
if (!result.success)
|
|
170
|
-
return
|
|
253
|
+
return [];
|
|
171
254
|
return result.stdout
|
|
172
255
|
.split("\n")
|
|
173
|
-
.
|
|
256
|
+
.map((line) => line.trim().split(/\s+/)[1])
|
|
257
|
+
.filter((p) => Boolean(p));
|
|
258
|
+
}
|
|
259
|
+
/** True when .gitmodules registers a submodule at the given path (sources/<alias> or oms/<alias>). */
|
|
260
|
+
function isRegisteredSubmodule(repoRoot, sourcePath) {
|
|
261
|
+
return registeredSubmodulePaths(repoRoot).includes(sourcePath);
|
|
262
|
+
}
|
|
263
|
+
/** True when .gitmodules still registers at least one submodule. */
|
|
264
|
+
function hasRegisteredSubmodules(repoRoot) {
|
|
265
|
+
return registeredSubmodulePaths(repoRoot).length > 0;
|
|
174
266
|
}
|
|
175
267
|
/** A submodule is initialized when its working tree has a .git gitlink file/dir. */
|
|
176
268
|
function submoduleInitialized(repoRoot, alias) {
|
|
@@ -232,6 +324,21 @@ function attachBranch(repoRoot, alias, branch) {
|
|
|
232
324
|
runSub(repoRoot, alias, ["branch", "--set-upstream-to", `origin/${branch}`, branch]);
|
|
233
325
|
}
|
|
234
326
|
}
|
|
327
|
+
/**
|
|
328
|
+
* Reconcile the submodule's git remotes with the declared `remotes` map: add missing remotes and
|
|
329
|
+
* update URLs that drifted. Non-destructive — remotes no longer in oms.yaml are left untouched.
|
|
330
|
+
*/
|
|
331
|
+
function ensureRemotes(repoRoot, alias, remotes) {
|
|
332
|
+
for (const [name, url] of Object.entries(remotes)) {
|
|
333
|
+
const existing = runSub(repoRoot, alias, ["remote", "get-url", name]);
|
|
334
|
+
if (!existing.success) {
|
|
335
|
+
runSub(repoRoot, alias, ["remote", "add", name, url]);
|
|
336
|
+
}
|
|
337
|
+
else if (existing.stdout.trim() !== url) {
|
|
338
|
+
runSub(repoRoot, alias, ["remote", "set-url", name, url]);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
235
342
|
/** Stage the submodule gitlink in the parent so a moved pointer shows up and is ready to commit. */
|
|
236
343
|
function stagePointer(repoRoot, alias) {
|
|
237
344
|
runGit(repoRoot, ["add", "--", submodulePath(alias)]);
|
|
@@ -245,7 +352,7 @@ function emitLegacyRenameMessage(dir, found) {
|
|
|
245
352
|
.join(" and ");
|
|
246
353
|
log.error(`detected legacy ${artifacts} at ${dir}.\n` +
|
|
247
354
|
` oh-my-space 0.4.0 renamed the manifest to ${MANIFEST_FILENAME} and the data directory to ${DATA_DIRNAME}/.\n` +
|
|
248
|
-
` See ${RENAME_MIGRATION_DOC} for the manual steps. Aborting to avoid destructive change.`);
|
|
355
|
+
` See ${docUrl(RENAME_MIGRATION_DOC)} for the manual steps. Aborting to avoid destructive change.`);
|
|
249
356
|
}
|
|
250
357
|
/** Block when the active oms.yaml workspace still has 0.3.x rename artifacts at its root. */
|
|
251
358
|
function abortOnLegacyRenameAt(repoRoot) {
|
|
@@ -283,7 +390,7 @@ function abortOnLegacyWorktree(repoRoot, repos) {
|
|
|
283
390
|
if (!stale)
|
|
284
391
|
return false;
|
|
285
392
|
log.error(`detected a legacy bare clone at ${DATA_DIRNAME}/${stale.alias}/.bare. oh-my-space 0.6.0 manages sources as git submodules.\n` +
|
|
286
|
-
` See ${WORKTREE_MIGRATION_DOC} for the manual steps. Aborting to avoid destructive change.`);
|
|
393
|
+
` See ${docUrl(WORKTREE_MIGRATION_DOC)} for the manual steps. Aborting to avoid destructive change.`);
|
|
287
394
|
return true;
|
|
288
395
|
}
|
|
289
396
|
/** Submodules live inside the parent's history, so oms/ must not be gitignored. Strip a managed entry. */
|
|
@@ -347,11 +454,11 @@ function syncRepo(repo, repoRoot) {
|
|
|
347
454
|
"ls-remote",
|
|
348
455
|
"--exit-code",
|
|
349
456
|
"--heads",
|
|
350
|
-
repo.
|
|
457
|
+
repo.remotes.origin,
|
|
351
458
|
repo.branch,
|
|
352
459
|
]);
|
|
353
460
|
if (lsRemote.exitCode === 2) {
|
|
354
|
-
log.error(`${alias}: branch "${repo.branch}" not found on ${repo.
|
|
461
|
+
log.error(`${alias}: branch "${repo.branch}" not found on ${repo.remotes.origin}. Push the branch upstream or fix the alias, then retry.`);
|
|
355
462
|
return "failed";
|
|
356
463
|
}
|
|
357
464
|
if (!lsRemote.success && lsRemote.exitCode !== 2) {
|
|
@@ -364,14 +471,15 @@ function syncRepo(repo, repoRoot) {
|
|
|
364
471
|
if (!existsSync(gitmodules) && runGit(repoRoot, ["cat-file", "-e", "HEAD:.gitmodules"]).success) {
|
|
365
472
|
writeFileSync(gitmodules, "");
|
|
366
473
|
}
|
|
367
|
-
log.step(`${alias}: git submodule add${repo.branch ? ` -b ${repo.branch}` : ""} ${repo.
|
|
368
|
-
const args = ["submodule", "add", ...(repo.branch ? ["-b", repo.branch] : []), "--", repo.
|
|
474
|
+
log.step(`${alias}: git submodule add${repo.branch ? ` -b ${repo.branch}` : ""} ${repo.remotes.origin} ${path}`);
|
|
475
|
+
const args = ["submodule", "add", ...(repo.branch ? ["-b", repo.branch] : []), "--", repo.remotes.origin, path];
|
|
369
476
|
const add = runGit(repoRoot, args, true);
|
|
370
477
|
if (!add.success) {
|
|
371
478
|
log.error(`${alias}: git submodule add failed (exit ${add.exitCode})`);
|
|
372
479
|
cleanupFailedAdd(repoRoot, alias);
|
|
373
480
|
return "failed";
|
|
374
481
|
}
|
|
482
|
+
ensureRemotes(repoRoot, alias, repo.remotes);
|
|
375
483
|
const branch = repo.branch ?? currentBranch(aliasDir(repoRoot, alias));
|
|
376
484
|
if (branch)
|
|
377
485
|
attachBranch(repoRoot, alias, branch);
|
|
@@ -385,12 +493,14 @@ function syncRepo(repo, repoRoot) {
|
|
|
385
493
|
log.error(`${alias}: git submodule update --init failed (exit ${upd.exitCode})`);
|
|
386
494
|
return "failed";
|
|
387
495
|
}
|
|
496
|
+
ensureRemotes(repoRoot, alias, repo.remotes);
|
|
388
497
|
const branch = gitmodulesBranch(repoRoot, alias) ?? repo.branch;
|
|
389
498
|
if (branch)
|
|
390
499
|
attachBranch(repoRoot, alias, branch);
|
|
391
500
|
log.success(`${alias}: initialized${branch ? ` (branch=${branch})` : ""}`);
|
|
392
501
|
return "added";
|
|
393
502
|
}
|
|
503
|
+
ensureRemotes(repoRoot, alias, repo.remotes);
|
|
394
504
|
log.step(`${alias}: git fetch origin --prune`);
|
|
395
505
|
const fetch = runSub(repoRoot, alias, ["fetch", "origin", "--prune"], true);
|
|
396
506
|
if (!fetch.success) {
|
|
@@ -411,22 +521,34 @@ function unsyncRepo(repo, repoRoot, force) {
|
|
|
411
521
|
if (!registered && !exists)
|
|
412
522
|
return "nothing-to-remove";
|
|
413
523
|
if (!force && submoduleInitialized(repoRoot, alias) && isDirty(aliasDir(repoRoot, alias))) {
|
|
414
|
-
log.error(`${alias}: ${path} has uncommitted changes. Commit, stash, or pass --force.`);
|
|
524
|
+
log.error(`${alias}: ${path} has uncommitted or untracked changes. Commit, stash, remove them, or pass --force.`);
|
|
415
525
|
return "failed";
|
|
416
526
|
}
|
|
417
527
|
runGit(repoRoot, ["submodule", "deinit", ...(force ? ["-f"] : []), "--", path]);
|
|
418
528
|
const rm = runGit(repoRoot, ["rm", "-f", "--", path], true);
|
|
419
529
|
if (!rm.success) {
|
|
420
|
-
//
|
|
530
|
+
// git rm couldn't stage the removal (e.g. the submodule was never initialized).
|
|
421
531
|
runGit(repoRoot, ["rm", "-f", "--cached", "--", path]);
|
|
422
|
-
runGit(repoRoot, ["config", "--file", ".gitmodules", "--remove-section", `submodule.${path}`]);
|
|
423
|
-
if (existsSync(join(repoRoot, ".gitmodules")))
|
|
424
|
-
runGit(repoRoot, ["add", ".gitmodules"]);
|
|
425
532
|
}
|
|
533
|
+
// Always strip the registration explicitly: git rm's implicit .gitmodules edit is unreliable
|
|
534
|
+
// across git versions/states, and when it silently no-ops the section is orphaned for good.
|
|
535
|
+
// A missing section just makes these exit non-zero — harmless, output stays captured.
|
|
536
|
+
runGit(repoRoot, ["config", "--file", ".gitmodules", "--remove-section", `submodule.${path}`]);
|
|
537
|
+
if (existsSync(join(repoRoot, ".gitmodules")))
|
|
538
|
+
runGit(repoRoot, ["add", ".gitmodules"]);
|
|
539
|
+
// Drop the matching .git/config section too, in case deinit was skipped or failed.
|
|
540
|
+
runGit(repoRoot, ["config", "--remove-section", `submodule.${path}`]);
|
|
426
541
|
try {
|
|
427
542
|
rmSync(join(repoRoot, ".git", "modules", DATA_DIRNAME, alias), { recursive: true, force: true });
|
|
428
543
|
}
|
|
429
544
|
catch { }
|
|
545
|
+
// Drop the now-empty .git/modules/oms/ container so no stale gitdir scaffolding lingers.
|
|
546
|
+
try {
|
|
547
|
+
const modulesParent = join(repoRoot, ".git", "modules", DATA_DIRNAME);
|
|
548
|
+
if (existsSync(modulesParent) && readdirSync(modulesParent).length === 0)
|
|
549
|
+
rmdirSync(modulesParent);
|
|
550
|
+
}
|
|
551
|
+
catch { }
|
|
430
552
|
if (existsSync(aliasDir(repoRoot, alias))) {
|
|
431
553
|
rmSync(aliasDir(repoRoot, alias), { recursive: true, force: true });
|
|
432
554
|
}
|
|
@@ -437,30 +559,32 @@ function unsyncRepo(repo, repoRoot, force) {
|
|
|
437
559
|
rmdirSync(parent);
|
|
438
560
|
}
|
|
439
561
|
catch { }
|
|
440
|
-
// Drop
|
|
562
|
+
// Drop .gitmodules once it no longer registers any submodule (judged by content, not byte-emptiness).
|
|
441
563
|
const gitmodules = join(repoRoot, ".gitmodules");
|
|
442
|
-
if (existsSync(gitmodules) &&
|
|
564
|
+
if (existsSync(gitmodules) && !hasRegisteredSubmodules(repoRoot)) {
|
|
443
565
|
if (!runGit(repoRoot, ["rm", "-f", "--", ".gitmodules"]).success) {
|
|
444
566
|
rmSync(gitmodules, { force: true });
|
|
445
567
|
}
|
|
446
568
|
}
|
|
447
569
|
return "removed";
|
|
448
570
|
}
|
|
449
|
-
function fetchRepo(repo, repoRoot) {
|
|
571
|
+
function fetchRepo(repo, repoRoot, remotes) {
|
|
450
572
|
if (!submoduleInitialized(repoRoot, repo.alias)) {
|
|
451
573
|
log.error(`${repo.alias}: not synced. Run "oms sync ${repo.alias}" first.`);
|
|
452
574
|
return "failed";
|
|
453
575
|
}
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
576
|
+
for (const remote of remotes) {
|
|
577
|
+
log.step(`${repo.alias}: git fetch ${remote} --prune`);
|
|
578
|
+
const r = runSub(repoRoot, repo.alias, ["fetch", remote, "--prune"], true);
|
|
579
|
+
if (!r.success) {
|
|
580
|
+
log.error(`${repo.alias}: fetch ${remote} failed (exit ${r.exitCode})`);
|
|
581
|
+
return "failed";
|
|
582
|
+
}
|
|
459
583
|
}
|
|
460
|
-
log.
|
|
461
|
-
return "
|
|
584
|
+
log.success(`${repo.alias}: fetched (${remotes.join(", ")})`);
|
|
585
|
+
return "fetched";
|
|
462
586
|
}
|
|
463
|
-
function pullRepo(repo, repoRoot) {
|
|
587
|
+
function pullRepo(repo, repoRoot, remote) {
|
|
464
588
|
if (!submoduleInitialized(repoRoot, repo.alias)) {
|
|
465
589
|
log.error(`${repo.alias}: not synced. Run "oms sync ${repo.alias}" first.`);
|
|
466
590
|
return "failed";
|
|
@@ -470,17 +594,17 @@ function pullRepo(repo, repoRoot) {
|
|
|
470
594
|
log.error(`${repo.alias}: detached HEAD. Run "oms checkout ${repo.alias} <branch>" before pulling.`);
|
|
471
595
|
return "failed";
|
|
472
596
|
}
|
|
473
|
-
log.step(`${repo.alias}/${branch}: git pull --ff-only`);
|
|
474
|
-
const r = runSub(repoRoot, repo.alias, ["pull", "--ff-only"], true);
|
|
597
|
+
log.step(`${repo.alias}/${branch}: git pull --ff-only ${remote} ${branch}`);
|
|
598
|
+
const r = runSub(repoRoot, repo.alias, ["pull", "--ff-only", remote, branch], true);
|
|
475
599
|
if (r.success) {
|
|
476
600
|
stagePointer(repoRoot, repo.alias);
|
|
477
|
-
log.success(`${repo.alias}/${branch}: pulled`);
|
|
601
|
+
log.success(`${repo.alias}/${branch}: pulled from ${remote}`);
|
|
478
602
|
return "pulled";
|
|
479
603
|
}
|
|
480
|
-
log.error(`${repo.alias}/${branch}: pull failed (exit ${r.exitCode})`);
|
|
604
|
+
log.error(`${repo.alias}/${branch}: pull from ${remote} failed (exit ${r.exitCode})`);
|
|
481
605
|
return "failed";
|
|
482
606
|
}
|
|
483
|
-
function pushRepo(repo, repoRoot, commit) {
|
|
607
|
+
function pushRepo(repo, repoRoot, remotes, commit) {
|
|
484
608
|
if (!submoduleInitialized(repoRoot, repo.alias)) {
|
|
485
609
|
log.error(`${repo.alias}: not synced. Run "oms sync ${repo.alias}" first.`);
|
|
486
610
|
return "failed";
|
|
@@ -490,18 +614,22 @@ function pushRepo(repo, repoRoot, commit) {
|
|
|
490
614
|
log.error(`${repo.alias}: detached HEAD. Run "oms checkout ${repo.alias} <branch>" before pushing.`);
|
|
491
615
|
return "failed";
|
|
492
616
|
}
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
log.
|
|
497
|
-
|
|
617
|
+
for (const remote of remotes) {
|
|
618
|
+
// Only origin sets upstream — repointing @{u} to a fork would skew "oms status" ahead/behind.
|
|
619
|
+
const args = remote === "origin" ? ["push", "-u", "origin", branch] : ["push", remote, branch];
|
|
620
|
+
log.step(`${repo.alias}/${branch}: git ${args.join(" ")}`);
|
|
621
|
+
const r = runSub(repoRoot, repo.alias, args, true);
|
|
622
|
+
if (!r.success) {
|
|
623
|
+
log.error(`${repo.alias}/${branch}: push to ${remote} failed (exit ${r.exitCode})`);
|
|
624
|
+
return "failed";
|
|
625
|
+
}
|
|
498
626
|
}
|
|
499
627
|
stagePointer(repoRoot, repo.alias);
|
|
500
628
|
if (commit) {
|
|
501
629
|
const sha = shortSha(aliasDir(repoRoot, repo.alias));
|
|
502
630
|
runGit(repoRoot, ["commit", "-m", `oms: bump ${repo.alias} to ${sha}`, "--", submodulePath(repo.alias)]);
|
|
503
631
|
}
|
|
504
|
-
log.success(`${repo.alias}/${branch}: pushed${commit ? " and recorded pointer" : ""}`);
|
|
632
|
+
log.success(`${repo.alias}/${branch}: pushed to ${remotes.join(", ")}${commit ? " and recorded pointer" : ""}`);
|
|
505
633
|
return "pushed";
|
|
506
634
|
}
|
|
507
635
|
function printSummary(results) {
|
|
@@ -616,13 +744,20 @@ async function runManage(command, aliases, options) {
|
|
|
616
744
|
const picked = await selectRepos(repos, aliases, options, command);
|
|
617
745
|
if (!picked || picked.length === 0)
|
|
618
746
|
return 1;
|
|
619
|
-
const results =
|
|
747
|
+
const results = [];
|
|
748
|
+
for (const repo of picked) {
|
|
749
|
+
const remotes = await resolveRemotes(repo, options.remote, command);
|
|
750
|
+
if (!remotes || remotes.length === 0) {
|
|
751
|
+
results.push("failed");
|
|
752
|
+
continue;
|
|
753
|
+
}
|
|
620
754
|
if (command === "fetch")
|
|
621
|
-
|
|
622
|
-
if (command === "pull")
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
755
|
+
results.push(fetchRepo(repo, repoRoot, remotes));
|
|
756
|
+
else if (command === "pull")
|
|
757
|
+
results.push(pullRepo(repo, repoRoot, remotes[0]));
|
|
758
|
+
else
|
|
759
|
+
results.push(pushRepo(repo, repoRoot, remotes, options.commit ?? false));
|
|
760
|
+
}
|
|
626
761
|
if (results.length > 1 || options.all)
|
|
627
762
|
printSummary(results);
|
|
628
763
|
return exitFromResults(results);
|
|
@@ -650,6 +785,11 @@ async function runUnsync(aliases, options) {
|
|
|
650
785
|
});
|
|
651
786
|
if (results.length > 1 || options.all)
|
|
652
787
|
printSummary(results);
|
|
788
|
+
// Name the failed aliases so a buried failure among several isn't read as "all unsynced".
|
|
789
|
+
const failed = picked.filter((_, i) => results[i] === "failed").map((r) => r.alias);
|
|
790
|
+
if (failed.length > 0) {
|
|
791
|
+
log.error(`Not unsynced: ${failed.join(", ")}. The submodule had uncommitted or untracked changes — commit/stash/remove them, or re-run with --force.`);
|
|
792
|
+
}
|
|
653
793
|
return exitFromResults(results);
|
|
654
794
|
}
|
|
655
795
|
async function runCheckout(alias, branch, options) {
|
|
@@ -763,7 +903,9 @@ async function runStatus(aliases, options) {
|
|
|
763
903
|
const INIT_TEMPLATE = `# yaml-language-server: $schema=https://raw.githubusercontent.com/divlook/oh-my-space/main/oms.schema.json
|
|
764
904
|
repos:
|
|
765
905
|
- alias: example
|
|
766
|
-
|
|
906
|
+
remotes:
|
|
907
|
+
origin: git@github.com:example/repo.git
|
|
908
|
+
# upstream: git@github.com:upstream/repo.git
|
|
767
909
|
branch: main
|
|
768
910
|
`;
|
|
769
911
|
/** 현재 디렉터리에 기본 oms.yaml 템플릿을 생성합니다. */
|
|
@@ -780,7 +922,7 @@ async function runInit(options) {
|
|
|
780
922
|
if (!isGitRepo(process.cwd())) {
|
|
781
923
|
log.info(`oms manages sources as git submodules; run "git init" here if this is not a git repo yet.`);
|
|
782
924
|
}
|
|
783
|
-
log.info(`edit alias/
|
|
925
|
+
log.info(`edit alias/remotes/branch, then run "oms sync".`);
|
|
784
926
|
return 0;
|
|
785
927
|
}
|
|
786
928
|
async function runDoctor() {
|
|
@@ -857,6 +999,16 @@ function readPackageVersion() {
|
|
|
857
999
|
const pkg = readJson(join(packageRoot, "package.json"));
|
|
858
1000
|
return pkg?.version ?? "0.0.0";
|
|
859
1001
|
}
|
|
1002
|
+
/** The commit baked in at build time, or null when unavailable (dev/no-git build). */
|
|
1003
|
+
function readBuildCommit() {
|
|
1004
|
+
const info = readJson(join(dirname(fileURLToPath(import.meta.url)), "build-info.json"));
|
|
1005
|
+
return info?.commit ?? null;
|
|
1006
|
+
}
|
|
1007
|
+
/** Clickable GitHub permalink for a repo doc, pinned to the build commit; falls back to the version tag. */
|
|
1008
|
+
function docUrl(relPath) {
|
|
1009
|
+
const ref = readBuildCommit() ?? `v${readPackageVersion()}`;
|
|
1010
|
+
return `${DOCS_REPO_BLOB_BASE}/${ref}/${relPath}`;
|
|
1011
|
+
}
|
|
860
1012
|
async function exitWith(action) {
|
|
861
1013
|
try {
|
|
862
1014
|
process.exit(await action);
|
|
@@ -929,29 +1081,33 @@ program
|
|
|
929
1081
|
.action(async (alias, branch, options) => {
|
|
930
1082
|
await exitWith(runCheckout(alias, branch, options));
|
|
931
1083
|
});
|
|
1084
|
+
const collectRemote = (value, acc) => [...acc, value];
|
|
932
1085
|
program
|
|
933
1086
|
.command("fetch")
|
|
934
|
-
.description("Run git fetch
|
|
1087
|
+
.description("Run git fetch <remote> --prune in each submodule (defaults to origin).")
|
|
935
1088
|
.argument("[aliases...]", "repo aliases to fetch (omit for interactive multi-select)")
|
|
936
1089
|
.option("--all", "fetch every registered source repo")
|
|
1090
|
+
.option("--remote <name>", "remote to fetch (repeatable; omit to choose interactively)", collectRemote, [])
|
|
937
1091
|
.addHelpText("after", exitHelp)
|
|
938
1092
|
.action(async (aliases, options) => {
|
|
939
1093
|
await exitWith(runManage("fetch", aliases, options));
|
|
940
1094
|
});
|
|
941
1095
|
program
|
|
942
1096
|
.command("pull")
|
|
943
|
-
.description("Run git pull --ff-only on each submodule's current branch and stage the moved pointer.")
|
|
1097
|
+
.description("Run git pull --ff-only <remote> on each submodule's current branch and stage the moved pointer (defaults to origin).")
|
|
944
1098
|
.argument("[aliases...]", "repo aliases to pull (omit for interactive multi-select)")
|
|
945
1099
|
.option("--all", "pull every registered source repo")
|
|
1100
|
+
.option("--remote <name>", "remote to pull from (single; omit to choose interactively)", collectRemote, [])
|
|
946
1101
|
.addHelpText("after", exitHelp)
|
|
947
1102
|
.action(async (aliases, options) => {
|
|
948
1103
|
await exitWith(runManage("pull", aliases, options));
|
|
949
1104
|
});
|
|
950
1105
|
program
|
|
951
1106
|
.command("push")
|
|
952
|
-
.description("Run git push
|
|
1107
|
+
.description("Run git push <remote> <branch> on each listed submodule (creating the remote branch on first push) and stage the moved pointer (defaults to origin).")
|
|
953
1108
|
.argument("<aliases...>", "repo aliases to push")
|
|
954
1109
|
.option("--commit", "also commit the pointer update in the parent repo")
|
|
1110
|
+
.option("--remote <name>", "remote to push to (repeatable; omit to choose interactively)", collectRemote, [])
|
|
955
1111
|
.addHelpText("after", exitHelp)
|
|
956
1112
|
.action(async (aliases, options) => {
|
|
957
1113
|
await exitWith(runManage("push", aliases, options));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "oh-my-space",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.1",
|
|
4
4
|
"description": "CLI for managing external source repositories declared in oms.yaml.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://github.com/divlook/oh-my-space",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"node": ">=20.19.0"
|
|
36
36
|
},
|
|
37
37
|
"scripts": {
|
|
38
|
-
"build": "tsc -p tsconfig.build.json && node -e \"require('node:fs').chmodSync('dist/oms.js', 0o755)\"",
|
|
38
|
+
"build": "tsc -p tsconfig.build.json && node -e \"require('node:fs').chmodSync('dist/oms.js', 0o755)\" && node scripts/gen-build-info.mjs",
|
|
39
39
|
"test": "npm run build && node --test tests/*.test.js",
|
|
40
40
|
"prepack": "npm test",
|
|
41
41
|
"changeset": "changeset",
|