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.
- package/README.md +15 -29
- package/dist/oms.js +156 -51
- 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 `
|
|
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
|
|
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
|
|
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 `
|
|
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
|
-
|
|
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
|
-
`
|
|
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 `
|
|
85
|
-
| `oms sync <alias>` / `--all` | workspace root | Bare-clones missing repos, fetches origin, creates the baseline worktree. | Adds `
|
|
86
|
-
| `oms unsync <alias>` / `--all` | workspace root | Removes every worktree, the bare clone, and the `.git` placeholder for the alias. | Keeps the `
|
|
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
|
-
## `
|
|
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
|
-
|
|
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
|
-
|
|
114
|
+
## Migrating between versions
|
|
115
115
|
|
|
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
|
|
116
|
+
자세한 마이그레이션 절차는 [`docs/migrations/`](./docs/migrations/) 에 버전별로 정리되어 있습니다.
|
|
127
117
|
|
|
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
|
-
```
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
37
|
+
throw new Error(`${MANIFEST_FILENAME}: "repos" must be an array`);
|
|
30
38
|
}
|
|
31
39
|
if (repos.length === 0) {
|
|
32
|
-
throw new Error(
|
|
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(
|
|
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(
|
|
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(
|
|
56
|
+
throw new Error(`${MANIFEST_FILENAME}: ${where} missing required "alias"`);
|
|
49
57
|
}
|
|
50
58
|
if (!ALIAS_PATTERN.test(r.alias)) {
|
|
51
|
-
throw new Error(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
|
165
|
-
|
|
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
|
|
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
|
|
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}:
|
|
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}:
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
521
|
-
return { repos: validateSources(parseYaml(readFileSync(
|
|
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
|
|
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:
|
|
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(
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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.
|
|
4
|
-
"description": "CLI for managing external source repositories declared in
|
|
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
|
-
"
|
|
16
|
-
"
|
|
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": "
|
|
43
|
+
"release": "changeset publish"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
46
|
"@changesets/changelog-github": "^0.7.0",
|