oh-my-space 0.7.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,7 +10,7 @@ Command name: `oms`
10
10
 
11
11
  Git submodules give you three things for free: a **reproducible pin** (the parent records each source's exact commit), **visibility** (`git status` shows when a pointer moved), and **history** (the pointer travels with your commits). What they make awkward is day-to-day branch work. `oms` keeps the native benefits and removes the friction:
12
12
 
13
- - **Create a new branch locally, with no remote precondition.** `oms checkout <alias> <branch>` makes the branch right away even if it does not exist on the remote yet. The remote branch is created lazily on your first `oms push`.
13
+ - **Create a new branch locally, with no remote precondition.** `oms switch <alias> <branch>` makes the branch right away even if it does not exist on the remote yet; `oms checkout <alias> <branch>` fetches origin and checks out an existing remote branch as a tracking branch. The remote branch is created lazily on your first `oms push`.
14
14
  - **Always on a branch, never detached.** `oms sync` attaches the baseline branch at the pinned commit instead of leaving a detached HEAD.
15
15
  - **Pointer moves stay visible.** `oms pull` / `oms push` stage the updated gitlink in the parent so you can see and commit it; `oms status` surfaces drift.
16
16
 
@@ -67,16 +67,19 @@ oms sync --list # list registered source repos
67
67
  oms sync <alias>... # add/initialize submodules on their baseline branch
68
68
  oms sync --all # sync every registered source repo
69
69
  oms status # branch / pointer / dirty / ahead-behind per submodule
70
- oms checkout <alias> <branch> # switch (or create a brand-new local branch)
70
+ oms switch <alias> <branch> # switch to a local branch, or create a brand-new one
71
+ oms checkout <alias> <branch> # fetch origin, then check out a remote branch (tracking)
71
72
  oms push <alias>... # push the branch (lazy remote) + stage the pointer
72
73
  oms push <alias> --remote upstream # target a specific remote (repeatable; defaults to origin)
73
74
  oms unsync <alias>... # deinitialize and remove a submodule
74
75
  ```
75
76
 
77
+ For `oms switch` and `oms checkout`, omitting the alias and/or branch prompts you to pick one interactively (synced submodules, and local or `origin/*` branches respectively).
78
+
76
79
  A typical new-branch flow:
77
80
 
78
81
  ```bash
79
- oms checkout api feature/login # local branch, no remote needed
82
+ oms switch api feature/login # local branch, no remote needed
80
83
  # ... edit, commit inside oms/api ...
81
84
  oms push api # creates origin/feature/login and stages the pointer
82
85
  git add oms/api && git commit # record the pointer in your project history
@@ -104,7 +107,8 @@ oms/
104
107
  | `oms doctor` | project root or child path | Checks `oms.yaml`, git availability, that the workspace is a git repo, and each alias's submodule state. | Returns exit 2 if any warning is raised. |
105
108
  | `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. |
106
109
  | `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. |
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. |
110
+ | `oms switch [alias] [branch]` | workspace root | `git switch` to a LOCAL branch, creating it locally if it does not exist yet (no remote required). | `--from <ref>` sets the start point for a new branch. Omit alias/branch to pick interactively (or create a new branch). |
111
+ | `oms checkout [alias] [branch]` | workspace root | `git fetch origin --prune`, then check out a REMOTE branch (`origin/*`) as a local tracking branch (or switch to an existing local counterpart). | Omit alias/branch to pick interactively. To create a brand-new local branch, use `oms switch`. |
108
112
  | `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
113
  | `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
114
  | `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. |
@@ -138,6 +142,7 @@ JSON schema: [`oms.schema.json`](./oms.schema.json) (also reachable at `https://
138
142
 
139
143
  Detailed migration steps are organized per version under [`docs/migrations/`](./docs/migrations/).
140
144
 
145
+ - [0.7.x → 0.8.0](./docs/migrations/0.7.x-to-0.8.0.md) — splits `oms checkout` into `oms switch` (local branches) and `oms checkout` (remote branches)
141
146
  - [0.5.x → 0.6.0](./docs/migrations/0.5.x-to-0.6.0.md) — switches the data model from bare clone + worktrees back to git submodules
142
147
  - [0.3.x → 0.4.0](./docs/migrations/0.3.x-to-0.4.0.md) — renames `sources.yaml`/`sources/` to `oms.yaml`/`oms/`
143
148
  - [0.2.x → 0.3.0](./docs/migrations/0.2.x-to-0.3.0.md) — (historical) switched submodules to bare clone + worktrees
@@ -1 +1 @@
1
- {"commit":"b3b6890088ebe3eb5dd965d2fdd730a60b5a0a01"}
1
+ {"commit":"1151afc09f63460e2973421140929733dc020eda"}
package/dist/oms.js CHANGED
@@ -4,7 +4,7 @@ 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, select } from "@clack/prompts";
7
+ import { cancel, isCancel, log, multiselect, select, text } from "@clack/prompts";
8
8
  import { parse as parseYaml } from "yaml";
9
9
  const ALIAS_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
10
10
  const REMOTE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/;
@@ -138,6 +138,88 @@ async function selectInteractive(repos, actionLabel) {
138
138
  .map((alias) => repos.find((r) => r.alias === alias))
139
139
  .filter((r) => r !== undefined);
140
140
  }
141
+ /**
142
+ * Resolve a single alias for a per-repo branch command (switch/checkout). An explicit alias is
143
+ * validated and must be a synced submodule; when omitted, the user picks one interactively from the
144
+ * synced submodules. Returns null (with a clear message) on an unknown/unsynced alias, an empty set,
145
+ * a non-interactive shell, or cancellation.
146
+ */
147
+ async function resolveInitializedAlias(repos, repoRoot, alias, actionLabel) {
148
+ if (alias) {
149
+ const repo = repos.find((r) => r.alias === alias);
150
+ if (!repo) {
151
+ log.error(`Unknown alias "${alias}". Use "oms sync --list" to see registered aliases.`);
152
+ return null;
153
+ }
154
+ if (!submoduleInitialized(repoRoot, alias)) {
155
+ log.error(`${alias}: not synced. Run "oms sync ${alias}" first.`);
156
+ return null;
157
+ }
158
+ return repo;
159
+ }
160
+ const initialized = repos.filter((r) => submoduleInitialized(repoRoot, r.alias));
161
+ if (initialized.length === 0) {
162
+ log.error(`No synced submodules to ${actionLabel}. Run "oms sync" first.`);
163
+ return null;
164
+ }
165
+ if (!process.stdin.isTTY) {
166
+ log.error(`No alias given and stdin is not a TTY. Pass an alias: "oms ${actionLabel} <alias>".`);
167
+ return null;
168
+ }
169
+ const choice = await select({
170
+ message: `Select a source repo to ${actionLabel}`,
171
+ options: initialized.map((r) => ({
172
+ value: r.alias,
173
+ label: r.alias,
174
+ hint: r.branch ? `branch: ${r.branch}` : undefined,
175
+ })),
176
+ });
177
+ if (isCancel(choice)) {
178
+ cancel("Cancelled.");
179
+ return null;
180
+ }
181
+ return initialized.find((r) => r.alias === choice) ?? null;
182
+ }
183
+ /** Sentinel chosen in pickBranch to create a new branch instead of selecting an existing one. */
184
+ const CREATE_NEW_BRANCH = "\0create-new-branch";
185
+ /**
186
+ * Prompt for a branch from the given list. When allowCreate is set, a "create new branch" option
187
+ * collects a name via a text prompt. Returns null (with a clear message) on a non-interactive shell,
188
+ * an empty list with no create option, an empty name, or cancellation.
189
+ */
190
+ async function pickBranch(branches, message, allowCreate) {
191
+ if (!process.stdin.isTTY) {
192
+ log.error(`No branch given and stdin is not a TTY. Pass a branch name explicitly.`);
193
+ return null;
194
+ }
195
+ if (branches.length === 0 && !allowCreate) {
196
+ log.error(`No branches available to select.`);
197
+ return null;
198
+ }
199
+ const options = [
200
+ ...(allowCreate ? [{ value: CREATE_NEW_BRANCH, label: "+ create new branch" }] : []),
201
+ ...branches.map((b) => ({ value: b, label: b })),
202
+ ];
203
+ const choice = await select({ message, options });
204
+ if (isCancel(choice)) {
205
+ cancel("Cancelled.");
206
+ return null;
207
+ }
208
+ if (choice === CREATE_NEW_BRANCH) {
209
+ const name = await text({ message: "New branch name", placeholder: "feature/login" });
210
+ if (isCancel(name)) {
211
+ cancel("Cancelled.");
212
+ return null;
213
+ }
214
+ const trimmed = name.trim();
215
+ if (!trimmed) {
216
+ log.error("Branch name is empty.");
217
+ return null;
218
+ }
219
+ return trimmed;
220
+ }
221
+ return choice;
222
+ }
141
223
  /**
142
224
  * Decide which remote(s) a fetch/pull/push targets for one repo. Honors an explicit --remote list,
143
225
  * otherwise prompts interactively on a TTY (origin preselected) and falls back to origin off-TTY.
@@ -286,6 +368,25 @@ function localBranchExists(dir, branch) {
286
368
  function remoteBranchExists(dir, branch) {
287
369
  return runGit(dir, ["rev-parse", "--verify", "--quiet", `refs/remotes/origin/${branch}`]).success;
288
370
  }
371
+ /** Local branch short names (refs/heads), e.g. ["main", "dev"]. Empty on failure or none. */
372
+ function listLocalBranches(dir) {
373
+ const r = runGit(dir, ["for-each-ref", "--format=%(refname:short)", "refs/heads"]);
374
+ if (!r.success)
375
+ return [];
376
+ return r.stdout.split("\n").map((s) => s.trim()).filter((s) => s.length > 0);
377
+ }
378
+ /** Remote branch short names under origin, with the "origin/" prefix stripped and origin/HEAD excluded. */
379
+ function listRemoteBranches(dir) {
380
+ const r = runGit(dir, ["for-each-ref", "--format=%(refname:short)", "refs/remotes/origin"]);
381
+ if (!r.success)
382
+ return [];
383
+ return r.stdout
384
+ .split("\n")
385
+ .map((s) => s.trim())
386
+ .filter((s) => s.length > 0 && s !== "origin/HEAD")
387
+ .map((s) => (s.startsWith("origin/") ? s.slice("origin/".length) : s))
388
+ .filter((s) => s !== "HEAD");
389
+ }
289
390
  function isDirty(dir) {
290
391
  const r = runGit(dir, ["status", "--porcelain"]);
291
392
  return r.success && r.stdout.trim().length > 0;
@@ -591,7 +692,7 @@ function pullRepo(repo, repoRoot, remote) {
591
692
  }
592
693
  const branch = currentBranch(aliasDir(repoRoot, repo.alias));
593
694
  if (!branch) {
594
- log.error(`${repo.alias}: detached HEAD. Run "oms checkout ${repo.alias} <branch>" before pulling.`);
695
+ log.error(`${repo.alias}: detached HEAD. Run "oms switch ${repo.alias} <branch>" before pulling.`);
595
696
  return "failed";
596
697
  }
597
698
  log.step(`${repo.alias}/${branch}: git pull --ff-only ${remote} ${branch}`);
@@ -611,7 +712,7 @@ function pushRepo(repo, repoRoot, remotes, commit) {
611
712
  }
612
713
  const branch = currentBranch(aliasDir(repoRoot, repo.alias));
613
714
  if (!branch) {
614
- log.error(`${repo.alias}: detached HEAD. Run "oms checkout ${repo.alias} <branch>" before pushing.`);
715
+ log.error(`${repo.alias}: detached HEAD. Run "oms switch ${repo.alias} <branch>" before pushing.`);
615
716
  return "failed";
616
717
  }
617
718
  for (const remote of remotes) {
@@ -792,47 +893,87 @@ async function runUnsync(aliases, options) {
792
893
  }
793
894
  return exitFromResults(results);
794
895
  }
795
- async function runCheckout(alias, branch, options) {
896
+ /**
897
+ * LOCAL branch management: switch the submodule to an existing local branch or create a new one.
898
+ * No remote is consulted — creating a brand-new branch needs no remote precondition and sets no
899
+ * upstream (that is checkout's job). Omitting alias and/or branch prompts for them interactively.
900
+ */
901
+ async function runSwitch(alias, branch, options) {
796
902
  const loaded = loadForSubmodules();
797
903
  if (!loaded)
798
904
  return 1;
799
905
  const { repos, repoRoot } = loaded;
800
- const repo = repos.find((r) => r.alias === alias);
801
- if (!repo) {
802
- log.error(`Unknown alias "${alias}". Use "oms sync --list" to see registered aliases.`);
906
+ const repo = await resolveInitializedAlias(repos, repoRoot, alias, "switch");
907
+ if (!repo)
803
908
  return 1;
909
+ const dir = aliasDir(repoRoot, repo.alias);
910
+ let target = branch;
911
+ if (!target) {
912
+ const picked = await pickBranch(listLocalBranches(dir), `${repo.alias}: select a local branch`, true);
913
+ if (!picked)
914
+ return 1;
915
+ target = picked;
916
+ }
917
+ if (localBranchExists(dir, target)) {
918
+ log.step(`${repo.alias}: git switch ${target}`);
919
+ const r = runSub(repoRoot, repo.alias, ["switch", target], true);
920
+ if (!r.success)
921
+ return 2;
922
+ log.success(`${repo.alias}: on ${target}`);
923
+ return 0;
804
924
  }
805
- if (!submoduleInitialized(repoRoot, alias)) {
806
- log.error(`${alias}: not synced. Run "oms sync ${alias}" first.`);
925
+ // Brand-new local branch: no remote precondition, no upstream tracking (use "oms checkout" for that).
926
+ const args = ["switch", "-c", target, ...(options.from ? [options.from] : [])];
927
+ log.step(`${repo.alias}: git ${args.join(" ")}`);
928
+ const r = runSub(repoRoot, repo.alias, args, true);
929
+ if (!r.success)
930
+ return 2;
931
+ log.success(`${repo.alias}: created new local branch ${target}. Push it with "oms push ${repo.alias}".`);
932
+ return 0;
933
+ }
934
+ /**
935
+ * REMOTE branch exploration: fetch origin, then check out a remote branch as a local tracking
936
+ * branch (or switch to an existing local counterpart). Omitting alias and/or branch prompts for
937
+ * them interactively. Creating brand-new local branches is "oms switch"'s job.
938
+ */
939
+ async function runCheckout(alias, branch) {
940
+ const loaded = loadForSubmodules();
941
+ if (!loaded)
807
942
  return 1;
808
- }
809
- const dir = aliasDir(repoRoot, alias);
810
- // Best-effort refresh so an existing remote branch is recognized; offline is fine.
811
- runSub(repoRoot, alias, ["fetch", "origin", "--quiet"]);
812
- if (localBranchExists(dir, branch)) {
813
- log.step(`${alias}: git switch ${branch}`);
814
- const r = runSub(repoRoot, alias, ["switch", branch], true);
943
+ const { repos, repoRoot } = loaded;
944
+ const repo = await resolveInitializedAlias(repos, repoRoot, alias, "checkout");
945
+ if (!repo)
946
+ return 1;
947
+ const dir = aliasDir(repoRoot, repo.alias);
948
+ log.step(`${repo.alias}: git fetch origin --prune`);
949
+ const fetch = runSub(repoRoot, repo.alias, ["fetch", "origin", "--prune"], true);
950
+ if (!fetch.success)
951
+ return 2;
952
+ let target = branch;
953
+ if (!target) {
954
+ const picked = await pickBranch(listRemoteBranches(dir), `${repo.alias}: select a remote branch (origin/*)`, false);
955
+ if (!picked)
956
+ return 1;
957
+ target = picked;
958
+ }
959
+ if (localBranchExists(dir, target)) {
960
+ log.step(`${repo.alias}: git switch ${target}`);
961
+ const r = runSub(repoRoot, repo.alias, ["switch", target], true);
815
962
  if (!r.success)
816
963
  return 2;
817
- log.success(`${alias}: on ${branch}`);
964
+ log.success(`${repo.alias}: on ${target}`);
818
965
  return 0;
819
966
  }
820
- if (remoteBranchExists(dir, branch)) {
821
- log.step(`${alias}: git switch -c ${branch} origin/${branch}`);
822
- const r = runSub(repoRoot, alias, ["switch", "-c", branch, `origin/${branch}`], true);
967
+ if (remoteBranchExists(dir, target)) {
968
+ log.step(`${repo.alias}: git switch -c ${target} origin/${target}`);
969
+ const r = runSub(repoRoot, repo.alias, ["switch", "-c", target, `origin/${target}`], true);
823
970
  if (!r.success)
824
971
  return 2;
825
- log.success(`${alias}: on ${branch} (tracking origin/${branch})`);
972
+ log.success(`${repo.alias}: on ${target} (tracking origin/${target})`);
826
973
  return 0;
827
974
  }
828
- // Brand-new branch: created locally, no remote precondition. Push later with "oms push".
829
- const args = ["switch", "-c", branch, ...(options.from ? [options.from] : [])];
830
- log.step(`${alias}: git ${args.join(" ")}`);
831
- const r = runSub(repoRoot, alias, args, true);
832
- if (!r.success)
833
- return 2;
834
- log.success(`${alias}: created new local branch ${branch}. Push it with "oms push ${alias}".`);
835
- return 0;
975
+ log.error(`${repo.alias}: "${target}" not found on origin. To create a new local branch, run "oms switch ${repo.alias} ${target}".`);
976
+ return 1;
836
977
  }
837
978
  /** Parse the leading status char from `git submodule status` (' ' ok, '+' moved, '-' uninit, 'U' conflict). */
838
979
  function pinState(repoRoot, alias) {
@@ -978,7 +1119,7 @@ async function runDoctor() {
978
1119
  const dir = aliasDir(repoRoot, repo.alias);
979
1120
  const branch = currentBranch(dir);
980
1121
  if (!branch) {
981
- log.warn(`${repo.alias}: detached HEAD. Run "oms checkout ${repo.alias} <branch>" to get on a branch.`);
1122
+ log.warn(`${repo.alias}: detached HEAD. Run "oms switch ${repo.alias} <branch>" to get on a branch.`);
982
1123
  warnings++;
983
1124
  }
984
1125
  else {
@@ -1024,6 +1165,7 @@ const commandNames = new Set([
1024
1165
  "doctor",
1025
1166
  "sync",
1026
1167
  "status",
1168
+ "switch",
1027
1169
  "checkout",
1028
1170
  "fetch",
1029
1171
  "pull",
@@ -1072,14 +1214,23 @@ program
1072
1214
  await exitWith(runStatus(aliases, options));
1073
1215
  });
1074
1216
  program
1075
- .command("checkout")
1076
- .description("Switch a submodule to <branch>, creating it locally if it does not exist yet (no remote required).")
1077
- .argument("<alias>", "registered source alias")
1078
- .argument("<branch>", "branch name (may include slashes)")
1217
+ .command("switch")
1218
+ .description("Switch a submodule to a LOCAL branch, creating it locally if it does not exist yet (no remote required).")
1219
+ .argument("[alias]", "registered source alias (omit to pick interactively)")
1220
+ .argument("[branch]", "local branch name (omit to pick from local branches or create one)")
1079
1221
  .option("--from <ref>", "start point for a new branch (default: current HEAD)")
1080
1222
  .addHelpText("after", exitHelp)
1081
1223
  .action(async (alias, branch, options) => {
1082
- await exitWith(runCheckout(alias, branch, options));
1224
+ await exitWith(runSwitch(alias, branch, options));
1225
+ });
1226
+ program
1227
+ .command("checkout")
1228
+ .description("Fetch origin, then check out a REMOTE branch (origin/*) as a local tracking branch.")
1229
+ .argument("[alias]", "registered source alias (omit to pick interactively)")
1230
+ .argument("[branch]", "remote branch name (omit to pick from origin/* branches)")
1231
+ .addHelpText("after", exitHelp)
1232
+ .action(async (alias, branch) => {
1233
+ await exitWith(runCheckout(alias, branch));
1083
1234
  });
1084
1235
  const collectRemote = (value, acc) => [...acc, value];
1085
1236
  program
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oh-my-space",
3
- "version": "0.7.1",
3
+ "version": "0.8.0",
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",