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 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
- url: git@github.com:example/api.git
52
+ remotes:
53
+ origin: git@github.com:example/api.git
53
54
  branch: main
54
55
  - alias: web
55
- url: git@github.com:example/web.git
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`, `url`, and optional `branch` (the baseline).
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 origin --prune` in each submodule. | |
105
- | `oms pull ...` | workspace root | `git pull --ff-only` on each submodule's current branch, then stages the moved pointer. | Requires the submodule to be on a branch with upstream. |
106
- | `oms push <alias>...` | workspace root | `git push -u origin <branch>` (creating the remote branch on first push), then stages the moved pointer. | `--commit` also commits the pointer update in the parent. |
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
- url: git@github.com:example/service-a.git
119
+ remotes:
120
+ origin: git@github.com:example/service-a.git
116
121
  branch: main
117
122
  - alias: docs
118
- url: https://github.com/example/docs.git
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
- - `url` is required.
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", "url", "branch"]);
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.url !== "string" || r.url.length === 0) {
66
- throw new Error(`${MANIFEST_FILENAME}: ${where} missing required "url"`);
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, url: r.url, branch });
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("URL".length, ...repos.map((r) => r.url.length));
85
- console.log(dim(`${pad("ALIAS", aliasW)} ${pad("URL", urlW)} BRANCH`));
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.url, urlW)} ${r.branch ?? ""}`);
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
- /** True when .gitmodules registers a submodule at the given path (sources/<alias> or oms/<alias>). */
159
- function isRegisteredSubmodule(repoRoot, sourcePath) {
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 false;
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 false;
253
+ return [];
171
254
  return result.stdout
172
255
  .split("\n")
173
- .some((line) => line.trim().split(/\s+/)[1] === sourcePath);
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.url,
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.url}. Push the branch upstream or fix the alias, then retry.`);
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.url} ${path}`);
368
- const args = ["submodule", "add", ...(repo.branch ? ["-b", repo.branch] : []), "--", repo.url, path];
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
- // Fall back to manual unregistration (e.g. the submodule was never initialized).
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 a .gitmodules that no longer registers any submodule (git rm leaves it empty).
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) && readFileSync(gitmodules, "utf8").trim().length === 0) {
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
- log.step(`${repo.alias}: git fetch origin --prune`);
455
- const r = runSub(repoRoot, repo.alias, ["fetch", "origin", "--prune"], true);
456
- if (r.success) {
457
- log.success(`${repo.alias}: fetched`);
458
- return "fetched";
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.error(`${repo.alias}: fetch failed (exit ${r.exitCode})`);
461
- return "failed";
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
- log.step(`${repo.alias}/${branch}: git push -u origin ${branch}`);
494
- const r = runSub(repoRoot, repo.alias, ["push", "-u", "origin", branch], true);
495
- if (!r.success) {
496
- log.error(`${repo.alias}/${branch}: push failed (exit ${r.exitCode})`);
497
- return "failed";
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 = picked.map((repo) => {
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
- return fetchRepo(repo, repoRoot);
622
- if (command === "pull")
623
- return pullRepo(repo, repoRoot);
624
- return pushRepo(repo, repoRoot, options.commit ?? false);
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
- url: git@github.com:example/repo.git
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/url/branch, then run "oms sync".`);
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 origin --prune in each submodule.")
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 -u origin <branch> on each listed submodule (creating the remote branch on first push) and stage the moved pointer.")
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.6.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",