oh-my-space 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +64 -27
- package/dist/oms.js +557 -146
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# oh-my-space
|
|
2
2
|
|
|
3
|
-
`oh-my-space` provides the `oms` CLI for managing external source repositories declared in `sources.yaml`.
|
|
3
|
+
`oh-my-space` provides the `oms` CLI for managing external source repositories declared in `sources.yaml`. Each registered repo lives as a bare clone under `sources/<alias>/.bare/` and the baseline branch (and any additional ones you ask for) checks out as a Git worktree at `sources/<alias>/<branch>/`.
|
|
4
4
|
|
|
5
5
|
Package name: `oh-my-space`
|
|
6
6
|
|
|
@@ -9,7 +9,7 @@ Command name: `oms`
|
|
|
9
9
|
## Requirements
|
|
10
10
|
|
|
11
11
|
- [Node.js](https://nodejs.org) `>=20.19.0` for running `oms`; development uses the version in `.nvmrc` (`24`)
|
|
12
|
-
- git
|
|
12
|
+
- git `>=2.40` (worktree `--track -B` flow)
|
|
13
13
|
|
|
14
14
|
For local development:
|
|
15
15
|
|
|
@@ -43,39 +43,53 @@ repos:
|
|
|
43
43
|
url: git@github.com:example/web.git
|
|
44
44
|
```
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
Sync and explore:
|
|
47
47
|
|
|
48
48
|
```bash
|
|
49
|
-
oms doctor
|
|
50
|
-
oms sync --list
|
|
51
|
-
oms sync <alias>...
|
|
52
|
-
oms sync --all
|
|
53
|
-
oms sync
|
|
54
|
-
oms
|
|
55
|
-
oms
|
|
56
|
-
oms
|
|
49
|
+
oms doctor # validate sources.yaml + bare/worktree state
|
|
50
|
+
oms sync --list # list registered source repos
|
|
51
|
+
oms sync <alias>... # bare-clone + create the baseline worktree
|
|
52
|
+
oms sync --all # sync every registered source repo
|
|
53
|
+
oms sync # interactive multi-select
|
|
54
|
+
oms worktree add <alias> <branch> # add another worktree alongside the baseline
|
|
55
|
+
oms worktree list [alias] # show every worktree
|
|
56
|
+
oms worktree remove <alias> <br> # remove a single worktree
|
|
57
|
+
oms unsync <alias>... # remove ALL worktrees + the bare clone
|
|
57
58
|
```
|
|
58
59
|
|
|
59
|
-
|
|
60
|
+
`oms sync` adds `sources/` to your workspace `.gitignore` on first run; the bare clone and worktrees stay out of your project history.
|
|
60
61
|
|
|
61
|
-
|
|
62
|
+
## Layout
|
|
62
63
|
|
|
63
|
-
|
|
64
|
-
| --- | --- | --- | --- |
|
|
65
|
-
| `oms doctor` | project root or child path | Validates `sources.yaml` and checks `git --version`. | Fails when `sources.yaml` is missing or invalid. |
|
|
66
|
-
| `oms sync <alias>` / `--all` | workspace root | Adds missing submodules and initializes/updates registered ones. | Syncs `sources.yaml` to `sources/<alias>/`. |
|
|
67
|
-
| `oms unsync <alias>` / `--all` | workspace root | Deinits the submodule and removes `sources/<alias>/` (leaves the staged change to commit). | Keeps the entry in `sources.yaml`; pass `--force` to discard uncommitted changes in the worktree. |
|
|
68
|
-
| `oms fetch ...` | selected checked-out submodule worktree | `git fetch --all --prune` | Does not change the superproject gitlink. |
|
|
69
|
-
| `oms pull ...` | selected checked-out submodule worktree | `git pull --ff-only` | Requires a branch with upstream; detached HEAD/no-upstream states fail. |
|
|
70
|
-
| `oms push <alias>...` | explicitly selected checked-out submodule worktree | `git push` | No `--all`, force push, or automatic upstream setup. |
|
|
64
|
+
For an alias `api` with the baseline branch `main` and an extra `feature/foo` worktree:
|
|
71
65
|
|
|
72
|
-
|
|
66
|
+
```
|
|
67
|
+
sources/
|
|
68
|
+
└── api/
|
|
69
|
+
├── .bare/ # git clone --bare result
|
|
70
|
+
├── .git # 'gitdir: ./.bare' placeholder
|
|
71
|
+
├── main/ # worktree for main (upstream: origin/main)
|
|
72
|
+
└── feature/
|
|
73
|
+
└── foo/ # worktree for feature/foo
|
|
74
|
+
```
|
|
73
75
|
|
|
74
|
-
|
|
76
|
+
Slash-containing branch names are kept nested as directories.
|
|
75
77
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
78
|
+
## Managing source repositories
|
|
79
|
+
|
|
80
|
+
`sources.yaml` declares each source repo with `alias`, `url`, and optional `branch` (the baseline). Additional worktrees are created on demand with `oms worktree add` and are *not* persisted in `sources.yaml` — they're treated as per-machine working space.
|
|
81
|
+
|
|
82
|
+
| Command | Runs in | Does | Notes |
|
|
83
|
+
| --- | --- | --- | --- |
|
|
84
|
+
| `oms doctor` | project root or child path | Checks `sources.yaml`, git availability, `.gitignore`, and each alias's bare clone + `remote.origin.fetch`. | Returns exit 2 if any warning is raised. |
|
|
85
|
+
| `oms sync <alias>` / `--all` | workspace root | Bare-clones missing repos, fetches origin, creates the baseline worktree. | Adds `sources/` to `.gitignore` if absent. |
|
|
86
|
+
| `oms unsync <alias>` / `--all` | workspace root | Removes every worktree, the bare clone, and the `.git` placeholder for the alias. | Keeps the `sources.yaml` entry. Use `--force` to discard uncommitted changes. |
|
|
87
|
+
| `oms fetch ...` | workspace root | `git fetch origin --prune` on each bare clone. | Worktrees are not touched. |
|
|
88
|
+
| `oms pull ...` | workspace root | `git pull --ff-only` on the baseline worktree of each selected alias. | Requires the worktree to be on its branch with upstream set (the default after `oms sync`). |
|
|
89
|
+
| `oms push <alias>...` | workspace root | `git push` on the baseline worktree of each explicitly listed alias. | No `--all`, no force, no automatic upstream setup. |
|
|
90
|
+
| `oms worktree add <alias> <branch>` | workspace root | `git worktree add --track -B <branch> <branch> origin/<branch>` inside the alias. | Slash branches become nested directories. |
|
|
91
|
+
| `oms worktree list [alias]` | anywhere under root | Lists every worktree for the alias (or all aliases). | |
|
|
92
|
+
| `oms worktree remove <alias> <branch>` | workspace root | `git worktree remove <branch>` plus parent-directory cleanup for slash branches. | `--force` discards uncommitted changes. |
|
|
79
93
|
|
|
80
94
|
## `sources.yaml` format
|
|
81
95
|
|
|
@@ -93,7 +107,30 @@ Rules:
|
|
|
93
107
|
- `repos` must be a non-empty array.
|
|
94
108
|
- `alias` must be unique and match `/^[a-z0-9][a-z0-9-]*$/`.
|
|
95
109
|
- `url` is required.
|
|
96
|
-
- `branch` is optional
|
|
110
|
+
- `branch` is optional; when omitted the bare clone's default branch is used as the baseline worktree.
|
|
111
|
+
|
|
112
|
+
## Migrating from 0.2.x
|
|
113
|
+
|
|
114
|
+
0.2.x stored each alias as a Git submodule. 0.3.0 replaces that with a bare clone + worktrees. The data model is breaking — `oms sync` refuses to run while `.gitmodules` still registers a `sources/<alias>` path. Convert your workspace manually:
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
# 1. For each alias in sources.yaml, deinit and remove the submodule.
|
|
118
|
+
git submodule deinit -f sources/<alias>
|
|
119
|
+
git rm -f sources/<alias>
|
|
120
|
+
rm -rf .git/modules/<alias>
|
|
121
|
+
|
|
122
|
+
# 2. Drop the now-empty .gitmodules if nothing else uses it.
|
|
123
|
+
[ -s .gitmodules ] || rm -f .gitmodules
|
|
124
|
+
|
|
125
|
+
# 3. Add sources/ to .gitignore (oms sync will also do this).
|
|
126
|
+
grep -qxF 'sources/' .gitignore 2>/dev/null || echo 'sources/' >> .gitignore
|
|
127
|
+
|
|
128
|
+
# 4. Re-sync with the new model.
|
|
129
|
+
oms sync --all
|
|
130
|
+
|
|
131
|
+
# 5. Commit the result.
|
|
132
|
+
git add . && git commit -m "chore: migrate to oh-my-space 0.3.0 (bare + worktree)"
|
|
133
|
+
```
|
|
97
134
|
|
|
98
135
|
## License
|
|
99
136
|
|
package/dist/oms.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { spawnSync } from "node:child_process";
|
|
3
|
-
import { existsSync, readFileSync, rmSync } from "node:fs";
|
|
4
|
-
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, rmdirSync, writeFileSync, } from "node:fs";
|
|
4
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { Command } from "commander";
|
|
7
7
|
import { cancel, isCancel, log, multiselect } from "@clack/prompts";
|
|
@@ -9,16 +9,10 @@ import { parse as parseYaml } from "yaml";
|
|
|
9
9
|
const ALIAS_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
|
10
10
|
const ALLOWED_TOP_KEYS = new Set(["repos"]);
|
|
11
11
|
const ALLOWED_ITEM_KEYS = new Set(["alias", "url", "branch"]);
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
};
|
|
17
|
-
const RESULT_BY_COMMAND = {
|
|
18
|
-
fetch: "fetched",
|
|
19
|
-
pull: "pulled",
|
|
20
|
-
push: "pushed",
|
|
21
|
-
};
|
|
12
|
+
const FETCH_REFSPEC = "+refs/heads/*:refs/remotes/origin/*";
|
|
13
|
+
const GITIGNORE_ENTRY = "sources/";
|
|
14
|
+
const MIN_GIT_MAJOR = 2;
|
|
15
|
+
const MIN_GIT_MINOR = 40;
|
|
22
16
|
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
23
17
|
const useColor = process.stdout.isTTY;
|
|
24
18
|
const dim = (s) => (useColor ? `\x1b[2m${s}\x1b[0m` : s);
|
|
@@ -117,6 +111,23 @@ function runGit(cwd, args, inheritOutput = false) {
|
|
|
117
111
|
stdout: inheritOutput ? "" : (result.stdout ?? ""),
|
|
118
112
|
};
|
|
119
113
|
}
|
|
114
|
+
/** Run git from sources/<alias>/ with safe.bareRepository=all, so the .git placeholder routes to .bare. */
|
|
115
|
+
function runBareGit(repoRoot, alias, args, inheritOutput = false) {
|
|
116
|
+
return runGit(aliasDir(repoRoot, alias), ["-c", "safe.bareRepository=all", ...args], inheritOutput);
|
|
117
|
+
}
|
|
118
|
+
function parseGitVersion(s) {
|
|
119
|
+
const m = s.match(/git version (\d+)\.(\d+)/);
|
|
120
|
+
if (!m)
|
|
121
|
+
return null;
|
|
122
|
+
return { major: Number.parseInt(m[1], 10), minor: Number.parseInt(m[2], 10) };
|
|
123
|
+
}
|
|
124
|
+
function isGitVersionSupported(v) {
|
|
125
|
+
if (v.major > MIN_GIT_MAJOR)
|
|
126
|
+
return true;
|
|
127
|
+
if (v.major < MIN_GIT_MAJOR)
|
|
128
|
+
return false;
|
|
129
|
+
return v.minor >= MIN_GIT_MINOR;
|
|
130
|
+
}
|
|
120
131
|
function findWorkspaceRoot(options = {}) {
|
|
121
132
|
let current = resolve(options.cwd ?? process.cwd());
|
|
122
133
|
while (true) {
|
|
@@ -128,6 +139,18 @@ function findWorkspaceRoot(options = {}) {
|
|
|
128
139
|
current = parent;
|
|
129
140
|
}
|
|
130
141
|
}
|
|
142
|
+
function aliasDir(repoRoot, alias) {
|
|
143
|
+
return join(repoRoot, "sources", alias);
|
|
144
|
+
}
|
|
145
|
+
function bareDir(repoRoot, alias) {
|
|
146
|
+
return join(aliasDir(repoRoot, alias), ".bare");
|
|
147
|
+
}
|
|
148
|
+
function gitPlaceholderPath(repoRoot, alias) {
|
|
149
|
+
return join(aliasDir(repoRoot, alias), ".git");
|
|
150
|
+
}
|
|
151
|
+
function worktreePath(repoRoot, alias, branch) {
|
|
152
|
+
return join(aliasDir(repoRoot, alias), branch);
|
|
153
|
+
}
|
|
131
154
|
function isRegisteredSubmodule(repoRoot, sourcePath) {
|
|
132
155
|
if (!existsSync(join(repoRoot, ".gitmodules")))
|
|
133
156
|
return false;
|
|
@@ -144,143 +167,342 @@ function isRegisteredSubmodule(repoRoot, sourcePath) {
|
|
|
144
167
|
.split("\n")
|
|
145
168
|
.some((line) => line.trim().split(/\s+/)[1] === sourcePath);
|
|
146
169
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
if (!existsSync(
|
|
170
|
+
/** Returns true when .gitmodules registers any sources/<alias> entry for the loaded repos. */
|
|
171
|
+
function hasLegacySubmoduleLayout(repoRoot, repos) {
|
|
172
|
+
if (!existsSync(join(repoRoot, ".gitmodules")))
|
|
150
173
|
return false;
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
174
|
+
return repos.some((repo) => isRegisteredSubmodule(repoRoot, `sources/${repo.alias}`));
|
|
175
|
+
}
|
|
176
|
+
function abortOnLegacy(repoRoot, repos) {
|
|
177
|
+
if (!hasLegacySubmoduleLayout(repoRoot, repos))
|
|
178
|
+
return false;
|
|
179
|
+
log.error('detected legacy submodule layout (.gitmodules registers sources/<alias>). oh-my-space 0.3.0 uses bare clone + worktrees.\n' +
|
|
180
|
+
' See README "Migrating from 0.2.x" for the manual steps. Aborting to avoid destructive change.');
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
function ensureGitignore(repoRoot) {
|
|
184
|
+
const path = join(repoRoot, ".gitignore");
|
|
185
|
+
let content = "";
|
|
186
|
+
if (existsSync(path)) {
|
|
187
|
+
content = readFileSync(path, "utf8");
|
|
188
|
+
const present = content
|
|
189
|
+
.split("\n")
|
|
190
|
+
.some((line) => line.trim() === GITIGNORE_ENTRY || line.trim() === `/${GITIGNORE_ENTRY}`);
|
|
191
|
+
if (present)
|
|
192
|
+
return;
|
|
164
193
|
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
194
|
+
const needsNewline = content.length > 0 && !content.endsWith("\n");
|
|
195
|
+
writeFileSync(path, `${content}${needsNewline ? "\n" : ""}${GITIGNORE_ENTRY}\n`);
|
|
196
|
+
log.info(`added ${GITIGNORE_ENTRY} to .gitignore`);
|
|
197
|
+
}
|
|
198
|
+
function setupBareRepo(repo, repoRoot) {
|
|
199
|
+
const alias = repo.alias;
|
|
200
|
+
const aliasPath = aliasDir(repoRoot, alias);
|
|
201
|
+
const barePath = bareDir(repoRoot, alias);
|
|
202
|
+
mkdirSync(aliasPath, { recursive: true });
|
|
203
|
+
const relBare = relative(repoRoot, barePath);
|
|
204
|
+
log.step(`git clone --bare ${repo.url} ${relBare}`);
|
|
205
|
+
const clone = runGit(repoRoot, ["clone", "--bare", repo.url, relBare], true);
|
|
206
|
+
if (!clone.success) {
|
|
207
|
+
log.error(`${alias}: git clone --bare failed (exit ${clone.exitCode})`);
|
|
208
|
+
if (existsSync(barePath))
|
|
209
|
+
rmSync(barePath, { recursive: true, force: true });
|
|
210
|
+
return false;
|
|
168
211
|
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
212
|
+
// Write the placeholder before any further runBareGit calls, otherwise git ascends
|
|
213
|
+
// out of sources/<alias>/ looking for a repo and touches the wrong .git.
|
|
214
|
+
writeFileSync(gitPlaceholderPath(repoRoot, alias), "gitdir: ./.bare\n");
|
|
215
|
+
const config = runBareGit(repoRoot, alias, [
|
|
216
|
+
"config",
|
|
217
|
+
"remote.origin.fetch",
|
|
218
|
+
FETCH_REFSPEC,
|
|
219
|
+
]);
|
|
220
|
+
if (!config.success) {
|
|
221
|
+
log.error(`${alias}: failed to set remote.origin.fetch (exit ${config.exitCode})`);
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
log.step(`${alias}: git fetch origin`);
|
|
225
|
+
const fetch = runBareGit(repoRoot, alias, ["fetch", "origin", "--prune"], true);
|
|
226
|
+
if (!fetch.success) {
|
|
227
|
+
log.error(`${alias}: initial fetch failed (exit ${fetch.exitCode})`);
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
return true;
|
|
231
|
+
}
|
|
232
|
+
function detectDefaultBranch(repoRoot, alias) {
|
|
233
|
+
const r = runBareGit(repoRoot, alias, ["symbolic-ref", "--short", "HEAD"]);
|
|
234
|
+
if (r.success) {
|
|
235
|
+
const out = r.stdout.trim();
|
|
236
|
+
return out.length > 0 ? out : null;
|
|
237
|
+
}
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
function listExistingWorktrees(repoRoot, alias) {
|
|
241
|
+
if (!existsSync(bareDir(repoRoot, alias)))
|
|
242
|
+
return [];
|
|
243
|
+
const result = runBareGit(repoRoot, alias, ["worktree", "list", "--porcelain"]);
|
|
244
|
+
if (!result.success)
|
|
245
|
+
return [];
|
|
246
|
+
const aliasPath = aliasDir(repoRoot, alias);
|
|
247
|
+
const entries = [];
|
|
248
|
+
const blocks = result.stdout
|
|
249
|
+
.split(/\n\n+/)
|
|
250
|
+
.map((b) => b.trim())
|
|
251
|
+
.filter((b) => b.length > 0);
|
|
252
|
+
for (const block of blocks) {
|
|
253
|
+
let path = "";
|
|
254
|
+
let branch = "(detached)";
|
|
255
|
+
let bare = false;
|
|
256
|
+
for (const line of block.split("\n")) {
|
|
257
|
+
if (line.startsWith("worktree "))
|
|
258
|
+
path = line.slice("worktree ".length);
|
|
259
|
+
else if (line.startsWith("branch ")) {
|
|
260
|
+
branch = line.slice("branch ".length).replace(/^refs\/heads\//, "");
|
|
261
|
+
}
|
|
262
|
+
else if (line === "bare")
|
|
263
|
+
bare = true;
|
|
174
264
|
}
|
|
265
|
+
if (!path || bare)
|
|
266
|
+
continue;
|
|
267
|
+
const rel = relative(aliasPath, path);
|
|
268
|
+
// Skip worktrees outside sources/<alias>/ (shouldn't happen, but be safe)
|
|
269
|
+
if (rel.startsWith(".."))
|
|
270
|
+
continue;
|
|
271
|
+
entries.push({ branch, path, relativePath: rel });
|
|
175
272
|
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
273
|
+
return entries;
|
|
274
|
+
}
|
|
275
|
+
function cleanupEmptyParents(repoRoot, alias, branch) {
|
|
276
|
+
const parts = branch.split("/").filter((p) => p.length > 0);
|
|
277
|
+
if (parts.length < 2)
|
|
278
|
+
return;
|
|
279
|
+
for (let i = parts.length - 1; i >= 1; i--) {
|
|
280
|
+
const dirPath = join(aliasDir(repoRoot, alias), ...parts.slice(0, i));
|
|
281
|
+
try {
|
|
282
|
+
const entries = readdirSync(dirPath);
|
|
283
|
+
if (entries.length === 0) {
|
|
284
|
+
rmdirSync(dirPath);
|
|
285
|
+
}
|
|
286
|
+
else {
|
|
287
|
+
break;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
catch {
|
|
291
|
+
break;
|
|
182
292
|
}
|
|
183
|
-
didSomething = true;
|
|
184
293
|
}
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
294
|
+
}
|
|
295
|
+
function addWorktree(repo, repoRoot, branch) {
|
|
296
|
+
const wtPath = worktreePath(repoRoot, repo.alias, branch);
|
|
297
|
+
if (existsSync(wtPath)) {
|
|
298
|
+
log.warn(`${repo.alias}: sources/${repo.alias}/${branch} already exists; skipping.`);
|
|
299
|
+
return true;
|
|
300
|
+
}
|
|
301
|
+
log.step(`${repo.alias}: git worktree add --track -B ${branch} ${branch} origin/${branch}`);
|
|
302
|
+
const result = runBareGit(repoRoot, repo.alias, ["worktree", "add", "--track", "-B", branch, branch, `origin/${branch}`], true);
|
|
303
|
+
if (!result.success) {
|
|
304
|
+
log.error(`${repo.alias}: worktree add for ${branch} failed (exit ${result.exitCode})`);
|
|
305
|
+
cleanupEmptyParents(repoRoot, repo.alias, branch);
|
|
306
|
+
return false;
|
|
307
|
+
}
|
|
308
|
+
return true;
|
|
309
|
+
}
|
|
310
|
+
function removeWorktree(repo, repoRoot, branch, force) {
|
|
311
|
+
const wtPath = worktreePath(repoRoot, repo.alias, branch);
|
|
312
|
+
if (!existsSync(wtPath))
|
|
313
|
+
return true;
|
|
314
|
+
const args = ["worktree", "remove", ...(force ? ["--force"] : []), branch];
|
|
315
|
+
log.step(`${repo.alias}: git worktree remove ${branch}${force ? " --force" : ""}`);
|
|
316
|
+
const result = runBareGit(repoRoot, repo.alias, args, true);
|
|
317
|
+
if (!result.success) {
|
|
318
|
+
log.error(`${repo.alias}: worktree remove for ${branch} failed (exit ${result.exitCode})`);
|
|
319
|
+
return false;
|
|
320
|
+
}
|
|
321
|
+
cleanupEmptyParents(repoRoot, repo.alias, branch);
|
|
322
|
+
return true;
|
|
323
|
+
}
|
|
324
|
+
function syncRepo(repo, repoRoot) {
|
|
325
|
+
const alias = repo.alias;
|
|
326
|
+
const aliasPath = aliasDir(repoRoot, alias);
|
|
327
|
+
const barePath = bareDir(repoRoot, alias);
|
|
328
|
+
const placeholderPath = gitPlaceholderPath(repoRoot, alias);
|
|
329
|
+
const bareExists = existsSync(barePath);
|
|
330
|
+
if (!bareExists) {
|
|
331
|
+
if (existsSync(aliasPath)) {
|
|
332
|
+
const stray = readdirSync(aliasPath).filter((e) => e !== ".git");
|
|
333
|
+
if (stray.length > 0) {
|
|
334
|
+
log.error(`${alias}: sources/${alias}/ already exists but is not a bare clone. Move or remove it manually, then retry.`);
|
|
335
|
+
return "failed";
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
if (repo.branch) {
|
|
339
|
+
const lsRemote = runGit(repoRoot, [
|
|
340
|
+
"ls-remote",
|
|
341
|
+
"--exit-code",
|
|
342
|
+
"--heads",
|
|
343
|
+
repo.url,
|
|
344
|
+
repo.branch,
|
|
345
|
+
]);
|
|
346
|
+
if (lsRemote.exitCode === 2) {
|
|
347
|
+
log.error(`${alias}: branch "${repo.branch}" not found on ${repo.url}. Push the branch upstream or fix the alias, then retry.`);
|
|
348
|
+
return "failed";
|
|
349
|
+
}
|
|
350
|
+
if (!lsRemote.success && lsRemote.exitCode !== 2) {
|
|
351
|
+
log.warn(`${alias}: branch existence check failed (exit ${lsRemote.exitCode}); proceeding.`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
if (!setupBareRepo(repo, repoRoot)) {
|
|
355
|
+
if (existsSync(barePath))
|
|
356
|
+
rmSync(barePath, { recursive: true, force: true });
|
|
357
|
+
if (existsSync(placeholderPath))
|
|
358
|
+
rmSync(placeholderPath);
|
|
359
|
+
try {
|
|
360
|
+
if (readdirSync(aliasPath).length === 0)
|
|
361
|
+
rmdirSync(aliasPath);
|
|
362
|
+
}
|
|
363
|
+
catch { }
|
|
189
364
|
return "failed";
|
|
190
365
|
}
|
|
191
|
-
|
|
366
|
+
const branch = repo.branch ?? detectDefaultBranch(repoRoot, alias);
|
|
367
|
+
if (!branch) {
|
|
368
|
+
log.error(`${alias}: could not determine default branch from bare clone. Set "branch:" in sources.yaml.`);
|
|
369
|
+
return "failed";
|
|
370
|
+
}
|
|
371
|
+
if (!addWorktree(repo, repoRoot, branch))
|
|
372
|
+
return "failed";
|
|
373
|
+
log.success(`${alias}: added (branch=${branch})`);
|
|
374
|
+
return "added";
|
|
192
375
|
}
|
|
193
|
-
|
|
194
|
-
"
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
if (moduleDirExists) {
|
|
201
|
-
rmSync(modulesDir, { recursive: true, force: true });
|
|
202
|
-
didSomething = true;
|
|
203
|
-
}
|
|
204
|
-
return didSomething ? "removed" : "nothing-to-remove";
|
|
205
|
-
}
|
|
206
|
-
function syncSubmodule(repo, repoRoot) {
|
|
207
|
-
const sourcePath = `sources/${repo.alias}`;
|
|
208
|
-
const dest = join(repoRoot, sourcePath);
|
|
209
|
-
const registered = isRegisteredSubmodule(repoRoot, sourcePath);
|
|
210
|
-
if (!registered && existsSync(dest)) {
|
|
211
|
-
log.error(`${repo.alias}: ${sourcePath} already exists but is not registered as a git submodule. Move or remove it manually, then retry.`);
|
|
376
|
+
if (!existsSync(placeholderPath)) {
|
|
377
|
+
writeFileSync(placeholderPath, "gitdir: ./.bare\n");
|
|
378
|
+
}
|
|
379
|
+
log.step(`${alias}: git fetch origin`);
|
|
380
|
+
const fetch = runBareGit(repoRoot, alias, ["fetch", "origin", "--prune"], true);
|
|
381
|
+
if (!fetch.success) {
|
|
382
|
+
log.error(`${alias}: fetch failed (exit ${fetch.exitCode})`);
|
|
212
383
|
return "failed";
|
|
213
384
|
}
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
"--exit-code",
|
|
218
|
-
"--heads",
|
|
219
|
-
repo.url,
|
|
220
|
-
repo.branch,
|
|
221
|
-
]);
|
|
222
|
-
if (lsRemote.exitCode === 2) {
|
|
223
|
-
log.error(`${repo.alias}: branch "${repo.branch}" not found on ${repo.url}. Push the branch upstream or fix the alias, then retry.`);
|
|
385
|
+
const branch = repo.branch ?? detectDefaultBranch(repoRoot, alias);
|
|
386
|
+
if (branch && !existsSync(worktreePath(repoRoot, alias, branch))) {
|
|
387
|
+
if (!addWorktree(repo, repoRoot, branch))
|
|
224
388
|
return "failed";
|
|
389
|
+
}
|
|
390
|
+
runBareGit(repoRoot, alias, ["worktree", "prune"]);
|
|
391
|
+
log.success(`${alias}: updated`);
|
|
392
|
+
return "updated";
|
|
393
|
+
}
|
|
394
|
+
function unsyncRepo(repo, repoRoot, force) {
|
|
395
|
+
const aliasPath = aliasDir(repoRoot, repo.alias);
|
|
396
|
+
const barePath = bareDir(repoRoot, repo.alias);
|
|
397
|
+
const placeholderPath = gitPlaceholderPath(repoRoot, repo.alias);
|
|
398
|
+
const aliasExists = existsSync(aliasPath);
|
|
399
|
+
const bareExists = existsSync(barePath);
|
|
400
|
+
if (!aliasExists && !bareExists)
|
|
401
|
+
return "nothing-to-remove";
|
|
402
|
+
const worktrees = bareExists ? listExistingWorktrees(repoRoot, repo.alias) : [];
|
|
403
|
+
if (!force) {
|
|
404
|
+
for (const wt of worktrees) {
|
|
405
|
+
const status = runGit(wt.path, ["status", "--porcelain"]);
|
|
406
|
+
if (status.success && status.stdout.trim().length > 0) {
|
|
407
|
+
log.error(`${repo.alias}: worktree at sources/${repo.alias}/${wt.relativePath} has uncommitted changes. Commit, stash, or pass --force.`);
|
|
408
|
+
return "failed";
|
|
409
|
+
}
|
|
225
410
|
}
|
|
226
|
-
|
|
227
|
-
|
|
411
|
+
}
|
|
412
|
+
for (const wt of worktrees) {
|
|
413
|
+
const target = wt.branch === "(detached)" ? wt.relativePath : wt.branch;
|
|
414
|
+
if (!removeWorktree(repo, repoRoot, target, force)) {
|
|
415
|
+
return "failed";
|
|
228
416
|
}
|
|
229
417
|
}
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
sourcePath,
|
|
240
|
-
];
|
|
241
|
-
log.step(`git ${args.join(" ")}`);
|
|
242
|
-
const result = runGit(repoRoot, args, true);
|
|
243
|
-
if (result.success) {
|
|
244
|
-
log.success(`${repo.alias}: ${registered ? "updated" : "added"}`);
|
|
245
|
-
return registered ? "updated" : "added";
|
|
246
|
-
}
|
|
247
|
-
log.error(`${repo.alias}: submodule ${registered ? "update" : "add"} failed (exit ${result.exitCode})`);
|
|
248
|
-
if (!registered) {
|
|
249
|
-
const cleanup = removeSubmoduleArtifacts(repo, repoRoot, { force: true });
|
|
250
|
-
if (cleanup === "removed") {
|
|
251
|
-
log.info(`${repo.alias}: cleaned up partial submodule state. Retry once the upstream issue is resolved.`);
|
|
418
|
+
if (existsSync(barePath))
|
|
419
|
+
rmSync(barePath, { recursive: true, force: true });
|
|
420
|
+
if (existsSync(placeholderPath))
|
|
421
|
+
rmSync(placeholderPath);
|
|
422
|
+
try {
|
|
423
|
+
if (existsSync(aliasPath)) {
|
|
424
|
+
const remaining = readdirSync(aliasPath);
|
|
425
|
+
if (remaining.length === 0)
|
|
426
|
+
rmdirSync(aliasPath);
|
|
252
427
|
}
|
|
253
428
|
}
|
|
429
|
+
catch { }
|
|
430
|
+
return "removed";
|
|
431
|
+
}
|
|
432
|
+
function fetchRepo(repo, repoRoot) {
|
|
433
|
+
if (!existsSync(bareDir(repoRoot, repo.alias))) {
|
|
434
|
+
log.error(`${repo.alias}: not synced. Run "oms sync ${repo.alias}" first.`);
|
|
435
|
+
return "failed";
|
|
436
|
+
}
|
|
437
|
+
log.step(`${repo.alias}: git fetch origin --prune`);
|
|
438
|
+
const r = runBareGit(repoRoot, repo.alias, ["fetch", "origin", "--prune"], true);
|
|
439
|
+
if (r.success) {
|
|
440
|
+
log.success(`${repo.alias}: fetched`);
|
|
441
|
+
return "fetched";
|
|
442
|
+
}
|
|
443
|
+
log.error(`${repo.alias}: fetch failed (exit ${r.exitCode})`);
|
|
254
444
|
return "failed";
|
|
255
445
|
}
|
|
256
|
-
function
|
|
257
|
-
const
|
|
258
|
-
if (
|
|
259
|
-
|
|
446
|
+
function resolveTargetWorktree(repo, repoRoot) {
|
|
447
|
+
const defaultBranch = repo.branch ?? detectDefaultBranch(repoRoot, repo.alias);
|
|
448
|
+
if (defaultBranch) {
|
|
449
|
+
const path = worktreePath(repoRoot, repo.alias, defaultBranch);
|
|
450
|
+
if (existsSync(path))
|
|
451
|
+
return { branch: defaultBranch, path };
|
|
452
|
+
}
|
|
453
|
+
const wts = listExistingWorktrees(repoRoot, repo.alias);
|
|
454
|
+
if (wts.length === 1) {
|
|
455
|
+
const only = wts[0];
|
|
456
|
+
return { branch: only.branch === "(detached)" ? only.relativePath : only.branch, path: only.path };
|
|
457
|
+
}
|
|
458
|
+
return null;
|
|
459
|
+
}
|
|
460
|
+
function pullRepo(repo, repoRoot) {
|
|
461
|
+
if (!existsSync(bareDir(repoRoot, repo.alias))) {
|
|
462
|
+
log.error(`${repo.alias}: not synced. Run "oms sync ${repo.alias}" first.`);
|
|
260
463
|
return "failed";
|
|
261
464
|
}
|
|
262
|
-
const
|
|
263
|
-
if (
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
"--abbrev-ref",
|
|
267
|
-
"--symbolic-full-name",
|
|
268
|
-
"@{u}",
|
|
269
|
-
]);
|
|
270
|
-
if (!upstream.success) {
|
|
271
|
-
log.error(`${repo.alias}: git push requires the current branch to have an upstream. Set it manually before pushing.`);
|
|
272
|
-
return "failed";
|
|
273
|
-
}
|
|
465
|
+
const target = resolveTargetWorktree(repo, repoRoot);
|
|
466
|
+
if (!target) {
|
|
467
|
+
log.error(`${repo.alias}: cannot determine which worktree to pull. Use "oms worktree list ${repo.alias}".`);
|
|
468
|
+
return "failed";
|
|
274
469
|
}
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
log.success(`${repo.alias}: ${operation}`);
|
|
281
|
-
return operation;
|
|
470
|
+
log.step(`${repo.alias}/${target.branch}: git pull --ff-only`);
|
|
471
|
+
const r = runGit(target.path, ["pull", "--ff-only"], true);
|
|
472
|
+
if (r.success) {
|
|
473
|
+
log.success(`${repo.alias}/${target.branch}: pulled`);
|
|
474
|
+
return "pulled";
|
|
282
475
|
}
|
|
283
|
-
log.error(`${repo.alias}:
|
|
476
|
+
log.error(`${repo.alias}/${target.branch}: pull failed (exit ${r.exitCode})`);
|
|
477
|
+
return "failed";
|
|
478
|
+
}
|
|
479
|
+
function pushRepo(repo, repoRoot) {
|
|
480
|
+
if (!existsSync(bareDir(repoRoot, repo.alias))) {
|
|
481
|
+
log.error(`${repo.alias}: not synced. Run "oms sync ${repo.alias}" first.`);
|
|
482
|
+
return "failed";
|
|
483
|
+
}
|
|
484
|
+
const target = resolveTargetWorktree(repo, repoRoot);
|
|
485
|
+
if (!target) {
|
|
486
|
+
log.error(`${repo.alias}: cannot determine which worktree to push. Use "oms worktree list ${repo.alias}".`);
|
|
487
|
+
return "failed";
|
|
488
|
+
}
|
|
489
|
+
const upstream = runGit(target.path, [
|
|
490
|
+
"rev-parse",
|
|
491
|
+
"--abbrev-ref",
|
|
492
|
+
"--symbolic-full-name",
|
|
493
|
+
"@{u}",
|
|
494
|
+
]);
|
|
495
|
+
if (!upstream.success) {
|
|
496
|
+
log.error(`${repo.alias}/${target.branch}: git push requires an upstream. Set it manually before pushing.`);
|
|
497
|
+
return "failed";
|
|
498
|
+
}
|
|
499
|
+
log.step(`${repo.alias}/${target.branch}: git push`);
|
|
500
|
+
const r = runGit(target.path, ["-c", "push.autoSetupRemote=false", "push"], true);
|
|
501
|
+
if (r.success) {
|
|
502
|
+
log.success(`${repo.alias}/${target.branch}: pushed`);
|
|
503
|
+
return "pushed";
|
|
504
|
+
}
|
|
505
|
+
log.error(`${repo.alias}/${target.branch}: push failed (exit ${r.exitCode})`);
|
|
284
506
|
return "failed";
|
|
285
507
|
}
|
|
286
508
|
function printSummary(results) {
|
|
@@ -350,10 +572,13 @@ async function runSync(aliases, options) {
|
|
|
350
572
|
printList(repos);
|
|
351
573
|
return 0;
|
|
352
574
|
}
|
|
575
|
+
if (abortOnLegacy(repoRoot, repos))
|
|
576
|
+
return 1;
|
|
353
577
|
const picked = await selectRepos(repos, aliases, options, "sync");
|
|
354
578
|
if (!picked || picked.length === 0)
|
|
355
579
|
return 1;
|
|
356
|
-
|
|
580
|
+
ensureGitignore(repoRoot);
|
|
581
|
+
const results = picked.map((repo) => syncRepo(repo, repoRoot));
|
|
357
582
|
if (results.length > 1 || options.all)
|
|
358
583
|
printSummary(results);
|
|
359
584
|
return exitFromResults(results);
|
|
@@ -363,10 +588,13 @@ async function runManage(command, aliases, options) {
|
|
|
363
588
|
if (!loaded)
|
|
364
589
|
return 1;
|
|
365
590
|
const { repos, repoRoot } = loaded;
|
|
591
|
+
if (abortOnLegacy(repoRoot, repos))
|
|
592
|
+
return 1;
|
|
366
593
|
const picked = await selectRepos(repos, aliases, options, command);
|
|
367
594
|
if (!picked || picked.length === 0)
|
|
368
595
|
return 1;
|
|
369
|
-
const
|
|
596
|
+
const fn = command === "fetch" ? fetchRepo : command === "pull" ? pullRepo : pushRepo;
|
|
597
|
+
const results = picked.map((repo) => fn(repo, repoRoot));
|
|
370
598
|
if (results.length > 1 || options.all)
|
|
371
599
|
printSummary(results);
|
|
372
600
|
return exitFromResults(results);
|
|
@@ -376,14 +604,16 @@ async function runUnsync(aliases, options) {
|
|
|
376
604
|
if (!loaded)
|
|
377
605
|
return 1;
|
|
378
606
|
const { repos, repoRoot } = loaded;
|
|
607
|
+
if (abortOnLegacy(repoRoot, repos))
|
|
608
|
+
return 1;
|
|
379
609
|
const picked = await selectRepos(repos, aliases, options, "unsync");
|
|
380
610
|
if (!picked || picked.length === 0)
|
|
381
611
|
return 1;
|
|
382
612
|
const results = picked.map((repo) => {
|
|
383
613
|
log.step(`${repo.alias}: unsync`);
|
|
384
|
-
const outcome =
|
|
614
|
+
const outcome = unsyncRepo(repo, repoRoot, options.force ?? false);
|
|
385
615
|
if (outcome === "removed") {
|
|
386
|
-
log.success(`${repo.alias}: unsynced
|
|
616
|
+
log.success(`${repo.alias}: unsynced`);
|
|
387
617
|
return "unsynced";
|
|
388
618
|
}
|
|
389
619
|
if (outcome === "nothing-to-remove") {
|
|
@@ -396,19 +626,160 @@ async function runUnsync(aliases, options) {
|
|
|
396
626
|
printSummary(results);
|
|
397
627
|
return exitFromResults(results);
|
|
398
628
|
}
|
|
399
|
-
async function
|
|
629
|
+
async function runWorktreeAdd(alias, branch) {
|
|
630
|
+
const loaded = loadRepos();
|
|
631
|
+
if (!loaded)
|
|
632
|
+
return 1;
|
|
633
|
+
const { repos, repoRoot } = loaded;
|
|
634
|
+
if (abortOnLegacy(repoRoot, repos))
|
|
635
|
+
return 1;
|
|
636
|
+
const repo = repos.find((r) => r.alias === alias);
|
|
637
|
+
if (!repo) {
|
|
638
|
+
log.error(`Unknown alias "${alias}". Use "oms sync --list" to see registered aliases.`);
|
|
639
|
+
return 1;
|
|
640
|
+
}
|
|
641
|
+
if (!existsSync(bareDir(repoRoot, alias))) {
|
|
642
|
+
log.error(`${alias}: not synced. Run "oms sync ${alias}" first.`);
|
|
643
|
+
return 1;
|
|
644
|
+
}
|
|
645
|
+
if (existsSync(worktreePath(repoRoot, alias, branch))) {
|
|
646
|
+
log.error(`${alias}: worktree for ${branch} already exists at sources/${alias}/${branch}.`);
|
|
647
|
+
return 1;
|
|
648
|
+
}
|
|
649
|
+
log.step(`${alias}: git fetch origin --prune (refresh before add)`);
|
|
650
|
+
runBareGit(repoRoot, alias, ["fetch", "origin", "--prune"], true);
|
|
651
|
+
if (!addWorktree(repo, repoRoot, branch))
|
|
652
|
+
return 2;
|
|
653
|
+
log.success(`${alias}: added worktree for ${branch}`);
|
|
654
|
+
return 0;
|
|
655
|
+
}
|
|
656
|
+
async function runWorktreeList(alias) {
|
|
657
|
+
const loaded = loadRepos();
|
|
658
|
+
if (!loaded)
|
|
659
|
+
return 1;
|
|
660
|
+
const { repos, repoRoot } = loaded;
|
|
661
|
+
if (abortOnLegacy(repoRoot, repos))
|
|
662
|
+
return 1;
|
|
663
|
+
const targets = alias ? repos.filter((r) => r.alias === alias) : repos;
|
|
664
|
+
if (alias && targets.length === 0) {
|
|
665
|
+
log.error(`Unknown alias "${alias}".`);
|
|
666
|
+
return 1;
|
|
667
|
+
}
|
|
668
|
+
const rows = [];
|
|
669
|
+
for (const repo of targets) {
|
|
670
|
+
if (!existsSync(bareDir(repoRoot, repo.alias))) {
|
|
671
|
+
rows.push({ alias: repo.alias, branch: "(not synced)", path: "-" });
|
|
672
|
+
continue;
|
|
673
|
+
}
|
|
674
|
+
const wts = listExistingWorktrees(repoRoot, repo.alias);
|
|
675
|
+
if (wts.length === 0) {
|
|
676
|
+
rows.push({ alias: repo.alias, branch: "(no worktrees)", path: "-" });
|
|
677
|
+
continue;
|
|
678
|
+
}
|
|
679
|
+
for (const wt of wts) {
|
|
680
|
+
rows.push({
|
|
681
|
+
alias: repo.alias,
|
|
682
|
+
branch: wt.branch,
|
|
683
|
+
path: `sources/${repo.alias}/${wt.relativePath}`,
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
const aliasW = Math.max("ALIAS".length, ...rows.map((r) => r.alias.length));
|
|
688
|
+
const branchW = Math.max("BRANCH".length, ...rows.map((r) => r.branch.length));
|
|
689
|
+
console.log(dim(`${pad("ALIAS", aliasW)} ${pad("BRANCH", branchW)} PATH`));
|
|
690
|
+
for (const r of rows) {
|
|
691
|
+
console.log(`${pad(r.alias, aliasW)} ${pad(r.branch, branchW)} ${r.path}`);
|
|
692
|
+
}
|
|
693
|
+
return 0;
|
|
694
|
+
}
|
|
695
|
+
async function runWorktreeRemove(alias, branch, options) {
|
|
400
696
|
const loaded = loadRepos();
|
|
401
697
|
if (!loaded)
|
|
402
698
|
return 1;
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
699
|
+
const { repos, repoRoot } = loaded;
|
|
700
|
+
if (abortOnLegacy(repoRoot, repos))
|
|
701
|
+
return 1;
|
|
702
|
+
const repo = repos.find((r) => r.alias === alias);
|
|
703
|
+
if (!repo) {
|
|
704
|
+
log.error(`Unknown alias "${alias}".`);
|
|
705
|
+
return 1;
|
|
706
|
+
}
|
|
707
|
+
const wtPath = worktreePath(repoRoot, alias, branch);
|
|
708
|
+
if (!existsSync(wtPath)) {
|
|
709
|
+
log.info(`${alias}: no worktree for ${branch}`);
|
|
408
710
|
return 0;
|
|
409
711
|
}
|
|
410
|
-
|
|
411
|
-
|
|
712
|
+
if (!options.force) {
|
|
713
|
+
const status = runGit(wtPath, ["status", "--porcelain"]);
|
|
714
|
+
if (status.success && status.stdout.trim().length > 0) {
|
|
715
|
+
log.error(`${alias}/${branch}: has uncommitted changes. Commit, stash, or pass --force.`);
|
|
716
|
+
return 2;
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
if (!removeWorktree(repo, repoRoot, branch, options.force ?? false))
|
|
720
|
+
return 2;
|
|
721
|
+
log.success(`${alias}: removed worktree for ${branch}`);
|
|
722
|
+
return 0;
|
|
723
|
+
}
|
|
724
|
+
async function runDoctor() {
|
|
725
|
+
const loaded = loadRepos();
|
|
726
|
+
if (!loaded)
|
|
727
|
+
return 1;
|
|
728
|
+
const { repos, repoRoot } = loaded;
|
|
729
|
+
log.success(`Workspace root: ${repoRoot}`);
|
|
730
|
+
log.success(`sources.yaml: ${repos.length} repo(s) configured`);
|
|
731
|
+
const git = spawnSync("git", ["--version"], {
|
|
732
|
+
encoding: "utf8",
|
|
733
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
734
|
+
});
|
|
735
|
+
if (git.status !== 0) {
|
|
736
|
+
log.error("git: not found");
|
|
737
|
+
return 1;
|
|
738
|
+
}
|
|
739
|
+
log.success(`git: ${git.stdout.trim()}`);
|
|
740
|
+
let warnings = 0;
|
|
741
|
+
const parsed = parseGitVersion(git.stdout);
|
|
742
|
+
if (!parsed) {
|
|
743
|
+
log.warn(`git: could not parse version from "${git.stdout.trim()}"; oms expects git >=${MIN_GIT_MAJOR}.${MIN_GIT_MINOR}.`);
|
|
744
|
+
warnings++;
|
|
745
|
+
}
|
|
746
|
+
else if (!isGitVersionSupported(parsed)) {
|
|
747
|
+
log.warn(`git ${parsed.major}.${parsed.minor} is older than the recommended ${MIN_GIT_MAJOR}.${MIN_GIT_MINOR}. oms uses "git worktree add --track -B" which may fail on older releases — upgrade git.`);
|
|
748
|
+
warnings++;
|
|
749
|
+
}
|
|
750
|
+
if (hasLegacySubmoduleLayout(repoRoot, repos)) {
|
|
751
|
+
log.warn('detected legacy submodule layout (.gitmodules registers sources/<alias>). See README "Migrating from 0.2.x".');
|
|
752
|
+
warnings++;
|
|
753
|
+
}
|
|
754
|
+
const gi = join(repoRoot, ".gitignore");
|
|
755
|
+
const giHasSources = existsSync(gi)
|
|
756
|
+
&& readFileSync(gi, "utf8")
|
|
757
|
+
.split("\n")
|
|
758
|
+
.some((l) => l.trim() === GITIGNORE_ENTRY || l.trim() === `/${GITIGNORE_ENTRY}`);
|
|
759
|
+
if (!giHasSources) {
|
|
760
|
+
log.warn(`.gitignore does not exclude ${GITIGNORE_ENTRY}. Run "oms sync" to add it automatically.`);
|
|
761
|
+
warnings++;
|
|
762
|
+
}
|
|
763
|
+
for (const repo of repos) {
|
|
764
|
+
const bare = bareDir(repoRoot, repo.alias);
|
|
765
|
+
if (!existsSync(bare)) {
|
|
766
|
+
log.info(`${repo.alias}: not synced`);
|
|
767
|
+
continue;
|
|
768
|
+
}
|
|
769
|
+
const refspec = runBareGit(repoRoot, repo.alias, ["config", "--get", "remote.origin.fetch"]);
|
|
770
|
+
if (!refspec.success || refspec.stdout.trim().length === 0) {
|
|
771
|
+
log.warn(`${repo.alias}: bare clone is missing remote.origin.fetch. Run: git -C sources/${repo.alias}/.bare -c safe.bareRepository=all config remote.origin.fetch '${FETCH_REFSPEC}'`);
|
|
772
|
+
warnings++;
|
|
773
|
+
}
|
|
774
|
+
else {
|
|
775
|
+
log.success(`${repo.alias}: bare clone OK (refspec: ${refspec.stdout.trim()})`);
|
|
776
|
+
}
|
|
777
|
+
if (!existsSync(gitPlaceholderPath(repoRoot, repo.alias))) {
|
|
778
|
+
log.warn(`${repo.alias}: missing .git placeholder at sources/${repo.alias}/.git. Recreate with: echo 'gitdir: ./.bare' > sources/${repo.alias}/.git`);
|
|
779
|
+
warnings++;
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
return warnings > 0 ? 2 : 0;
|
|
412
783
|
}
|
|
413
784
|
function readJson(path) {
|
|
414
785
|
if (!existsSync(path))
|
|
@@ -429,23 +800,32 @@ async function exitWith(action) {
|
|
|
429
800
|
}
|
|
430
801
|
}
|
|
431
802
|
const exitHelp = "\nExit codes: 0 ok | 1 usage/config error | 2 one or more git operations failed.";
|
|
432
|
-
const commandNames = new Set([
|
|
803
|
+
const commandNames = new Set([
|
|
804
|
+
"doctor",
|
|
805
|
+
"sync",
|
|
806
|
+
"fetch",
|
|
807
|
+
"pull",
|
|
808
|
+
"push",
|
|
809
|
+
"unsync",
|
|
810
|
+
"worktree",
|
|
811
|
+
"help",
|
|
812
|
+
]);
|
|
433
813
|
const program = new Command();
|
|
434
814
|
program
|
|
435
815
|
.name("oms")
|
|
436
|
-
.description("Manage source repositories listed in sources.yaml under sources/<alias>/.")
|
|
816
|
+
.description("Manage source repositories listed in sources.yaml as bare clones + worktrees under sources/<alias>/.")
|
|
437
817
|
.version(readPackageVersion())
|
|
438
818
|
.addHelpText("after", exitHelp);
|
|
439
819
|
program
|
|
440
820
|
.command("doctor")
|
|
441
|
-
.description("Check sources.yaml
|
|
821
|
+
.description("Check sources.yaml, git availability, bare-clone state, and .gitignore for each registered alias.")
|
|
442
822
|
.addHelpText("after", exitHelp)
|
|
443
823
|
.action(async () => {
|
|
444
824
|
await exitWith(runDoctor());
|
|
445
825
|
});
|
|
446
826
|
program
|
|
447
827
|
.command("sync")
|
|
448
|
-
.description("
|
|
828
|
+
.description("Bare-clone each registered repo into sources/<alias>/.bare and create the baseline worktree at sources/<alias>/<branch>/.")
|
|
449
829
|
.argument("[aliases...]", "repo aliases to sync (omit for interactive multi-select)")
|
|
450
830
|
.option("--all", "sync every registered source repo")
|
|
451
831
|
.option("--list", "print registered repos")
|
|
@@ -455,7 +835,7 @@ program
|
|
|
455
835
|
});
|
|
456
836
|
program
|
|
457
837
|
.command("fetch")
|
|
458
|
-
.description("Run git fetch
|
|
838
|
+
.description("Run git fetch origin --prune in each bare clone.")
|
|
459
839
|
.argument("[aliases...]", "repo aliases to fetch (omit for interactive multi-select)")
|
|
460
840
|
.option("--all", "fetch every registered source repo")
|
|
461
841
|
.addHelpText("after", exitHelp)
|
|
@@ -464,7 +844,7 @@ program
|
|
|
464
844
|
});
|
|
465
845
|
program
|
|
466
846
|
.command("pull")
|
|
467
|
-
.description("Run git pull --ff-only
|
|
847
|
+
.description("Run git pull --ff-only in the baseline worktree of each selected alias; requires upstream.")
|
|
468
848
|
.argument("[aliases...]", "repo aliases to pull (omit for interactive multi-select)")
|
|
469
849
|
.option("--all", "pull every registered source repo")
|
|
470
850
|
.addHelpText("after", exitHelp)
|
|
@@ -473,7 +853,7 @@ program
|
|
|
473
853
|
});
|
|
474
854
|
program
|
|
475
855
|
.command("push")
|
|
476
|
-
.description("Run git push
|
|
856
|
+
.description("Run git push in the baseline worktree of each explicitly listed alias; no --all, force, or upstream setup.")
|
|
477
857
|
.argument("<aliases...>", "repo aliases to push")
|
|
478
858
|
.addHelpText("after", exitHelp)
|
|
479
859
|
.action(async (aliases) => {
|
|
@@ -481,14 +861,45 @@ program
|
|
|
481
861
|
});
|
|
482
862
|
program
|
|
483
863
|
.command("unsync")
|
|
484
|
-
.description("Remove
|
|
864
|
+
.description("Remove all worktrees and the bare clone for each alias (keeps sources.yaml entry).")
|
|
485
865
|
.argument("[aliases...]", "repo aliases to unsync (omit for interactive multi-select)")
|
|
486
866
|
.option("--all", "unsync every registered source repo")
|
|
487
|
-
.option("--force", "discard uncommitted changes in
|
|
867
|
+
.option("--force", "discard uncommitted changes in worktrees")
|
|
488
868
|
.addHelpText("after", exitHelp)
|
|
489
869
|
.action(async (aliases, options) => {
|
|
490
870
|
await exitWith(runUnsync(aliases, options));
|
|
491
871
|
});
|
|
872
|
+
const worktreeCmd = program
|
|
873
|
+
.command("worktree")
|
|
874
|
+
.description("Manage additional worktrees for a synced source repo.")
|
|
875
|
+
.addHelpText("after", exitHelp);
|
|
876
|
+
worktreeCmd
|
|
877
|
+
.command("add")
|
|
878
|
+
.description("Create a worktree for <branch> at sources/<alias>/<branch>/.")
|
|
879
|
+
.argument("<alias>", "registered source alias")
|
|
880
|
+
.argument("<branch>", "branch name (may include slashes)")
|
|
881
|
+
.addHelpText("after", exitHelp)
|
|
882
|
+
.action(async (alias, branch) => {
|
|
883
|
+
await exitWith(runWorktreeAdd(alias, branch));
|
|
884
|
+
});
|
|
885
|
+
worktreeCmd
|
|
886
|
+
.command("list")
|
|
887
|
+
.description("List existing worktrees for the given alias (or all aliases).")
|
|
888
|
+
.argument("[alias]", "registered source alias")
|
|
889
|
+
.addHelpText("after", exitHelp)
|
|
890
|
+
.action(async (alias) => {
|
|
891
|
+
await exitWith(runWorktreeList(alias));
|
|
892
|
+
});
|
|
893
|
+
worktreeCmd
|
|
894
|
+
.command("remove")
|
|
895
|
+
.description("Remove the worktree at sources/<alias>/<branch>.")
|
|
896
|
+
.argument("<alias>", "registered source alias")
|
|
897
|
+
.argument("<branch>", "branch name (may include slashes)")
|
|
898
|
+
.option("--force", "discard uncommitted changes")
|
|
899
|
+
.addHelpText("after", exitHelp)
|
|
900
|
+
.action(async (alias, branch, options) => {
|
|
901
|
+
await exitWith(runWorktreeRemove(alias, branch, options));
|
|
902
|
+
});
|
|
492
903
|
const requestedCommand = process.argv[2];
|
|
493
904
|
if (requestedCommand && !requestedCommand.startsWith("-") && !commandNames.has(requestedCommand)) {
|
|
494
905
|
console.error(`error: unknown command '${requestedCommand}'`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "oh-my-space",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "CLI for managing external source repositories declared in sources.yaml.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://github.com/divlook/oh-my-space",
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"prepack": "npm test",
|
|
41
41
|
"changeset": "changeset",
|
|
42
42
|
"version": "changeset version",
|
|
43
|
-
"release": "
|
|
43
|
+
"release": "changeset publish"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
46
|
"@changesets/changelog-github": "^0.7.0",
|