oh-my-space 0.2.0 → 0.3.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.
Files changed (3) hide show
  1. package/README.md +64 -27
  2. package/dist/oms.js +533 -144
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # oh-my-space
2
2
 
3
- `oh-my-space` provides the `oms` CLI for managing external source repositories declared in `sources.yaml`. It syncs those entries into Git submodules under `sources/<alias>/` and runs safe fetch/pull/push operations inside selected source worktrees.
3
+ `oh-my-space` provides the `oms` CLI for managing external source repositories declared in `sources.yaml`. Each registered repo lives as a bare clone under `sources/<alias>/.bare/` and the baseline branch (and any additional ones you ask for) checks out as a Git worktree at `sources/<alias>/<branch>/`.
4
4
 
5
5
  Package name: `oh-my-space`
6
6
 
@@ -9,7 +9,7 @@ Command name: `oms`
9
9
  ## Requirements
10
10
 
11
11
  - [Node.js](https://nodejs.org) `>=20.19.0` for running `oms`; development uses the version in `.nvmrc` (`24`)
12
- - git
12
+ - git `>=2.40` (worktree `--track -B` flow)
13
13
 
14
14
  For local development:
15
15
 
@@ -43,39 +43,53 @@ repos:
43
43
  url: git@github.com:example/web.git
44
44
  ```
45
45
 
46
- List, sync, and unsync registered source repositories:
46
+ Sync and explore:
47
47
 
48
48
  ```bash
49
- oms doctor # check sources.yaml and git availability
50
- oms sync --list # list registered source repos
51
- oms sync <alias>... # add/init/update submodules by alias
52
- oms sync --all # add/init/update every registered source repo
53
- oms sync # interactive multi-select
54
- oms unsync <alias>... # remove submodule + worktree (sources.yaml entry kept)
55
- oms unsync --all # unsync every registered source repo
56
- oms unsync --force ... # discard uncommitted changes in the source worktree
49
+ oms doctor # validate sources.yaml + bare/worktree state
50
+ oms sync --list # list registered source repos
51
+ oms sync <alias>... # bare-clone + create the baseline worktree
52
+ oms sync --all # sync every registered source repo
53
+ oms sync # interactive multi-select
54
+ oms worktree add <alias> <branch> # add another worktree alongside the baseline
55
+ oms worktree list [alias] # show every worktree
56
+ oms worktree remove <alias> <br> # remove a single worktree
57
+ oms unsync <alias>... # remove ALL worktrees + the bare clone
57
58
  ```
58
59
 
59
- ## Managing source repositories
60
+ `oms sync` adds `sources/` to your workspace `.gitignore` on first run; the bare clone and worktrees stay out of your project history.
60
61
 
61
- `sources.yaml` declares each source repo with `alias`, `url`, and optional `branch`. Checkouts live as Git submodules under `sources/<alias>/`.
62
+ ## Layout
62
63
 
63
- | Command | Runs in | Does | Notes |
64
- | --- | --- | --- | --- |
65
- | `oms doctor` | project root or child path | Validates `sources.yaml` and checks `git --version`. | Fails when `sources.yaml` is missing or invalid. |
66
- | `oms sync <alias>` / `--all` | workspace root | Adds missing submodules and initializes/updates registered ones. | Syncs `sources.yaml` to `sources/<alias>/`. |
67
- | `oms unsync <alias>` / `--all` | workspace root | Deinits the submodule and removes `sources/<alias>/` (leaves the staged change to commit). | Keeps the entry in `sources.yaml`; pass `--force` to discard uncommitted changes in the worktree. |
68
- | `oms fetch ...` | selected checked-out submodule worktree | `git fetch --all --prune` | Does not change the superproject gitlink. |
69
- | `oms pull ...` | selected checked-out submodule worktree | `git pull --ff-only` | Requires a branch with upstream; detached HEAD/no-upstream states fail. |
70
- | `oms push <alias>...` | explicitly selected checked-out submodule worktree | `git push` | No `--all`, force push, or automatic upstream setup. |
64
+ For an alias `api` with the baseline branch `main` and an extra `feature/foo` worktree:
71
65
 
72
- To add a repo, add it under `repos:` in `sources.yaml`, run `oms sync <alias>`, then commit `sources.yaml`, `.gitmodules`, and the gitlink change.
66
+ ```
67
+ sources/
68
+ └── api/
69
+ ├── .bare/ # git clone --bare result
70
+ ├── .git # 'gitdir: ./.bare' placeholder
71
+ ├── main/ # worktree for main (upstream: origin/main)
72
+ └── feature/
73
+ └── foo/ # worktree for feature/foo
74
+ ```
73
75
 
74
- Standard Git submodule commands still work, for example:
76
+ Slash-containing branch names are kept nested as directories.
75
77
 
76
- ```bash
77
- git submodule update --init --recursive
78
- ```
78
+ ## Managing source repositories
79
+
80
+ `sources.yaml` declares each source repo with `alias`, `url`, and optional `branch` (the baseline). Additional worktrees are created on demand with `oms worktree add` and are *not* persisted in `sources.yaml` — they're treated as per-machine working space.
81
+
82
+ | Command | Runs in | Does | Notes |
83
+ | --- | --- | --- | --- |
84
+ | `oms doctor` | project root or child path | Checks `sources.yaml`, git availability, `.gitignore`, and each alias's bare clone + `remote.origin.fetch`. | Returns exit 2 if any warning is raised. |
85
+ | `oms sync <alias>` / `--all` | workspace root | Bare-clones missing repos, fetches origin, creates the baseline worktree. | Adds `sources/` to `.gitignore` if absent. |
86
+ | `oms unsync <alias>` / `--all` | workspace root | Removes every worktree, the bare clone, and the `.git` placeholder for the alias. | Keeps the `sources.yaml` entry. Use `--force` to discard uncommitted changes. |
87
+ | `oms fetch ...` | workspace root | `git fetch origin --prune` on each bare clone. | Worktrees are not touched. |
88
+ | `oms pull ...` | workspace root | `git pull --ff-only` on the baseline worktree of each selected alias. | Requires the worktree to be on its branch with upstream set (the default after `oms sync`). |
89
+ | `oms push <alias>...` | workspace root | `git push` on the baseline worktree of each explicitly listed alias. | No `--all`, no force, no automatic upstream setup. |
90
+ | `oms worktree add <alias> <branch>` | workspace root | `git worktree add --track -B <branch> <branch> origin/<branch>` inside the alias. | Slash branches become nested directories. |
91
+ | `oms worktree list [alias]` | anywhere under root | Lists every worktree for the alias (or all aliases). | |
92
+ | `oms worktree remove <alias> <branch>` | workspace root | `git worktree remove <branch>` plus parent-directory cleanup for slash branches. | `--force` discards uncommitted changes. |
79
93
 
80
94
  ## `sources.yaml` format
81
95
 
@@ -93,7 +107,30 @@ Rules:
93
107
  - `repos` must be a non-empty array.
94
108
  - `alias` must be unique and match `/^[a-z0-9][a-z0-9-]*$/`.
95
109
  - `url` is required.
96
- - `branch` is optional and is passed to `git submodule add --branch` only when adding a new submodule.
110
+ - `branch` is optional; when omitted the bare clone's default branch is used as the baseline worktree.
111
+
112
+ ## Migrating from 0.2.x
113
+
114
+ 0.2.x stored each alias as a Git submodule. 0.3.0 replaces that with a bare clone + worktrees. The data model is breaking — `oms sync` refuses to run while `.gitmodules` still registers a `sources/<alias>` path. Convert your workspace manually:
115
+
116
+ ```bash
117
+ # 1. For each alias in sources.yaml, deinit and remove the submodule.
118
+ git submodule deinit -f sources/<alias>
119
+ git rm -f sources/<alias>
120
+ rm -rf .git/modules/<alias>
121
+
122
+ # 2. Drop the now-empty .gitmodules if nothing else uses it.
123
+ [ -s .gitmodules ] || rm -f .gitmodules
124
+
125
+ # 3. Add sources/ to .gitignore (oms sync will also do this).
126
+ grep -qxF 'sources/' .gitignore 2>/dev/null || echo 'sources/' >> .gitignore
127
+
128
+ # 4. Re-sync with the new model.
129
+ oms sync --all
130
+
131
+ # 5. Commit the result.
132
+ git add . && git commit -m "chore: migrate to oh-my-space 0.3.0 (bare + worktree)"
133
+ ```
97
134
 
98
135
  ## License
99
136
 
package/dist/oms.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawnSync } from "node:child_process";
3
- import { existsSync, readFileSync, rmSync } from "node:fs";
4
- import { dirname, join, resolve } from "node:path";
3
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, rmdirSync, writeFileSync, } from "node:fs";
4
+ import { dirname, join, relative, resolve } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { Command } from "commander";
7
7
  import { cancel, isCancel, log, multiselect } from "@clack/prompts";
@@ -9,16 +9,8 @@ import { parse as parseYaml } from "yaml";
9
9
  const ALIAS_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
10
10
  const ALLOWED_TOP_KEYS = new Set(["repos"]);
11
11
  const ALLOWED_ITEM_KEYS = new Set(["alias", "url", "branch"]);
12
- const MANAGE_GIT_ARGS = {
13
- fetch: ["fetch", "--all", "--prune"],
14
- pull: ["pull", "--ff-only"],
15
- push: ["-c", "push.autoSetupRemote=false", "push"],
16
- };
17
- const RESULT_BY_COMMAND = {
18
- fetch: "fetched",
19
- pull: "pulled",
20
- push: "pushed",
21
- };
12
+ const FETCH_REFSPEC = "+refs/heads/*:refs/remotes/origin/*";
13
+ const GITIGNORE_ENTRY = "sources/";
22
14
  const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
23
15
  const useColor = process.stdout.isTTY;
24
16
  const dim = (s) => (useColor ? `\x1b[2m${s}\x1b[0m` : s);
@@ -117,6 +109,10 @@ function runGit(cwd, args, inheritOutput = false) {
117
109
  stdout: inheritOutput ? "" : (result.stdout ?? ""),
118
110
  };
119
111
  }
112
+ /** Run git from sources/<alias>/ with safe.bareRepository=all, so the .git placeholder routes to .bare. */
113
+ function runBareGit(repoRoot, alias, args, inheritOutput = false) {
114
+ return runGit(aliasDir(repoRoot, alias), ["-c", "safe.bareRepository=all", ...args], inheritOutput);
115
+ }
120
116
  function findWorkspaceRoot(options = {}) {
121
117
  let current = resolve(options.cwd ?? process.cwd());
122
118
  while (true) {
@@ -128,6 +124,18 @@ function findWorkspaceRoot(options = {}) {
128
124
  current = parent;
129
125
  }
130
126
  }
127
+ function aliasDir(repoRoot, alias) {
128
+ return join(repoRoot, "sources", alias);
129
+ }
130
+ function bareDir(repoRoot, alias) {
131
+ return join(aliasDir(repoRoot, alias), ".bare");
132
+ }
133
+ function gitPlaceholderPath(repoRoot, alias) {
134
+ return join(aliasDir(repoRoot, alias), ".git");
135
+ }
136
+ function worktreePath(repoRoot, alias, branch) {
137
+ return join(aliasDir(repoRoot, alias), branch);
138
+ }
131
139
  function isRegisteredSubmodule(repoRoot, sourcePath) {
132
140
  if (!existsSync(join(repoRoot, ".gitmodules")))
133
141
  return false;
@@ -144,143 +152,342 @@ function isRegisteredSubmodule(repoRoot, sourcePath) {
144
152
  .split("\n")
145
153
  .some((line) => line.trim().split(/\s+/)[1] === sourcePath);
146
154
  }
147
- function isCheckedOutGitRepo(repoRoot, sourcePath) {
148
- const dest = join(repoRoot, sourcePath);
149
- if (!existsSync(dest))
155
+ /** Returns true when .gitmodules registers any sources/<alias> entry for the loaded repos. */
156
+ function hasLegacySubmoduleLayout(repoRoot, repos) {
157
+ if (!existsSync(join(repoRoot, ".gitmodules")))
150
158
  return false;
151
- const result = runGit(dest, ["rev-parse", "--is-inside-work-tree"]);
152
- return result.success && result.stdout.trim() === "true";
153
- }
154
- function removeSubmoduleArtifacts(repo, repoRoot, options = {}) {
155
- const sourcePath = `sources/${repo.alias}`;
156
- const dest = join(repoRoot, sourcePath);
157
- const modulesDir = join(repoRoot, ".git", "modules", repo.alias);
158
- const registered = isRegisteredSubmodule(repoRoot, sourcePath);
159
- const worktreeExists = existsSync(dest);
160
- const worktreeIsGitRepo = worktreeExists && isCheckedOutGitRepo(repoRoot, sourcePath);
161
- const moduleDirExists = existsSync(modulesDir);
162
- if (!registered && !worktreeExists && !moduleDirExists) {
163
- return "nothing-to-remove";
159
+ return repos.some((repo) => isRegisteredSubmodule(repoRoot, `sources/${repo.alias}`));
160
+ }
161
+ function abortOnLegacy(repoRoot, repos) {
162
+ if (!hasLegacySubmoduleLayout(repoRoot, repos))
163
+ return false;
164
+ log.error('detected legacy submodule layout (.gitmodules registers sources/<alias>). oh-my-space 0.3.0 uses bare clone + worktrees.\n' +
165
+ ' See README "Migrating from 0.2.x" for the manual steps. Aborting to avoid destructive change.');
166
+ return true;
167
+ }
168
+ function ensureGitignore(repoRoot) {
169
+ const path = join(repoRoot, ".gitignore");
170
+ let content = "";
171
+ if (existsSync(path)) {
172
+ content = readFileSync(path, "utf8");
173
+ const present = content
174
+ .split("\n")
175
+ .some((line) => line.trim() === GITIGNORE_ENTRY || line.trim() === `/${GITIGNORE_ENTRY}`);
176
+ if (present)
177
+ return;
164
178
  }
165
- if (worktreeExists && !registered && !moduleDirExists && !worktreeIsGitRepo) {
166
- log.error(`${repo.alias}: ${sourcePath} exists but is not a git submodule. Remove it manually if intended.`);
167
- return "failed";
179
+ const needsNewline = content.length > 0 && !content.endsWith("\n");
180
+ writeFileSync(path, `${content}${needsNewline ? "\n" : ""}${GITIGNORE_ENTRY}\n`);
181
+ log.info(`added ${GITIGNORE_ENTRY} to .gitignore`);
182
+ }
183
+ function setupBareRepo(repo, repoRoot) {
184
+ const alias = repo.alias;
185
+ const aliasPath = aliasDir(repoRoot, alias);
186
+ const barePath = bareDir(repoRoot, alias);
187
+ mkdirSync(aliasPath, { recursive: true });
188
+ const relBare = relative(repoRoot, barePath);
189
+ log.step(`git clone --bare ${repo.url} ${relBare}`);
190
+ const clone = runGit(repoRoot, ["clone", "--bare", repo.url, relBare], true);
191
+ if (!clone.success) {
192
+ log.error(`${alias}: git clone --bare failed (exit ${clone.exitCode})`);
193
+ if (existsSync(barePath))
194
+ rmSync(barePath, { recursive: true, force: true });
195
+ return false;
168
196
  }
169
- if (!options.force && worktreeIsGitRepo) {
170
- const status = runGit(dest, ["status", "--porcelain"]);
171
- if (status.success && status.stdout.trim().length > 0) {
172
- log.error(`${repo.alias}: ${sourcePath} has uncommitted changes. Commit or stash them, or pass --force to discard.`);
173
- return "failed";
197
+ // Write the placeholder before any further runBareGit calls, otherwise git ascends
198
+ // out of sources/<alias>/ looking for a repo and touches the wrong .git.
199
+ writeFileSync(gitPlaceholderPath(repoRoot, alias), "gitdir: ./.bare\n");
200
+ const config = runBareGit(repoRoot, alias, [
201
+ "config",
202
+ "remote.origin.fetch",
203
+ FETCH_REFSPEC,
204
+ ]);
205
+ if (!config.success) {
206
+ log.error(`${alias}: failed to set remote.origin.fetch (exit ${config.exitCode})`);
207
+ return false;
208
+ }
209
+ log.step(`${alias}: git fetch origin`);
210
+ const fetch = runBareGit(repoRoot, alias, ["fetch", "origin", "--prune"], true);
211
+ if (!fetch.success) {
212
+ log.error(`${alias}: initial fetch failed (exit ${fetch.exitCode})`);
213
+ return false;
214
+ }
215
+ return true;
216
+ }
217
+ function detectDefaultBranch(repoRoot, alias) {
218
+ const r = runBareGit(repoRoot, alias, ["symbolic-ref", "--short", "HEAD"]);
219
+ if (r.success) {
220
+ const out = r.stdout.trim();
221
+ return out.length > 0 ? out : null;
222
+ }
223
+ return null;
224
+ }
225
+ function listExistingWorktrees(repoRoot, alias) {
226
+ if (!existsSync(bareDir(repoRoot, alias)))
227
+ return [];
228
+ const result = runBareGit(repoRoot, alias, ["worktree", "list", "--porcelain"]);
229
+ if (!result.success)
230
+ return [];
231
+ const aliasPath = aliasDir(repoRoot, alias);
232
+ const entries = [];
233
+ const blocks = result.stdout
234
+ .split(/\n\n+/)
235
+ .map((b) => b.trim())
236
+ .filter((b) => b.length > 0);
237
+ for (const block of blocks) {
238
+ let path = "";
239
+ let branch = "(detached)";
240
+ let bare = false;
241
+ for (const line of block.split("\n")) {
242
+ if (line.startsWith("worktree "))
243
+ path = line.slice("worktree ".length);
244
+ else if (line.startsWith("branch ")) {
245
+ branch = line.slice("branch ".length).replace(/^refs\/heads\//, "");
246
+ }
247
+ else if (line === "bare")
248
+ bare = true;
174
249
  }
250
+ if (!path || bare)
251
+ continue;
252
+ const rel = relative(aliasPath, path);
253
+ // Skip worktrees outside sources/<alias>/ (shouldn't happen, but be safe)
254
+ if (rel.startsWith(".."))
255
+ continue;
256
+ entries.push({ branch, path, relativePath: rel });
175
257
  }
176
- let didSomething = false;
177
- if (registered) {
178
- const deinit = runGit(repoRoot, ["submodule", "deinit", "-f", sourcePath]);
179
- if (!deinit.success) {
180
- log.error(`${repo.alias}: git submodule deinit failed (exit ${deinit.exitCode}) for ${sourcePath}`);
181
- return "failed";
258
+ return entries;
259
+ }
260
+ function cleanupEmptyParents(repoRoot, alias, branch) {
261
+ const parts = branch.split("/").filter((p) => p.length > 0);
262
+ if (parts.length < 2)
263
+ return;
264
+ for (let i = parts.length - 1; i >= 1; i--) {
265
+ const dirPath = join(aliasDir(repoRoot, alias), ...parts.slice(0, i));
266
+ try {
267
+ const entries = readdirSync(dirPath);
268
+ if (entries.length === 0) {
269
+ rmdirSync(dirPath);
270
+ }
271
+ else {
272
+ break;
273
+ }
182
274
  }
183
- didSomething = true;
275
+ catch {
276
+ break;
277
+ }
278
+ }
279
+ }
280
+ function addWorktree(repo, repoRoot, branch) {
281
+ const wtPath = worktreePath(repoRoot, repo.alias, branch);
282
+ if (existsSync(wtPath)) {
283
+ log.warn(`${repo.alias}: sources/${repo.alias}/${branch} already exists; skipping.`);
284
+ return true;
285
+ }
286
+ log.step(`${repo.alias}: git worktree add --track -B ${branch} ${branch} origin/${branch}`);
287
+ const result = runBareGit(repoRoot, repo.alias, ["worktree", "add", "--track", "-B", branch, branch, `origin/${branch}`], true);
288
+ if (!result.success) {
289
+ log.error(`${repo.alias}: worktree add for ${branch} failed (exit ${result.exitCode})`);
290
+ cleanupEmptyParents(repoRoot, repo.alias, branch);
291
+ return false;
184
292
  }
185
- if (registered || worktreeIsGitRepo) {
186
- const rm = runGit(repoRoot, ["rm", "-f", sourcePath]);
187
- if (!rm.success) {
188
- log.error(`${repo.alias}: git rm failed (exit ${rm.exitCode}) for ${sourcePath}`);
293
+ return true;
294
+ }
295
+ function removeWorktree(repo, repoRoot, branch, force) {
296
+ const wtPath = worktreePath(repoRoot, repo.alias, branch);
297
+ if (!existsSync(wtPath))
298
+ return true;
299
+ const args = ["worktree", "remove", ...(force ? ["--force"] : []), branch];
300
+ log.step(`${repo.alias}: git worktree remove ${branch}${force ? " --force" : ""}`);
301
+ const result = runBareGit(repoRoot, repo.alias, args, true);
302
+ if (!result.success) {
303
+ log.error(`${repo.alias}: worktree remove for ${branch} failed (exit ${result.exitCode})`);
304
+ return false;
305
+ }
306
+ cleanupEmptyParents(repoRoot, repo.alias, branch);
307
+ return true;
308
+ }
309
+ function syncRepo(repo, repoRoot) {
310
+ const alias = repo.alias;
311
+ const aliasPath = aliasDir(repoRoot, alias);
312
+ const barePath = bareDir(repoRoot, alias);
313
+ const placeholderPath = gitPlaceholderPath(repoRoot, alias);
314
+ const bareExists = existsSync(barePath);
315
+ if (!bareExists) {
316
+ if (existsSync(aliasPath)) {
317
+ const stray = readdirSync(aliasPath).filter((e) => e !== ".git");
318
+ if (stray.length > 0) {
319
+ log.error(`${alias}: sources/${alias}/ already exists but is not a bare clone. Move or remove it manually, then retry.`);
320
+ return "failed";
321
+ }
322
+ }
323
+ if (repo.branch) {
324
+ const lsRemote = runGit(repoRoot, [
325
+ "ls-remote",
326
+ "--exit-code",
327
+ "--heads",
328
+ repo.url,
329
+ repo.branch,
330
+ ]);
331
+ if (lsRemote.exitCode === 2) {
332
+ log.error(`${alias}: branch "${repo.branch}" not found on ${repo.url}. Push the branch upstream or fix the alias, then retry.`);
333
+ return "failed";
334
+ }
335
+ if (!lsRemote.success && lsRemote.exitCode !== 2) {
336
+ log.warn(`${alias}: branch existence check failed (exit ${lsRemote.exitCode}); proceeding.`);
337
+ }
338
+ }
339
+ if (!setupBareRepo(repo, repoRoot)) {
340
+ if (existsSync(barePath))
341
+ rmSync(barePath, { recursive: true, force: true });
342
+ if (existsSync(placeholderPath))
343
+ rmSync(placeholderPath);
344
+ try {
345
+ if (readdirSync(aliasPath).length === 0)
346
+ rmdirSync(aliasPath);
347
+ }
348
+ catch { }
189
349
  return "failed";
190
350
  }
191
- didSomething = true;
351
+ const branch = repo.branch ?? detectDefaultBranch(repoRoot, alias);
352
+ if (!branch) {
353
+ log.error(`${alias}: could not determine default branch from bare clone. Set "branch:" in sources.yaml.`);
354
+ return "failed";
355
+ }
356
+ if (!addWorktree(repo, repoRoot, branch))
357
+ return "failed";
358
+ log.success(`${alias}: added (branch=${branch})`);
359
+ return "added";
192
360
  }
193
- runGit(repoRoot, [
194
- "config",
195
- "--file",
196
- ".gitmodules",
197
- "--remove-section",
198
- `submodule.${repo.alias}`,
199
- ]);
200
- if (moduleDirExists) {
201
- rmSync(modulesDir, { recursive: true, force: true });
202
- didSomething = true;
203
- }
204
- return didSomething ? "removed" : "nothing-to-remove";
205
- }
206
- function syncSubmodule(repo, repoRoot) {
207
- const sourcePath = `sources/${repo.alias}`;
208
- const dest = join(repoRoot, sourcePath);
209
- const registered = isRegisteredSubmodule(repoRoot, sourcePath);
210
- if (!registered && existsSync(dest)) {
211
- log.error(`${repo.alias}: ${sourcePath} already exists but is not registered as a git submodule. Move or remove it manually, then retry.`);
361
+ if (!existsSync(placeholderPath)) {
362
+ writeFileSync(placeholderPath, "gitdir: ./.bare\n");
363
+ }
364
+ log.step(`${alias}: git fetch origin`);
365
+ const fetch = runBareGit(repoRoot, alias, ["fetch", "origin", "--prune"], true);
366
+ if (!fetch.success) {
367
+ log.error(`${alias}: fetch failed (exit ${fetch.exitCode})`);
212
368
  return "failed";
213
369
  }
214
- if (!registered && repo.branch) {
215
- const lsRemote = runGit(repoRoot, [
216
- "ls-remote",
217
- "--exit-code",
218
- "--heads",
219
- repo.url,
220
- repo.branch,
221
- ]);
222
- if (lsRemote.exitCode === 2) {
223
- log.error(`${repo.alias}: branch "${repo.branch}" not found on ${repo.url}. Push the branch upstream or fix the alias, then retry.`);
370
+ const branch = repo.branch ?? detectDefaultBranch(repoRoot, alias);
371
+ if (branch && !existsSync(worktreePath(repoRoot, alias, branch))) {
372
+ if (!addWorktree(repo, repoRoot, branch))
224
373
  return "failed";
374
+ }
375
+ runBareGit(repoRoot, alias, ["worktree", "prune"]);
376
+ log.success(`${alias}: updated`);
377
+ return "updated";
378
+ }
379
+ function unsyncRepo(repo, repoRoot, force) {
380
+ const aliasPath = aliasDir(repoRoot, repo.alias);
381
+ const barePath = bareDir(repoRoot, repo.alias);
382
+ const placeholderPath = gitPlaceholderPath(repoRoot, repo.alias);
383
+ const aliasExists = existsSync(aliasPath);
384
+ const bareExists = existsSync(barePath);
385
+ if (!aliasExists && !bareExists)
386
+ return "nothing-to-remove";
387
+ const worktrees = bareExists ? listExistingWorktrees(repoRoot, repo.alias) : [];
388
+ if (!force) {
389
+ for (const wt of worktrees) {
390
+ const status = runGit(wt.path, ["status", "--porcelain"]);
391
+ if (status.success && status.stdout.trim().length > 0) {
392
+ log.error(`${repo.alias}: worktree at sources/${repo.alias}/${wt.relativePath} has uncommitted changes. Commit, stash, or pass --force.`);
393
+ return "failed";
394
+ }
225
395
  }
226
- if (!lsRemote.success && lsRemote.exitCode !== 2) {
227
- log.warn(`${repo.alias}: branch existence check failed (exit ${lsRemote.exitCode}); proceeding with submodule add.`);
396
+ }
397
+ for (const wt of worktrees) {
398
+ const target = wt.branch === "(detached)" ? wt.relativePath : wt.branch;
399
+ if (!removeWorktree(repo, repoRoot, target, force)) {
400
+ return "failed";
228
401
  }
229
402
  }
230
- const args = registered
231
- ? ["submodule", "update", "--init", "--recursive", sourcePath]
232
- : [
233
- "submodule",
234
- "add",
235
- "--name",
236
- repo.alias,
237
- ...(repo.branch ? ["--branch", repo.branch] : []),
238
- repo.url,
239
- sourcePath,
240
- ];
241
- log.step(`git ${args.join(" ")}`);
242
- const result = runGit(repoRoot, args, true);
243
- if (result.success) {
244
- log.success(`${repo.alias}: ${registered ? "updated" : "added"}`);
245
- return registered ? "updated" : "added";
246
- }
247
- log.error(`${repo.alias}: submodule ${registered ? "update" : "add"} failed (exit ${result.exitCode})`);
248
- if (!registered) {
249
- const cleanup = removeSubmoduleArtifacts(repo, repoRoot, { force: true });
250
- if (cleanup === "removed") {
251
- log.info(`${repo.alias}: cleaned up partial submodule state. Retry once the upstream issue is resolved.`);
403
+ if (existsSync(barePath))
404
+ rmSync(barePath, { recursive: true, force: true });
405
+ if (existsSync(placeholderPath))
406
+ rmSync(placeholderPath);
407
+ try {
408
+ if (existsSync(aliasPath)) {
409
+ const remaining = readdirSync(aliasPath);
410
+ if (remaining.length === 0)
411
+ rmdirSync(aliasPath);
252
412
  }
253
413
  }
414
+ catch { }
415
+ return "removed";
416
+ }
417
+ function fetchRepo(repo, repoRoot) {
418
+ if (!existsSync(bareDir(repoRoot, repo.alias))) {
419
+ log.error(`${repo.alias}: not synced. Run "oms sync ${repo.alias}" first.`);
420
+ return "failed";
421
+ }
422
+ log.step(`${repo.alias}: git fetch origin --prune`);
423
+ const r = runBareGit(repoRoot, repo.alias, ["fetch", "origin", "--prune"], true);
424
+ if (r.success) {
425
+ log.success(`${repo.alias}: fetched`);
426
+ return "fetched";
427
+ }
428
+ log.error(`${repo.alias}: fetch failed (exit ${r.exitCode})`);
254
429
  return "failed";
255
430
  }
256
- function manageSubmodule(repo, repoRoot, command) {
257
- const sourcePath = `sources/${repo.alias}`;
258
- if (!isRegisteredSubmodule(repoRoot, sourcePath) || !isCheckedOutGitRepo(repoRoot, sourcePath)) {
259
- log.error(`${repo.alias}: ${sourcePath} is not a checked-out source submodule. Run "oms sync ${repo.alias}" first.`);
431
+ function resolveTargetWorktree(repo, repoRoot) {
432
+ const defaultBranch = repo.branch ?? detectDefaultBranch(repoRoot, repo.alias);
433
+ if (defaultBranch) {
434
+ const path = worktreePath(repoRoot, repo.alias, defaultBranch);
435
+ if (existsSync(path))
436
+ return { branch: defaultBranch, path };
437
+ }
438
+ const wts = listExistingWorktrees(repoRoot, repo.alias);
439
+ if (wts.length === 1) {
440
+ const only = wts[0];
441
+ return { branch: only.branch === "(detached)" ? only.relativePath : only.branch, path: only.path };
442
+ }
443
+ return null;
444
+ }
445
+ function pullRepo(repo, repoRoot) {
446
+ if (!existsSync(bareDir(repoRoot, repo.alias))) {
447
+ log.error(`${repo.alias}: not synced. Run "oms sync ${repo.alias}" first.`);
260
448
  return "failed";
261
449
  }
262
- const sourceWorktree = join(repoRoot, sourcePath);
263
- if (command === "push") {
264
- const upstream = runGit(sourceWorktree, [
265
- "rev-parse",
266
- "--abbrev-ref",
267
- "--symbolic-full-name",
268
- "@{u}",
269
- ]);
270
- if (!upstream.success) {
271
- log.error(`${repo.alias}: git push requires the current branch to have an upstream. Set it manually before pushing.`);
272
- return "failed";
273
- }
450
+ const target = resolveTargetWorktree(repo, repoRoot);
451
+ if (!target) {
452
+ log.error(`${repo.alias}: cannot determine which worktree to pull. Use "oms worktree list ${repo.alias}".`);
453
+ return "failed";
274
454
  }
275
- const args = MANAGE_GIT_ARGS[command];
276
- log.step(`${repo.alias}: git ${args.join(" ")}`);
277
- const result = runGit(sourceWorktree, args, true);
278
- if (result.success) {
279
- const operation = RESULT_BY_COMMAND[command];
280
- log.success(`${repo.alias}: ${operation}`);
281
- return operation;
455
+ log.step(`${repo.alias}/${target.branch}: git pull --ff-only`);
456
+ const r = runGit(target.path, ["pull", "--ff-only"], true);
457
+ if (r.success) {
458
+ log.success(`${repo.alias}/${target.branch}: pulled`);
459
+ return "pulled";
282
460
  }
283
- log.error(`${repo.alias}: git ${command} failed (exit ${result.exitCode})`);
461
+ log.error(`${repo.alias}/${target.branch}: pull failed (exit ${r.exitCode})`);
462
+ return "failed";
463
+ }
464
+ function pushRepo(repo, repoRoot) {
465
+ if (!existsSync(bareDir(repoRoot, repo.alias))) {
466
+ log.error(`${repo.alias}: not synced. Run "oms sync ${repo.alias}" first.`);
467
+ return "failed";
468
+ }
469
+ const target = resolveTargetWorktree(repo, repoRoot);
470
+ if (!target) {
471
+ log.error(`${repo.alias}: cannot determine which worktree to push. Use "oms worktree list ${repo.alias}".`);
472
+ return "failed";
473
+ }
474
+ const upstream = runGit(target.path, [
475
+ "rev-parse",
476
+ "--abbrev-ref",
477
+ "--symbolic-full-name",
478
+ "@{u}",
479
+ ]);
480
+ if (!upstream.success) {
481
+ log.error(`${repo.alias}/${target.branch}: git push requires an upstream. Set it manually before pushing.`);
482
+ return "failed";
483
+ }
484
+ log.step(`${repo.alias}/${target.branch}: git push`);
485
+ const r = runGit(target.path, ["-c", "push.autoSetupRemote=false", "push"], true);
486
+ if (r.success) {
487
+ log.success(`${repo.alias}/${target.branch}: pushed`);
488
+ return "pushed";
489
+ }
490
+ log.error(`${repo.alias}/${target.branch}: push failed (exit ${r.exitCode})`);
284
491
  return "failed";
285
492
  }
286
493
  function printSummary(results) {
@@ -350,10 +557,13 @@ async function runSync(aliases, options) {
350
557
  printList(repos);
351
558
  return 0;
352
559
  }
560
+ if (abortOnLegacy(repoRoot, repos))
561
+ return 1;
353
562
  const picked = await selectRepos(repos, aliases, options, "sync");
354
563
  if (!picked || picked.length === 0)
355
564
  return 1;
356
- const results = picked.map((repo) => syncSubmodule(repo, repoRoot));
565
+ ensureGitignore(repoRoot);
566
+ const results = picked.map((repo) => syncRepo(repo, repoRoot));
357
567
  if (results.length > 1 || options.all)
358
568
  printSummary(results);
359
569
  return exitFromResults(results);
@@ -363,10 +573,13 @@ async function runManage(command, aliases, options) {
363
573
  if (!loaded)
364
574
  return 1;
365
575
  const { repos, repoRoot } = loaded;
576
+ if (abortOnLegacy(repoRoot, repos))
577
+ return 1;
366
578
  const picked = await selectRepos(repos, aliases, options, command);
367
579
  if (!picked || picked.length === 0)
368
580
  return 1;
369
- const results = picked.map((repo) => manageSubmodule(repo, repoRoot, command));
581
+ const fn = command === "fetch" ? fetchRepo : command === "pull" ? pullRepo : pushRepo;
582
+ const results = picked.map((repo) => fn(repo, repoRoot));
370
583
  if (results.length > 1 || options.all)
371
584
  printSummary(results);
372
585
  return exitFromResults(results);
@@ -376,14 +589,16 @@ async function runUnsync(aliases, options) {
376
589
  if (!loaded)
377
590
  return 1;
378
591
  const { repos, repoRoot } = loaded;
592
+ if (abortOnLegacy(repoRoot, repos))
593
+ return 1;
379
594
  const picked = await selectRepos(repos, aliases, options, "unsync");
380
595
  if (!picked || picked.length === 0)
381
596
  return 1;
382
597
  const results = picked.map((repo) => {
383
598
  log.step(`${repo.alias}: unsync`);
384
- const outcome = removeSubmoduleArtifacts(repo, repoRoot, { force: options.force });
599
+ const outcome = unsyncRepo(repo, repoRoot, options.force ?? false);
385
600
  if (outcome === "removed") {
386
- log.success(`${repo.alias}: unsynced (commit staged changes in .gitmodules and sources/${repo.alias} to finalize)`);
601
+ log.success(`${repo.alias}: unsynced`);
387
602
  return "unsynced";
388
603
  }
389
604
  if (outcome === "nothing-to-remove") {
@@ -396,19 +611,153 @@ async function runUnsync(aliases, options) {
396
611
  printSummary(results);
397
612
  return exitFromResults(results);
398
613
  }
614
+ async function runWorktreeAdd(alias, branch) {
615
+ const loaded = loadRepos();
616
+ if (!loaded)
617
+ return 1;
618
+ const { repos, repoRoot } = loaded;
619
+ if (abortOnLegacy(repoRoot, repos))
620
+ return 1;
621
+ const repo = repos.find((r) => r.alias === alias);
622
+ if (!repo) {
623
+ log.error(`Unknown alias "${alias}". Use "oms sync --list" to see registered aliases.`);
624
+ return 1;
625
+ }
626
+ if (!existsSync(bareDir(repoRoot, alias))) {
627
+ log.error(`${alias}: not synced. Run "oms sync ${alias}" first.`);
628
+ return 1;
629
+ }
630
+ if (existsSync(worktreePath(repoRoot, alias, branch))) {
631
+ log.error(`${alias}: worktree for ${branch} already exists at sources/${alias}/${branch}.`);
632
+ return 1;
633
+ }
634
+ log.step(`${alias}: git fetch origin --prune (refresh before add)`);
635
+ runBareGit(repoRoot, alias, ["fetch", "origin", "--prune"], true);
636
+ if (!addWorktree(repo, repoRoot, branch))
637
+ return 2;
638
+ log.success(`${alias}: added worktree for ${branch}`);
639
+ return 0;
640
+ }
641
+ async function runWorktreeList(alias) {
642
+ const loaded = loadRepos();
643
+ if (!loaded)
644
+ return 1;
645
+ const { repos, repoRoot } = loaded;
646
+ if (abortOnLegacy(repoRoot, repos))
647
+ return 1;
648
+ const targets = alias ? repos.filter((r) => r.alias === alias) : repos;
649
+ if (alias && targets.length === 0) {
650
+ log.error(`Unknown alias "${alias}".`);
651
+ return 1;
652
+ }
653
+ const rows = [];
654
+ for (const repo of targets) {
655
+ if (!existsSync(bareDir(repoRoot, repo.alias))) {
656
+ rows.push({ alias: repo.alias, branch: "(not synced)", path: "-" });
657
+ continue;
658
+ }
659
+ const wts = listExistingWorktrees(repoRoot, repo.alias);
660
+ if (wts.length === 0) {
661
+ rows.push({ alias: repo.alias, branch: "(no worktrees)", path: "-" });
662
+ continue;
663
+ }
664
+ for (const wt of wts) {
665
+ rows.push({
666
+ alias: repo.alias,
667
+ branch: wt.branch,
668
+ path: `sources/${repo.alias}/${wt.relativePath}`,
669
+ });
670
+ }
671
+ }
672
+ const aliasW = Math.max("ALIAS".length, ...rows.map((r) => r.alias.length));
673
+ const branchW = Math.max("BRANCH".length, ...rows.map((r) => r.branch.length));
674
+ console.log(dim(`${pad("ALIAS", aliasW)} ${pad("BRANCH", branchW)} PATH`));
675
+ for (const r of rows) {
676
+ console.log(`${pad(r.alias, aliasW)} ${pad(r.branch, branchW)} ${r.path}`);
677
+ }
678
+ return 0;
679
+ }
680
+ async function runWorktreeRemove(alias, branch, options) {
681
+ const loaded = loadRepos();
682
+ if (!loaded)
683
+ return 1;
684
+ const { repos, repoRoot } = loaded;
685
+ if (abortOnLegacy(repoRoot, repos))
686
+ return 1;
687
+ const repo = repos.find((r) => r.alias === alias);
688
+ if (!repo) {
689
+ log.error(`Unknown alias "${alias}".`);
690
+ return 1;
691
+ }
692
+ const wtPath = worktreePath(repoRoot, alias, branch);
693
+ if (!existsSync(wtPath)) {
694
+ log.info(`${alias}: no worktree for ${branch}`);
695
+ return 0;
696
+ }
697
+ if (!options.force) {
698
+ const status = runGit(wtPath, ["status", "--porcelain"]);
699
+ if (status.success && status.stdout.trim().length > 0) {
700
+ log.error(`${alias}/${branch}: has uncommitted changes. Commit, stash, or pass --force.`);
701
+ return 2;
702
+ }
703
+ }
704
+ if (!removeWorktree(repo, repoRoot, branch, options.force ?? false))
705
+ return 2;
706
+ log.success(`${alias}: removed worktree for ${branch}`);
707
+ return 0;
708
+ }
399
709
  async function runDoctor() {
400
710
  const loaded = loadRepos();
401
711
  if (!loaded)
402
712
  return 1;
403
- log.success(`Workspace root: ${loaded.repoRoot}`);
404
- log.success(`sources.yaml: ${loaded.repos.length} repo(s) configured`);
405
- const git = spawnSync("git", ["--version"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
713
+ const { repos, repoRoot } = loaded;
714
+ log.success(`Workspace root: ${repoRoot}`);
715
+ log.success(`sources.yaml: ${repos.length} repo(s) configured`);
716
+ const git = spawnSync("git", ["--version"], {
717
+ encoding: "utf8",
718
+ stdio: ["ignore", "pipe", "pipe"],
719
+ });
406
720
  if (git.status === 0) {
407
721
  log.success(`git: ${git.stdout.trim()}`);
408
- return 0;
409
722
  }
410
- log.error("git: not found");
411
- return 1;
723
+ else {
724
+ log.error("git: not found");
725
+ return 1;
726
+ }
727
+ let warnings = 0;
728
+ if (hasLegacySubmoduleLayout(repoRoot, repos)) {
729
+ log.warn('detected legacy submodule layout (.gitmodules registers sources/<alias>). See README "Migrating from 0.2.x".');
730
+ warnings++;
731
+ }
732
+ const gi = join(repoRoot, ".gitignore");
733
+ const giHasSources = existsSync(gi)
734
+ && readFileSync(gi, "utf8")
735
+ .split("\n")
736
+ .some((l) => l.trim() === GITIGNORE_ENTRY || l.trim() === `/${GITIGNORE_ENTRY}`);
737
+ if (!giHasSources) {
738
+ log.warn(`.gitignore does not exclude ${GITIGNORE_ENTRY}. Run "oms sync" to add it automatically.`);
739
+ warnings++;
740
+ }
741
+ for (const repo of repos) {
742
+ const bare = bareDir(repoRoot, repo.alias);
743
+ if (!existsSync(bare)) {
744
+ log.info(`${repo.alias}: not synced`);
745
+ continue;
746
+ }
747
+ const refspec = runBareGit(repoRoot, repo.alias, ["config", "--get", "remote.origin.fetch"]);
748
+ if (!refspec.success || refspec.stdout.trim().length === 0) {
749
+ log.warn(`${repo.alias}: bare clone is missing remote.origin.fetch. Run: git -C sources/${repo.alias}/.bare -c safe.bareRepository=all config remote.origin.fetch '${FETCH_REFSPEC}'`);
750
+ warnings++;
751
+ }
752
+ else {
753
+ log.success(`${repo.alias}: bare clone OK (refspec: ${refspec.stdout.trim()})`);
754
+ }
755
+ if (!existsSync(gitPlaceholderPath(repoRoot, repo.alias))) {
756
+ log.warn(`${repo.alias}: missing .git placeholder at sources/${repo.alias}/.git. Recreate with: echo 'gitdir: ./.bare' > sources/${repo.alias}/.git`);
757
+ warnings++;
758
+ }
759
+ }
760
+ return warnings > 0 ? 2 : 0;
412
761
  }
413
762
  function readJson(path) {
414
763
  if (!existsSync(path))
@@ -429,23 +778,32 @@ async function exitWith(action) {
429
778
  }
430
779
  }
431
780
  const exitHelp = "\nExit codes: 0 ok | 1 usage/config error | 2 one or more git operations failed.";
432
- const commandNames = new Set(["doctor", "sync", "fetch", "pull", "push", "unsync", "help"]);
781
+ const commandNames = new Set([
782
+ "doctor",
783
+ "sync",
784
+ "fetch",
785
+ "pull",
786
+ "push",
787
+ "unsync",
788
+ "worktree",
789
+ "help",
790
+ ]);
433
791
  const program = new Command();
434
792
  program
435
793
  .name("oms")
436
- .description("Manage source repositories listed in sources.yaml under sources/<alias>/.")
794
+ .description("Manage source repositories listed in sources.yaml as bare clones + worktrees under sources/<alias>/.")
437
795
  .version(readPackageVersion())
438
796
  .addHelpText("after", exitHelp);
439
797
  program
440
798
  .command("doctor")
441
- .description("Check sources.yaml configuration and git availability.")
799
+ .description("Check sources.yaml, git availability, bare-clone state, and .gitignore for each registered alias.")
442
800
  .addHelpText("after", exitHelp)
443
801
  .action(async () => {
444
802
  await exitWith(runDoctor());
445
803
  });
446
804
  program
447
805
  .command("sync")
448
- .description("Sync sources.yaml entries into git submodules under sources/<alias>/.")
806
+ .description("Bare-clone each registered repo into sources/<alias>/.bare and create the baseline worktree at sources/<alias>/<branch>/.")
449
807
  .argument("[aliases...]", "repo aliases to sync (omit for interactive multi-select)")
450
808
  .option("--all", "sync every registered source repo")
451
809
  .option("--list", "print registered repos")
@@ -455,7 +813,7 @@ program
455
813
  });
456
814
  program
457
815
  .command("fetch")
458
- .description("Run git fetch --all --prune inside checked-out source submodule worktrees.")
816
+ .description("Run git fetch origin --prune in each bare clone.")
459
817
  .argument("[aliases...]", "repo aliases to fetch (omit for interactive multi-select)")
460
818
  .option("--all", "fetch every registered source repo")
461
819
  .addHelpText("after", exitHelp)
@@ -464,7 +822,7 @@ program
464
822
  });
465
823
  program
466
824
  .command("pull")
467
- .description("Run git pull --ff-only inside checked-out source submodule worktrees; requires branch/upstream.")
825
+ .description("Run git pull --ff-only in the baseline worktree of each selected alias; requires upstream.")
468
826
  .argument("[aliases...]", "repo aliases to pull (omit for interactive multi-select)")
469
827
  .option("--all", "pull every registered source repo")
470
828
  .addHelpText("after", exitHelp)
@@ -473,7 +831,7 @@ program
473
831
  });
474
832
  program
475
833
  .command("push")
476
- .description("Run git push inside explicit source submodule worktrees; no --all, force, or upstream setup.")
834
+ .description("Run git push in the baseline worktree of each explicitly listed alias; no --all, force, or upstream setup.")
477
835
  .argument("<aliases...>", "repo aliases to push")
478
836
  .addHelpText("after", exitHelp)
479
837
  .action(async (aliases) => {
@@ -481,14 +839,45 @@ program
481
839
  });
482
840
  program
483
841
  .command("unsync")
484
- .description("Remove submodule registration and worktree (keeps sources.yaml entry). Leaves staged changes in .gitmodules and the submodule path to commit.")
842
+ .description("Remove all worktrees and the bare clone for each alias (keeps sources.yaml entry).")
485
843
  .argument("[aliases...]", "repo aliases to unsync (omit for interactive multi-select)")
486
844
  .option("--all", "unsync every registered source repo")
487
- .option("--force", "discard uncommitted changes in the source worktree")
845
+ .option("--force", "discard uncommitted changes in worktrees")
488
846
  .addHelpText("after", exitHelp)
489
847
  .action(async (aliases, options) => {
490
848
  await exitWith(runUnsync(aliases, options));
491
849
  });
850
+ const worktreeCmd = program
851
+ .command("worktree")
852
+ .description("Manage additional worktrees for a synced source repo.")
853
+ .addHelpText("after", exitHelp);
854
+ worktreeCmd
855
+ .command("add")
856
+ .description("Create a worktree for <branch> at sources/<alias>/<branch>/.")
857
+ .argument("<alias>", "registered source alias")
858
+ .argument("<branch>", "branch name (may include slashes)")
859
+ .addHelpText("after", exitHelp)
860
+ .action(async (alias, branch) => {
861
+ await exitWith(runWorktreeAdd(alias, branch));
862
+ });
863
+ worktreeCmd
864
+ .command("list")
865
+ .description("List existing worktrees for the given alias (or all aliases).")
866
+ .argument("[alias]", "registered source alias")
867
+ .addHelpText("after", exitHelp)
868
+ .action(async (alias) => {
869
+ await exitWith(runWorktreeList(alias));
870
+ });
871
+ worktreeCmd
872
+ .command("remove")
873
+ .description("Remove the worktree at sources/<alias>/<branch>.")
874
+ .argument("<alias>", "registered source alias")
875
+ .argument("<branch>", "branch name (may include slashes)")
876
+ .option("--force", "discard uncommitted changes")
877
+ .addHelpText("after", exitHelp)
878
+ .action(async (alias, branch, options) => {
879
+ await exitWith(runWorktreeRemove(alias, branch, options));
880
+ });
492
881
  const requestedCommand = process.argv[2];
493
882
  if (requestedCommand && !requestedCommand.startsWith("-") && !commandNames.has(requestedCommand)) {
494
883
  console.error(`error: unknown command '${requestedCommand}'`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oh-my-space",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "CLI for managing external source repositories declared in sources.yaml.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/divlook/oh-my-space",