@cldmv/git-embedded 1.0.0 → 1.1.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.
@@ -1,54 +1,155 @@
1
1
  #!/usr/bin/env bash
2
2
  # reference-transaction
3
3
  #
4
- # Refuses HEAD-moving git operations if any embedded git repo in the parent's
5
- # tree has uncommitted changes. Without this, a `git checkout B` in the parent
6
- # would silently leave the child stale (with the post-checkout updater unable
7
- # to update over dirty state), producing a confusing inconsistent state.
4
+ # Guards HEAD-moving git operations against harming embedded child repos.
5
+ # The companion update-embedded-repos hook (post-checkout/-merge/-rewrite)
6
+ # force-syncs every child to the pin recorded in the parent's new HEAD; this
7
+ # hook refuses, up-front, the moves where that sync (or the move itself)
8
+ # would confuse or destroy in-flight child work.
8
9
  #
9
- # Requires git 2.28+ (released July 2020, when reference-transaction was
10
- # introduced).
10
+ # IMPORTANT PLUMBING FACT: a plain `git commit` moves the ref HEAD points to,
11
+ # reported in the reference transaction as HEAD (git <=2.43) or as the branch
12
+ # ref refs/heads/<branch> (git 2.54+) — the guard watches BOTH. Either way,
13
+ # "HEAD moved" alone cannot distinguish a commit from a checkout, so the mode
14
+ # logic below reasons about what the move would actually DO to each child, and
15
+ # classifies append-vs-jump by commit parentage where it matters.
11
16
  #
12
- # Phases (passed as $1 by git):
13
- # - prepared: updates queued, not yet applied. Exiting non-zero ABORTS the
14
- # transaction. This is the only phase we act on.
15
- # - committed: updates already applied (informational; we ignore).
16
- # - aborted: updates rejected by some other handler (informational).
17
+ # Mode (git config embedded.guard local overrides global; default precise):
18
+ # precise Block only when a DIRTY child's HEAD differs from the pin in the
19
+ # NEW commit i.e. exactly when update-embedded-repos would try to
20
+ # move a child that has uncommitted changes. Clean children, and
21
+ # dirty children whose pin already equals their HEAD (the sync
22
+ # no-ops), never block.
23
+ # strict The everything-synced policy. Any dirty child blocks any HEAD
24
+ # move. Additionally, on APPENDS (commit/merge/cherry-pick — the
25
+ # new commit lists the old HEAD among its parents) every child's
26
+ # pin in the new commit must equal that child's current HEAD, so a
27
+ # parent commit can never ship stale pins. Jumps (checkout/reset)
28
+ # only require all-clean: their pins are EXPECTED to differ, and
29
+ # the post-hook sync moves the (clean) children afterwards.
30
+ # off No guarding.
17
31
  #
18
- # The hook reads proposed ref updates from stdin, one per line:
19
- # <old_sha> <new_sha> <ref-name>
32
+ # One-shot override: git -c embedded.guard=<mode> <command>
20
33
  #
21
- # We filter to HEAD updates with old != new (an actual HEAD move). Other ref
22
- # updates (branch fast-forwards from fetch, stash refs, tag creation, etc.)
23
- # don't touch the working tree and shouldn't be guarded.
24
-
25
- # Only the 'prepared' phase can reject the transaction.
34
+ # Requires git 2.28+ (reference-transaction hook).
35
+ #
36
+ # Phases (passed as $1 by git): only 'prepared' can reject the transaction.
26
37
  [ "$1" = "prepared" ] || exit 0
27
38
 
28
- # Detect whether any update in this transaction moves HEAD.
29
- moving_head=0
39
+ guard_mode=$(git config --get embedded.guard 2>/dev/null)
40
+ case "$guard_mode" in
41
+ off) exit 0 ;;
42
+ strict | precise) ;;
43
+ *) guard_mode="precise" ;;
44
+ esac
45
+
46
+ # Anchor every git command we run INSIDE a child to the child's own repo:
47
+ # GIT_CEILING_DIRECTORIES stops repo discovery from walking UP into this parent,
48
+ # so a child with a broken/corrupt .git fails cleanly here (empty HEAD, no
49
+ # symref) instead of silently resolving the PARENT repo's HEAD.
50
+ parent_root=$(pwd)
51
+ child_git() (
52
+ cd "$1" 2>/dev/null || return 1
53
+ shift
54
+ GIT_CEILING_DIRECTORIES="$parent_root" git "$@"
55
+ )
56
+
57
+ # Detect a HEAD move and capture its endpoints. Stdin lines:
58
+ # <old_sha> <new_sha> <ref-name>
59
+ # Other ref updates (fetch fast-forwards, stash refs, tags) don't touch the
60
+ # working tree and aren't guarded.
61
+ zeros="0000000000000000000000000000000000000000"
62
+ # The ref a working-tree HEAD move touches is either literal HEAD (a detached
63
+ # checkout, or a commit on a detached HEAD) or — when HEAD is on a branch — the
64
+ # BRANCH ref HEAD points to. git <=2.43 emitted a redundant HEAD line for a
65
+ # commit, so matching "HEAD" alone sufficed; git 2.54 emits ONLY the branch ref
66
+ # (refs/heads/<branch>) for a commit, so without also matching the current
67
+ # branch every commit slips past the guard. Resolve HEAD's branch and watch both.
68
+ head_ref=$(git symbolic-ref --quiet HEAD 2>/dev/null)
69
+ moving=0
70
+ old_head=""
71
+ new_head=""
30
72
  while read old_sha new_sha ref; do
31
- [ "$ref" = "HEAD" ] || continue
73
+ [ "$ref" = "HEAD" ] || { [ -n "$head_ref" ] && [ "$ref" = "$head_ref" ]; } || continue
32
74
  [ "$old_sha" = "$new_sha" ] && continue
33
- moving_head=1
75
+ moving=1
76
+ old_head=$old_sha
77
+ new_head=$new_sha
34
78
  done
79
+ [ "$moving" = "1" ] || exit 0
80
+ [ -n "$new_head" ] && [ "$new_head" != "$zeros" ] || exit 0
35
81
 
36
- # Nothing to check if HEAD is not moving.
37
- [ "$moving_head" = "1" ] || exit 0
82
+ # Append vs jump (strict mode only cares): an append's new commit lists the
83
+ # CURRENT (pre-move) HEAD among its parents — commit, merge, cherry-pick step.
84
+ # The pre-move HEAD is resolved directly rather than trusting the transaction
85
+ # line's old value: a checkout-to-SHA (detach) reports its HEAD line with the
86
+ # null SHA on the old side, which must NOT read as "initial commit". An unborn
87
+ # HEAD (the real initial commit) counts as an append. Known edge: switching to
88
+ # a branch whose tip is a direct child of the current HEAD is indistinguishable
89
+ # from a commit by parentage and is treated as an append; in strict mode use
90
+ # `git -c embedded.guard=precise checkout …` if that blocks a legitimate move.
91
+ is_append=0
92
+ current_head=$(git rev-parse -q --verify "HEAD^{commit}" 2>/dev/null)
93
+ if [ -z "$current_head" ]; then
94
+ is_append=1
95
+ else
96
+ for parent in $(git rev-list --parents -n 1 "$new_head" 2>/dev/null | cut -d' ' -f2-); do
97
+ [ "$parent" = "$current_head" ] && is_append=1
98
+ done
99
+ fi
38
100
 
39
- # Walk every gitlink in the current HEAD and check for dirty state.
40
- # A gitlink is a tree entry with type 'commit' (mode 160000). The path is
41
- # the directory in the parent's working tree that holds the embedded repo.
42
- while read mode type sha path; do
43
- [ "$type" = "commit" ] || continue
101
+ # Walk every gitlink in the NEW commit those pins are what the post-hook
102
+ # sync will enforce after the move. Tree entries from `git ls-tree -r`:
103
+ # <mode> SP <type> SP <sha> TAB <path>
104
+ block=0
105
+ # Split ls-tree output on the TAB so a child path containing spaces stays intact
106
+ # (the meta side, `<mode> <type> <sha>`, is space-separated and re-split below).
107
+ while IFS=$'\t' read -r meta path; do
108
+ set -- $meta
109
+ entry_type=$2
110
+ pin=$3
111
+ [ "$entry_type" = "commit" ] || continue
44
112
  [ -d "$path/.git" ] || [ -f "$path/.git" ] || continue
45
113
 
46
- # Refuse if there are uncommitted changes inside the embedded repo.
47
- if ! (cd "$path" && git diff-index --quiet HEAD --) 2>/dev/null; then
48
- echo "git-embedded: $path has uncommitted changes" >&2
49
- echo " commit or stash inside $path/ before moving HEAD here" >&2
50
- exit 1
114
+ # --verify: fail cleanly with EMPTY stdout when HEAD can't resolve (plain
115
+ # `rev-parse HEAD` echoes the literal "HEAD" on an unborn branch, which would
116
+ # slip past the emptiness check below and be misread as a dirty child).
117
+ child_head=$(child_git "$path" rev-parse --verify HEAD 2>/dev/null)
118
+ if [ -z "$child_head" ]; then
119
+ # HEAD is unreadable. Distinguish a legitimately UNBORN child (fresh
120
+ # `git init`, no commits yet — HEAD is still a valid symref to a branch)
121
+ # from a genuinely broken/corrupt repo (neither a commit nor a symref
122
+ # resolves). An unborn child has nothing to guard, so skip it. In strict
123
+ # mode, fail closed on a broken one rather than silently waving it through;
124
+ # precise stays lenient and skips either way.
125
+ if [ "$guard_mode" = "strict" ] && ! child_git "$path" symbolic-ref --quiet HEAD >/dev/null 2>&1; then
126
+ echo "git-embedded: ✗ $path — cannot read HEAD (missing or corrupt repo?) (embedded.guard=strict)" >&2
127
+ echo " fix or re-restore the child at '$path'/, or bypass once with -c embedded.guard=off" >&2
128
+ block=1
129
+ fi
130
+ continue
131
+ fi
132
+ dirty=0
133
+ child_git "$path" diff-index --quiet HEAD -- 2>/dev/null || dirty=1
134
+
135
+ if [ "$guard_mode" = "strict" ]; then
136
+ if [ "$dirty" = "1" ]; then
137
+ echo "git-embedded: ✗ $path has uncommitted changes (embedded.guard=strict)" >&2
138
+ echo " commit or stash inside '$path'/ before moving HEAD here" >&2
139
+ block=1
140
+ elif [ "$is_append" = "1" ] && [ "$child_head" != "$pin" ]; then
141
+ echo "git-embedded: ✗ $path is not synced: pin ${pin:0:12} != child HEAD ${child_head:0:12} (embedded.guard=strict)" >&2
142
+ echo " record the child's current commit (git add -- '$path') or move the child to the pin before committing the parent" >&2
143
+ block=1
144
+ fi
145
+ else # precise
146
+ if [ "$dirty" = "1" ] && [ "$child_head" != "$pin" ]; then
147
+ echo "git-embedded: ✗ $path has uncommitted changes and this move would re-pin it (${child_head:0:12} → ${pin:0:12})" >&2
148
+ echo " commit or stash inside '$path'/ before moving HEAD here" >&2
149
+ block=1
150
+ fi
51
151
  fi
52
- done < <(git ls-tree -r HEAD 2>/dev/null)
152
+ done < <(git ls-tree -r "$new_head" 2>/dev/null)
53
153
 
154
+ [ "$block" = "1" ] && exit 1
54
155
  exit 0
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cldmv/git-embedded",
3
- "version": "1.0.0",
3
+ "version": "1.1.1",
4
4
  "description": "Manage embedded git repositories (anonymous gitlinks) without .gitmodules. Provides hooks that restore standard git-command ergonomics for embedded children while keeping the child's origin URL out of the public parent repo.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -67,9 +67,11 @@
67
67
  },
68
68
  "scripts": {
69
69
  "start": "node bin/git-embedded.mjs",
70
- "test": "vitest run --config .configs/vitest.config.mjs",
70
+ "build:ci": "echo '✓ no build step'",
71
+ "test": "node tests/run-vitest.mjs",
71
72
  "test:watch": "vitest --config .configs/vitest.config.mjs",
72
- "coverage": "vitest run --coverage --config .configs/vitest.config.mjs",
73
+ "coverage": "node tests/run-vitest.mjs --coverage-quiet",
74
+ "ci:coverage": "npm run coverage",
73
75
  "lint": "eslint --config .configs/eslint.config.mjs .",
74
76
  "lint:fix": "eslint --config .configs/eslint.config.mjs . --fix",
75
77
  "format": "prettier --config .configs/.prettierrc --write .",
@@ -84,13 +86,14 @@
84
86
  "marked-terminal": "^7.3.0"
85
87
  },
86
88
  "devDependencies": {
89
+ "@cldmv/vitest-runner": "^1.2.0",
87
90
  "@eslint/js": "^9.18.0",
88
91
  "@eslint/json": "^0.10.0",
89
92
  "@eslint/markdown": "^6.2.2",
90
- "@vitest/coverage-v8": "^2.1.9",
93
+ "@vitest/coverage-v8": "^4.1.10",
91
94
  "eslint": "^9.18.0",
92
95
  "globals": "^15.14.0",
93
96
  "prettier": "^3.4.2",
94
- "vitest": "^2.1.9"
97
+ "vitest": "^4.1.10"
95
98
  }
96
99
  }
@@ -0,0 +1,71 @@
1
+ import { self, context } from "@cldmv/slothlet/runtime";
2
+
3
+ export const spec = {
4
+ command: "export",
5
+ description:
6
+ "Serialize the local-config registry to a manifest JSON (stdout by default). The manifest is a TRANSFER FILE — carry it out-of-band and NEVER commit it; committing child URLs defeats anonymous gitlinks.",
7
+ options: [
8
+ ["-o <file>", "Write the manifest to <file> instead of stdout"],
9
+ ["--scan", "Record every present child (like 'record') before exporting"]
10
+ ],
11
+ examples: ["$ git-embedded export", "$ git-embedded export -o children.json", "$ git-embedded export --scan -o children.json"]
12
+ };
13
+
14
+ /**
15
+ * Append `relPath` to the repo's `.git/info/exclude` if not already listed, so a
16
+ * manifest written inside the worktree is not accidentally staged.
17
+ * @param {string} gitDir absolute git dir
18
+ * @param {string} relPath worktree-relative path to exclude
19
+ * @returns {boolean} true when a new line was added
20
+ */
21
+ function addToExclude(gitDir, relPath) {
22
+ const { fs, path } = context;
23
+ const exclude = path.join(gitDir, "info", "exclude");
24
+ let body = "";
25
+ try {
26
+ body = fs.readFileSync(exclude, "utf8");
27
+ } catch {
28
+ body = "";
29
+ }
30
+ const lines = body.split(/\r?\n/).map((l) => l.trim());
31
+ if (lines.includes(relPath) || lines.includes(`/${relPath}`)) return false;
32
+ fs.mkdirSync(path.dirname(exclude), { recursive: true });
33
+ const prefix = body.length === 0 || body.endsWith("\n") ? "" : "\n";
34
+ fs.appendFileSync(exclude, `${prefix}${relPath}\n`);
35
+ return true;
36
+ }
37
+
38
+ export function run(opts = {}) {
39
+ const { fs, path } = context;
40
+ const cwd = process.cwd();
41
+ const root = self.git.getRepoRoot(cwd) || cwd;
42
+
43
+ if (opts.scan) self.embedded.record({ cwd });
44
+
45
+ const entries = self.embedded.registry.entries(root);
46
+ const manifest = self.embedded.manifest.build(entries);
47
+ const text = self.embedded.manifest.serialize(manifest);
48
+
49
+ const outFile = opts.o;
50
+ if (!outFile) {
51
+ process.stdout.write(text);
52
+ return;
53
+ }
54
+
55
+ const abs = path.isAbsolute(outFile) ? outFile : path.resolve(cwd, outFile);
56
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
57
+ fs.writeFileSync(abs, text);
58
+ self.report.success(`Wrote manifest to ${abs} (${Object.keys(manifest.children).length} children).`);
59
+ self.report.warn("This manifest contains child URLs — do NOT commit it. Carry it out-of-band.");
60
+
61
+ const rel = path.relative(root, abs);
62
+ const insideWorktree = rel && !rel.startsWith("..") && !path.isAbsolute(rel);
63
+ if (insideWorktree) {
64
+ const gitDir = self.git.getGitDir(cwd);
65
+ if (gitDir && addToExclude(gitDir, rel.split(path.sep).join("/"))) {
66
+ self.report.plain(` (added ${rel} to .git/info/exclude as a courtesy)`);
67
+ }
68
+ }
69
+ }
70
+
71
+ export default { spec, run };
@@ -19,6 +19,7 @@ export async function run(opts = {}) {
19
19
  if (cfg.status === 0) {
20
20
  self.report.success("Silenced 'embedded git repository' advice (git config advice.addEmbeddedRepo=false).");
21
21
  } else {
22
+ /* v8 ignore next -- git normally writes config failures to stderr; the `|| stdout` fallback covers an empty-stderr failure (e.g. signal kill) — real, just not reproducible in the suite */
22
23
  self.report.warn(`Could not set git config advice.addEmbeddedRepo: ${cfg.stderr || cfg.stdout}`);
23
24
  }
24
25
  }
@@ -105,6 +105,7 @@ async function bootstrapAndInstall(result, opts) {
105
105
  if (out.fallbackToCopy.length > 0) self.report.warn(`Filesystem fallback to copy for ${out.fallbackToCopy.length} entries`);
106
106
  const gitConfig = context.spawnSync("git", ["config", "--global", "core.hooksPath", dir], { encoding: "utf8" });
107
107
  if (gitConfig.status !== 0) {
108
+ /* v8 ignore next -- git normally writes config failures to stderr; the `|| stdout` fallback covers an empty-stderr failure (e.g. signal kill) — real, just not reproducible in the suite */
108
109
  self.report.error(`git config --global core.hooksPath failed: ${gitConfig.stderr || gitConfig.stdout}`);
109
110
  process.exit(1);
110
111
  }
@@ -5,7 +5,7 @@ export const spec = {
5
5
  description:
6
6
  "Clone a remote repo into <local-path> and stage it as an anonymous gitlink. Does NOT commit (you may want to stage other things in the same commit).",
7
7
  args: [
8
- ["<local-path>", "Where to clone the child repo (created if missing)"],
8
+ ["<local-path>", "Where to clone the child repo (created if missing; an empty gitlink dir is accepted)"],
9
9
  ["<remote-url>", "The child repo's clone URL (will NOT be recorded in .gitmodules)"]
10
10
  ],
11
11
  examples: [
@@ -14,28 +14,75 @@ export const spec = {
14
14
  ]
15
15
  };
16
16
 
17
+ /**
18
+ * Whether the target path blocks a fresh clone. A missing path is fine, and an
19
+ * empty REAL directory is fine (a fresh clone of the parent materializes each
20
+ * gitlink as an empty dir). Everything else is refused: a directory with
21
+ * contents, an existing repo, a file, an unreadable directory, or a SYMLINK —
22
+ * even one pointing at an empty dir, since cloning through it would write
23
+ * outside the repo. lstat so links are seen (and broken links caught), never
24
+ * followed.
25
+ * @param {string} target
26
+ * @returns {boolean}
27
+ */
28
+ function blocksClone(target) {
29
+ const { fs } = context;
30
+ let st;
31
+ try {
32
+ st = fs.lstatSync(target);
33
+ } catch (err) {
34
+ // Only a missing path (ENOENT) is safe — git clone creates it.
35
+ return err.code !== "ENOENT";
36
+ }
37
+ if (st.isSymbolicLink() || !st.isDirectory()) return true;
38
+ try {
39
+ return fs.readdirSync(target).length > 0;
40
+ } catch {
41
+ return true; // unreadable directory
42
+ }
43
+ }
44
+
17
45
  export function run(localPath, remoteUrl) {
18
- const { fs, spawnSync } = context;
46
+ const { spawnSync, path } = context;
19
47
 
20
- if (fs.existsSync(localPath)) {
21
- self.report.error(`${localPath} already exists. Remove it or pick a different path before linking.`);
48
+ // Normalize to the repo-root-relative, slash-normalized gitlink path — the
49
+ // key restore/gitlinks/export all use. "./tests" or "tests/" must record as
50
+ // "tests", and a target outside the worktree is refused outright.
51
+ const root = self.git.getRepoRoot() || process.cwd();
52
+ const rel = path.relative(root, path.resolve(process.cwd(), localPath)).split(path.sep).join("/");
53
+ if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) {
54
+ self.report.error(`${localPath} is outside the repository worktree — link inside the parent repo.`);
22
55
  process.exit(2);
23
56
  }
24
57
 
25
- self.report.plain(`Cloning ${remoteUrl} into ${localPath}…`);
26
- const clone = spawnSync("git", ["clone", remoteUrl, localPath], { stdio: "inherit" });
58
+ if (blocksClone(localPath)) {
59
+ self.report.error(`${localPath} exists and is not an empty directory. Remove it or pick a different path before linking.`);
60
+ process.exit(2);
61
+ }
62
+
63
+ self.report.plain(`Cloning ${remoteUrl} into ${rel}…`);
64
+ // `--` ends option parsing: a URL or path starting with "-" must never be
65
+ // interpreted as a git option (e.g. --upload-pack).
66
+ const clone = spawnSync("git", ["clone", "--", remoteUrl, localPath], { stdio: "inherit" });
27
67
  if (clone.status !== 0) {
28
68
  self.report.error(`git clone exited with status ${clone.status}`);
69
+ /* v8 ignore next -- spawnSync returns a null status on spawn failure (git not on PATH) or signal termination — the real case `|| 1` guards — but the suite always has git present, so it can't be reproduced here (ignored, not tested) */
29
70
  process.exit(clone.status || 1);
30
71
  }
31
72
 
32
- const add = spawnSync("git", ["add", localPath], { stdio: "inherit" });
73
+ const add = spawnSync("git", ["add", "--", localPath], { stdio: "inherit" });
33
74
  if (add.status !== 0) {
34
75
  self.report.error(`git add ${localPath} exited with status ${add.status}`);
76
+ /* v8 ignore next -- spawnSync returns a null status on spawn failure (git not on PATH) or signal termination — the real case `|| 1` guards — but the suite always has git present, so it can't be reproduced here (ignored, not tested) */
35
77
  process.exit(add.status || 1);
36
78
  }
37
79
 
38
- self.report.success(`Staged gitlink at ${localPath} (no .gitmodules entry written).`);
80
+ // Record the URL + branch into the parent's LOCAL config registry (never
81
+ // committed) so a later restore/export already knows this child — keyed by
82
+ // the NORMALIZED gitlink path so day-2 restore/export find it.
83
+ self.embedded.registry.recordOne(rel, root);
84
+
85
+ self.report.success(`Staged gitlink at ${rel} (no .gitmodules entry written).`);
39
86
  self.report.plain("Commit when ready: git commit -m 'embed <name>'");
40
87
  }
41
88
 
@@ -6,6 +6,7 @@ const NAME_TO_SOURCE = {
6
6
  "post-rewrite": "update-embedded-repos",
7
7
  "reference-transaction": "reference-transaction",
8
8
  "update-embedded-repos": "update-embedded-repos",
9
+ "pre-push": "pre-push",
9
10
  _dispatch: "_dispatch.template",
10
11
  dispatcher: "_dispatch.template"
11
12
  };
@@ -0,0 +1,36 @@
1
+ import { self } from "@cldmv/slothlet/runtime";
2
+
3
+ export const spec = {
4
+ command: "record",
5
+ description:
6
+ "Record the origin URL (and current branch) of each embedded child present on disk into the parent's LOCAL config registry, so a later export or re-restore does not have to re-derive it. The registry is never committed.",
7
+ args: [["[paths...]", "Restrict to these gitlink paths (default: every child present on disk)"]],
8
+ examples: ["$ git-embedded record", "$ git-embedded record tests vendor/foo"]
9
+ };
10
+
11
+ const LABEL = {
12
+ recorded: (r) => `${r.path} → ${r.url}${r.branch ? ` (${r.branch})` : ""}`,
13
+ "no-repo": (r) => `${r.path} not present on disk`,
14
+ "no-origin": (r) => `${r.path} has no remote.origin.url`
15
+ };
16
+
17
+ export function run(paths = []) {
18
+ const { results } = self.embedded.record({ cwd: process.cwd(), paths });
19
+
20
+ if (!results.length) {
21
+ self.report.plain("No embedded children present on disk to record.");
22
+ return;
23
+ }
24
+
25
+ for (const r of results) {
26
+ const line = LABEL[r.outcome] ? LABEL[r.outcome](r) : `${r.path}: ${r.outcome}`;
27
+ if (r.outcome === "recorded") self.report.success(line);
28
+ else self.report.warn(line);
29
+ }
30
+
31
+ const recorded = results.filter((r) => r.outcome === "recorded").length;
32
+ self.report.plain("");
33
+ self.report.success(`Recorded ${recorded} of ${results.length} into the local registry (not committed).`);
34
+ }
35
+
36
+ export default { spec, run };
@@ -0,0 +1,74 @@
1
+ import { self } from "@cldmv/slothlet/runtime";
2
+
3
+ export const spec = {
4
+ command: "restore",
5
+ description:
6
+ "Clone missing embedded child repos and check out their pinned SHAs. Each child's URL is resolved strictest-first — local config, a manifest (--from), --base, then the parent's origin convention — and every clone is SHA-verified so a wrong guess fails closed. A branch from the registry/manifest (or inferred when exactly one origin branch contains the pin) puts the child ON that branch at the pin; otherwise the checkout is detached.",
7
+ args: [["[paths...]", "Restrict to these gitlink paths (default: every embedded gitlink)"]],
8
+ options: [
9
+ ["--from <manifest>", "Read child URLs from a manifest JSON file (a transfer file; never committed)"],
10
+ ["--base <url-base>", "Derive each child URL as <url-base>/<basename>.git"],
11
+ ["--skip <paths>", "Comma-separated gitlink paths to skip (for a partial restore without access to a private child)"],
12
+ ["--dry-run", "Report what would happen without cloning or writing config"]
13
+ ],
14
+ examples: [
15
+ "$ git-embedded restore",
16
+ "$ git-embedded restore tests",
17
+ "$ git-embedded restore --from children.json",
18
+ "$ git-embedded restore --base git@example.com:org",
19
+ "$ git-embedded restore --skip tests --dry-run"
20
+ ]
21
+ };
22
+
23
+ const LABEL = {
24
+ restored: (r) => `${r.dryRun ? "would restore" : "restored"} ${r.path} from ${r.source} (${r.url})${r.branch ? ` on branch ${r.branch}` : ""}`,
25
+ "already-present": (r) => `${r.path} already present`,
26
+ skipped: (r) => `${r.path} skipped`,
27
+ unresolved: (r) => `${r.path} unresolved${r.note ? ` — ${r.note}` : ""}`,
28
+ "pinned-mismatch": (r) => `${r.path} pinned-mismatch${r.note ? ` — ${r.note}` : ""}`
29
+ };
30
+
31
+ export function run(paths = [], opts = {}) {
32
+ const skip =
33
+ typeof opts.skip === "string"
34
+ ? opts.skip
35
+ .split(",")
36
+ .map((s) => s.trim())
37
+ .filter(Boolean)
38
+ : [];
39
+
40
+ const { results, exitCode } = self.embedded.restore({
41
+ cwd: process.cwd(),
42
+ paths,
43
+ from: opts.from || null,
44
+ base: opts.base || null,
45
+ skip,
46
+ dryRun: Boolean(opts.dryRun)
47
+ });
48
+
49
+ if (!results.length) {
50
+ self.report.plain("No embedded gitlinks in HEAD.");
51
+ process.exit(0);
52
+ }
53
+
54
+ for (const r of results) {
55
+ const line = LABEL[r.outcome] ? LABEL[r.outcome](r) : `${r.path}: ${r.outcome}`;
56
+ if (r.outcome === "restored") self.report.success(line);
57
+ else if (r.outcome === "unresolved" || r.outcome === "pinned-mismatch") self.report.error(line);
58
+ else self.report.warn(line);
59
+ }
60
+
61
+ // Count each outcome into exactly one bucket — "unchanged" is only
62
+ // already-present, never a failure or a skip counted twice.
63
+ const restored = results.filter((r) => r.outcome === "restored").length;
64
+ const unchanged = results.filter((r) => r.outcome === "already-present").length;
65
+ const skipped = results.filter((r) => r.outcome === "skipped").length;
66
+ const failed = results.filter((r) => r.outcome === "unresolved" || r.outcome === "pinned-mismatch").length;
67
+ self.report.plain("");
68
+ self.report.plain(
69
+ `${restored} ${opts.dryRun ? "resolvable" : "restored"}, ${unchanged} unchanged${skipped ? `, ${skipped} skipped` : ""}, ${failed} failed.`
70
+ );
71
+ process.exit(exitCode);
72
+ }
73
+
74
+ export default { spec, run };
@@ -0,0 +1,70 @@
1
+ import { self } from "@cldmv/slothlet/runtime";
2
+
3
+ export const spec = {
4
+ command: "sync",
5
+ description:
6
+ "Move already-present embedded children to the pins in the parent's HEAD (day-2, after pulling the parent — sync never touches the parent itself). Clean children follow the pin: the registered branch fast-forwards, a detached child snaps. Dirty children, commits beyond the pin, and unregistered branches are your work — reported and left alone.",
7
+ args: [["[paths...]", "Restrict to these gitlink paths (default: every embedded gitlink)"]],
8
+ options: [
9
+ ["--skip <paths>", "Comma-separated gitlink paths to skip"],
10
+ ["--dry-run", "Report what would happen without fetching or moving anything"]
11
+ ],
12
+ examples: ["$ git pull && git-embedded sync", "$ git-embedded sync tests", "$ git-embedded sync --dry-run"]
13
+ };
14
+
15
+ const LABEL = {
16
+ synced: (r) =>
17
+ `${r.dryRun ? "would sync" : "synced"} ${r.path} → ${r.sha.slice(0, 12)}${r.branch ? ` (branch ${r.branch})` : " (detached)"}${r.note ? ` — ${r.note}` : ""}`,
18
+ "in-sync": (r) => `${r.path} already at pin`,
19
+ dirty: (r) => `${r.path} ${r.note}`,
20
+ ahead: (r) => `${r.path} ${r.note}`,
21
+ "unregistered-branch": (r) => `${r.path} ${r.note}`,
22
+ "pin-unavailable": (r) => `${r.path} pin-unavailable${r.note ? ` — ${r.note}` : ""}`,
23
+ "sync-failed": (r) => `${r.path} sync-failed${r.note ? ` — ${r.note}` : ""}`,
24
+ skipped: (r) => `${r.path} skipped`,
25
+ "no-repo": (r) => `${r.path} ${r.note}`
26
+ };
27
+
28
+ export function run(paths = [], opts = {}) {
29
+ const skip =
30
+ typeof opts.skip === "string"
31
+ ? opts.skip
32
+ .split(",")
33
+ .map((s) => s.trim())
34
+ .filter(Boolean)
35
+ : [];
36
+
37
+ const { results, exitCode } = self.embedded.sync({
38
+ cwd: process.cwd(),
39
+ paths,
40
+ skip,
41
+ dryRun: Boolean(opts.dryRun)
42
+ });
43
+
44
+ if (!results.length) {
45
+ self.report.plain("No embedded children present to sync.");
46
+ process.exit(0);
47
+ }
48
+
49
+ for (const r of results) {
50
+ const line = LABEL[r.outcome] ? LABEL[r.outcome](r) : `${r.path}: ${r.outcome}`;
51
+ if (r.outcome === "synced") self.report.success(line);
52
+ else if (r.outcome === "pin-unavailable" || r.outcome === "sync-failed") self.report.error(line);
53
+ else self.report.warn(line);
54
+ }
55
+
56
+ // Each outcome lands in exactly one bucket; "left alone" collects the
57
+ // deliberate your-work outcomes, which are not failures.
58
+ const synced = results.filter((r) => r.outcome === "synced").length;
59
+ const unchanged = results.filter((r) => r.outcome === "in-sync").length;
60
+ const leftAlone = results.filter((r) => ["dirty", "ahead", "unregistered-branch"].includes(r.outcome)).length;
61
+ const skipped = results.filter((r) => ["skipped", "no-repo"].includes(r.outcome)).length;
62
+ const failed = results.filter((r) => r.outcome === "pin-unavailable" || r.outcome === "sync-failed").length;
63
+ self.report.plain("");
64
+ self.report.plain(
65
+ `${synced} ${opts.dryRun ? "syncable" : "synced"}, ${unchanged} unchanged, ${leftAlone} left alone${skipped ? `, ${skipped} skipped` : ""}, ${failed} failed.`
66
+ );
67
+ process.exit(exitCode);
68
+ }
69
+
70
+ export default { spec, run };
@@ -100,8 +100,11 @@ export function makeCustomHelp(HelpClass, deps) {
100
100
  for (const ex of examples) {
101
101
  const colored = ex.replace(argPattern, (match, p1, p2, p3, p4) => {
102
102
  if (p2) return color(chalk.magenta, match);
103
+ /* v8 ignore else -- defensive: argPattern's two alternatives each
104
+ require 1+ chars in their capture group, so a successful match
105
+ always sets p2 or p4; the else has no reachable real input. */
103
106
  if (p4) return color(chalk.yellow, match);
104
- return match;
107
+ else return match;
105
108
  });
106
109
  output.push(` ${colored}`);
107
110
  }
@@ -197,6 +200,9 @@ function getFullCommandChain(cmd) {
197
200
  function wrapTextWithHangingIndent(text, indent, label, labelColor, width) {
198
201
  const pad = " ".repeat(indent);
199
202
  const prefix = "- ";
203
+ /* v8 ignore next -- defensive: both call sites (Aliases/Description below)
204
+ always pass a non-empty literal label, and this private helper has no
205
+ other caller, so the empty-label fallback has no reachable real input. */
200
206
  const labelStr = label ? label + ": " : "";
201
207
  const hangingPad = pad + " ".repeat(prefix.length + labelStr.length);
202
208
  const maxWidth = (width || process.stdout.columns || 80) - (pad.length + prefix.length + labelStr.length);