oh-my-space 0.3.1 → 0.5.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 +19 -29
  2. package/dist/oms.js +161 -48
  3. package/package.json +4 -4
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,9 +32,10 @@ bun install -g oh-my-space
32
32
 
33
33
  ## Quick start
34
34
 
35
- Create a `sources.yaml` in your project root:
35
+ Run `oms init` to scaffold a starter `oms.yaml` (also adds `oms/` to `.gitignore`), or create one by hand in your project root:
36
36
 
37
37
  ```yaml
38
+ # yaml-language-server: $schema=https://raw.githubusercontent.com/divlook/oh-my-space/main/oms.schema.json
38
39
  repos:
39
40
  - alias: api
40
41
  url: git@github.com:example/api.git
@@ -46,7 +47,8 @@ repos:
46
47
  Sync and explore:
47
48
 
48
49
  ```bash
49
- oms doctor # validate sources.yaml + bare/worktree state
50
+ oms init # scaffold a starter oms.yaml
51
+ oms doctor # validate oms.yaml + bare/worktree state
50
52
  oms sync --list # list registered source repos
51
53
  oms sync <alias>... # bare-clone + create the baseline worktree
52
54
  oms sync --all # sync every registered source repo
@@ -57,14 +59,14 @@ oms worktree remove <alias> <br> # remove a single worktree
57
59
  oms unsync <alias>... # remove ALL worktrees + the bare clone
58
60
  ```
59
61
 
60
- `oms sync` adds `sources/` to your workspace `.gitignore` on first run; the bare clone and worktrees stay out of your project history.
62
+ `oms sync` adds `oms/` to your workspace `.gitignore` on first run; the bare clone and worktrees stay out of your project history.
61
63
 
62
64
  ## Layout
63
65
 
64
66
  For an alias `api` with the baseline branch `main` and an extra `feature/foo` worktree:
65
67
 
66
68
  ```
67
- sources/
69
+ oms/
68
70
  └── api/
69
71
  ├── .bare/ # git clone --bare result
70
72
  ├── .git # 'gitdir: ./.bare' placeholder
@@ -77,13 +79,14 @@ Slash-containing branch names are kept nested as directories.
77
79
 
78
80
  ## Managing source repositories
79
81
 
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.
82
+ `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
83
 
82
84
  | Command | Runs in | Does | Notes |
83
85
  | --- | --- | --- | --- |
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. |
86
+ | `oms init` | current directory | Writes a starter `oms.yaml` and adds `oms/` to `.gitignore`. | Refuses if `oms.yaml` exists; use `--force` to overwrite. |
87
+ | `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. |
88
+ | `oms sync <alias>` / `--all` | workspace root | Bare-clones missing repos, fetches origin, creates the baseline worktree. | Adds `oms/` to `.gitignore` if absent. |
89
+ | `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
90
  | `oms fetch ...` | workspace root | `git fetch origin --prune` on each bare clone. | Worktrees are not touched. |
88
91
  | `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
92
  | `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,9 +94,10 @@ Slash-containing branch names are kept nested as directories.
91
94
  | `oms worktree list [alias]` | anywhere under root | Lists every worktree for the alias (or all aliases). | |
92
95
  | `oms worktree remove <alias> <branch>` | workspace root | `git worktree remove <branch>` plus parent-directory cleanup for slash branches. | `--force` discards uncommitted changes. |
93
96
 
94
- ## `sources.yaml` format
97
+ ## `oms.yaml` format
95
98
 
96
99
  ```yaml
100
+ # yaml-language-server: $schema=https://raw.githubusercontent.com/divlook/oh-my-space/main/oms.schema.json
97
101
  repos:
98
102
  - alias: service-a
99
103
  url: git@github.com:example/service-a.git
@@ -109,28 +113,14 @@ Rules:
109
113
  - `url` is required.
110
114
  - `branch` is optional; when omitted the bare clone's default branch is used as the baseline worktree.
111
115
 
112
- ## Migrating from 0.2.x
116
+ 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
117
 
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:
118
+ ## Migrating between versions
115
119
 
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
120
+ 자세한 마이그레이션 절차는 [`docs/migrations/`](./docs/migrations/) 에 버전별로 정리되어 있습니다.
127
121
 
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
- ```
122
+ - [0.3.x → 0.4.0](./docs/migrations/0.3.x-to-0.4.0.md) — `sources.yaml`/`sources/` 가 `oms.yaml`/`oms/` 로 rename
123
+ - [0.2.x → 0.3.0](./docs/migrations/0.2.x-to-0.3.0.md) — submodule 에서 bare clone + worktree 로 데이터 모델 전환
134
124
 
135
125
  ## License
136
126
 
package/dist/oms.js CHANGED
@@ -10,7 +10,14 @@ 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 GITIGNORE_COMMENT = "# managed by oms";
17
+ const LEGACY_MANIFEST = "sources.yaml";
18
+ const LEGACY_DATA_DIRNAME = "sources";
19
+ const LEGACY_MIGRATION_DOC = "docs/migrations/0.3.x-to-0.4.0.md";
20
+ const SUBMODULE_MIGRATION_DOC = "docs/migrations/0.2.x-to-0.3.0.md";
14
21
  const MIN_GIT_MAJOR = 2;
15
22
  const MIN_GIT_MINOR = 40;
16
23
  const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
@@ -18,51 +25,51 @@ const useColor = process.stdout.isTTY;
18
25
  const dim = (s) => (useColor ? `\x1b[2m${s}\x1b[0m` : s);
19
26
  function validateSources(data) {
20
27
  if (!data || typeof data !== "object" || Array.isArray(data)) {
21
- throw new Error("sources.yaml: root must be a mapping");
28
+ throw new Error(`${MANIFEST_FILENAME}: root must be a mapping`);
22
29
  }
23
30
  const obj = data;
24
31
  for (const key of Object.keys(obj)) {
25
32
  if (!ALLOWED_TOP_KEYS.has(key)) {
26
- throw new Error(`sources.yaml: unknown top-level key "${key}"`);
33
+ throw new Error(`${MANIFEST_FILENAME}: unknown top-level key "${key}"`);
27
34
  }
28
35
  }
29
36
  const { repos } = obj;
30
37
  if (!Array.isArray(repos)) {
31
- throw new Error('sources.yaml: "repos" must be an array');
38
+ throw new Error(`${MANIFEST_FILENAME}: "repos" must be an array`);
32
39
  }
33
40
  if (repos.length === 0) {
34
- throw new Error('sources.yaml: "repos" must have at least one item');
41
+ throw new Error(`${MANIFEST_FILENAME}: "repos" must have at least one item`);
35
42
  }
36
43
  const validated = [];
37
44
  const seen = new Set();
38
45
  repos.forEach((item, idx) => {
39
46
  const where = `repos[${idx}]`;
40
47
  if (!item || typeof item !== "object" || Array.isArray(item)) {
41
- throw new Error(`sources.yaml: ${where} must be a mapping`);
48
+ throw new Error(`${MANIFEST_FILENAME}: ${where} must be a mapping`);
42
49
  }
43
50
  const r = item;
44
51
  for (const key of Object.keys(r)) {
45
52
  if (!ALLOWED_ITEM_KEYS.has(key)) {
46
- throw new Error(`sources.yaml: ${where} has unknown key "${key}"`);
53
+ throw new Error(`${MANIFEST_FILENAME}: ${where} has unknown key "${key}"`);
47
54
  }
48
55
  }
49
56
  if (typeof r.alias !== "string" || r.alias.length === 0) {
50
- throw new Error(`sources.yaml: ${where} missing required "alias"`);
57
+ throw new Error(`${MANIFEST_FILENAME}: ${where} missing required "alias"`);
51
58
  }
52
59
  if (!ALIAS_PATTERN.test(r.alias)) {
53
- throw new Error(`sources.yaml: ${where}.alias "${r.alias}" must match ${ALIAS_PATTERN}`);
60
+ throw new Error(`${MANIFEST_FILENAME}: ${where}.alias "${r.alias}" must match ${ALIAS_PATTERN}`);
54
61
  }
55
62
  if (seen.has(r.alias)) {
56
- throw new Error(`sources.yaml: duplicate alias "${r.alias}"`);
63
+ throw new Error(`${MANIFEST_FILENAME}: duplicate alias "${r.alias}"`);
57
64
  }
58
65
  seen.add(r.alias);
59
66
  if (typeof r.url !== "string" || r.url.length === 0) {
60
- throw new Error(`sources.yaml: ${where} missing required "url"`);
67
+ throw new Error(`${MANIFEST_FILENAME}: ${where} missing required "url"`);
61
68
  }
62
69
  let branch;
63
70
  if (r.branch !== undefined) {
64
71
  if (typeof r.branch !== "string" || r.branch.length === 0) {
65
- throw new Error(`sources.yaml: ${where}.branch must be a non-empty string`);
72
+ throw new Error(`${MANIFEST_FILENAME}: ${where}.branch must be a non-empty string`);
66
73
  }
67
74
  branch = r.branch;
68
75
  }
@@ -111,7 +118,7 @@ function runGit(cwd, args, inheritOutput = false) {
111
118
  stdout: inheritOutput ? "" : (result.stdout ?? ""),
112
119
  };
113
120
  }
114
- /** Run git from sources/<alias>/ with safe.bareRepository=all, so the .git placeholder routes to .bare. */
121
+ /** Run git from oms/<alias>/ with safe.bareRepository=all, so the .git placeholder routes to .bare. */
115
122
  function runBareGit(repoRoot, alias, args, inheritOutput = false) {
116
123
  return runGit(aliasDir(repoRoot, alias), ["-c", "safe.bareRepository=all", ...args], inheritOutput);
117
124
  }
@@ -131,7 +138,7 @@ function isGitVersionSupported(v) {
131
138
  function findWorkspaceRoot(options = {}) {
132
139
  let current = resolve(options.cwd ?? process.cwd());
133
140
  while (true) {
134
- if (existsSync(join(current, "sources.yaml")))
141
+ if (existsSync(join(current, MANIFEST_FILENAME)))
135
142
  return current;
136
143
  const parent = dirname(current);
137
144
  if (parent === current)
@@ -140,7 +147,7 @@ function findWorkspaceRoot(options = {}) {
140
147
  }
141
148
  }
142
149
  function aliasDir(repoRoot, alias) {
143
- return join(repoRoot, "sources", alias);
150
+ return join(repoRoot, DATA_DIRNAME, alias);
144
151
  }
145
152
  function bareDir(repoRoot, alias) {
146
153
  return join(aliasDir(repoRoot, alias), ".bare");
@@ -167,19 +174,68 @@ function isRegisteredSubmodule(repoRoot, sourcePath) {
167
174
  .split("\n")
168
175
  .some((line) => line.trim().split(/\s+/)[1] === sourcePath);
169
176
  }
170
- /** Returns true when .gitmodules registers any sources/<alias> entry for the loaded repos. */
177
+ /** Returns true when .gitmodules registers any sources/<alias> or oms/<alias> entry for the loaded repos. */
171
178
  function hasLegacySubmoduleLayout(repoRoot, repos) {
172
179
  if (!existsSync(join(repoRoot, ".gitmodules")))
173
180
  return false;
174
- return repos.some((repo) => isRegisteredSubmodule(repoRoot, `sources/${repo.alias}`));
181
+ return repos.some((repo) => isRegisteredSubmodule(repoRoot, `${LEGACY_DATA_DIRNAME}/${repo.alias}`)
182
+ || isRegisteredSubmodule(repoRoot, `${DATA_DIRNAME}/${repo.alias}`));
175
183
  }
176
184
  function abortOnLegacy(repoRoot, repos) {
177
185
  if (!hasLegacySubmoduleLayout(repoRoot, repos))
178
186
  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.');
187
+ log.error('detected legacy submodule layout (.gitmodules registers an alias path). oh-my-space 0.3.0+ uses bare clone + worktrees.\n' +
188
+ ` See ${SUBMODULE_MIGRATION_DOC} for the manual steps. Aborting to avoid destructive change.`);
181
189
  return true;
182
190
  }
191
+ /** Inspect a single directory for 0.3.x rename artifacts. */
192
+ function detectLegacyRenameArtifactsAt(dir) {
193
+ const manifest = existsSync(join(dir, LEGACY_MANIFEST));
194
+ const data = existsSync(join(dir, LEGACY_DATA_DIRNAME));
195
+ if (!manifest && !data)
196
+ return null;
197
+ return { manifest, data };
198
+ }
199
+ function emitLegacyRenameMessage(dir, found) {
200
+ const artifacts = [
201
+ found.manifest ? `'${LEGACY_MANIFEST}'` : null,
202
+ found.data ? `'${LEGACY_DATA_DIRNAME}/'` : null,
203
+ ]
204
+ .filter((s) => s !== null)
205
+ .join(" and ");
206
+ log.error(`detected legacy ${artifacts} at ${dir}.\n` +
207
+ ` oh-my-space 0.4.0 renamed the manifest to ${MANIFEST_FILENAME} and the data directory to ${DATA_DIRNAME}/.\n` +
208
+ ` See ${LEGACY_MIGRATION_DOC} for the manual steps. Aborting to avoid destructive change.`);
209
+ }
210
+ /** Block when the active oms.yaml workspace still has legacy artifacts at its root. */
211
+ function abortOnLegacyRenameAt(repoRoot) {
212
+ const found = detectLegacyRenameArtifactsAt(repoRoot);
213
+ if (!found)
214
+ return false;
215
+ emitLegacyRenameMessage(repoRoot, found);
216
+ return true;
217
+ }
218
+ /**
219
+ * When oms.yaml could not be found, walk upward looking for a 0.3.x manifest (sources.yaml).
220
+ * Only the manifest is trusted as a positive signal — a stray sources/ directory by itself
221
+ * could belong to an unrelated tool. Returns true if a hint was emitted.
222
+ */
223
+ function emitLegacyRenameHintWalkUp(options = {}) {
224
+ let current = resolve(options.cwd ?? process.cwd());
225
+ while (true) {
226
+ if (existsSync(join(current, LEGACY_MANIFEST))) {
227
+ emitLegacyRenameMessage(current, {
228
+ manifest: true,
229
+ data: existsSync(join(current, LEGACY_DATA_DIRNAME)),
230
+ });
231
+ return true;
232
+ }
233
+ const parent = dirname(current);
234
+ if (parent === current)
235
+ return false;
236
+ current = parent;
237
+ }
238
+ }
183
239
  function ensureGitignore(repoRoot) {
184
240
  const path = join(repoRoot, ".gitignore");
185
241
  let content = "";
@@ -192,7 +248,8 @@ function ensureGitignore(repoRoot) {
192
248
  return;
193
249
  }
194
250
  const needsNewline = content.length > 0 && !content.endsWith("\n");
195
- writeFileSync(path, `${content}${needsNewline ? "\n" : ""}${GITIGNORE_ENTRY}\n`);
251
+ const block = `${GITIGNORE_COMMENT}\n${GITIGNORE_ENTRY}\n`;
252
+ writeFileSync(path, `${content}${needsNewline ? "\n" : ""}${block}`);
196
253
  log.info(`added ${GITIGNORE_ENTRY} to .gitignore`);
197
254
  }
198
255
  function setupBareRepo(repo, repoRoot) {
@@ -210,7 +267,7 @@ function setupBareRepo(repo, repoRoot) {
210
267
  return false;
211
268
  }
212
269
  // 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.
270
+ // out of oms/<alias>/ looking for a repo and touches the wrong .git.
214
271
  writeFileSync(gitPlaceholderPath(repoRoot, alias), "gitdir: ./.bare\n");
215
272
  const config = runBareGit(repoRoot, alias, [
216
273
  "config",
@@ -265,7 +322,7 @@ function listExistingWorktrees(repoRoot, alias) {
265
322
  if (!path || bare)
266
323
  continue;
267
324
  const rel = relative(aliasPath, path);
268
- // Skip worktrees outside sources/<alias>/ (shouldn't happen, but be safe)
325
+ // Skip worktrees outside oms/<alias>/ (shouldn't happen, but be safe)
269
326
  if (rel.startsWith(".."))
270
327
  continue;
271
328
  entries.push({ branch, path, relativePath: rel });
@@ -295,7 +352,7 @@ function cleanupEmptyParents(repoRoot, alias, branch) {
295
352
  function addWorktree(repo, repoRoot, branch) {
296
353
  const wtPath = worktreePath(repoRoot, repo.alias, branch);
297
354
  if (existsSync(wtPath)) {
298
- log.warn(`${repo.alias}: sources/${repo.alias}/${branch} already exists; skipping.`);
355
+ log.warn(`${repo.alias}: ${DATA_DIRNAME}/${repo.alias}/${branch} already exists; skipping.`);
299
356
  return true;
300
357
  }
301
358
  log.step(`${repo.alias}: git worktree add --track -B ${branch} ${branch} origin/${branch}`);
@@ -331,7 +388,7 @@ function syncRepo(repo, repoRoot) {
331
388
  if (existsSync(aliasPath)) {
332
389
  const stray = readdirSync(aliasPath).filter((e) => e !== ".git");
333
390
  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.`);
391
+ log.error(`${alias}: ${DATA_DIRNAME}/${alias}/ already exists but is not a bare clone. Move or remove it manually, then retry.`);
335
392
  return "failed";
336
393
  }
337
394
  }
@@ -365,7 +422,7 @@ function syncRepo(repo, repoRoot) {
365
422
  }
366
423
  const branch = repo.branch ?? detectDefaultBranch(repoRoot, alias);
367
424
  if (!branch) {
368
- log.error(`${alias}: could not determine default branch from bare clone. Set "branch:" in sources.yaml.`);
425
+ log.error(`${alias}: could not determine default branch from bare clone. Set "branch:" in ${MANIFEST_FILENAME}.`);
369
426
  return "failed";
370
427
  }
371
428
  if (!addWorktree(repo, repoRoot, branch))
@@ -404,7 +461,7 @@ function unsyncRepo(repo, repoRoot, force) {
404
461
  for (const wt of worktrees) {
405
462
  const status = runGit(wt.path, ["status", "--porcelain"]);
406
463
  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.`);
464
+ log.error(`${repo.alias}: worktree at ${DATA_DIRNAME}/${repo.alias}/${wt.relativePath} has uncommitted changes. Commit, stash, or pass --force.`);
408
465
  return "failed";
409
466
  }
410
467
  }
@@ -528,12 +585,12 @@ function exitFromResults(results) {
528
585
  function loadRepos(options = {}) {
529
586
  const repoRoot = findWorkspaceRoot(options);
530
587
  if (!repoRoot) {
531
- log.error("Could not find sources.yaml in the current directory or its parents. Create a sources.yaml in this project, then retry.");
588
+ log.error(`Could not find ${MANIFEST_FILENAME} in the current directory or its parents. Create a ${MANIFEST_FILENAME} in this project, then retry.`);
532
589
  return null;
533
590
  }
534
591
  try {
535
- const sourcesPath = join(repoRoot, "sources.yaml");
536
- return { repos: validateSources(parseYaml(readFileSync(sourcesPath, "utf8"))), repoRoot };
592
+ const manifestPath = join(repoRoot, MANIFEST_FILENAME);
593
+ return { repos: validateSources(parseYaml(readFileSync(manifestPath, "utf8"))), repoRoot };
537
594
  }
538
595
  catch (e) {
539
596
  log.error(e instanceof Error ? e.message : String(e));
@@ -565,9 +622,13 @@ async function selectRepos(repos, aliases, options, actionLabel) {
565
622
  }
566
623
  async function runSync(aliases, options) {
567
624
  const loaded = loadRepos();
568
- if (!loaded)
625
+ if (!loaded) {
626
+ emitLegacyRenameHintWalkUp();
569
627
  return 1;
628
+ }
570
629
  const { repos, repoRoot } = loaded;
630
+ if (abortOnLegacyRenameAt(repoRoot))
631
+ return 1;
571
632
  if (options.list) {
572
633
  printList(repos);
573
634
  return 0;
@@ -585,9 +646,13 @@ async function runSync(aliases, options) {
585
646
  }
586
647
  async function runManage(command, aliases, options) {
587
648
  const loaded = loadRepos();
588
- if (!loaded)
649
+ if (!loaded) {
650
+ emitLegacyRenameHintWalkUp();
589
651
  return 1;
652
+ }
590
653
  const { repos, repoRoot } = loaded;
654
+ if (abortOnLegacyRenameAt(repoRoot))
655
+ return 1;
591
656
  if (abortOnLegacy(repoRoot, repos))
592
657
  return 1;
593
658
  const picked = await selectRepos(repos, aliases, options, command);
@@ -601,9 +666,13 @@ async function runManage(command, aliases, options) {
601
666
  }
602
667
  async function runUnsync(aliases, options) {
603
668
  const loaded = loadRepos();
604
- if (!loaded)
669
+ if (!loaded) {
670
+ emitLegacyRenameHintWalkUp();
605
671
  return 1;
672
+ }
606
673
  const { repos, repoRoot } = loaded;
674
+ if (abortOnLegacyRenameAt(repoRoot))
675
+ return 1;
607
676
  if (abortOnLegacy(repoRoot, repos))
608
677
  return 1;
609
678
  const picked = await selectRepos(repos, aliases, options, "unsync");
@@ -628,9 +697,13 @@ async function runUnsync(aliases, options) {
628
697
  }
629
698
  async function runWorktreeAdd(alias, branch) {
630
699
  const loaded = loadRepos();
631
- if (!loaded)
700
+ if (!loaded) {
701
+ emitLegacyRenameHintWalkUp();
632
702
  return 1;
703
+ }
633
704
  const { repos, repoRoot } = loaded;
705
+ if (abortOnLegacyRenameAt(repoRoot))
706
+ return 1;
634
707
  if (abortOnLegacy(repoRoot, repos))
635
708
  return 1;
636
709
  const repo = repos.find((r) => r.alias === alias);
@@ -643,7 +716,7 @@ async function runWorktreeAdd(alias, branch) {
643
716
  return 1;
644
717
  }
645
718
  if (existsSync(worktreePath(repoRoot, alias, branch))) {
646
- log.error(`${alias}: worktree for ${branch} already exists at sources/${alias}/${branch}.`);
719
+ log.error(`${alias}: worktree for ${branch} already exists at ${DATA_DIRNAME}/${alias}/${branch}.`);
647
720
  return 1;
648
721
  }
649
722
  log.step(`${alias}: git fetch origin --prune (refresh before add)`);
@@ -655,9 +728,13 @@ async function runWorktreeAdd(alias, branch) {
655
728
  }
656
729
  async function runWorktreeList(alias) {
657
730
  const loaded = loadRepos();
658
- if (!loaded)
731
+ if (!loaded) {
732
+ emitLegacyRenameHintWalkUp();
659
733
  return 1;
734
+ }
660
735
  const { repos, repoRoot } = loaded;
736
+ if (abortOnLegacyRenameAt(repoRoot))
737
+ return 1;
661
738
  if (abortOnLegacy(repoRoot, repos))
662
739
  return 1;
663
740
  const targets = alias ? repos.filter((r) => r.alias === alias) : repos;
@@ -680,7 +757,7 @@ async function runWorktreeList(alias) {
680
757
  rows.push({
681
758
  alias: repo.alias,
682
759
  branch: wt.branch,
683
- path: `sources/${repo.alias}/${wt.relativePath}`,
760
+ path: `${DATA_DIRNAME}/${repo.alias}/${wt.relativePath}`,
684
761
  });
685
762
  }
686
763
  }
@@ -694,9 +771,13 @@ async function runWorktreeList(alias) {
694
771
  }
695
772
  async function runWorktreeRemove(alias, branch, options) {
696
773
  const loaded = loadRepos();
697
- if (!loaded)
774
+ if (!loaded) {
775
+ emitLegacyRenameHintWalkUp();
698
776
  return 1;
777
+ }
699
778
  const { repos, repoRoot } = loaded;
779
+ if (abortOnLegacyRenameAt(repoRoot))
780
+ return 1;
700
781
  if (abortOnLegacy(repoRoot, repos))
701
782
  return 1;
702
783
  const repo = repos.find((r) => r.alias === alias);
@@ -721,13 +802,36 @@ async function runWorktreeRemove(alias, branch, options) {
721
802
  log.success(`${alias}: removed worktree for ${branch}`);
722
803
  return 0;
723
804
  }
805
+ const INIT_TEMPLATE = `# yaml-language-server: $schema=https://raw.githubusercontent.com/divlook/oh-my-space/main/oms.schema.json
806
+ repos:
807
+ - alias: example
808
+ url: git@github.com:example/repo.git
809
+ branch: main
810
+ `;
811
+ /** 현재 디렉터리에 기본 oms.yaml 템플릿을 생성합니다. */
812
+ async function runInit(options) {
813
+ const target = join(process.cwd(), MANIFEST_FILENAME);
814
+ if (existsSync(target) && !options.force) {
815
+ log.error(`${MANIFEST_FILENAME} already exists at ${target}. Use --force to overwrite.`);
816
+ return 1;
817
+ }
818
+ writeFileSync(target, INIT_TEMPLATE);
819
+ log.success(`created ${MANIFEST_FILENAME} at ${target}`);
820
+ ensureGitignore(process.cwd());
821
+ log.info(`edit alias/url/branch, then run "oms sync".`);
822
+ return 0;
823
+ }
724
824
  async function runDoctor() {
725
825
  const loaded = loadRepos();
726
- if (!loaded)
826
+ if (!loaded) {
827
+ emitLegacyRenameHintWalkUp();
727
828
  return 1;
829
+ }
728
830
  const { repos, repoRoot } = loaded;
831
+ if (abortOnLegacyRenameAt(repoRoot))
832
+ return 1;
729
833
  log.success(`Workspace root: ${repoRoot}`);
730
- log.success(`sources.yaml: ${repos.length} repo(s) configured`);
834
+ log.success(`${MANIFEST_FILENAME}: ${repos.length} repo(s) configured`);
731
835
  const git = spawnSync("git", ["--version"], {
732
836
  encoding: "utf8",
733
837
  stdio: ["ignore", "pipe", "pipe"],
@@ -748,7 +852,7 @@ async function runDoctor() {
748
852
  warnings++;
749
853
  }
750
854
  if (hasLegacySubmoduleLayout(repoRoot, repos)) {
751
- log.warn('detected legacy submodule layout (.gitmodules registers sources/<alias>). See README "Migrating from 0.2.x".');
855
+ log.warn(`detected legacy submodule layout (.gitmodules registers an alias path). See ${SUBMODULE_MIGRATION_DOC}.`);
752
856
  warnings++;
753
857
  }
754
858
  const gi = join(repoRoot, ".gitignore");
@@ -768,14 +872,14 @@ async function runDoctor() {
768
872
  }
769
873
  const refspec = runBareGit(repoRoot, repo.alias, ["config", "--get", "remote.origin.fetch"]);
770
874
  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}'`);
875
+ 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}'`);
772
876
  warnings++;
773
877
  }
774
878
  else {
775
879
  log.success(`${repo.alias}: bare clone OK (refspec: ${refspec.stdout.trim()})`);
776
880
  }
777
881
  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`);
882
+ log.warn(`${repo.alias}: missing .git placeholder at ${DATA_DIRNAME}/${repo.alias}/.git. Recreate with: echo 'gitdir: ./.bare' > ${DATA_DIRNAME}/${repo.alias}/.git`);
779
883
  warnings++;
780
884
  }
781
885
  }
@@ -801,6 +905,7 @@ async function exitWith(action) {
801
905
  }
802
906
  const exitHelp = "\nExit codes: 0 ok | 1 usage/config error | 2 one or more git operations failed.";
803
907
  const commandNames = new Set([
908
+ "init",
804
909
  "doctor",
805
910
  "sync",
806
911
  "fetch",
@@ -813,19 +918,27 @@ const commandNames = new Set([
813
918
  const program = new Command();
814
919
  program
815
920
  .name("oms")
816
- .description("Manage source repositories listed in sources.yaml as bare clones + worktrees under sources/<alias>/.")
921
+ .description(`Manage source repositories listed in ${MANIFEST_FILENAME} as bare clones + worktrees under ${DATA_DIRNAME}/<alias>/.`)
817
922
  .version(readPackageVersion())
818
923
  .addHelpText("after", exitHelp);
924
+ program
925
+ .command("init")
926
+ .description(`Create a starter ${MANIFEST_FILENAME} in the current directory.`)
927
+ .option("--force", `overwrite an existing ${MANIFEST_FILENAME}`)
928
+ .addHelpText("after", exitHelp)
929
+ .action(async (options) => {
930
+ await exitWith(runInit(options));
931
+ });
819
932
  program
820
933
  .command("doctor")
821
- .description("Check sources.yaml, git availability, bare-clone state, and .gitignore for each registered alias.")
934
+ .description(`Check ${MANIFEST_FILENAME}, git availability, bare-clone state, and .gitignore for each registered alias.`)
822
935
  .addHelpText("after", exitHelp)
823
936
  .action(async () => {
824
937
  await exitWith(runDoctor());
825
938
  });
826
939
  program
827
940
  .command("sync")
828
- .description("Bare-clone each registered repo into sources/<alias>/.bare and create the baseline worktree at sources/<alias>/<branch>/.")
941
+ .description(`Bare-clone each registered repo into ${DATA_DIRNAME}/<alias>/.bare and create the baseline worktree at ${DATA_DIRNAME}/<alias>/<branch>/.`)
829
942
  .argument("[aliases...]", "repo aliases to sync (omit for interactive multi-select)")
830
943
  .option("--all", "sync every registered source repo")
831
944
  .option("--list", "print registered repos")
@@ -861,7 +974,7 @@ program
861
974
  });
862
975
  program
863
976
  .command("unsync")
864
- .description("Remove all worktrees and the bare clone for each alias (keeps sources.yaml entry).")
977
+ .description(`Remove all worktrees and the bare clone for each alias (keeps ${MANIFEST_FILENAME} entry).`)
865
978
  .argument("[aliases...]", "repo aliases to unsync (omit for interactive multi-select)")
866
979
  .option("--all", "unsync every registered source repo")
867
980
  .option("--force", "discard uncommitted changes in worktrees")
@@ -875,7 +988,7 @@ const worktreeCmd = program
875
988
  .addHelpText("after", exitHelp);
876
989
  worktreeCmd
877
990
  .command("add")
878
- .description("Create a worktree for <branch> at sources/<alias>/<branch>/.")
991
+ .description(`Create a worktree for <branch> at ${DATA_DIRNAME}/<alias>/<branch>/.`)
879
992
  .argument("<alias>", "registered source alias")
880
993
  .argument("<branch>", "branch name (may include slashes)")
881
994
  .addHelpText("after", exitHelp)
@@ -892,7 +1005,7 @@ worktreeCmd
892
1005
  });
893
1006
  worktreeCmd
894
1007
  .command("remove")
895
- .description("Remove the worktree at sources/<alias>/<branch>.")
1008
+ .description(`Remove the worktree at ${DATA_DIRNAME}/<alias>/<branch>.`)
896
1009
  .argument("<alias>", "registered source alias")
897
1010
  .argument("<branch>", "branch name (may include slashes)")
898
1011
  .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.1",
4
- "description": "CLI for managing external source repositories declared in sources.yaml.",
3
+ "version": "0.5.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
  ],