oh-my-space 0.3.0 → 0.4.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 +15 -29
  2. package/dist/oms.js +156 -51
  3. package/package.json +5 -5
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`. 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>/`.
3
+ `oh-my-space` provides the `oms` CLI for managing external source repositories declared in `oms.yaml`. Each registered repo lives as a bare clone under `oms/<alias>/.bare/` and the baseline branch (and any additional ones you ask for) checks out as a Git worktree at `oms/<alias>/<branch>/`.
4
4
 
5
5
  Package name: `oh-my-space`
6
6
 
@@ -32,7 +32,7 @@ bun install -g oh-my-space
32
32
 
33
33
  ## Quick start
34
34
 
35
- Create a `sources.yaml` in your project root:
35
+ Create an `oms.yaml` in your project root:
36
36
 
37
37
  ```yaml
38
38
  repos:
@@ -46,7 +46,7 @@ repos:
46
46
  Sync and explore:
47
47
 
48
48
  ```bash
49
- oms doctor # validate sources.yaml + bare/worktree state
49
+ oms doctor # validate oms.yaml + bare/worktree state
50
50
  oms sync --list # list registered source repos
51
51
  oms sync <alias>... # bare-clone + create the baseline worktree
52
52
  oms sync --all # sync every registered source repo
@@ -57,14 +57,14 @@ oms worktree remove <alias> <br> # remove a single worktree
57
57
  oms unsync <alias>... # remove ALL worktrees + the bare clone
58
58
  ```
59
59
 
60
- `oms sync` adds `sources/` to your workspace `.gitignore` on first run; the bare clone and worktrees stay out of your project history.
60
+ `oms sync` adds `oms/` to your workspace `.gitignore` on first run; the bare clone and worktrees stay out of your project history.
61
61
 
62
62
  ## Layout
63
63
 
64
64
  For an alias `api` with the baseline branch `main` and an extra `feature/foo` worktree:
65
65
 
66
66
  ```
67
- sources/
67
+ oms/
68
68
  └── api/
69
69
  ├── .bare/ # git clone --bare result
70
70
  ├── .git # 'gitdir: ./.bare' placeholder
@@ -77,13 +77,13 @@ Slash-containing branch names are kept nested as directories.
77
77
 
78
78
  ## Managing source repositories
79
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.
80
+ `oms.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 `oms.yaml` — they're treated as per-machine working space.
81
81
 
82
82
  | Command | Runs in | Does | Notes |
83
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. |
84
+ | `oms doctor` | project root or child path | Checks `oms.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 `oms/` 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 `oms.yaml` entry. Use `--force` to discard uncommitted changes. |
87
87
  | `oms fetch ...` | workspace root | `git fetch origin --prune` on each bare clone. | Worktrees are not touched. |
88
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
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. |
@@ -91,7 +91,7 @@ Slash-containing branch names are kept nested as directories.
91
91
  | `oms worktree list [alias]` | anywhere under root | Lists every worktree for the alias (or all aliases). | |
92
92
  | `oms worktree remove <alias> <branch>` | workspace root | `git worktree remove <branch>` plus parent-directory cleanup for slash branches. | `--force` discards uncommitted changes. |
93
93
 
94
- ## `sources.yaml` format
94
+ ## `oms.yaml` format
95
95
 
96
96
  ```yaml
97
97
  repos:
@@ -109,28 +109,14 @@ Rules:
109
109
  - `url` is required.
110
110
  - `branch` is optional; when omitted the bare clone's default branch is used as the baseline worktree.
111
111
 
112
- ## Migrating from 0.2.x
112
+ JSON schema: [`oms.schema.json`](./oms.schema.json) (also reachable at `https://raw.githubusercontent.com/divlook/oh-my-space/main/oms.schema.json` for YAML LSPs).
113
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:
114
+ ## Migrating between versions
115
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
116
+ 자세한 마이그레이션 절차는 [`docs/migrations/`](./docs/migrations/) 에 버전별로 정리되어 있습니다.
127
117
 
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
- ```
118
+ - [0.3.x → 0.4.0](./docs/migrations/0.3.x-to-0.4.0.md) — `sources.yaml`/`sources/` 가 `oms.yaml`/`oms/` 로 rename
119
+ - [0.2.x → 0.3.0](./docs/migrations/0.2.x-to-0.3.0.md) — submodule 에서 bare clone + worktree 로 데이터 모델 전환
134
120
 
135
121
  ## License
136
122
 
package/dist/oms.js CHANGED
@@ -10,57 +10,65 @@ 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
12
  const FETCH_REFSPEC = "+refs/heads/*:refs/remotes/origin/*";
13
- const GITIGNORE_ENTRY = "sources/";
13
+ const MANIFEST_FILENAME = "oms.yaml";
14
+ const DATA_DIRNAME = "oms";
15
+ const GITIGNORE_ENTRY = `${DATA_DIRNAME}/`;
16
+ const LEGACY_MANIFEST = "sources.yaml";
17
+ const LEGACY_DATA_DIRNAME = "sources";
18
+ const LEGACY_MIGRATION_DOC = "docs/migrations/0.3.x-to-0.4.0.md";
19
+ const SUBMODULE_MIGRATION_DOC = "docs/migrations/0.2.x-to-0.3.0.md";
20
+ const MIN_GIT_MAJOR = 2;
21
+ const MIN_GIT_MINOR = 40;
14
22
  const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
15
23
  const useColor = process.stdout.isTTY;
16
24
  const dim = (s) => (useColor ? `\x1b[2m${s}\x1b[0m` : s);
17
25
  function validateSources(data) {
18
26
  if (!data || typeof data !== "object" || Array.isArray(data)) {
19
- throw new Error("sources.yaml: root must be a mapping");
27
+ throw new Error(`${MANIFEST_FILENAME}: root must be a mapping`);
20
28
  }
21
29
  const obj = data;
22
30
  for (const key of Object.keys(obj)) {
23
31
  if (!ALLOWED_TOP_KEYS.has(key)) {
24
- throw new Error(`sources.yaml: unknown top-level key "${key}"`);
32
+ throw new Error(`${MANIFEST_FILENAME}: unknown top-level key "${key}"`);
25
33
  }
26
34
  }
27
35
  const { repos } = obj;
28
36
  if (!Array.isArray(repos)) {
29
- throw new Error('sources.yaml: "repos" must be an array');
37
+ throw new Error(`${MANIFEST_FILENAME}: "repos" must be an array`);
30
38
  }
31
39
  if (repos.length === 0) {
32
- throw new Error('sources.yaml: "repos" must have at least one item');
40
+ throw new Error(`${MANIFEST_FILENAME}: "repos" must have at least one item`);
33
41
  }
34
42
  const validated = [];
35
43
  const seen = new Set();
36
44
  repos.forEach((item, idx) => {
37
45
  const where = `repos[${idx}]`;
38
46
  if (!item || typeof item !== "object" || Array.isArray(item)) {
39
- throw new Error(`sources.yaml: ${where} must be a mapping`);
47
+ throw new Error(`${MANIFEST_FILENAME}: ${where} must be a mapping`);
40
48
  }
41
49
  const r = item;
42
50
  for (const key of Object.keys(r)) {
43
51
  if (!ALLOWED_ITEM_KEYS.has(key)) {
44
- throw new Error(`sources.yaml: ${where} has unknown key "${key}"`);
52
+ throw new Error(`${MANIFEST_FILENAME}: ${where} has unknown key "${key}"`);
45
53
  }
46
54
  }
47
55
  if (typeof r.alias !== "string" || r.alias.length === 0) {
48
- throw new Error(`sources.yaml: ${where} missing required "alias"`);
56
+ throw new Error(`${MANIFEST_FILENAME}: ${where} missing required "alias"`);
49
57
  }
50
58
  if (!ALIAS_PATTERN.test(r.alias)) {
51
- throw new Error(`sources.yaml: ${where}.alias "${r.alias}" must match ${ALIAS_PATTERN}`);
59
+ throw new Error(`${MANIFEST_FILENAME}: ${where}.alias "${r.alias}" must match ${ALIAS_PATTERN}`);
52
60
  }
53
61
  if (seen.has(r.alias)) {
54
- throw new Error(`sources.yaml: duplicate alias "${r.alias}"`);
62
+ throw new Error(`${MANIFEST_FILENAME}: duplicate alias "${r.alias}"`);
55
63
  }
56
64
  seen.add(r.alias);
57
65
  if (typeof r.url !== "string" || r.url.length === 0) {
58
- throw new Error(`sources.yaml: ${where} missing required "url"`);
66
+ throw new Error(`${MANIFEST_FILENAME}: ${where} missing required "url"`);
59
67
  }
60
68
  let branch;
61
69
  if (r.branch !== undefined) {
62
70
  if (typeof r.branch !== "string" || r.branch.length === 0) {
63
- throw new Error(`sources.yaml: ${where}.branch must be a non-empty string`);
71
+ throw new Error(`${MANIFEST_FILENAME}: ${where}.branch must be a non-empty string`);
64
72
  }
65
73
  branch = r.branch;
66
74
  }
@@ -109,14 +117,27 @@ function runGit(cwd, args, inheritOutput = false) {
109
117
  stdout: inheritOutput ? "" : (result.stdout ?? ""),
110
118
  };
111
119
  }
112
- /** Run git from sources/<alias>/ with safe.bareRepository=all, so the .git placeholder routes to .bare. */
120
+ /** Run git from oms/<alias>/ with safe.bareRepository=all, so the .git placeholder routes to .bare. */
113
121
  function runBareGit(repoRoot, alias, args, inheritOutput = false) {
114
122
  return runGit(aliasDir(repoRoot, alias), ["-c", "safe.bareRepository=all", ...args], inheritOutput);
115
123
  }
124
+ function parseGitVersion(s) {
125
+ const m = s.match(/git version (\d+)\.(\d+)/);
126
+ if (!m)
127
+ return null;
128
+ return { major: Number.parseInt(m[1], 10), minor: Number.parseInt(m[2], 10) };
129
+ }
130
+ function isGitVersionSupported(v) {
131
+ if (v.major > MIN_GIT_MAJOR)
132
+ return true;
133
+ if (v.major < MIN_GIT_MAJOR)
134
+ return false;
135
+ return v.minor >= MIN_GIT_MINOR;
136
+ }
116
137
  function findWorkspaceRoot(options = {}) {
117
138
  let current = resolve(options.cwd ?? process.cwd());
118
139
  while (true) {
119
- if (existsSync(join(current, "sources.yaml")))
140
+ if (existsSync(join(current, MANIFEST_FILENAME)))
120
141
  return current;
121
142
  const parent = dirname(current);
122
143
  if (parent === current)
@@ -125,7 +146,7 @@ function findWorkspaceRoot(options = {}) {
125
146
  }
126
147
  }
127
148
  function aliasDir(repoRoot, alias) {
128
- return join(repoRoot, "sources", alias);
149
+ return join(repoRoot, DATA_DIRNAME, alias);
129
150
  }
130
151
  function bareDir(repoRoot, alias) {
131
152
  return join(aliasDir(repoRoot, alias), ".bare");
@@ -152,19 +173,68 @@ function isRegisteredSubmodule(repoRoot, sourcePath) {
152
173
  .split("\n")
153
174
  .some((line) => line.trim().split(/\s+/)[1] === sourcePath);
154
175
  }
155
- /** Returns true when .gitmodules registers any sources/<alias> entry for the loaded repos. */
176
+ /** Returns true when .gitmodules registers any sources/<alias> or oms/<alias> entry for the loaded repos. */
156
177
  function hasLegacySubmoduleLayout(repoRoot, repos) {
157
178
  if (!existsSync(join(repoRoot, ".gitmodules")))
158
179
  return false;
159
- return repos.some((repo) => isRegisteredSubmodule(repoRoot, `sources/${repo.alias}`));
180
+ return repos.some((repo) => isRegisteredSubmodule(repoRoot, `${LEGACY_DATA_DIRNAME}/${repo.alias}`)
181
+ || isRegisteredSubmodule(repoRoot, `${DATA_DIRNAME}/${repo.alias}`));
160
182
  }
161
183
  function abortOnLegacy(repoRoot, repos) {
162
184
  if (!hasLegacySubmoduleLayout(repoRoot, repos))
163
185
  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.');
186
+ log.error('detected legacy submodule layout (.gitmodules registers an alias path). oh-my-space 0.3.0+ uses bare clone + worktrees.\n' +
187
+ ` See ${SUBMODULE_MIGRATION_DOC} for the manual steps. Aborting to avoid destructive change.`);
166
188
  return true;
167
189
  }
190
+ /** Inspect a single directory for 0.3.x rename artifacts. */
191
+ function detectLegacyRenameArtifactsAt(dir) {
192
+ const manifest = existsSync(join(dir, LEGACY_MANIFEST));
193
+ const data = existsSync(join(dir, LEGACY_DATA_DIRNAME));
194
+ if (!manifest && !data)
195
+ return null;
196
+ return { manifest, data };
197
+ }
198
+ function emitLegacyRenameMessage(dir, found) {
199
+ const artifacts = [
200
+ found.manifest ? `'${LEGACY_MANIFEST}'` : null,
201
+ found.data ? `'${LEGACY_DATA_DIRNAME}/'` : null,
202
+ ]
203
+ .filter((s) => s !== null)
204
+ .join(" and ");
205
+ log.error(`detected legacy ${artifacts} at ${dir}.\n` +
206
+ ` oh-my-space 0.4.0 renamed the manifest to ${MANIFEST_FILENAME} and the data directory to ${DATA_DIRNAME}/.\n` +
207
+ ` See ${LEGACY_MIGRATION_DOC} for the manual steps. Aborting to avoid destructive change.`);
208
+ }
209
+ /** Block when the active oms.yaml workspace still has legacy artifacts at its root. */
210
+ function abortOnLegacyRenameAt(repoRoot) {
211
+ const found = detectLegacyRenameArtifactsAt(repoRoot);
212
+ if (!found)
213
+ return false;
214
+ emitLegacyRenameMessage(repoRoot, found);
215
+ return true;
216
+ }
217
+ /**
218
+ * When oms.yaml could not be found, walk upward looking for a 0.3.x manifest (sources.yaml).
219
+ * Only the manifest is trusted as a positive signal — a stray sources/ directory by itself
220
+ * could belong to an unrelated tool. Returns true if a hint was emitted.
221
+ */
222
+ function emitLegacyRenameHintWalkUp(options = {}) {
223
+ let current = resolve(options.cwd ?? process.cwd());
224
+ while (true) {
225
+ if (existsSync(join(current, LEGACY_MANIFEST))) {
226
+ emitLegacyRenameMessage(current, {
227
+ manifest: true,
228
+ data: existsSync(join(current, LEGACY_DATA_DIRNAME)),
229
+ });
230
+ return true;
231
+ }
232
+ const parent = dirname(current);
233
+ if (parent === current)
234
+ return false;
235
+ current = parent;
236
+ }
237
+ }
168
238
  function ensureGitignore(repoRoot) {
169
239
  const path = join(repoRoot, ".gitignore");
170
240
  let content = "";
@@ -195,7 +265,7 @@ function setupBareRepo(repo, repoRoot) {
195
265
  return false;
196
266
  }
197
267
  // 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.
268
+ // out of oms/<alias>/ looking for a repo and touches the wrong .git.
199
269
  writeFileSync(gitPlaceholderPath(repoRoot, alias), "gitdir: ./.bare\n");
200
270
  const config = runBareGit(repoRoot, alias, [
201
271
  "config",
@@ -250,7 +320,7 @@ function listExistingWorktrees(repoRoot, alias) {
250
320
  if (!path || bare)
251
321
  continue;
252
322
  const rel = relative(aliasPath, path);
253
- // Skip worktrees outside sources/<alias>/ (shouldn't happen, but be safe)
323
+ // Skip worktrees outside oms/<alias>/ (shouldn't happen, but be safe)
254
324
  if (rel.startsWith(".."))
255
325
  continue;
256
326
  entries.push({ branch, path, relativePath: rel });
@@ -280,7 +350,7 @@ function cleanupEmptyParents(repoRoot, alias, branch) {
280
350
  function addWorktree(repo, repoRoot, branch) {
281
351
  const wtPath = worktreePath(repoRoot, repo.alias, branch);
282
352
  if (existsSync(wtPath)) {
283
- log.warn(`${repo.alias}: sources/${repo.alias}/${branch} already exists; skipping.`);
353
+ log.warn(`${repo.alias}: ${DATA_DIRNAME}/${repo.alias}/${branch} already exists; skipping.`);
284
354
  return true;
285
355
  }
286
356
  log.step(`${repo.alias}: git worktree add --track -B ${branch} ${branch} origin/${branch}`);
@@ -316,7 +386,7 @@ function syncRepo(repo, repoRoot) {
316
386
  if (existsSync(aliasPath)) {
317
387
  const stray = readdirSync(aliasPath).filter((e) => e !== ".git");
318
388
  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.`);
389
+ log.error(`${alias}: ${DATA_DIRNAME}/${alias}/ already exists but is not a bare clone. Move or remove it manually, then retry.`);
320
390
  return "failed";
321
391
  }
322
392
  }
@@ -350,7 +420,7 @@ function syncRepo(repo, repoRoot) {
350
420
  }
351
421
  const branch = repo.branch ?? detectDefaultBranch(repoRoot, alias);
352
422
  if (!branch) {
353
- log.error(`${alias}: could not determine default branch from bare clone. Set "branch:" in sources.yaml.`);
423
+ log.error(`${alias}: could not determine default branch from bare clone. Set "branch:" in ${MANIFEST_FILENAME}.`);
354
424
  return "failed";
355
425
  }
356
426
  if (!addWorktree(repo, repoRoot, branch))
@@ -389,7 +459,7 @@ function unsyncRepo(repo, repoRoot, force) {
389
459
  for (const wt of worktrees) {
390
460
  const status = runGit(wt.path, ["status", "--porcelain"]);
391
461
  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.`);
462
+ log.error(`${repo.alias}: worktree at ${DATA_DIRNAME}/${repo.alias}/${wt.relativePath} has uncommitted changes. Commit, stash, or pass --force.`);
393
463
  return "failed";
394
464
  }
395
465
  }
@@ -513,12 +583,12 @@ function exitFromResults(results) {
513
583
  function loadRepos(options = {}) {
514
584
  const repoRoot = findWorkspaceRoot(options);
515
585
  if (!repoRoot) {
516
- log.error("Could not find sources.yaml in the current directory or its parents. Create a sources.yaml in this project, then retry.");
586
+ log.error(`Could not find ${MANIFEST_FILENAME} in the current directory or its parents. Create a ${MANIFEST_FILENAME} in this project, then retry.`);
517
587
  return null;
518
588
  }
519
589
  try {
520
- const sourcesPath = join(repoRoot, "sources.yaml");
521
- return { repos: validateSources(parseYaml(readFileSync(sourcesPath, "utf8"))), repoRoot };
590
+ const manifestPath = join(repoRoot, MANIFEST_FILENAME);
591
+ return { repos: validateSources(parseYaml(readFileSync(manifestPath, "utf8"))), repoRoot };
522
592
  }
523
593
  catch (e) {
524
594
  log.error(e instanceof Error ? e.message : String(e));
@@ -550,9 +620,13 @@ async function selectRepos(repos, aliases, options, actionLabel) {
550
620
  }
551
621
  async function runSync(aliases, options) {
552
622
  const loaded = loadRepos();
553
- if (!loaded)
623
+ if (!loaded) {
624
+ emitLegacyRenameHintWalkUp();
554
625
  return 1;
626
+ }
555
627
  const { repos, repoRoot } = loaded;
628
+ if (abortOnLegacyRenameAt(repoRoot))
629
+ return 1;
556
630
  if (options.list) {
557
631
  printList(repos);
558
632
  return 0;
@@ -570,9 +644,13 @@ async function runSync(aliases, options) {
570
644
  }
571
645
  async function runManage(command, aliases, options) {
572
646
  const loaded = loadRepos();
573
- if (!loaded)
647
+ if (!loaded) {
648
+ emitLegacyRenameHintWalkUp();
574
649
  return 1;
650
+ }
575
651
  const { repos, repoRoot } = loaded;
652
+ if (abortOnLegacyRenameAt(repoRoot))
653
+ return 1;
576
654
  if (abortOnLegacy(repoRoot, repos))
577
655
  return 1;
578
656
  const picked = await selectRepos(repos, aliases, options, command);
@@ -586,9 +664,13 @@ async function runManage(command, aliases, options) {
586
664
  }
587
665
  async function runUnsync(aliases, options) {
588
666
  const loaded = loadRepos();
589
- if (!loaded)
667
+ if (!loaded) {
668
+ emitLegacyRenameHintWalkUp();
590
669
  return 1;
670
+ }
591
671
  const { repos, repoRoot } = loaded;
672
+ if (abortOnLegacyRenameAt(repoRoot))
673
+ return 1;
592
674
  if (abortOnLegacy(repoRoot, repos))
593
675
  return 1;
594
676
  const picked = await selectRepos(repos, aliases, options, "unsync");
@@ -613,9 +695,13 @@ async function runUnsync(aliases, options) {
613
695
  }
614
696
  async function runWorktreeAdd(alias, branch) {
615
697
  const loaded = loadRepos();
616
- if (!loaded)
698
+ if (!loaded) {
699
+ emitLegacyRenameHintWalkUp();
617
700
  return 1;
701
+ }
618
702
  const { repos, repoRoot } = loaded;
703
+ if (abortOnLegacyRenameAt(repoRoot))
704
+ return 1;
619
705
  if (abortOnLegacy(repoRoot, repos))
620
706
  return 1;
621
707
  const repo = repos.find((r) => r.alias === alias);
@@ -628,7 +714,7 @@ async function runWorktreeAdd(alias, branch) {
628
714
  return 1;
629
715
  }
630
716
  if (existsSync(worktreePath(repoRoot, alias, branch))) {
631
- log.error(`${alias}: worktree for ${branch} already exists at sources/${alias}/${branch}.`);
717
+ log.error(`${alias}: worktree for ${branch} already exists at ${DATA_DIRNAME}/${alias}/${branch}.`);
632
718
  return 1;
633
719
  }
634
720
  log.step(`${alias}: git fetch origin --prune (refresh before add)`);
@@ -640,9 +726,13 @@ async function runWorktreeAdd(alias, branch) {
640
726
  }
641
727
  async function runWorktreeList(alias) {
642
728
  const loaded = loadRepos();
643
- if (!loaded)
729
+ if (!loaded) {
730
+ emitLegacyRenameHintWalkUp();
644
731
  return 1;
732
+ }
645
733
  const { repos, repoRoot } = loaded;
734
+ if (abortOnLegacyRenameAt(repoRoot))
735
+ return 1;
646
736
  if (abortOnLegacy(repoRoot, repos))
647
737
  return 1;
648
738
  const targets = alias ? repos.filter((r) => r.alias === alias) : repos;
@@ -665,7 +755,7 @@ async function runWorktreeList(alias) {
665
755
  rows.push({
666
756
  alias: repo.alias,
667
757
  branch: wt.branch,
668
- path: `sources/${repo.alias}/${wt.relativePath}`,
758
+ path: `${DATA_DIRNAME}/${repo.alias}/${wt.relativePath}`,
669
759
  });
670
760
  }
671
761
  }
@@ -679,9 +769,13 @@ async function runWorktreeList(alias) {
679
769
  }
680
770
  async function runWorktreeRemove(alias, branch, options) {
681
771
  const loaded = loadRepos();
682
- if (!loaded)
772
+ if (!loaded) {
773
+ emitLegacyRenameHintWalkUp();
683
774
  return 1;
775
+ }
684
776
  const { repos, repoRoot } = loaded;
777
+ if (abortOnLegacyRenameAt(repoRoot))
778
+ return 1;
685
779
  if (abortOnLegacy(repoRoot, repos))
686
780
  return 1;
687
781
  const repo = repos.find((r) => r.alias === alias);
@@ -708,25 +802,36 @@ async function runWorktreeRemove(alias, branch, options) {
708
802
  }
709
803
  async function runDoctor() {
710
804
  const loaded = loadRepos();
711
- if (!loaded)
805
+ if (!loaded) {
806
+ emitLegacyRenameHintWalkUp();
712
807
  return 1;
808
+ }
713
809
  const { repos, repoRoot } = loaded;
810
+ if (abortOnLegacyRenameAt(repoRoot))
811
+ return 1;
714
812
  log.success(`Workspace root: ${repoRoot}`);
715
- log.success(`sources.yaml: ${repos.length} repo(s) configured`);
813
+ log.success(`${MANIFEST_FILENAME}: ${repos.length} repo(s) configured`);
716
814
  const git = spawnSync("git", ["--version"], {
717
815
  encoding: "utf8",
718
816
  stdio: ["ignore", "pipe", "pipe"],
719
817
  });
720
- if (git.status === 0) {
721
- log.success(`git: ${git.stdout.trim()}`);
722
- }
723
- else {
818
+ if (git.status !== 0) {
724
819
  log.error("git: not found");
725
820
  return 1;
726
821
  }
822
+ log.success(`git: ${git.stdout.trim()}`);
727
823
  let warnings = 0;
824
+ const parsed = parseGitVersion(git.stdout);
825
+ if (!parsed) {
826
+ log.warn(`git: could not parse version from "${git.stdout.trim()}"; oms expects git >=${MIN_GIT_MAJOR}.${MIN_GIT_MINOR}.`);
827
+ warnings++;
828
+ }
829
+ else if (!isGitVersionSupported(parsed)) {
830
+ 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.`);
831
+ warnings++;
832
+ }
728
833
  if (hasLegacySubmoduleLayout(repoRoot, repos)) {
729
- log.warn('detected legacy submodule layout (.gitmodules registers sources/<alias>). See README "Migrating from 0.2.x".');
834
+ log.warn(`detected legacy submodule layout (.gitmodules registers an alias path). See ${SUBMODULE_MIGRATION_DOC}.`);
730
835
  warnings++;
731
836
  }
732
837
  const gi = join(repoRoot, ".gitignore");
@@ -746,14 +851,14 @@ async function runDoctor() {
746
851
  }
747
852
  const refspec = runBareGit(repoRoot, repo.alias, ["config", "--get", "remote.origin.fetch"]);
748
853
  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}'`);
854
+ log.warn(`${repo.alias}: bare clone is missing remote.origin.fetch. Run: git -C ${DATA_DIRNAME}/${repo.alias}/.bare -c safe.bareRepository=all config remote.origin.fetch '${FETCH_REFSPEC}'`);
750
855
  warnings++;
751
856
  }
752
857
  else {
753
858
  log.success(`${repo.alias}: bare clone OK (refspec: ${refspec.stdout.trim()})`);
754
859
  }
755
860
  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`);
861
+ log.warn(`${repo.alias}: missing .git placeholder at ${DATA_DIRNAME}/${repo.alias}/.git. Recreate with: echo 'gitdir: ./.bare' > ${DATA_DIRNAME}/${repo.alias}/.git`);
757
862
  warnings++;
758
863
  }
759
864
  }
@@ -791,19 +896,19 @@ const commandNames = new Set([
791
896
  const program = new Command();
792
897
  program
793
898
  .name("oms")
794
- .description("Manage source repositories listed in sources.yaml as bare clones + worktrees under sources/<alias>/.")
899
+ .description(`Manage source repositories listed in ${MANIFEST_FILENAME} as bare clones + worktrees under ${DATA_DIRNAME}/<alias>/.`)
795
900
  .version(readPackageVersion())
796
901
  .addHelpText("after", exitHelp);
797
902
  program
798
903
  .command("doctor")
799
- .description("Check sources.yaml, git availability, bare-clone state, and .gitignore for each registered alias.")
904
+ .description(`Check ${MANIFEST_FILENAME}, git availability, bare-clone state, and .gitignore for each registered alias.`)
800
905
  .addHelpText("after", exitHelp)
801
906
  .action(async () => {
802
907
  await exitWith(runDoctor());
803
908
  });
804
909
  program
805
910
  .command("sync")
806
- .description("Bare-clone each registered repo into sources/<alias>/.bare and create the baseline worktree at sources/<alias>/<branch>/.")
911
+ .description(`Bare-clone each registered repo into ${DATA_DIRNAME}/<alias>/.bare and create the baseline worktree at ${DATA_DIRNAME}/<alias>/<branch>/.`)
807
912
  .argument("[aliases...]", "repo aliases to sync (omit for interactive multi-select)")
808
913
  .option("--all", "sync every registered source repo")
809
914
  .option("--list", "print registered repos")
@@ -839,7 +944,7 @@ program
839
944
  });
840
945
  program
841
946
  .command("unsync")
842
- .description("Remove all worktrees and the bare clone for each alias (keeps sources.yaml entry).")
947
+ .description(`Remove all worktrees and the bare clone for each alias (keeps ${MANIFEST_FILENAME} entry).`)
843
948
  .argument("[aliases...]", "repo aliases to unsync (omit for interactive multi-select)")
844
949
  .option("--all", "unsync every registered source repo")
845
950
  .option("--force", "discard uncommitted changes in worktrees")
@@ -853,7 +958,7 @@ const worktreeCmd = program
853
958
  .addHelpText("after", exitHelp);
854
959
  worktreeCmd
855
960
  .command("add")
856
- .description("Create a worktree for <branch> at sources/<alias>/<branch>/.")
961
+ .description(`Create a worktree for <branch> at ${DATA_DIRNAME}/<alias>/<branch>/.`)
857
962
  .argument("<alias>", "registered source alias")
858
963
  .argument("<branch>", "branch name (may include slashes)")
859
964
  .addHelpText("after", exitHelp)
@@ -870,7 +975,7 @@ worktreeCmd
870
975
  });
871
976
  worktreeCmd
872
977
  .command("remove")
873
- .description("Remove the worktree at sources/<alias>/<branch>.")
978
+ .description(`Remove the worktree at ${DATA_DIRNAME}/<alias>/<branch>.`)
874
979
  .argument("<alias>", "registered source alias")
875
980
  .argument("<branch>", "branch name (may include slashes)")
876
981
  .option("--force", "discard uncommitted changes")
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "oh-my-space",
3
- "version": "0.3.0",
4
- "description": "CLI for managing external source repositories declared in sources.yaml.",
3
+ "version": "0.4.0",
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",
7
7
  "repository": {
@@ -12,8 +12,8 @@
12
12
  "url": "https://github.com/divlook/oh-my-space/issues"
13
13
  },
14
14
  "keywords": [
15
- "sources",
16
- "submodule",
15
+ "oms",
16
+ "worktree",
17
17
  "git",
18
18
  "cli"
19
19
  ],
@@ -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",