oh-my-space 0.2.0 → 0.3.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 +64 -27
- package/dist/oms.js +533 -144
- package/package.json +1 -1
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,8 @@ 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
|
-
pull: ["pull", "--ff-only"],
|
|
15
|
-
push: ["-c", "push.autoSetupRemote=false", "push"],
|
|
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/";
|
|
22
14
|
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
23
15
|
const useColor = process.stdout.isTTY;
|
|
24
16
|
const dim = (s) => (useColor ? `\x1b[2m${s}\x1b[0m` : s);
|
|
@@ -117,6 +109,10 @@ function runGit(cwd, args, inheritOutput = false) {
|
|
|
117
109
|
stdout: inheritOutput ? "" : (result.stdout ?? ""),
|
|
118
110
|
};
|
|
119
111
|
}
|
|
112
|
+
/** Run git from sources/<alias>/ with safe.bareRepository=all, so the .git placeholder routes to .bare. */
|
|
113
|
+
function runBareGit(repoRoot, alias, args, inheritOutput = false) {
|
|
114
|
+
return runGit(aliasDir(repoRoot, alias), ["-c", "safe.bareRepository=all", ...args], inheritOutput);
|
|
115
|
+
}
|
|
120
116
|
function findWorkspaceRoot(options = {}) {
|
|
121
117
|
let current = resolve(options.cwd ?? process.cwd());
|
|
122
118
|
while (true) {
|
|
@@ -128,6 +124,18 @@ function findWorkspaceRoot(options = {}) {
|
|
|
128
124
|
current = parent;
|
|
129
125
|
}
|
|
130
126
|
}
|
|
127
|
+
function aliasDir(repoRoot, alias) {
|
|
128
|
+
return join(repoRoot, "sources", alias);
|
|
129
|
+
}
|
|
130
|
+
function bareDir(repoRoot, alias) {
|
|
131
|
+
return join(aliasDir(repoRoot, alias), ".bare");
|
|
132
|
+
}
|
|
133
|
+
function gitPlaceholderPath(repoRoot, alias) {
|
|
134
|
+
return join(aliasDir(repoRoot, alias), ".git");
|
|
135
|
+
}
|
|
136
|
+
function worktreePath(repoRoot, alias, branch) {
|
|
137
|
+
return join(aliasDir(repoRoot, alias), branch);
|
|
138
|
+
}
|
|
131
139
|
function isRegisteredSubmodule(repoRoot, sourcePath) {
|
|
132
140
|
if (!existsSync(join(repoRoot, ".gitmodules")))
|
|
133
141
|
return false;
|
|
@@ -144,143 +152,342 @@ function isRegisteredSubmodule(repoRoot, sourcePath) {
|
|
|
144
152
|
.split("\n")
|
|
145
153
|
.some((line) => line.trim().split(/\s+/)[1] === sourcePath);
|
|
146
154
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
if (!existsSync(
|
|
155
|
+
/** Returns true when .gitmodules registers any sources/<alias> entry for the loaded repos. */
|
|
156
|
+
function hasLegacySubmoduleLayout(repoRoot, repos) {
|
|
157
|
+
if (!existsSync(join(repoRoot, ".gitmodules")))
|
|
150
158
|
return false;
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
159
|
+
return repos.some((repo) => isRegisteredSubmodule(repoRoot, `sources/${repo.alias}`));
|
|
160
|
+
}
|
|
161
|
+
function abortOnLegacy(repoRoot, repos) {
|
|
162
|
+
if (!hasLegacySubmoduleLayout(repoRoot, repos))
|
|
163
|
+
return false;
|
|
164
|
+
log.error('detected legacy submodule layout (.gitmodules registers sources/<alias>). oh-my-space 0.3.0 uses bare clone + worktrees.\n' +
|
|
165
|
+
' See README "Migrating from 0.2.x" for the manual steps. Aborting to avoid destructive change.');
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
function ensureGitignore(repoRoot) {
|
|
169
|
+
const path = join(repoRoot, ".gitignore");
|
|
170
|
+
let content = "";
|
|
171
|
+
if (existsSync(path)) {
|
|
172
|
+
content = readFileSync(path, "utf8");
|
|
173
|
+
const present = content
|
|
174
|
+
.split("\n")
|
|
175
|
+
.some((line) => line.trim() === GITIGNORE_ENTRY || line.trim() === `/${GITIGNORE_ENTRY}`);
|
|
176
|
+
if (present)
|
|
177
|
+
return;
|
|
164
178
|
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
179
|
+
const needsNewline = content.length > 0 && !content.endsWith("\n");
|
|
180
|
+
writeFileSync(path, `${content}${needsNewline ? "\n" : ""}${GITIGNORE_ENTRY}\n`);
|
|
181
|
+
log.info(`added ${GITIGNORE_ENTRY} to .gitignore`);
|
|
182
|
+
}
|
|
183
|
+
function setupBareRepo(repo, repoRoot) {
|
|
184
|
+
const alias = repo.alias;
|
|
185
|
+
const aliasPath = aliasDir(repoRoot, alias);
|
|
186
|
+
const barePath = bareDir(repoRoot, alias);
|
|
187
|
+
mkdirSync(aliasPath, { recursive: true });
|
|
188
|
+
const relBare = relative(repoRoot, barePath);
|
|
189
|
+
log.step(`git clone --bare ${repo.url} ${relBare}`);
|
|
190
|
+
const clone = runGit(repoRoot, ["clone", "--bare", repo.url, relBare], true);
|
|
191
|
+
if (!clone.success) {
|
|
192
|
+
log.error(`${alias}: git clone --bare failed (exit ${clone.exitCode})`);
|
|
193
|
+
if (existsSync(barePath))
|
|
194
|
+
rmSync(barePath, { recursive: true, force: true });
|
|
195
|
+
return false;
|
|
168
196
|
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
197
|
+
// Write the placeholder before any further runBareGit calls, otherwise git ascends
|
|
198
|
+
// out of sources/<alias>/ looking for a repo and touches the wrong .git.
|
|
199
|
+
writeFileSync(gitPlaceholderPath(repoRoot, alias), "gitdir: ./.bare\n");
|
|
200
|
+
const config = runBareGit(repoRoot, alias, [
|
|
201
|
+
"config",
|
|
202
|
+
"remote.origin.fetch",
|
|
203
|
+
FETCH_REFSPEC,
|
|
204
|
+
]);
|
|
205
|
+
if (!config.success) {
|
|
206
|
+
log.error(`${alias}: failed to set remote.origin.fetch (exit ${config.exitCode})`);
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
log.step(`${alias}: git fetch origin`);
|
|
210
|
+
const fetch = runBareGit(repoRoot, alias, ["fetch", "origin", "--prune"], true);
|
|
211
|
+
if (!fetch.success) {
|
|
212
|
+
log.error(`${alias}: initial fetch failed (exit ${fetch.exitCode})`);
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
return true;
|
|
216
|
+
}
|
|
217
|
+
function detectDefaultBranch(repoRoot, alias) {
|
|
218
|
+
const r = runBareGit(repoRoot, alias, ["symbolic-ref", "--short", "HEAD"]);
|
|
219
|
+
if (r.success) {
|
|
220
|
+
const out = r.stdout.trim();
|
|
221
|
+
return out.length > 0 ? out : null;
|
|
222
|
+
}
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
function listExistingWorktrees(repoRoot, alias) {
|
|
226
|
+
if (!existsSync(bareDir(repoRoot, alias)))
|
|
227
|
+
return [];
|
|
228
|
+
const result = runBareGit(repoRoot, alias, ["worktree", "list", "--porcelain"]);
|
|
229
|
+
if (!result.success)
|
|
230
|
+
return [];
|
|
231
|
+
const aliasPath = aliasDir(repoRoot, alias);
|
|
232
|
+
const entries = [];
|
|
233
|
+
const blocks = result.stdout
|
|
234
|
+
.split(/\n\n+/)
|
|
235
|
+
.map((b) => b.trim())
|
|
236
|
+
.filter((b) => b.length > 0);
|
|
237
|
+
for (const block of blocks) {
|
|
238
|
+
let path = "";
|
|
239
|
+
let branch = "(detached)";
|
|
240
|
+
let bare = false;
|
|
241
|
+
for (const line of block.split("\n")) {
|
|
242
|
+
if (line.startsWith("worktree "))
|
|
243
|
+
path = line.slice("worktree ".length);
|
|
244
|
+
else if (line.startsWith("branch ")) {
|
|
245
|
+
branch = line.slice("branch ".length).replace(/^refs\/heads\//, "");
|
|
246
|
+
}
|
|
247
|
+
else if (line === "bare")
|
|
248
|
+
bare = true;
|
|
174
249
|
}
|
|
250
|
+
if (!path || bare)
|
|
251
|
+
continue;
|
|
252
|
+
const rel = relative(aliasPath, path);
|
|
253
|
+
// Skip worktrees outside sources/<alias>/ (shouldn't happen, but be safe)
|
|
254
|
+
if (rel.startsWith(".."))
|
|
255
|
+
continue;
|
|
256
|
+
entries.push({ branch, path, relativePath: rel });
|
|
175
257
|
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
258
|
+
return entries;
|
|
259
|
+
}
|
|
260
|
+
function cleanupEmptyParents(repoRoot, alias, branch) {
|
|
261
|
+
const parts = branch.split("/").filter((p) => p.length > 0);
|
|
262
|
+
if (parts.length < 2)
|
|
263
|
+
return;
|
|
264
|
+
for (let i = parts.length - 1; i >= 1; i--) {
|
|
265
|
+
const dirPath = join(aliasDir(repoRoot, alias), ...parts.slice(0, i));
|
|
266
|
+
try {
|
|
267
|
+
const entries = readdirSync(dirPath);
|
|
268
|
+
if (entries.length === 0) {
|
|
269
|
+
rmdirSync(dirPath);
|
|
270
|
+
}
|
|
271
|
+
else {
|
|
272
|
+
break;
|
|
273
|
+
}
|
|
182
274
|
}
|
|
183
|
-
|
|
275
|
+
catch {
|
|
276
|
+
break;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
function addWorktree(repo, repoRoot, branch) {
|
|
281
|
+
const wtPath = worktreePath(repoRoot, repo.alias, branch);
|
|
282
|
+
if (existsSync(wtPath)) {
|
|
283
|
+
log.warn(`${repo.alias}: sources/${repo.alias}/${branch} already exists; skipping.`);
|
|
284
|
+
return true;
|
|
285
|
+
}
|
|
286
|
+
log.step(`${repo.alias}: git worktree add --track -B ${branch} ${branch} origin/${branch}`);
|
|
287
|
+
const result = runBareGit(repoRoot, repo.alias, ["worktree", "add", "--track", "-B", branch, branch, `origin/${branch}`], true);
|
|
288
|
+
if (!result.success) {
|
|
289
|
+
log.error(`${repo.alias}: worktree add for ${branch} failed (exit ${result.exitCode})`);
|
|
290
|
+
cleanupEmptyParents(repoRoot, repo.alias, branch);
|
|
291
|
+
return false;
|
|
184
292
|
}
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
293
|
+
return true;
|
|
294
|
+
}
|
|
295
|
+
function removeWorktree(repo, repoRoot, branch, force) {
|
|
296
|
+
const wtPath = worktreePath(repoRoot, repo.alias, branch);
|
|
297
|
+
if (!existsSync(wtPath))
|
|
298
|
+
return true;
|
|
299
|
+
const args = ["worktree", "remove", ...(force ? ["--force"] : []), branch];
|
|
300
|
+
log.step(`${repo.alias}: git worktree remove ${branch}${force ? " --force" : ""}`);
|
|
301
|
+
const result = runBareGit(repoRoot, repo.alias, args, true);
|
|
302
|
+
if (!result.success) {
|
|
303
|
+
log.error(`${repo.alias}: worktree remove for ${branch} failed (exit ${result.exitCode})`);
|
|
304
|
+
return false;
|
|
305
|
+
}
|
|
306
|
+
cleanupEmptyParents(repoRoot, repo.alias, branch);
|
|
307
|
+
return true;
|
|
308
|
+
}
|
|
309
|
+
function syncRepo(repo, repoRoot) {
|
|
310
|
+
const alias = repo.alias;
|
|
311
|
+
const aliasPath = aliasDir(repoRoot, alias);
|
|
312
|
+
const barePath = bareDir(repoRoot, alias);
|
|
313
|
+
const placeholderPath = gitPlaceholderPath(repoRoot, alias);
|
|
314
|
+
const bareExists = existsSync(barePath);
|
|
315
|
+
if (!bareExists) {
|
|
316
|
+
if (existsSync(aliasPath)) {
|
|
317
|
+
const stray = readdirSync(aliasPath).filter((e) => e !== ".git");
|
|
318
|
+
if (stray.length > 0) {
|
|
319
|
+
log.error(`${alias}: sources/${alias}/ already exists but is not a bare clone. Move or remove it manually, then retry.`);
|
|
320
|
+
return "failed";
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
if (repo.branch) {
|
|
324
|
+
const lsRemote = runGit(repoRoot, [
|
|
325
|
+
"ls-remote",
|
|
326
|
+
"--exit-code",
|
|
327
|
+
"--heads",
|
|
328
|
+
repo.url,
|
|
329
|
+
repo.branch,
|
|
330
|
+
]);
|
|
331
|
+
if (lsRemote.exitCode === 2) {
|
|
332
|
+
log.error(`${alias}: branch "${repo.branch}" not found on ${repo.url}. Push the branch upstream or fix the alias, then retry.`);
|
|
333
|
+
return "failed";
|
|
334
|
+
}
|
|
335
|
+
if (!lsRemote.success && lsRemote.exitCode !== 2) {
|
|
336
|
+
log.warn(`${alias}: branch existence check failed (exit ${lsRemote.exitCode}); proceeding.`);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
if (!setupBareRepo(repo, repoRoot)) {
|
|
340
|
+
if (existsSync(barePath))
|
|
341
|
+
rmSync(barePath, { recursive: true, force: true });
|
|
342
|
+
if (existsSync(placeholderPath))
|
|
343
|
+
rmSync(placeholderPath);
|
|
344
|
+
try {
|
|
345
|
+
if (readdirSync(aliasPath).length === 0)
|
|
346
|
+
rmdirSync(aliasPath);
|
|
347
|
+
}
|
|
348
|
+
catch { }
|
|
189
349
|
return "failed";
|
|
190
350
|
}
|
|
191
|
-
|
|
351
|
+
const branch = repo.branch ?? detectDefaultBranch(repoRoot, alias);
|
|
352
|
+
if (!branch) {
|
|
353
|
+
log.error(`${alias}: could not determine default branch from bare clone. Set "branch:" in sources.yaml.`);
|
|
354
|
+
return "failed";
|
|
355
|
+
}
|
|
356
|
+
if (!addWorktree(repo, repoRoot, branch))
|
|
357
|
+
return "failed";
|
|
358
|
+
log.success(`${alias}: added (branch=${branch})`);
|
|
359
|
+
return "added";
|
|
192
360
|
}
|
|
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.`);
|
|
361
|
+
if (!existsSync(placeholderPath)) {
|
|
362
|
+
writeFileSync(placeholderPath, "gitdir: ./.bare\n");
|
|
363
|
+
}
|
|
364
|
+
log.step(`${alias}: git fetch origin`);
|
|
365
|
+
const fetch = runBareGit(repoRoot, alias, ["fetch", "origin", "--prune"], true);
|
|
366
|
+
if (!fetch.success) {
|
|
367
|
+
log.error(`${alias}: fetch failed (exit ${fetch.exitCode})`);
|
|
212
368
|
return "failed";
|
|
213
369
|
}
|
|
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.`);
|
|
370
|
+
const branch = repo.branch ?? detectDefaultBranch(repoRoot, alias);
|
|
371
|
+
if (branch && !existsSync(worktreePath(repoRoot, alias, branch))) {
|
|
372
|
+
if (!addWorktree(repo, repoRoot, branch))
|
|
224
373
|
return "failed";
|
|
374
|
+
}
|
|
375
|
+
runBareGit(repoRoot, alias, ["worktree", "prune"]);
|
|
376
|
+
log.success(`${alias}: updated`);
|
|
377
|
+
return "updated";
|
|
378
|
+
}
|
|
379
|
+
function unsyncRepo(repo, repoRoot, force) {
|
|
380
|
+
const aliasPath = aliasDir(repoRoot, repo.alias);
|
|
381
|
+
const barePath = bareDir(repoRoot, repo.alias);
|
|
382
|
+
const placeholderPath = gitPlaceholderPath(repoRoot, repo.alias);
|
|
383
|
+
const aliasExists = existsSync(aliasPath);
|
|
384
|
+
const bareExists = existsSync(barePath);
|
|
385
|
+
if (!aliasExists && !bareExists)
|
|
386
|
+
return "nothing-to-remove";
|
|
387
|
+
const worktrees = bareExists ? listExistingWorktrees(repoRoot, repo.alias) : [];
|
|
388
|
+
if (!force) {
|
|
389
|
+
for (const wt of worktrees) {
|
|
390
|
+
const status = runGit(wt.path, ["status", "--porcelain"]);
|
|
391
|
+
if (status.success && status.stdout.trim().length > 0) {
|
|
392
|
+
log.error(`${repo.alias}: worktree at sources/${repo.alias}/${wt.relativePath} has uncommitted changes. Commit, stash, or pass --force.`);
|
|
393
|
+
return "failed";
|
|
394
|
+
}
|
|
225
395
|
}
|
|
226
|
-
|
|
227
|
-
|
|
396
|
+
}
|
|
397
|
+
for (const wt of worktrees) {
|
|
398
|
+
const target = wt.branch === "(detached)" ? wt.relativePath : wt.branch;
|
|
399
|
+
if (!removeWorktree(repo, repoRoot, target, force)) {
|
|
400
|
+
return "failed";
|
|
228
401
|
}
|
|
229
402
|
}
|
|
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.`);
|
|
403
|
+
if (existsSync(barePath))
|
|
404
|
+
rmSync(barePath, { recursive: true, force: true });
|
|
405
|
+
if (existsSync(placeholderPath))
|
|
406
|
+
rmSync(placeholderPath);
|
|
407
|
+
try {
|
|
408
|
+
if (existsSync(aliasPath)) {
|
|
409
|
+
const remaining = readdirSync(aliasPath);
|
|
410
|
+
if (remaining.length === 0)
|
|
411
|
+
rmdirSync(aliasPath);
|
|
252
412
|
}
|
|
253
413
|
}
|
|
414
|
+
catch { }
|
|
415
|
+
return "removed";
|
|
416
|
+
}
|
|
417
|
+
function fetchRepo(repo, repoRoot) {
|
|
418
|
+
if (!existsSync(bareDir(repoRoot, repo.alias))) {
|
|
419
|
+
log.error(`${repo.alias}: not synced. Run "oms sync ${repo.alias}" first.`);
|
|
420
|
+
return "failed";
|
|
421
|
+
}
|
|
422
|
+
log.step(`${repo.alias}: git fetch origin --prune`);
|
|
423
|
+
const r = runBareGit(repoRoot, repo.alias, ["fetch", "origin", "--prune"], true);
|
|
424
|
+
if (r.success) {
|
|
425
|
+
log.success(`${repo.alias}: fetched`);
|
|
426
|
+
return "fetched";
|
|
427
|
+
}
|
|
428
|
+
log.error(`${repo.alias}: fetch failed (exit ${r.exitCode})`);
|
|
254
429
|
return "failed";
|
|
255
430
|
}
|
|
256
|
-
function
|
|
257
|
-
const
|
|
258
|
-
if (
|
|
259
|
-
|
|
431
|
+
function resolveTargetWorktree(repo, repoRoot) {
|
|
432
|
+
const defaultBranch = repo.branch ?? detectDefaultBranch(repoRoot, repo.alias);
|
|
433
|
+
if (defaultBranch) {
|
|
434
|
+
const path = worktreePath(repoRoot, repo.alias, defaultBranch);
|
|
435
|
+
if (existsSync(path))
|
|
436
|
+
return { branch: defaultBranch, path };
|
|
437
|
+
}
|
|
438
|
+
const wts = listExistingWorktrees(repoRoot, repo.alias);
|
|
439
|
+
if (wts.length === 1) {
|
|
440
|
+
const only = wts[0];
|
|
441
|
+
return { branch: only.branch === "(detached)" ? only.relativePath : only.branch, path: only.path };
|
|
442
|
+
}
|
|
443
|
+
return null;
|
|
444
|
+
}
|
|
445
|
+
function pullRepo(repo, repoRoot) {
|
|
446
|
+
if (!existsSync(bareDir(repoRoot, repo.alias))) {
|
|
447
|
+
log.error(`${repo.alias}: not synced. Run "oms sync ${repo.alias}" first.`);
|
|
260
448
|
return "failed";
|
|
261
449
|
}
|
|
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
|
-
}
|
|
450
|
+
const target = resolveTargetWorktree(repo, repoRoot);
|
|
451
|
+
if (!target) {
|
|
452
|
+
log.error(`${repo.alias}: cannot determine which worktree to pull. Use "oms worktree list ${repo.alias}".`);
|
|
453
|
+
return "failed";
|
|
274
454
|
}
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
log.success(`${repo.alias}: ${operation}`);
|
|
281
|
-
return operation;
|
|
455
|
+
log.step(`${repo.alias}/${target.branch}: git pull --ff-only`);
|
|
456
|
+
const r = runGit(target.path, ["pull", "--ff-only"], true);
|
|
457
|
+
if (r.success) {
|
|
458
|
+
log.success(`${repo.alias}/${target.branch}: pulled`);
|
|
459
|
+
return "pulled";
|
|
282
460
|
}
|
|
283
|
-
log.error(`${repo.alias}:
|
|
461
|
+
log.error(`${repo.alias}/${target.branch}: pull failed (exit ${r.exitCode})`);
|
|
462
|
+
return "failed";
|
|
463
|
+
}
|
|
464
|
+
function pushRepo(repo, repoRoot) {
|
|
465
|
+
if (!existsSync(bareDir(repoRoot, repo.alias))) {
|
|
466
|
+
log.error(`${repo.alias}: not synced. Run "oms sync ${repo.alias}" first.`);
|
|
467
|
+
return "failed";
|
|
468
|
+
}
|
|
469
|
+
const target = resolveTargetWorktree(repo, repoRoot);
|
|
470
|
+
if (!target) {
|
|
471
|
+
log.error(`${repo.alias}: cannot determine which worktree to push. Use "oms worktree list ${repo.alias}".`);
|
|
472
|
+
return "failed";
|
|
473
|
+
}
|
|
474
|
+
const upstream = runGit(target.path, [
|
|
475
|
+
"rev-parse",
|
|
476
|
+
"--abbrev-ref",
|
|
477
|
+
"--symbolic-full-name",
|
|
478
|
+
"@{u}",
|
|
479
|
+
]);
|
|
480
|
+
if (!upstream.success) {
|
|
481
|
+
log.error(`${repo.alias}/${target.branch}: git push requires an upstream. Set it manually before pushing.`);
|
|
482
|
+
return "failed";
|
|
483
|
+
}
|
|
484
|
+
log.step(`${repo.alias}/${target.branch}: git push`);
|
|
485
|
+
const r = runGit(target.path, ["-c", "push.autoSetupRemote=false", "push"], true);
|
|
486
|
+
if (r.success) {
|
|
487
|
+
log.success(`${repo.alias}/${target.branch}: pushed`);
|
|
488
|
+
return "pushed";
|
|
489
|
+
}
|
|
490
|
+
log.error(`${repo.alias}/${target.branch}: push failed (exit ${r.exitCode})`);
|
|
284
491
|
return "failed";
|
|
285
492
|
}
|
|
286
493
|
function printSummary(results) {
|
|
@@ -350,10 +557,13 @@ async function runSync(aliases, options) {
|
|
|
350
557
|
printList(repos);
|
|
351
558
|
return 0;
|
|
352
559
|
}
|
|
560
|
+
if (abortOnLegacy(repoRoot, repos))
|
|
561
|
+
return 1;
|
|
353
562
|
const picked = await selectRepos(repos, aliases, options, "sync");
|
|
354
563
|
if (!picked || picked.length === 0)
|
|
355
564
|
return 1;
|
|
356
|
-
|
|
565
|
+
ensureGitignore(repoRoot);
|
|
566
|
+
const results = picked.map((repo) => syncRepo(repo, repoRoot));
|
|
357
567
|
if (results.length > 1 || options.all)
|
|
358
568
|
printSummary(results);
|
|
359
569
|
return exitFromResults(results);
|
|
@@ -363,10 +573,13 @@ async function runManage(command, aliases, options) {
|
|
|
363
573
|
if (!loaded)
|
|
364
574
|
return 1;
|
|
365
575
|
const { repos, repoRoot } = loaded;
|
|
576
|
+
if (abortOnLegacy(repoRoot, repos))
|
|
577
|
+
return 1;
|
|
366
578
|
const picked = await selectRepos(repos, aliases, options, command);
|
|
367
579
|
if (!picked || picked.length === 0)
|
|
368
580
|
return 1;
|
|
369
|
-
const
|
|
581
|
+
const fn = command === "fetch" ? fetchRepo : command === "pull" ? pullRepo : pushRepo;
|
|
582
|
+
const results = picked.map((repo) => fn(repo, repoRoot));
|
|
370
583
|
if (results.length > 1 || options.all)
|
|
371
584
|
printSummary(results);
|
|
372
585
|
return exitFromResults(results);
|
|
@@ -376,14 +589,16 @@ async function runUnsync(aliases, options) {
|
|
|
376
589
|
if (!loaded)
|
|
377
590
|
return 1;
|
|
378
591
|
const { repos, repoRoot } = loaded;
|
|
592
|
+
if (abortOnLegacy(repoRoot, repos))
|
|
593
|
+
return 1;
|
|
379
594
|
const picked = await selectRepos(repos, aliases, options, "unsync");
|
|
380
595
|
if (!picked || picked.length === 0)
|
|
381
596
|
return 1;
|
|
382
597
|
const results = picked.map((repo) => {
|
|
383
598
|
log.step(`${repo.alias}: unsync`);
|
|
384
|
-
const outcome =
|
|
599
|
+
const outcome = unsyncRepo(repo, repoRoot, options.force ?? false);
|
|
385
600
|
if (outcome === "removed") {
|
|
386
|
-
log.success(`${repo.alias}: unsynced
|
|
601
|
+
log.success(`${repo.alias}: unsynced`);
|
|
387
602
|
return "unsynced";
|
|
388
603
|
}
|
|
389
604
|
if (outcome === "nothing-to-remove") {
|
|
@@ -396,19 +611,153 @@ async function runUnsync(aliases, options) {
|
|
|
396
611
|
printSummary(results);
|
|
397
612
|
return exitFromResults(results);
|
|
398
613
|
}
|
|
614
|
+
async function runWorktreeAdd(alias, branch) {
|
|
615
|
+
const loaded = loadRepos();
|
|
616
|
+
if (!loaded)
|
|
617
|
+
return 1;
|
|
618
|
+
const { repos, repoRoot } = loaded;
|
|
619
|
+
if (abortOnLegacy(repoRoot, repos))
|
|
620
|
+
return 1;
|
|
621
|
+
const repo = repos.find((r) => r.alias === alias);
|
|
622
|
+
if (!repo) {
|
|
623
|
+
log.error(`Unknown alias "${alias}". Use "oms sync --list" to see registered aliases.`);
|
|
624
|
+
return 1;
|
|
625
|
+
}
|
|
626
|
+
if (!existsSync(bareDir(repoRoot, alias))) {
|
|
627
|
+
log.error(`${alias}: not synced. Run "oms sync ${alias}" first.`);
|
|
628
|
+
return 1;
|
|
629
|
+
}
|
|
630
|
+
if (existsSync(worktreePath(repoRoot, alias, branch))) {
|
|
631
|
+
log.error(`${alias}: worktree for ${branch} already exists at sources/${alias}/${branch}.`);
|
|
632
|
+
return 1;
|
|
633
|
+
}
|
|
634
|
+
log.step(`${alias}: git fetch origin --prune (refresh before add)`);
|
|
635
|
+
runBareGit(repoRoot, alias, ["fetch", "origin", "--prune"], true);
|
|
636
|
+
if (!addWorktree(repo, repoRoot, branch))
|
|
637
|
+
return 2;
|
|
638
|
+
log.success(`${alias}: added worktree for ${branch}`);
|
|
639
|
+
return 0;
|
|
640
|
+
}
|
|
641
|
+
async function runWorktreeList(alias) {
|
|
642
|
+
const loaded = loadRepos();
|
|
643
|
+
if (!loaded)
|
|
644
|
+
return 1;
|
|
645
|
+
const { repos, repoRoot } = loaded;
|
|
646
|
+
if (abortOnLegacy(repoRoot, repos))
|
|
647
|
+
return 1;
|
|
648
|
+
const targets = alias ? repos.filter((r) => r.alias === alias) : repos;
|
|
649
|
+
if (alias && targets.length === 0) {
|
|
650
|
+
log.error(`Unknown alias "${alias}".`);
|
|
651
|
+
return 1;
|
|
652
|
+
}
|
|
653
|
+
const rows = [];
|
|
654
|
+
for (const repo of targets) {
|
|
655
|
+
if (!existsSync(bareDir(repoRoot, repo.alias))) {
|
|
656
|
+
rows.push({ alias: repo.alias, branch: "(not synced)", path: "-" });
|
|
657
|
+
continue;
|
|
658
|
+
}
|
|
659
|
+
const wts = listExistingWorktrees(repoRoot, repo.alias);
|
|
660
|
+
if (wts.length === 0) {
|
|
661
|
+
rows.push({ alias: repo.alias, branch: "(no worktrees)", path: "-" });
|
|
662
|
+
continue;
|
|
663
|
+
}
|
|
664
|
+
for (const wt of wts) {
|
|
665
|
+
rows.push({
|
|
666
|
+
alias: repo.alias,
|
|
667
|
+
branch: wt.branch,
|
|
668
|
+
path: `sources/${repo.alias}/${wt.relativePath}`,
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
const aliasW = Math.max("ALIAS".length, ...rows.map((r) => r.alias.length));
|
|
673
|
+
const branchW = Math.max("BRANCH".length, ...rows.map((r) => r.branch.length));
|
|
674
|
+
console.log(dim(`${pad("ALIAS", aliasW)} ${pad("BRANCH", branchW)} PATH`));
|
|
675
|
+
for (const r of rows) {
|
|
676
|
+
console.log(`${pad(r.alias, aliasW)} ${pad(r.branch, branchW)} ${r.path}`);
|
|
677
|
+
}
|
|
678
|
+
return 0;
|
|
679
|
+
}
|
|
680
|
+
async function runWorktreeRemove(alias, branch, options) {
|
|
681
|
+
const loaded = loadRepos();
|
|
682
|
+
if (!loaded)
|
|
683
|
+
return 1;
|
|
684
|
+
const { repos, repoRoot } = loaded;
|
|
685
|
+
if (abortOnLegacy(repoRoot, repos))
|
|
686
|
+
return 1;
|
|
687
|
+
const repo = repos.find((r) => r.alias === alias);
|
|
688
|
+
if (!repo) {
|
|
689
|
+
log.error(`Unknown alias "${alias}".`);
|
|
690
|
+
return 1;
|
|
691
|
+
}
|
|
692
|
+
const wtPath = worktreePath(repoRoot, alias, branch);
|
|
693
|
+
if (!existsSync(wtPath)) {
|
|
694
|
+
log.info(`${alias}: no worktree for ${branch}`);
|
|
695
|
+
return 0;
|
|
696
|
+
}
|
|
697
|
+
if (!options.force) {
|
|
698
|
+
const status = runGit(wtPath, ["status", "--porcelain"]);
|
|
699
|
+
if (status.success && status.stdout.trim().length > 0) {
|
|
700
|
+
log.error(`${alias}/${branch}: has uncommitted changes. Commit, stash, or pass --force.`);
|
|
701
|
+
return 2;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
if (!removeWorktree(repo, repoRoot, branch, options.force ?? false))
|
|
705
|
+
return 2;
|
|
706
|
+
log.success(`${alias}: removed worktree for ${branch}`);
|
|
707
|
+
return 0;
|
|
708
|
+
}
|
|
399
709
|
async function runDoctor() {
|
|
400
710
|
const loaded = loadRepos();
|
|
401
711
|
if (!loaded)
|
|
402
712
|
return 1;
|
|
403
|
-
|
|
404
|
-
log.success(`
|
|
405
|
-
|
|
713
|
+
const { repos, repoRoot } = loaded;
|
|
714
|
+
log.success(`Workspace root: ${repoRoot}`);
|
|
715
|
+
log.success(`sources.yaml: ${repos.length} repo(s) configured`);
|
|
716
|
+
const git = spawnSync("git", ["--version"], {
|
|
717
|
+
encoding: "utf8",
|
|
718
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
719
|
+
});
|
|
406
720
|
if (git.status === 0) {
|
|
407
721
|
log.success(`git: ${git.stdout.trim()}`);
|
|
408
|
-
return 0;
|
|
409
722
|
}
|
|
410
|
-
|
|
411
|
-
|
|
723
|
+
else {
|
|
724
|
+
log.error("git: not found");
|
|
725
|
+
return 1;
|
|
726
|
+
}
|
|
727
|
+
let warnings = 0;
|
|
728
|
+
if (hasLegacySubmoduleLayout(repoRoot, repos)) {
|
|
729
|
+
log.warn('detected legacy submodule layout (.gitmodules registers sources/<alias>). See README "Migrating from 0.2.x".');
|
|
730
|
+
warnings++;
|
|
731
|
+
}
|
|
732
|
+
const gi = join(repoRoot, ".gitignore");
|
|
733
|
+
const giHasSources = existsSync(gi)
|
|
734
|
+
&& readFileSync(gi, "utf8")
|
|
735
|
+
.split("\n")
|
|
736
|
+
.some((l) => l.trim() === GITIGNORE_ENTRY || l.trim() === `/${GITIGNORE_ENTRY}`);
|
|
737
|
+
if (!giHasSources) {
|
|
738
|
+
log.warn(`.gitignore does not exclude ${GITIGNORE_ENTRY}. Run "oms sync" to add it automatically.`);
|
|
739
|
+
warnings++;
|
|
740
|
+
}
|
|
741
|
+
for (const repo of repos) {
|
|
742
|
+
const bare = bareDir(repoRoot, repo.alias);
|
|
743
|
+
if (!existsSync(bare)) {
|
|
744
|
+
log.info(`${repo.alias}: not synced`);
|
|
745
|
+
continue;
|
|
746
|
+
}
|
|
747
|
+
const refspec = runBareGit(repoRoot, repo.alias, ["config", "--get", "remote.origin.fetch"]);
|
|
748
|
+
if (!refspec.success || refspec.stdout.trim().length === 0) {
|
|
749
|
+
log.warn(`${repo.alias}: bare clone is missing remote.origin.fetch. Run: git -C sources/${repo.alias}/.bare -c safe.bareRepository=all config remote.origin.fetch '${FETCH_REFSPEC}'`);
|
|
750
|
+
warnings++;
|
|
751
|
+
}
|
|
752
|
+
else {
|
|
753
|
+
log.success(`${repo.alias}: bare clone OK (refspec: ${refspec.stdout.trim()})`);
|
|
754
|
+
}
|
|
755
|
+
if (!existsSync(gitPlaceholderPath(repoRoot, repo.alias))) {
|
|
756
|
+
log.warn(`${repo.alias}: missing .git placeholder at sources/${repo.alias}/.git. Recreate with: echo 'gitdir: ./.bare' > sources/${repo.alias}/.git`);
|
|
757
|
+
warnings++;
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
return warnings > 0 ? 2 : 0;
|
|
412
761
|
}
|
|
413
762
|
function readJson(path) {
|
|
414
763
|
if (!existsSync(path))
|
|
@@ -429,23 +778,32 @@ async function exitWith(action) {
|
|
|
429
778
|
}
|
|
430
779
|
}
|
|
431
780
|
const exitHelp = "\nExit codes: 0 ok | 1 usage/config error | 2 one or more git operations failed.";
|
|
432
|
-
const commandNames = new Set([
|
|
781
|
+
const commandNames = new Set([
|
|
782
|
+
"doctor",
|
|
783
|
+
"sync",
|
|
784
|
+
"fetch",
|
|
785
|
+
"pull",
|
|
786
|
+
"push",
|
|
787
|
+
"unsync",
|
|
788
|
+
"worktree",
|
|
789
|
+
"help",
|
|
790
|
+
]);
|
|
433
791
|
const program = new Command();
|
|
434
792
|
program
|
|
435
793
|
.name("oms")
|
|
436
|
-
.description("Manage source repositories listed in sources.yaml under sources/<alias>/.")
|
|
794
|
+
.description("Manage source repositories listed in sources.yaml as bare clones + worktrees under sources/<alias>/.")
|
|
437
795
|
.version(readPackageVersion())
|
|
438
796
|
.addHelpText("after", exitHelp);
|
|
439
797
|
program
|
|
440
798
|
.command("doctor")
|
|
441
|
-
.description("Check sources.yaml
|
|
799
|
+
.description("Check sources.yaml, git availability, bare-clone state, and .gitignore for each registered alias.")
|
|
442
800
|
.addHelpText("after", exitHelp)
|
|
443
801
|
.action(async () => {
|
|
444
802
|
await exitWith(runDoctor());
|
|
445
803
|
});
|
|
446
804
|
program
|
|
447
805
|
.command("sync")
|
|
448
|
-
.description("
|
|
806
|
+
.description("Bare-clone each registered repo into sources/<alias>/.bare and create the baseline worktree at sources/<alias>/<branch>/.")
|
|
449
807
|
.argument("[aliases...]", "repo aliases to sync (omit for interactive multi-select)")
|
|
450
808
|
.option("--all", "sync every registered source repo")
|
|
451
809
|
.option("--list", "print registered repos")
|
|
@@ -455,7 +813,7 @@ program
|
|
|
455
813
|
});
|
|
456
814
|
program
|
|
457
815
|
.command("fetch")
|
|
458
|
-
.description("Run git fetch
|
|
816
|
+
.description("Run git fetch origin --prune in each bare clone.")
|
|
459
817
|
.argument("[aliases...]", "repo aliases to fetch (omit for interactive multi-select)")
|
|
460
818
|
.option("--all", "fetch every registered source repo")
|
|
461
819
|
.addHelpText("after", exitHelp)
|
|
@@ -464,7 +822,7 @@ program
|
|
|
464
822
|
});
|
|
465
823
|
program
|
|
466
824
|
.command("pull")
|
|
467
|
-
.description("Run git pull --ff-only
|
|
825
|
+
.description("Run git pull --ff-only in the baseline worktree of each selected alias; requires upstream.")
|
|
468
826
|
.argument("[aliases...]", "repo aliases to pull (omit for interactive multi-select)")
|
|
469
827
|
.option("--all", "pull every registered source repo")
|
|
470
828
|
.addHelpText("after", exitHelp)
|
|
@@ -473,7 +831,7 @@ program
|
|
|
473
831
|
});
|
|
474
832
|
program
|
|
475
833
|
.command("push")
|
|
476
|
-
.description("Run git push
|
|
834
|
+
.description("Run git push in the baseline worktree of each explicitly listed alias; no --all, force, or upstream setup.")
|
|
477
835
|
.argument("<aliases...>", "repo aliases to push")
|
|
478
836
|
.addHelpText("after", exitHelp)
|
|
479
837
|
.action(async (aliases) => {
|
|
@@ -481,14 +839,45 @@ program
|
|
|
481
839
|
});
|
|
482
840
|
program
|
|
483
841
|
.command("unsync")
|
|
484
|
-
.description("Remove
|
|
842
|
+
.description("Remove all worktrees and the bare clone for each alias (keeps sources.yaml entry).")
|
|
485
843
|
.argument("[aliases...]", "repo aliases to unsync (omit for interactive multi-select)")
|
|
486
844
|
.option("--all", "unsync every registered source repo")
|
|
487
|
-
.option("--force", "discard uncommitted changes in
|
|
845
|
+
.option("--force", "discard uncommitted changes in worktrees")
|
|
488
846
|
.addHelpText("after", exitHelp)
|
|
489
847
|
.action(async (aliases, options) => {
|
|
490
848
|
await exitWith(runUnsync(aliases, options));
|
|
491
849
|
});
|
|
850
|
+
const worktreeCmd = program
|
|
851
|
+
.command("worktree")
|
|
852
|
+
.description("Manage additional worktrees for a synced source repo.")
|
|
853
|
+
.addHelpText("after", exitHelp);
|
|
854
|
+
worktreeCmd
|
|
855
|
+
.command("add")
|
|
856
|
+
.description("Create a worktree for <branch> at sources/<alias>/<branch>/.")
|
|
857
|
+
.argument("<alias>", "registered source alias")
|
|
858
|
+
.argument("<branch>", "branch name (may include slashes)")
|
|
859
|
+
.addHelpText("after", exitHelp)
|
|
860
|
+
.action(async (alias, branch) => {
|
|
861
|
+
await exitWith(runWorktreeAdd(alias, branch));
|
|
862
|
+
});
|
|
863
|
+
worktreeCmd
|
|
864
|
+
.command("list")
|
|
865
|
+
.description("List existing worktrees for the given alias (or all aliases).")
|
|
866
|
+
.argument("[alias]", "registered source alias")
|
|
867
|
+
.addHelpText("after", exitHelp)
|
|
868
|
+
.action(async (alias) => {
|
|
869
|
+
await exitWith(runWorktreeList(alias));
|
|
870
|
+
});
|
|
871
|
+
worktreeCmd
|
|
872
|
+
.command("remove")
|
|
873
|
+
.description("Remove the worktree at sources/<alias>/<branch>.")
|
|
874
|
+
.argument("<alias>", "registered source alias")
|
|
875
|
+
.argument("<branch>", "branch name (may include slashes)")
|
|
876
|
+
.option("--force", "discard uncommitted changes")
|
|
877
|
+
.addHelpText("after", exitHelp)
|
|
878
|
+
.action(async (alias, branch, options) => {
|
|
879
|
+
await exitWith(runWorktreeRemove(alias, branch, options));
|
|
880
|
+
});
|
|
492
881
|
const requestedCommand = process.argv[2];
|
|
493
882
|
if (requestedCommand && !requestedCommand.startsWith("-") && !commandNames.has(requestedCommand)) {
|
|
494
883
|
console.error(`error: unknown command '${requestedCommand}'`);
|