oh-my-space 0.2.0 → 0.3.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.
Files changed (3) hide show
  1. package/README.md +64 -27
  2. package/dist/oms.js +557 -146
  3. package/package.json +2 -2
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,10 @@ 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/";
14
+ const MIN_GIT_MAJOR = 2;
15
+ const MIN_GIT_MINOR = 40;
22
16
  const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
23
17
  const useColor = process.stdout.isTTY;
24
18
  const dim = (s) => (useColor ? `\x1b[2m${s}\x1b[0m` : s);
@@ -117,6 +111,23 @@ function runGit(cwd, args, inheritOutput = false) {
117
111
  stdout: inheritOutput ? "" : (result.stdout ?? ""),
118
112
  };
119
113
  }
114
+ /** Run git from sources/<alias>/ with safe.bareRepository=all, so the .git placeholder routes to .bare. */
115
+ function runBareGit(repoRoot, alias, args, inheritOutput = false) {
116
+ return runGit(aliasDir(repoRoot, alias), ["-c", "safe.bareRepository=all", ...args], inheritOutput);
117
+ }
118
+ function parseGitVersion(s) {
119
+ const m = s.match(/git version (\d+)\.(\d+)/);
120
+ if (!m)
121
+ return null;
122
+ return { major: Number.parseInt(m[1], 10), minor: Number.parseInt(m[2], 10) };
123
+ }
124
+ function isGitVersionSupported(v) {
125
+ if (v.major > MIN_GIT_MAJOR)
126
+ return true;
127
+ if (v.major < MIN_GIT_MAJOR)
128
+ return false;
129
+ return v.minor >= MIN_GIT_MINOR;
130
+ }
120
131
  function findWorkspaceRoot(options = {}) {
121
132
  let current = resolve(options.cwd ?? process.cwd());
122
133
  while (true) {
@@ -128,6 +139,18 @@ function findWorkspaceRoot(options = {}) {
128
139
  current = parent;
129
140
  }
130
141
  }
142
+ function aliasDir(repoRoot, alias) {
143
+ return join(repoRoot, "sources", alias);
144
+ }
145
+ function bareDir(repoRoot, alias) {
146
+ return join(aliasDir(repoRoot, alias), ".bare");
147
+ }
148
+ function gitPlaceholderPath(repoRoot, alias) {
149
+ return join(aliasDir(repoRoot, alias), ".git");
150
+ }
151
+ function worktreePath(repoRoot, alias, branch) {
152
+ return join(aliasDir(repoRoot, alias), branch);
153
+ }
131
154
  function isRegisteredSubmodule(repoRoot, sourcePath) {
132
155
  if (!existsSync(join(repoRoot, ".gitmodules")))
133
156
  return false;
@@ -144,143 +167,342 @@ function isRegisteredSubmodule(repoRoot, sourcePath) {
144
167
  .split("\n")
145
168
  .some((line) => line.trim().split(/\s+/)[1] === sourcePath);
146
169
  }
147
- function isCheckedOutGitRepo(repoRoot, sourcePath) {
148
- const dest = join(repoRoot, sourcePath);
149
- if (!existsSync(dest))
170
+ /** Returns true when .gitmodules registers any sources/<alias> entry for the loaded repos. */
171
+ function hasLegacySubmoduleLayout(repoRoot, repos) {
172
+ if (!existsSync(join(repoRoot, ".gitmodules")))
150
173
  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";
174
+ return repos.some((repo) => isRegisteredSubmodule(repoRoot, `sources/${repo.alias}`));
175
+ }
176
+ function abortOnLegacy(repoRoot, repos) {
177
+ if (!hasLegacySubmoduleLayout(repoRoot, repos))
178
+ return false;
179
+ log.error('detected legacy submodule layout (.gitmodules registers sources/<alias>). oh-my-space 0.3.0 uses bare clone + worktrees.\n' +
180
+ ' See README "Migrating from 0.2.x" for the manual steps. Aborting to avoid destructive change.');
181
+ return true;
182
+ }
183
+ function ensureGitignore(repoRoot) {
184
+ const path = join(repoRoot, ".gitignore");
185
+ let content = "";
186
+ if (existsSync(path)) {
187
+ content = readFileSync(path, "utf8");
188
+ const present = content
189
+ .split("\n")
190
+ .some((line) => line.trim() === GITIGNORE_ENTRY || line.trim() === `/${GITIGNORE_ENTRY}`);
191
+ if (present)
192
+ return;
164
193
  }
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";
194
+ const needsNewline = content.length > 0 && !content.endsWith("\n");
195
+ writeFileSync(path, `${content}${needsNewline ? "\n" : ""}${GITIGNORE_ENTRY}\n`);
196
+ log.info(`added ${GITIGNORE_ENTRY} to .gitignore`);
197
+ }
198
+ function setupBareRepo(repo, repoRoot) {
199
+ const alias = repo.alias;
200
+ const aliasPath = aliasDir(repoRoot, alias);
201
+ const barePath = bareDir(repoRoot, alias);
202
+ mkdirSync(aliasPath, { recursive: true });
203
+ const relBare = relative(repoRoot, barePath);
204
+ log.step(`git clone --bare ${repo.url} ${relBare}`);
205
+ const clone = runGit(repoRoot, ["clone", "--bare", repo.url, relBare], true);
206
+ if (!clone.success) {
207
+ log.error(`${alias}: git clone --bare failed (exit ${clone.exitCode})`);
208
+ if (existsSync(barePath))
209
+ rmSync(barePath, { recursive: true, force: true });
210
+ return false;
168
211
  }
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";
212
+ // Write the placeholder before any further runBareGit calls, otherwise git ascends
213
+ // out of sources/<alias>/ looking for a repo and touches the wrong .git.
214
+ writeFileSync(gitPlaceholderPath(repoRoot, alias), "gitdir: ./.bare\n");
215
+ const config = runBareGit(repoRoot, alias, [
216
+ "config",
217
+ "remote.origin.fetch",
218
+ FETCH_REFSPEC,
219
+ ]);
220
+ if (!config.success) {
221
+ log.error(`${alias}: failed to set remote.origin.fetch (exit ${config.exitCode})`);
222
+ return false;
223
+ }
224
+ log.step(`${alias}: git fetch origin`);
225
+ const fetch = runBareGit(repoRoot, alias, ["fetch", "origin", "--prune"], true);
226
+ if (!fetch.success) {
227
+ log.error(`${alias}: initial fetch failed (exit ${fetch.exitCode})`);
228
+ return false;
229
+ }
230
+ return true;
231
+ }
232
+ function detectDefaultBranch(repoRoot, alias) {
233
+ const r = runBareGit(repoRoot, alias, ["symbolic-ref", "--short", "HEAD"]);
234
+ if (r.success) {
235
+ const out = r.stdout.trim();
236
+ return out.length > 0 ? out : null;
237
+ }
238
+ return null;
239
+ }
240
+ function listExistingWorktrees(repoRoot, alias) {
241
+ if (!existsSync(bareDir(repoRoot, alias)))
242
+ return [];
243
+ const result = runBareGit(repoRoot, alias, ["worktree", "list", "--porcelain"]);
244
+ if (!result.success)
245
+ return [];
246
+ const aliasPath = aliasDir(repoRoot, alias);
247
+ const entries = [];
248
+ const blocks = result.stdout
249
+ .split(/\n\n+/)
250
+ .map((b) => b.trim())
251
+ .filter((b) => b.length > 0);
252
+ for (const block of blocks) {
253
+ let path = "";
254
+ let branch = "(detached)";
255
+ let bare = false;
256
+ for (const line of block.split("\n")) {
257
+ if (line.startsWith("worktree "))
258
+ path = line.slice("worktree ".length);
259
+ else if (line.startsWith("branch ")) {
260
+ branch = line.slice("branch ".length).replace(/^refs\/heads\//, "");
261
+ }
262
+ else if (line === "bare")
263
+ bare = true;
174
264
  }
265
+ if (!path || bare)
266
+ continue;
267
+ const rel = relative(aliasPath, path);
268
+ // Skip worktrees outside sources/<alias>/ (shouldn't happen, but be safe)
269
+ if (rel.startsWith(".."))
270
+ continue;
271
+ entries.push({ branch, path, relativePath: rel });
175
272
  }
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";
273
+ return entries;
274
+ }
275
+ function cleanupEmptyParents(repoRoot, alias, branch) {
276
+ const parts = branch.split("/").filter((p) => p.length > 0);
277
+ if (parts.length < 2)
278
+ return;
279
+ for (let i = parts.length - 1; i >= 1; i--) {
280
+ const dirPath = join(aliasDir(repoRoot, alias), ...parts.slice(0, i));
281
+ try {
282
+ const entries = readdirSync(dirPath);
283
+ if (entries.length === 0) {
284
+ rmdirSync(dirPath);
285
+ }
286
+ else {
287
+ break;
288
+ }
289
+ }
290
+ catch {
291
+ break;
182
292
  }
183
- didSomething = true;
184
293
  }
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}`);
294
+ }
295
+ function addWorktree(repo, repoRoot, branch) {
296
+ const wtPath = worktreePath(repoRoot, repo.alias, branch);
297
+ if (existsSync(wtPath)) {
298
+ log.warn(`${repo.alias}: sources/${repo.alias}/${branch} already exists; skipping.`);
299
+ return true;
300
+ }
301
+ log.step(`${repo.alias}: git worktree add --track -B ${branch} ${branch} origin/${branch}`);
302
+ const result = runBareGit(repoRoot, repo.alias, ["worktree", "add", "--track", "-B", branch, branch, `origin/${branch}`], true);
303
+ if (!result.success) {
304
+ log.error(`${repo.alias}: worktree add for ${branch} failed (exit ${result.exitCode})`);
305
+ cleanupEmptyParents(repoRoot, repo.alias, branch);
306
+ return false;
307
+ }
308
+ return true;
309
+ }
310
+ function removeWorktree(repo, repoRoot, branch, force) {
311
+ const wtPath = worktreePath(repoRoot, repo.alias, branch);
312
+ if (!existsSync(wtPath))
313
+ return true;
314
+ const args = ["worktree", "remove", ...(force ? ["--force"] : []), branch];
315
+ log.step(`${repo.alias}: git worktree remove ${branch}${force ? " --force" : ""}`);
316
+ const result = runBareGit(repoRoot, repo.alias, args, true);
317
+ if (!result.success) {
318
+ log.error(`${repo.alias}: worktree remove for ${branch} failed (exit ${result.exitCode})`);
319
+ return false;
320
+ }
321
+ cleanupEmptyParents(repoRoot, repo.alias, branch);
322
+ return true;
323
+ }
324
+ function syncRepo(repo, repoRoot) {
325
+ const alias = repo.alias;
326
+ const aliasPath = aliasDir(repoRoot, alias);
327
+ const barePath = bareDir(repoRoot, alias);
328
+ const placeholderPath = gitPlaceholderPath(repoRoot, alias);
329
+ const bareExists = existsSync(barePath);
330
+ if (!bareExists) {
331
+ if (existsSync(aliasPath)) {
332
+ const stray = readdirSync(aliasPath).filter((e) => e !== ".git");
333
+ if (stray.length > 0) {
334
+ log.error(`${alias}: sources/${alias}/ already exists but is not a bare clone. Move or remove it manually, then retry.`);
335
+ return "failed";
336
+ }
337
+ }
338
+ if (repo.branch) {
339
+ const lsRemote = runGit(repoRoot, [
340
+ "ls-remote",
341
+ "--exit-code",
342
+ "--heads",
343
+ repo.url,
344
+ repo.branch,
345
+ ]);
346
+ if (lsRemote.exitCode === 2) {
347
+ log.error(`${alias}: branch "${repo.branch}" not found on ${repo.url}. Push the branch upstream or fix the alias, then retry.`);
348
+ return "failed";
349
+ }
350
+ if (!lsRemote.success && lsRemote.exitCode !== 2) {
351
+ log.warn(`${alias}: branch existence check failed (exit ${lsRemote.exitCode}); proceeding.`);
352
+ }
353
+ }
354
+ if (!setupBareRepo(repo, repoRoot)) {
355
+ if (existsSync(barePath))
356
+ rmSync(barePath, { recursive: true, force: true });
357
+ if (existsSync(placeholderPath))
358
+ rmSync(placeholderPath);
359
+ try {
360
+ if (readdirSync(aliasPath).length === 0)
361
+ rmdirSync(aliasPath);
362
+ }
363
+ catch { }
189
364
  return "failed";
190
365
  }
191
- didSomething = true;
366
+ const branch = repo.branch ?? detectDefaultBranch(repoRoot, alias);
367
+ if (!branch) {
368
+ log.error(`${alias}: could not determine default branch from bare clone. Set "branch:" in sources.yaml.`);
369
+ return "failed";
370
+ }
371
+ if (!addWorktree(repo, repoRoot, branch))
372
+ return "failed";
373
+ log.success(`${alias}: added (branch=${branch})`);
374
+ return "added";
192
375
  }
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.`);
376
+ if (!existsSync(placeholderPath)) {
377
+ writeFileSync(placeholderPath, "gitdir: ./.bare\n");
378
+ }
379
+ log.step(`${alias}: git fetch origin`);
380
+ const fetch = runBareGit(repoRoot, alias, ["fetch", "origin", "--prune"], true);
381
+ if (!fetch.success) {
382
+ log.error(`${alias}: fetch failed (exit ${fetch.exitCode})`);
212
383
  return "failed";
213
384
  }
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.`);
385
+ const branch = repo.branch ?? detectDefaultBranch(repoRoot, alias);
386
+ if (branch && !existsSync(worktreePath(repoRoot, alias, branch))) {
387
+ if (!addWorktree(repo, repoRoot, branch))
224
388
  return "failed";
389
+ }
390
+ runBareGit(repoRoot, alias, ["worktree", "prune"]);
391
+ log.success(`${alias}: updated`);
392
+ return "updated";
393
+ }
394
+ function unsyncRepo(repo, repoRoot, force) {
395
+ const aliasPath = aliasDir(repoRoot, repo.alias);
396
+ const barePath = bareDir(repoRoot, repo.alias);
397
+ const placeholderPath = gitPlaceholderPath(repoRoot, repo.alias);
398
+ const aliasExists = existsSync(aliasPath);
399
+ const bareExists = existsSync(barePath);
400
+ if (!aliasExists && !bareExists)
401
+ return "nothing-to-remove";
402
+ const worktrees = bareExists ? listExistingWorktrees(repoRoot, repo.alias) : [];
403
+ if (!force) {
404
+ for (const wt of worktrees) {
405
+ const status = runGit(wt.path, ["status", "--porcelain"]);
406
+ if (status.success && status.stdout.trim().length > 0) {
407
+ log.error(`${repo.alias}: worktree at sources/${repo.alias}/${wt.relativePath} has uncommitted changes. Commit, stash, or pass --force.`);
408
+ return "failed";
409
+ }
225
410
  }
226
- if (!lsRemote.success && lsRemote.exitCode !== 2) {
227
- log.warn(`${repo.alias}: branch existence check failed (exit ${lsRemote.exitCode}); proceeding with submodule add.`);
411
+ }
412
+ for (const wt of worktrees) {
413
+ const target = wt.branch === "(detached)" ? wt.relativePath : wt.branch;
414
+ if (!removeWorktree(repo, repoRoot, target, force)) {
415
+ return "failed";
228
416
  }
229
417
  }
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.`);
418
+ if (existsSync(barePath))
419
+ rmSync(barePath, { recursive: true, force: true });
420
+ if (existsSync(placeholderPath))
421
+ rmSync(placeholderPath);
422
+ try {
423
+ if (existsSync(aliasPath)) {
424
+ const remaining = readdirSync(aliasPath);
425
+ if (remaining.length === 0)
426
+ rmdirSync(aliasPath);
252
427
  }
253
428
  }
429
+ catch { }
430
+ return "removed";
431
+ }
432
+ function fetchRepo(repo, repoRoot) {
433
+ if (!existsSync(bareDir(repoRoot, repo.alias))) {
434
+ log.error(`${repo.alias}: not synced. Run "oms sync ${repo.alias}" first.`);
435
+ return "failed";
436
+ }
437
+ log.step(`${repo.alias}: git fetch origin --prune`);
438
+ const r = runBareGit(repoRoot, repo.alias, ["fetch", "origin", "--prune"], true);
439
+ if (r.success) {
440
+ log.success(`${repo.alias}: fetched`);
441
+ return "fetched";
442
+ }
443
+ log.error(`${repo.alias}: fetch failed (exit ${r.exitCode})`);
254
444
  return "failed";
255
445
  }
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.`);
446
+ function resolveTargetWorktree(repo, repoRoot) {
447
+ const defaultBranch = repo.branch ?? detectDefaultBranch(repoRoot, repo.alias);
448
+ if (defaultBranch) {
449
+ const path = worktreePath(repoRoot, repo.alias, defaultBranch);
450
+ if (existsSync(path))
451
+ return { branch: defaultBranch, path };
452
+ }
453
+ const wts = listExistingWorktrees(repoRoot, repo.alias);
454
+ if (wts.length === 1) {
455
+ const only = wts[0];
456
+ return { branch: only.branch === "(detached)" ? only.relativePath : only.branch, path: only.path };
457
+ }
458
+ return null;
459
+ }
460
+ function pullRepo(repo, repoRoot) {
461
+ if (!existsSync(bareDir(repoRoot, repo.alias))) {
462
+ log.error(`${repo.alias}: not synced. Run "oms sync ${repo.alias}" first.`);
260
463
  return "failed";
261
464
  }
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
- }
465
+ const target = resolveTargetWorktree(repo, repoRoot);
466
+ if (!target) {
467
+ log.error(`${repo.alias}: cannot determine which worktree to pull. Use "oms worktree list ${repo.alias}".`);
468
+ return "failed";
274
469
  }
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;
470
+ log.step(`${repo.alias}/${target.branch}: git pull --ff-only`);
471
+ const r = runGit(target.path, ["pull", "--ff-only"], true);
472
+ if (r.success) {
473
+ log.success(`${repo.alias}/${target.branch}: pulled`);
474
+ return "pulled";
282
475
  }
283
- log.error(`${repo.alias}: git ${command} failed (exit ${result.exitCode})`);
476
+ log.error(`${repo.alias}/${target.branch}: pull failed (exit ${r.exitCode})`);
477
+ return "failed";
478
+ }
479
+ function pushRepo(repo, repoRoot) {
480
+ if (!existsSync(bareDir(repoRoot, repo.alias))) {
481
+ log.error(`${repo.alias}: not synced. Run "oms sync ${repo.alias}" first.`);
482
+ return "failed";
483
+ }
484
+ const target = resolveTargetWorktree(repo, repoRoot);
485
+ if (!target) {
486
+ log.error(`${repo.alias}: cannot determine which worktree to push. Use "oms worktree list ${repo.alias}".`);
487
+ return "failed";
488
+ }
489
+ const upstream = runGit(target.path, [
490
+ "rev-parse",
491
+ "--abbrev-ref",
492
+ "--symbolic-full-name",
493
+ "@{u}",
494
+ ]);
495
+ if (!upstream.success) {
496
+ log.error(`${repo.alias}/${target.branch}: git push requires an upstream. Set it manually before pushing.`);
497
+ return "failed";
498
+ }
499
+ log.step(`${repo.alias}/${target.branch}: git push`);
500
+ const r = runGit(target.path, ["-c", "push.autoSetupRemote=false", "push"], true);
501
+ if (r.success) {
502
+ log.success(`${repo.alias}/${target.branch}: pushed`);
503
+ return "pushed";
504
+ }
505
+ log.error(`${repo.alias}/${target.branch}: push failed (exit ${r.exitCode})`);
284
506
  return "failed";
285
507
  }
286
508
  function printSummary(results) {
@@ -350,10 +572,13 @@ async function runSync(aliases, options) {
350
572
  printList(repos);
351
573
  return 0;
352
574
  }
575
+ if (abortOnLegacy(repoRoot, repos))
576
+ return 1;
353
577
  const picked = await selectRepos(repos, aliases, options, "sync");
354
578
  if (!picked || picked.length === 0)
355
579
  return 1;
356
- const results = picked.map((repo) => syncSubmodule(repo, repoRoot));
580
+ ensureGitignore(repoRoot);
581
+ const results = picked.map((repo) => syncRepo(repo, repoRoot));
357
582
  if (results.length > 1 || options.all)
358
583
  printSummary(results);
359
584
  return exitFromResults(results);
@@ -363,10 +588,13 @@ async function runManage(command, aliases, options) {
363
588
  if (!loaded)
364
589
  return 1;
365
590
  const { repos, repoRoot } = loaded;
591
+ if (abortOnLegacy(repoRoot, repos))
592
+ return 1;
366
593
  const picked = await selectRepos(repos, aliases, options, command);
367
594
  if (!picked || picked.length === 0)
368
595
  return 1;
369
- const results = picked.map((repo) => manageSubmodule(repo, repoRoot, command));
596
+ const fn = command === "fetch" ? fetchRepo : command === "pull" ? pullRepo : pushRepo;
597
+ const results = picked.map((repo) => fn(repo, repoRoot));
370
598
  if (results.length > 1 || options.all)
371
599
  printSummary(results);
372
600
  return exitFromResults(results);
@@ -376,14 +604,16 @@ async function runUnsync(aliases, options) {
376
604
  if (!loaded)
377
605
  return 1;
378
606
  const { repos, repoRoot } = loaded;
607
+ if (abortOnLegacy(repoRoot, repos))
608
+ return 1;
379
609
  const picked = await selectRepos(repos, aliases, options, "unsync");
380
610
  if (!picked || picked.length === 0)
381
611
  return 1;
382
612
  const results = picked.map((repo) => {
383
613
  log.step(`${repo.alias}: unsync`);
384
- const outcome = removeSubmoduleArtifacts(repo, repoRoot, { force: options.force });
614
+ const outcome = unsyncRepo(repo, repoRoot, options.force ?? false);
385
615
  if (outcome === "removed") {
386
- log.success(`${repo.alias}: unsynced (commit staged changes in .gitmodules and sources/${repo.alias} to finalize)`);
616
+ log.success(`${repo.alias}: unsynced`);
387
617
  return "unsynced";
388
618
  }
389
619
  if (outcome === "nothing-to-remove") {
@@ -396,19 +626,160 @@ async function runUnsync(aliases, options) {
396
626
  printSummary(results);
397
627
  return exitFromResults(results);
398
628
  }
399
- async function runDoctor() {
629
+ async function runWorktreeAdd(alias, branch) {
630
+ const loaded = loadRepos();
631
+ if (!loaded)
632
+ return 1;
633
+ const { repos, repoRoot } = loaded;
634
+ if (abortOnLegacy(repoRoot, repos))
635
+ return 1;
636
+ const repo = repos.find((r) => r.alias === alias);
637
+ if (!repo) {
638
+ log.error(`Unknown alias "${alias}". Use "oms sync --list" to see registered aliases.`);
639
+ return 1;
640
+ }
641
+ if (!existsSync(bareDir(repoRoot, alias))) {
642
+ log.error(`${alias}: not synced. Run "oms sync ${alias}" first.`);
643
+ return 1;
644
+ }
645
+ if (existsSync(worktreePath(repoRoot, alias, branch))) {
646
+ log.error(`${alias}: worktree for ${branch} already exists at sources/${alias}/${branch}.`);
647
+ return 1;
648
+ }
649
+ log.step(`${alias}: git fetch origin --prune (refresh before add)`);
650
+ runBareGit(repoRoot, alias, ["fetch", "origin", "--prune"], true);
651
+ if (!addWorktree(repo, repoRoot, branch))
652
+ return 2;
653
+ log.success(`${alias}: added worktree for ${branch}`);
654
+ return 0;
655
+ }
656
+ async function runWorktreeList(alias) {
657
+ const loaded = loadRepos();
658
+ if (!loaded)
659
+ return 1;
660
+ const { repos, repoRoot } = loaded;
661
+ if (abortOnLegacy(repoRoot, repos))
662
+ return 1;
663
+ const targets = alias ? repos.filter((r) => r.alias === alias) : repos;
664
+ if (alias && targets.length === 0) {
665
+ log.error(`Unknown alias "${alias}".`);
666
+ return 1;
667
+ }
668
+ const rows = [];
669
+ for (const repo of targets) {
670
+ if (!existsSync(bareDir(repoRoot, repo.alias))) {
671
+ rows.push({ alias: repo.alias, branch: "(not synced)", path: "-" });
672
+ continue;
673
+ }
674
+ const wts = listExistingWorktrees(repoRoot, repo.alias);
675
+ if (wts.length === 0) {
676
+ rows.push({ alias: repo.alias, branch: "(no worktrees)", path: "-" });
677
+ continue;
678
+ }
679
+ for (const wt of wts) {
680
+ rows.push({
681
+ alias: repo.alias,
682
+ branch: wt.branch,
683
+ path: `sources/${repo.alias}/${wt.relativePath}`,
684
+ });
685
+ }
686
+ }
687
+ const aliasW = Math.max("ALIAS".length, ...rows.map((r) => r.alias.length));
688
+ const branchW = Math.max("BRANCH".length, ...rows.map((r) => r.branch.length));
689
+ console.log(dim(`${pad("ALIAS", aliasW)} ${pad("BRANCH", branchW)} PATH`));
690
+ for (const r of rows) {
691
+ console.log(`${pad(r.alias, aliasW)} ${pad(r.branch, branchW)} ${r.path}`);
692
+ }
693
+ return 0;
694
+ }
695
+ async function runWorktreeRemove(alias, branch, options) {
400
696
  const loaded = loadRepos();
401
697
  if (!loaded)
402
698
  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"] });
406
- if (git.status === 0) {
407
- log.success(`git: ${git.stdout.trim()}`);
699
+ const { repos, repoRoot } = loaded;
700
+ if (abortOnLegacy(repoRoot, repos))
701
+ return 1;
702
+ const repo = repos.find((r) => r.alias === alias);
703
+ if (!repo) {
704
+ log.error(`Unknown alias "${alias}".`);
705
+ return 1;
706
+ }
707
+ const wtPath = worktreePath(repoRoot, alias, branch);
708
+ if (!existsSync(wtPath)) {
709
+ log.info(`${alias}: no worktree for ${branch}`);
408
710
  return 0;
409
711
  }
410
- log.error("git: not found");
411
- return 1;
712
+ if (!options.force) {
713
+ const status = runGit(wtPath, ["status", "--porcelain"]);
714
+ if (status.success && status.stdout.trim().length > 0) {
715
+ log.error(`${alias}/${branch}: has uncommitted changes. Commit, stash, or pass --force.`);
716
+ return 2;
717
+ }
718
+ }
719
+ if (!removeWorktree(repo, repoRoot, branch, options.force ?? false))
720
+ return 2;
721
+ log.success(`${alias}: removed worktree for ${branch}`);
722
+ return 0;
723
+ }
724
+ async function runDoctor() {
725
+ const loaded = loadRepos();
726
+ if (!loaded)
727
+ return 1;
728
+ const { repos, repoRoot } = loaded;
729
+ log.success(`Workspace root: ${repoRoot}`);
730
+ log.success(`sources.yaml: ${repos.length} repo(s) configured`);
731
+ const git = spawnSync("git", ["--version"], {
732
+ encoding: "utf8",
733
+ stdio: ["ignore", "pipe", "pipe"],
734
+ });
735
+ if (git.status !== 0) {
736
+ log.error("git: not found");
737
+ return 1;
738
+ }
739
+ log.success(`git: ${git.stdout.trim()}`);
740
+ let warnings = 0;
741
+ const parsed = parseGitVersion(git.stdout);
742
+ if (!parsed) {
743
+ log.warn(`git: could not parse version from "${git.stdout.trim()}"; oms expects git >=${MIN_GIT_MAJOR}.${MIN_GIT_MINOR}.`);
744
+ warnings++;
745
+ }
746
+ else if (!isGitVersionSupported(parsed)) {
747
+ log.warn(`git ${parsed.major}.${parsed.minor} is older than the recommended ${MIN_GIT_MAJOR}.${MIN_GIT_MINOR}. oms uses "git worktree add --track -B" which may fail on older releases — upgrade git.`);
748
+ warnings++;
749
+ }
750
+ if (hasLegacySubmoduleLayout(repoRoot, repos)) {
751
+ log.warn('detected legacy submodule layout (.gitmodules registers sources/<alias>). See README "Migrating from 0.2.x".');
752
+ warnings++;
753
+ }
754
+ const gi = join(repoRoot, ".gitignore");
755
+ const giHasSources = existsSync(gi)
756
+ && readFileSync(gi, "utf8")
757
+ .split("\n")
758
+ .some((l) => l.trim() === GITIGNORE_ENTRY || l.trim() === `/${GITIGNORE_ENTRY}`);
759
+ if (!giHasSources) {
760
+ log.warn(`.gitignore does not exclude ${GITIGNORE_ENTRY}. Run "oms sync" to add it automatically.`);
761
+ warnings++;
762
+ }
763
+ for (const repo of repos) {
764
+ const bare = bareDir(repoRoot, repo.alias);
765
+ if (!existsSync(bare)) {
766
+ log.info(`${repo.alias}: not synced`);
767
+ continue;
768
+ }
769
+ const refspec = runBareGit(repoRoot, repo.alias, ["config", "--get", "remote.origin.fetch"]);
770
+ if (!refspec.success || refspec.stdout.trim().length === 0) {
771
+ 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}'`);
772
+ warnings++;
773
+ }
774
+ else {
775
+ log.success(`${repo.alias}: bare clone OK (refspec: ${refspec.stdout.trim()})`);
776
+ }
777
+ if (!existsSync(gitPlaceholderPath(repoRoot, repo.alias))) {
778
+ log.warn(`${repo.alias}: missing .git placeholder at sources/${repo.alias}/.git. Recreate with: echo 'gitdir: ./.bare' > sources/${repo.alias}/.git`);
779
+ warnings++;
780
+ }
781
+ }
782
+ return warnings > 0 ? 2 : 0;
412
783
  }
413
784
  function readJson(path) {
414
785
  if (!existsSync(path))
@@ -429,23 +800,32 @@ async function exitWith(action) {
429
800
  }
430
801
  }
431
802
  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"]);
803
+ const commandNames = new Set([
804
+ "doctor",
805
+ "sync",
806
+ "fetch",
807
+ "pull",
808
+ "push",
809
+ "unsync",
810
+ "worktree",
811
+ "help",
812
+ ]);
433
813
  const program = new Command();
434
814
  program
435
815
  .name("oms")
436
- .description("Manage source repositories listed in sources.yaml under sources/<alias>/.")
816
+ .description("Manage source repositories listed in sources.yaml as bare clones + worktrees under sources/<alias>/.")
437
817
  .version(readPackageVersion())
438
818
  .addHelpText("after", exitHelp);
439
819
  program
440
820
  .command("doctor")
441
- .description("Check sources.yaml configuration and git availability.")
821
+ .description("Check sources.yaml, git availability, bare-clone state, and .gitignore for each registered alias.")
442
822
  .addHelpText("after", exitHelp)
443
823
  .action(async () => {
444
824
  await exitWith(runDoctor());
445
825
  });
446
826
  program
447
827
  .command("sync")
448
- .description("Sync sources.yaml entries into git submodules under sources/<alias>/.")
828
+ .description("Bare-clone each registered repo into sources/<alias>/.bare and create the baseline worktree at sources/<alias>/<branch>/.")
449
829
  .argument("[aliases...]", "repo aliases to sync (omit for interactive multi-select)")
450
830
  .option("--all", "sync every registered source repo")
451
831
  .option("--list", "print registered repos")
@@ -455,7 +835,7 @@ program
455
835
  });
456
836
  program
457
837
  .command("fetch")
458
- .description("Run git fetch --all --prune inside checked-out source submodule worktrees.")
838
+ .description("Run git fetch origin --prune in each bare clone.")
459
839
  .argument("[aliases...]", "repo aliases to fetch (omit for interactive multi-select)")
460
840
  .option("--all", "fetch every registered source repo")
461
841
  .addHelpText("after", exitHelp)
@@ -464,7 +844,7 @@ program
464
844
  });
465
845
  program
466
846
  .command("pull")
467
- .description("Run git pull --ff-only inside checked-out source submodule worktrees; requires branch/upstream.")
847
+ .description("Run git pull --ff-only in the baseline worktree of each selected alias; requires upstream.")
468
848
  .argument("[aliases...]", "repo aliases to pull (omit for interactive multi-select)")
469
849
  .option("--all", "pull every registered source repo")
470
850
  .addHelpText("after", exitHelp)
@@ -473,7 +853,7 @@ program
473
853
  });
474
854
  program
475
855
  .command("push")
476
- .description("Run git push inside explicit source submodule worktrees; no --all, force, or upstream setup.")
856
+ .description("Run git push in the baseline worktree of each explicitly listed alias; no --all, force, or upstream setup.")
477
857
  .argument("<aliases...>", "repo aliases to push")
478
858
  .addHelpText("after", exitHelp)
479
859
  .action(async (aliases) => {
@@ -481,14 +861,45 @@ program
481
861
  });
482
862
  program
483
863
  .command("unsync")
484
- .description("Remove submodule registration and worktree (keeps sources.yaml entry). Leaves staged changes in .gitmodules and the submodule path to commit.")
864
+ .description("Remove all worktrees and the bare clone for each alias (keeps sources.yaml entry).")
485
865
  .argument("[aliases...]", "repo aliases to unsync (omit for interactive multi-select)")
486
866
  .option("--all", "unsync every registered source repo")
487
- .option("--force", "discard uncommitted changes in the source worktree")
867
+ .option("--force", "discard uncommitted changes in worktrees")
488
868
  .addHelpText("after", exitHelp)
489
869
  .action(async (aliases, options) => {
490
870
  await exitWith(runUnsync(aliases, options));
491
871
  });
872
+ const worktreeCmd = program
873
+ .command("worktree")
874
+ .description("Manage additional worktrees for a synced source repo.")
875
+ .addHelpText("after", exitHelp);
876
+ worktreeCmd
877
+ .command("add")
878
+ .description("Create a worktree for <branch> at sources/<alias>/<branch>/.")
879
+ .argument("<alias>", "registered source alias")
880
+ .argument("<branch>", "branch name (may include slashes)")
881
+ .addHelpText("after", exitHelp)
882
+ .action(async (alias, branch) => {
883
+ await exitWith(runWorktreeAdd(alias, branch));
884
+ });
885
+ worktreeCmd
886
+ .command("list")
887
+ .description("List existing worktrees for the given alias (or all aliases).")
888
+ .argument("[alias]", "registered source alias")
889
+ .addHelpText("after", exitHelp)
890
+ .action(async (alias) => {
891
+ await exitWith(runWorktreeList(alias));
892
+ });
893
+ worktreeCmd
894
+ .command("remove")
895
+ .description("Remove the worktree at sources/<alias>/<branch>.")
896
+ .argument("<alias>", "registered source alias")
897
+ .argument("<branch>", "branch name (may include slashes)")
898
+ .option("--force", "discard uncommitted changes")
899
+ .addHelpText("after", exitHelp)
900
+ .action(async (alias, branch, options) => {
901
+ await exitWith(runWorktreeRemove(alias, branch, options));
902
+ });
492
903
  const requestedCommand = process.argv[2];
493
904
  if (requestedCommand && !requestedCommand.startsWith("-") && !commandNames.has(requestedCommand)) {
494
905
  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.1",
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",
@@ -40,7 +40,7 @@
40
40
  "prepack": "npm test",
41
41
  "changeset": "changeset",
42
42
  "version": "changeset version",
43
- "release": "npm test && npm publish --provenance --access public"
43
+ "release": "changeset publish"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@changesets/changelog-github": "^0.7.0",