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.
- package/README.md +19 -29
- package/dist/oms.js +161 -48
- 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 `
|
|
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
|
-
|
|
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
|
|
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 `
|
|
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
|
-
|
|
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
|
-
`
|
|
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
|
|
85
|
-
| `oms
|
|
86
|
-
| `oms
|
|
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
|
-
## `
|
|
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
|
-
|
|
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
|
-
|
|
118
|
+
## Migrating between versions
|
|
115
119
|
|
|
116
|
-
|
|
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
|
-
|
|
129
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
38
|
+
throw new Error(`${MANIFEST_FILENAME}: "repos" must be an array`);
|
|
32
39
|
}
|
|
33
40
|
if (repos.length === 0) {
|
|
34
|
-
throw new Error(
|
|
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(
|
|
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(
|
|
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(
|
|
57
|
+
throw new Error(`${MANIFEST_FILENAME}: ${where} missing required "alias"`);
|
|
51
58
|
}
|
|
52
59
|
if (!ALIAS_PATTERN.test(r.alias)) {
|
|
53
|
-
throw new Error(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
|
180
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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}:
|
|
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}:
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
536
|
-
return { repos: validateSources(parseYaml(readFileSync(
|
|
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
|
|
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:
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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.
|
|
4
|
-
"description": "CLI for managing external source repositories declared in
|
|
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
|
-
"
|
|
16
|
-
"
|
|
15
|
+
"oms",
|
|
16
|
+
"worktree",
|
|
17
17
|
"git",
|
|
18
18
|
"cli"
|
|
19
19
|
],
|