@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.
- 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
|
@@ -1,54 +1,155 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
# reference-transaction
|
|
3
3
|
#
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
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
|
-
#
|
|
10
|
-
#
|
|
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
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
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
|
-
#
|
|
19
|
-
# <old_sha> <new_sha> <ref-name>
|
|
32
|
+
# One-shot override: git -c embedded.guard=<mode> <command>
|
|
20
33
|
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
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
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
37
|
-
|
|
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
|
|
40
|
-
#
|
|
41
|
-
#
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
#
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
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.
|
|
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
|
-
"
|
|
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": "
|
|
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": "^
|
|
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": "^
|
|
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 };
|
package/src/api/cli/init.mjs
CHANGED
|
@@ -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
|
}
|
package/src/api/cli/link.mjs
CHANGED
|
@@ -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 {
|
|
46
|
+
const { spawnSync, path } = context;
|
|
19
47
|
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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);
|