@cldmv/git-embedded 1.0.0 → 1.1.2
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 +93 -0
- package/bin/git-embedded.mjs +0 -0
- package/docs/design.md +102 -31
- package/hooks/pre-push +162 -0
- package/hooks/reference-transaction +136 -35
- package/hooks/update-embedded-repos +0 -0
- package/package.json +8 -5
- package/src/api/cli/export.mjs +71 -0
- package/src/api/cli/init.mjs +1 -0
- package/src/api/cli/install-hooks.mjs +1 -0
- package/src/api/cli/link.mjs +55 -8
- package/src/api/cli/print-hook-script.mjs +1 -0
- package/src/api/cli/record.mjs +36 -0
- package/src/api/cli/restore.mjs +74 -0
- package/src/api/cli/sync.mjs +70 -0
- package/src/api/commander/custom-help.mjs +7 -1
- package/src/api/embedded/branch.mjs +62 -0
- package/src/api/embedded/gitlinks.mjs +45 -0
- package/src/api/embedded/manifest.mjs +71 -0
- package/src/api/embedded/record.mjs +46 -0
- package/src/api/embedded/registry.mjs +119 -0
- package/src/api/embedded/resolve.mjs +87 -0
- package/src/api/embedded/restore.mjs +242 -0
- package/src/api/embedded/sync.mjs +255 -0
- package/src/api/git.mjs +3 -0
- package/src/api/install/hooks.mjs +3 -2
- package/src/api/report.mjs +3 -0
- package/src/lib/elevate-windows-child.mjs +0 -0
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { self, context } from "@cldmv/slothlet/runtime";
|
|
2
|
+
|
|
3
|
+
function git(args, opts = {}) {
|
|
4
|
+
const res = context.spawnSync("git", args, { encoding: "utf8", ...opts });
|
|
5
|
+
/* v8 ignore next -- res.status is null on spawn failure (git not on PATH) or signal kill —
|
|
6
|
+
the real case `?? 1` maps to exit 1. git() runs after gitlinks() so git is normally
|
|
7
|
+
spawnable, but a later spawn can still fail; it just isn't reproducible in the suite. */
|
|
8
|
+
return { code: res.status ?? 1, stdout: (res.stdout || "").trim(), stderr: (res.stderr || "").trim() };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Sync engine: move already-present embedded children to the pins in the
|
|
13
|
+
* parent's HEAD (day-2 — after the parent pulled new gitlink pins). The parent
|
|
14
|
+
* itself is never touched; pulling it first is the caller's step.
|
|
15
|
+
*
|
|
16
|
+
* Per child, in order:
|
|
17
|
+
* - symlinked gitlink path → `sync-failed`, refusing to touch it (never run
|
|
18
|
+
* git through a link out of the worktree — same guard as restore/link).
|
|
19
|
+
* - HEAD already at the pin → `in-sync` (done).
|
|
20
|
+
* - uncommitted changes → `dirty`, left alone (that's your work).
|
|
21
|
+
* - pin absent locally → one `git fetch origin`; still absent →
|
|
22
|
+
* `pin-unavailable` (a real failure — non-zero exit).
|
|
23
|
+
* - on the REGISTERED branch (`embedded.<path>.branch`) and clean →
|
|
24
|
+
* fast-forward-only: HEAD must be an ancestor of the pin, then the branch
|
|
25
|
+
* is moved to the pin (upstream refreshed best-effort). Ahead/diverged →
|
|
26
|
+
* `ahead`, left alone (your work).
|
|
27
|
+
* - on any other branch → `unregistered-branch`, left alone (reported).
|
|
28
|
+
* - detached and clean → detach to the pin.
|
|
29
|
+
*
|
|
30
|
+
* Only `pin-unavailable` and `sync-failed` (an unexpected git failure — reading
|
|
31
|
+
* HEAD or status, the branch move, or the checkout) make the exit code
|
|
32
|
+
* non-zero; the left-alone outcomes are deliberate protection of in-progress
|
|
33
|
+
* work, not errors.
|
|
34
|
+
*
|
|
35
|
+
* @param {object} [opts]
|
|
36
|
+
* @param {string} [opts.cwd] working directory inside the parent repo
|
|
37
|
+
* @param {string[]} [opts.paths] restrict to these gitlink paths (default: all)
|
|
38
|
+
* @param {string[]} [opts.skip] gitlink paths to skip
|
|
39
|
+
* @param {boolean} [opts.dryRun] classify and report only; fetch/move nothing
|
|
40
|
+
* @returns {{ results: Array<object>, exitCode: number }} per-child outcomes
|
|
41
|
+
* (`synced`, `in-sync`, `dirty`, `ahead`, `unregistered-branch`,
|
|
42
|
+
* `pin-unavailable`, `sync-failed`, `skipped`, `no-repo`) and a process exit
|
|
43
|
+
* code (non-zero when any child ends `pin-unavailable` or `sync-failed`)
|
|
44
|
+
*/
|
|
45
|
+
export default function sync(opts = {}) {
|
|
46
|
+
const { fs, path } = context;
|
|
47
|
+
const { cwd = process.cwd(), paths = [], skip = [], dryRun = false } = opts;
|
|
48
|
+
|
|
49
|
+
const root = self.git.getRepoRoot(cwd) || cwd;
|
|
50
|
+
|
|
51
|
+
// Same filter-spelling normalization as restore: gitlink paths are
|
|
52
|
+
// root-relative with forward slashes; accept "./tests", "tests/", "vendor\\foo".
|
|
53
|
+
const normalizePath = (p) =>
|
|
54
|
+
String(p)
|
|
55
|
+
.replace(/\\/g, "/")
|
|
56
|
+
.replace(/^\.\/+/, "")
|
|
57
|
+
.replace(/\/+$/, "");
|
|
58
|
+
const skipSet = new Set(skip.map(normalizePath));
|
|
59
|
+
const wantSet = paths.length ? new Set(paths.map(normalizePath)) : null;
|
|
60
|
+
|
|
61
|
+
const links = self.embedded.gitlinks(root);
|
|
62
|
+
const results = [];
|
|
63
|
+
|
|
64
|
+
for (const { path: childPath, sha } of links) {
|
|
65
|
+
if (wantSet && !wantSet.has(childPath)) continue;
|
|
66
|
+
|
|
67
|
+
const record = { path: childPath, sha, branch: null, note: null };
|
|
68
|
+
|
|
69
|
+
if (skipSet.has(childPath)) {
|
|
70
|
+
results.push({ ...record, outcome: "skipped" });
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const absChild = path.resolve(root, childPath);
|
|
75
|
+
|
|
76
|
+
// Refuse a symlinked gitlink path before touching it: every git command
|
|
77
|
+
// below runs with `-C absChild`, so a symlink pointing outside the parent
|
|
78
|
+
// worktree would have us fetch/checkout out there — the same risk restore
|
|
79
|
+
// and link already refuse. lstat sees the link itself (existsSync follows
|
|
80
|
+
// it); a symlink here is always an anomaly, so surface it (non-zero exit)
|
|
81
|
+
// even on an unfiltered run.
|
|
82
|
+
let linkStat = null;
|
|
83
|
+
try {
|
|
84
|
+
linkStat = fs.lstatSync(absChild);
|
|
85
|
+
} catch (err) {
|
|
86
|
+
// Only ENOENT means "missing". A non-ENOENT lstat error (EACCES/ENOTDIR
|
|
87
|
+
// on an existing path) is a real failure, not an absent child — surface
|
|
88
|
+
// it rather than silently proceeding.
|
|
89
|
+
if (err.code !== "ENOENT") {
|
|
90
|
+
/* v8 ignore next -- defensive: an fs error object always carries a `.code`, so the `|| err.message` fallback is unreachable. */
|
|
91
|
+
results.push({ ...record, outcome: "sync-failed", note: `gitlink path unreadable (${err.code || err.message})` });
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
/* ENOENT — missing; handled as no-repo below */
|
|
95
|
+
}
|
|
96
|
+
if (linkStat && linkStat.isSymbolicLink()) {
|
|
97
|
+
results.push({ ...record, outcome: "sync-failed", note: "gitlink path is a symbolic link — refusing to touch it" });
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (!fs.existsSync(path.join(absChild, ".git"))) {
|
|
102
|
+
// A missing child is restore's job, not sync's; report it only when the
|
|
103
|
+
// caller asked for this path explicitly (mirrors record's idiom).
|
|
104
|
+
if (wantSet) results.push({ ...record, outcome: "no-repo", note: "not present on disk — run restore" });
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const headRes = git(["-C", absChild, "rev-parse", "HEAD"]);
|
|
109
|
+
if (headRes.code !== 0) {
|
|
110
|
+
results.push({
|
|
111
|
+
...record,
|
|
112
|
+
outcome: "sync-failed",
|
|
113
|
+
/* v8 ignore next -- git normally writes to stderr on a rev-parse failure; the empty-stderr `|| exit N` fallback covers a stderr-less failure (e.g. signal kill) — real, just not reproducible in the suite */
|
|
114
|
+
note: `could not read HEAD: ${headRes.stderr || `git rev-parse exited ${headRes.code}`}`
|
|
115
|
+
});
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
const head = headRes.stdout;
|
|
119
|
+
if (head === sha) {
|
|
120
|
+
results.push({ ...record, outcome: "in-sync" });
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// A non-zero `git status` is a command failure (corrupt repo, permissions),
|
|
125
|
+
// not "uncommitted changes" — report it as sync-failed so the exit code is
|
|
126
|
+
// non-zero and stderr surfaces, instead of mislabeling it dirty.
|
|
127
|
+
const status = git(["-C", absChild, "status", "--porcelain"]);
|
|
128
|
+
if (status.code !== 0) {
|
|
129
|
+
/* v8 ignore next -- git normally writes to stderr on a status failure; the empty-stderr `|| exit N` fallback covers a stderr-less failure (e.g. signal kill) — real, just not reproducible in the suite */
|
|
130
|
+
results.push({ ...record, outcome: "sync-failed", note: `git status failed: ${status.stderr || `exit ${status.code}`}` });
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (status.stdout) {
|
|
134
|
+
results.push({ ...record, outcome: "dirty", note: "pin moved but child has uncommitted changes — left alone" });
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Pin availability: one fetch before giving up. A dry run must not write
|
|
139
|
+
// even to the object store, so it reports optimistically (like restore's
|
|
140
|
+
// dry run) with a note instead of fetching.
|
|
141
|
+
let pinPresent = git(["-C", absChild, "cat-file", "-e", `${sha}^{commit}`]).code === 0;
|
|
142
|
+
if (!pinPresent && !dryRun) {
|
|
143
|
+
const fetch = git(["-C", absChild, "fetch", "--quiet", "origin"]);
|
|
144
|
+
if (fetch.code !== 0) {
|
|
145
|
+
// A failed fetch (auth/network) is a real error, not "pin genuinely
|
|
146
|
+
// absent" — report sync-failed with stderr so it's actionable.
|
|
147
|
+
/* v8 ignore next -- git normally writes to stderr on a fetch failure; the empty-stderr `|| exit N` fallback covers a stderr-less failure (e.g. signal kill) — real, just not reproducible in the suite */
|
|
148
|
+
results.push({ ...record, outcome: "sync-failed", note: `git fetch origin failed: ${fetch.stderr || `exit ${fetch.code}`}` });
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
pinPresent = git(["-C", absChild, "cat-file", "-e", `${sha}^{commit}`]).code === 0;
|
|
152
|
+
if (!pinPresent) {
|
|
153
|
+
results.push({ ...record, outcome: "pin-unavailable", note: `pinned ${sha.slice(0, 12)} not found at origin after fetch` });
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (!pinPresent && dryRun) record.note = "pin not in the local object store — a real run would fetch origin first";
|
|
158
|
+
|
|
159
|
+
const branchRes = git(["-C", absChild, "branch", "--show-current"]);
|
|
160
|
+
/* v8 ignore start -- `git branch --show-current` normally succeeds here — HEAD
|
|
161
|
+
(rev-parse), the worktree (status), and the pin (cat-file) already succeeded, and
|
|
162
|
+
it neither locks the index nor inflates objects (an index.lock leaves it exit 0). A
|
|
163
|
+
hard failure (I/O error, signal kill) is real but not reproducible in the suite. */
|
|
164
|
+
if (branchRes.code !== 0) {
|
|
165
|
+
results.push({
|
|
166
|
+
...record,
|
|
167
|
+
outcome: "sync-failed",
|
|
168
|
+
note: `could not read current branch: ${branchRes.stderr || `git branch --show-current exited ${branchRes.code}`}`
|
|
169
|
+
});
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
/* v8 ignore stop */
|
|
173
|
+
const branch = branchRes.stdout || null;
|
|
174
|
+
const registered = self.embedded.registry.getBranch(childPath, root);
|
|
175
|
+
|
|
176
|
+
if (branch && (!registered || branch !== registered)) {
|
|
177
|
+
results.push({
|
|
178
|
+
...record,
|
|
179
|
+
branch,
|
|
180
|
+
outcome: "unregistered-branch",
|
|
181
|
+
note: `pin moved but child is on unregistered branch '${branch}' — left alone`
|
|
182
|
+
});
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (branch) {
|
|
187
|
+
// The child LIVES on this branch (registry says so) — move the branch to
|
|
188
|
+
// the pin, fast-forward only: HEAD must be an ancestor of the pin.
|
|
189
|
+
// Commits beyond the pin are your work and stay untouched.
|
|
190
|
+
// merge-base --is-ancestor exit codes: 0 = HEAD IS an ancestor of the pin
|
|
191
|
+
// (fast-forward), 1 = NOT an ancestor (real divergence — your work), and
|
|
192
|
+
// anything else (128) is a genuine git error (corrupt repo, missing
|
|
193
|
+
// objects). Only exit 1 means "ahead"; a 128 must surface as sync-failed,
|
|
194
|
+
// not be mislabeled as your work and silently left alone. With the pin
|
|
195
|
+
// object absent (dry run) ancestry is unknowable — stay optimistic like
|
|
196
|
+
// the rest of the dry-run path.
|
|
197
|
+
let ancestor;
|
|
198
|
+
if (pinPresent) {
|
|
199
|
+
const anc = git(["-C", absChild, "merge-base", "--is-ancestor", "HEAD", sha]);
|
|
200
|
+
if (anc.code !== 0 && anc.code !== 1) {
|
|
201
|
+
results.push({
|
|
202
|
+
...record,
|
|
203
|
+
branch,
|
|
204
|
+
outcome: "sync-failed",
|
|
205
|
+
/* v8 ignore next -- git normally writes to stderr on a merge-base error; the empty-stderr `|| exit N` fallback covers a stderr-less failure (e.g. signal kill) — real, just not reproducible in the suite */
|
|
206
|
+
note: `could not test ancestry: ${anc.stderr || `merge-base --is-ancestor exited ${anc.code}`}`
|
|
207
|
+
});
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
ancestor = anc.code === 0;
|
|
211
|
+
} else {
|
|
212
|
+
ancestor = true;
|
|
213
|
+
}
|
|
214
|
+
if (!ancestor) {
|
|
215
|
+
results.push({
|
|
216
|
+
...record,
|
|
217
|
+
branch,
|
|
218
|
+
outcome: "ahead",
|
|
219
|
+
note: `on '${branch}' with commits beyond the pin — left alone (your work)`
|
|
220
|
+
});
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
if (dryRun) {
|
|
224
|
+
results.push({ ...record, branch, outcome: "synced", dryRun: true });
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
if (!self.embedded.branch.attach(absChild, branch, sha)) {
|
|
228
|
+
results.push({ ...record, branch, outcome: "sync-failed", note: `could not move branch ${branch} to ${sha.slice(0, 12)}` });
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
results.push({ ...record, branch, outcome: "synced" });
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Detached and clean: snap to the pin, staying detached.
|
|
236
|
+
if (dryRun) {
|
|
237
|
+
results.push({ ...record, outcome: "synced", dryRun: true });
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
const checkout = git(["-C", absChild, "checkout", "--quiet", "--detach", sha]);
|
|
241
|
+
if (checkout.code !== 0) {
|
|
242
|
+
results.push({
|
|
243
|
+
...record,
|
|
244
|
+
outcome: "sync-failed",
|
|
245
|
+
/* v8 ignore next -- git normally writes to stderr on a checkout failure; the empty-stderr `|| exit N` fallback covers a stderr-less failure (e.g. signal kill) — real, just not reproducible in the suite */
|
|
246
|
+
note: `could not check out ${sha.slice(0, 12)}: ${checkout.stderr || `git checkout exited ${checkout.code}`}`
|
|
247
|
+
});
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
results.push({ ...record, outcome: "synced" });
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const exitCode = results.some((r) => r.outcome === "pin-unavailable" || r.outcome === "sync-failed") ? 1 : 0;
|
|
254
|
+
return { results, exitCode };
|
|
255
|
+
}
|
package/src/api/git.mjs
CHANGED
|
@@ -34,6 +34,9 @@ export function getEffectiveHooksPath(cwd = process.cwd()) {
|
|
|
34
34
|
const { path, os } = context;
|
|
35
35
|
const res = context.spawnSync("git", ["config", "--get", "core.hooksPath"], { cwd, encoding: "utf8" });
|
|
36
36
|
if (res.status !== 0) return null;
|
|
37
|
+
/* v8 ignore next -- defensive: a successful `git config --get` (status 0) always
|
|
38
|
+
writes the value plus a trailing newline, so res.stdout is never falsy here;
|
|
39
|
+
real git can't produce this fallback (verified empirically). */
|
|
37
40
|
const raw = (res.stdout || "").trim();
|
|
38
41
|
if (!raw) return null;
|
|
39
42
|
const expanded = raw.startsWith("~") ? path.join(os.homedir(), raw.slice(1)) : raw;
|
|
@@ -4,13 +4,14 @@ export const PACKAGE_HOOK_MAP = {
|
|
|
4
4
|
"post-checkout": "update-embedded-repos",
|
|
5
5
|
"post-merge": "update-embedded-repos",
|
|
6
6
|
"post-rewrite": "update-embedded-repos",
|
|
7
|
-
"reference-transaction": "reference-transaction"
|
|
7
|
+
"reference-transaction": "reference-transaction",
|
|
8
|
+
"pre-push": "pre-push"
|
|
8
9
|
};
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Install or uninstall the package's per-repo hook scripts.
|
|
12
13
|
*
|
|
13
|
-
* `op === "install"` copies the
|
|
14
|
+
* `op === "install"` copies the package hooks; `op === "uninstall"` removes only
|
|
14
15
|
* the ones recognizably owned by git-embedded (any file whose content includes
|
|
15
16
|
* the string "git-embedded").
|
|
16
17
|
*
|
package/src/api/report.mjs
CHANGED
|
@@ -63,6 +63,9 @@ export function detectionHeader(result) {
|
|
|
63
63
|
export function message(kind) {
|
|
64
64
|
const body = self.messages.load(kind);
|
|
65
65
|
const rendered = context.renderMarkdown(body);
|
|
66
|
+
/* v8 ignore next -- every messages/*.md file (plus empty/whitespace-only input) renders
|
|
67
|
+
with a trailing newline (verified), so the append branch isn't reached by the current
|
|
68
|
+
message set; marked doesn't guarantee a trailing newline universally, so the guard stays. */
|
|
66
69
|
process.stdout.write(rendered.endsWith("\n") ? rendered : rendered + "\n");
|
|
67
70
|
}
|
|
68
71
|
|
|
File without changes
|