@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.
@@ -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 four hooks; `op === "uninstall"` removes only
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
  *
@@ -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