@haiyangbg/buildbeat 3.1.0 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -10
- package/SKILL.md +19 -299
- package/docs/CAPABILITY-MATRIX.md +1 -1
- package/docs/README.md +5 -5
- package/docs/RELEASING.md +5 -5
- package/docs/v2/RFC-0001-product-definition.md +5 -5
- package/docs/v2/RFC-0002-domain-model.md +1 -1
- package/docs/v2/RFC-0003-workflow-policy.md +2 -2
- package/docs/v2/SPEC-0001-events-v1.md +1 -1
- package/docs/v2/guide/02-workflow-guide.md +4 -0
- package/docs/v2/guide/09-security-boundaries.md +1 -1
- package/docs/v2/guide/10-recovery.en.md +1 -1
- package/docs/v2/guide/10-recovery.md +1 -1
- package/docs/v2/skill/01-principles.md +26 -0
- package/docs/v2/skill/02-project-layout.md +31 -0
- package/docs/v2/skill/03-collaboration-rules.md +45 -0
- package/docs/v2/skill/04-rhythm-and-rituals.md +102 -0
- package/docs/v2/skill/05-red-lines.md +13 -0
- package/docs/v2/skill/06-bootstrap-and-takeover.md +84 -0
- package/docs/v2/skill/07-templates-and-lessons.md +24 -0
- package/package.json +5 -14
- package/src/v2/cli/run-config-check.js +10 -4
- package/src/v2/cli/run.js +7 -1
- package/src/v2/engine/yaml-subset.js +17 -11
- package/src/v2/presets/policies/ui-render-gate.yaml +1 -1
- package/src/v2/runtime/decisions.js +1 -1
- package/src/v2/runtime/gc.js +41 -25
- package/src/v2/runtime/metrics.js +3 -2
- package/src/v2/runtime/orchestrator.js +86 -11
- package/src/v2/workspace/workspace-manager.js +62 -3
- package/templates/v2/CLAUDE.md +1 -1
- package/templates/v2/run-config.example.yaml +2 -0
|
@@ -142,20 +142,26 @@ function parseList(lines, start, indent) {
|
|
|
142
142
|
}
|
|
143
143
|
const first = childLines[0].content;
|
|
144
144
|
const quotedScalar = first.startsWith('"') || first.startsWith("'");
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
line.lineNo,
|
|
152
|
-
);
|
|
145
|
+
if (childLines.length > 1) {
|
|
146
|
+
// Multi-line items keep the previous rule and messages unchanged.
|
|
147
|
+
if (!quotedScalar && first.includes(":")) {
|
|
148
|
+
result.push(parseMapFromLines(childLines, itemIndent));
|
|
149
|
+
} else {
|
|
150
|
+
throw new YamlSubsetError("unsupported list item shape", line.lineNo);
|
|
153
151
|
}
|
|
154
|
-
|
|
155
|
-
|
|
152
|
+
index = cursor;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (!quotedScalar && MAP_ITEM.test(first)) {
|
|
156
156
|
result.push(parseMapFromLines(childLines, itemIndent));
|
|
157
|
+
} else if (!quotedScalar && (first.includes(": ") || first.endsWith(":"))) {
|
|
158
|
+
// Real YAML reads "echo a: b" as a map; rather than guess, ask for quotes.
|
|
159
|
+
throw new YamlSubsetError(
|
|
160
|
+
`list item ${JSON.stringify(first)} contains ": "; quote it (- ${JSON.stringify(first)}) or write it as key: value`,
|
|
161
|
+
line.lineNo,
|
|
162
|
+
);
|
|
157
163
|
} else {
|
|
158
|
-
|
|
164
|
+
result.push(parseScalar(first, childLines[0].lineNo));
|
|
159
165
|
}
|
|
160
166
|
index = cursor;
|
|
161
167
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Invariant 22 (
|
|
1
|
+
# Invariant 22 (lessons.md「静态稿拍板 → 返工螺旋」, machine form): a UI delivery's spec approval
|
|
2
2
|
# subject must include renderable proof — a screenshot with a digest —
|
|
3
3
|
# before a human may stamp it. Static prose is not an approvable design.
|
|
4
4
|
kind: policy
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// An approval is recorded only after re-reading the workspace and confirming
|
|
3
3
|
// the subject is still exactly what the request showed — if the candidate
|
|
4
4
|
// moved or the tree is dirty, the request is refreshed instead of stamped
|
|
5
|
-
// (lessons
|
|
5
|
+
// (lessons.md「读过期 race」: no rubber-stamping a moved target). Every decision lands both
|
|
6
6
|
// as a DECISION_RECORDED event and as a line in the Git plane
|
|
7
7
|
// (delivery/work/<work>/decisions.jsonl).
|
|
8
8
|
|
package/src/v2/runtime/gc.js
CHANGED
|
@@ -19,7 +19,14 @@ import { join } from "node:path";
|
|
|
19
19
|
import { execFileSync } from "node:child_process";
|
|
20
20
|
|
|
21
21
|
import { EventLedger } from "../storage/event-ledger.js";
|
|
22
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
describeLockOwner,
|
|
24
|
+
inspectLock,
|
|
25
|
+
isRunLockName,
|
|
26
|
+
readback,
|
|
27
|
+
reclaimStaleLock,
|
|
28
|
+
withRepoGitLock,
|
|
29
|
+
} from "../workspace/workspace-manager.js";
|
|
23
30
|
import { resolveRepoRef } from "./repo-ref.js";
|
|
24
31
|
|
|
25
32
|
function git(cwd, args) {
|
|
@@ -65,18 +72,25 @@ export function planGc(repoRoot) {
|
|
|
65
72
|
const runsDir = join(repoRoot, ".buildbeat", "runtime", "runs");
|
|
66
73
|
const locksDir = join(repoRoot, ".buildbeat", "runtime", "locks");
|
|
67
74
|
const rows = [];
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
75
|
+
// Locks that belong to no single run (active-run, and the @work /
|
|
76
|
+
// @parallel / @repo-git locks): reclaimable only when their owner process
|
|
77
|
+
// is provably gone (same host, pid no longer exists).
|
|
78
|
+
const sharedLocks = existsSync(locksDir)
|
|
79
|
+
? readdirSync(locksDir)
|
|
80
|
+
.filter((entry) => entry.endsWith(".lock") && !isRunLockName(entry.slice(0, -".lock".length)))
|
|
81
|
+
.sort()
|
|
82
|
+
: [];
|
|
83
|
+
for (const entry of sharedLocks) {
|
|
84
|
+
const name = entry.slice(0, -".lock".length);
|
|
85
|
+
const lockPath = join(locksDir, entry);
|
|
86
|
+
const seen = inspectLock(lockPath);
|
|
87
|
+
const row = { run: "(repository)", status: `${name} lock`, actions: [], keep: [] };
|
|
74
88
|
if (seen.state === "dead") {
|
|
75
|
-
row.actions.push({ kind: "remove-lock", path:
|
|
89
|
+
row.actions.push({ kind: "remove-lock", path: lockPath, owner: seen.owner });
|
|
76
90
|
} else if (seen.state === "unknown") {
|
|
77
|
-
row.keep.push(
|
|
91
|
+
row.keep.push(`${name} lock has no owner record (older buildbeat?); remove it by hand once no buildbeat process is running`);
|
|
78
92
|
} else {
|
|
79
|
-
row.keep.push(
|
|
93
|
+
row.keep.push(`${name} lock held by ${describeLockOwner(seen.owner)}${seen.state === "foreign-host" ? " (another host)" : " (still running)"}`);
|
|
80
94
|
}
|
|
81
95
|
rows.push(row);
|
|
82
96
|
}
|
|
@@ -178,24 +192,26 @@ export function applyGc(repoRoot, rows, { force = false } = {}) {
|
|
|
178
192
|
result.error = "worktree dirty; rerun with --force true to discard";
|
|
179
193
|
continue;
|
|
180
194
|
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
195
|
+
withRepoGitLock(repoRoot, () => {
|
|
196
|
+
if (action.registered) {
|
|
197
|
+
const args = ["worktree", "remove"];
|
|
198
|
+
if (force || action.dirty) {
|
|
199
|
+
args.push("--force");
|
|
200
|
+
}
|
|
201
|
+
args.push(action.path);
|
|
202
|
+
git(repoRoot, args);
|
|
203
|
+
} else if (action.present) {
|
|
204
|
+
rmSync(action.path, { recursive: true, force: true });
|
|
185
205
|
}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
git(repoRoot, ["worktree", "prune"]);
|
|
193
|
-
} catch {
|
|
194
|
-
// prune is best-effort
|
|
195
|
-
}
|
|
206
|
+
try {
|
|
207
|
+
git(repoRoot, ["worktree", "prune"]);
|
|
208
|
+
} catch {
|
|
209
|
+
// prune is best-effort
|
|
210
|
+
}
|
|
211
|
+
});
|
|
196
212
|
result.done = true;
|
|
197
213
|
} else if (action.kind === "delete-branch") {
|
|
198
|
-
git(repoRoot, ["branch", "-D", action.branch]);
|
|
214
|
+
withRepoGitLock(repoRoot, () => git(repoRoot, ["branch", "-D", action.branch]));
|
|
199
215
|
result.done = true;
|
|
200
216
|
}
|
|
201
217
|
} catch (error) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// buildbeat metrics v0: local, read-only, derived entirely from run ledgers.
|
|
2
|
-
// No collection, no upload (V2-PLAN §6
|
|
3
|
-
// forever doing precise work on
|
|
2
|
+
// No collection, no upload (V2-PLAN §6; lessons.md「流程只管"怎么做对",不管
|
|
3
|
+
// "做的是不是对的事"」: without numbers you are forever doing precise work on
|
|
4
|
+
// the wrong thing).
|
|
4
5
|
|
|
5
6
|
import { existsSync, readdirSync } from "node:fs";
|
|
6
7
|
import { join } from "node:path";
|
|
@@ -21,9 +21,12 @@ import { EventLedger, canonicalJson } from "../storage/event-ledger.js";
|
|
|
21
21
|
import {
|
|
22
22
|
acquireLock,
|
|
23
23
|
createWorkspace,
|
|
24
|
+
describeLockOwner,
|
|
24
25
|
listChangedPaths,
|
|
26
|
+
liveParallelMarkers,
|
|
25
27
|
readback,
|
|
26
28
|
releaseLock,
|
|
29
|
+
withRepoGitLock,
|
|
27
30
|
} from "../workspace/workspace-manager.js";
|
|
28
31
|
import { writeRunRecord } from "./run-record.js";
|
|
29
32
|
import { computeWorkCost } from "./work-cost.js";
|
|
@@ -52,8 +55,14 @@ function sha256(text) {
|
|
|
52
55
|
return `sha256:${createHash("sha256").update(text, "utf8").digest("hex")}`;
|
|
53
56
|
}
|
|
54
57
|
|
|
55
|
-
//
|
|
56
|
-
// repository-wide lock
|
|
58
|
+
// By default one run drives a repository at a time: it holds the
|
|
59
|
+
// repository-wide active-run lock for its whole drive. A run whose config
|
|
60
|
+
// sets `parallel: true` instead holds a per-work lock and a marker, passing
|
|
61
|
+
// the active-run lock only briefly as a gate, so runs of different works can
|
|
62
|
+
// drive together while runs of the same work stay exclusive. Real incident:
|
|
63
|
+
// a session waited 3h23m behind another work's run although worktrees were
|
|
64
|
+
// already isolated; parallelism is opt-in because verifiers that bind fixed
|
|
65
|
+
// ports or share a database would collide.
|
|
57
66
|
const ACTIVE_LOCK = "active-run";
|
|
58
67
|
|
|
59
68
|
function lockActive(repoRoot) {
|
|
@@ -70,17 +79,81 @@ function lockActive(repoRoot) {
|
|
|
70
79
|
}
|
|
71
80
|
}
|
|
72
81
|
|
|
73
|
-
function
|
|
82
|
+
function lockActiveWaiting(repoRoot, waitMs) {
|
|
83
|
+
const deadline = Date.now() + waitMs;
|
|
84
|
+
for (;;) {
|
|
85
|
+
try {
|
|
86
|
+
acquireLock(repoRoot, ACTIVE_LOCK);
|
|
87
|
+
return;
|
|
88
|
+
} catch (error) {
|
|
89
|
+
if (!error.lock || Date.now() >= deadline) {
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
74
95
|
lockActive(repoRoot);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function holdRunLock(repoRoot, runId, fn) {
|
|
99
|
+
acquireLock(repoRoot, runId);
|
|
75
100
|
try {
|
|
76
|
-
|
|
101
|
+
return fn();
|
|
102
|
+
} finally {
|
|
103
|
+
releaseLock(repoRoot, runId);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function withRunLocks(repoRoot, runId, fn, { workId = null, parallel = false } = {}) {
|
|
108
|
+
if (!parallel) {
|
|
109
|
+
lockActive(repoRoot);
|
|
77
110
|
try {
|
|
78
|
-
|
|
111
|
+
const running = liveParallelMarkers(repoRoot);
|
|
112
|
+
if (running.length > 0) {
|
|
113
|
+
throw new OrchestratorError(
|
|
114
|
+
`another run is active in this repository (parallel run(s) ${running
|
|
115
|
+
.map((marker) => (marker.owner ? `${marker.run}, ${describeLockOwner(marker.owner)}` : marker.run))
|
|
116
|
+
.join("; ")}); this run is exclusive (set parallel: true in its run config to drive alongside other works)`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
return holdRunLock(repoRoot, runId, fn);
|
|
79
120
|
} finally {
|
|
80
|
-
releaseLock(repoRoot,
|
|
121
|
+
releaseLock(repoRoot, ACTIVE_LOCK);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (!workId) {
|
|
125
|
+
throw new OrchestratorError("a parallel run needs its work id");
|
|
126
|
+
}
|
|
127
|
+
const workLock = `@work.${workId}`;
|
|
128
|
+
const marker = `@parallel.${runId}`;
|
|
129
|
+
try {
|
|
130
|
+
acquireLock(repoRoot, workLock);
|
|
131
|
+
} catch (error) {
|
|
132
|
+
const detail = error.lock?.detail;
|
|
133
|
+
throw new OrchestratorError(
|
|
134
|
+
`another run of ${workId} is active (runs of the same work never drive together)${detail ? `; ${detail}` : ""}`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
// Gate: an exclusive run holds active-run for its whole drive, so a
|
|
139
|
+
// parallel run cannot slip in while it runs; the marker, created under
|
|
140
|
+
// the gate, is what an exclusive run checks before it starts. Another
|
|
141
|
+
// parallel run holds the gate for milliseconds, so a busy gate is waited
|
|
142
|
+
// for briefly before it is reported (two parallel starts at the same
|
|
143
|
+
// instant must both get through).
|
|
144
|
+
lockActiveWaiting(repoRoot, 2000);
|
|
145
|
+
try {
|
|
146
|
+
acquireLock(repoRoot, marker);
|
|
147
|
+
} finally {
|
|
148
|
+
releaseLock(repoRoot, ACTIVE_LOCK);
|
|
149
|
+
}
|
|
150
|
+
try {
|
|
151
|
+
return holdRunLock(repoRoot, runId, fn);
|
|
152
|
+
} finally {
|
|
153
|
+
releaseLock(repoRoot, marker);
|
|
81
154
|
}
|
|
82
155
|
} finally {
|
|
83
|
-
releaseLock(repoRoot,
|
|
156
|
+
releaseLock(repoRoot, workLock);
|
|
84
157
|
}
|
|
85
158
|
}
|
|
86
159
|
|
|
@@ -926,7 +999,7 @@ export function startRun(options) {
|
|
|
926
999
|
}
|
|
927
1000
|
|
|
928
1001
|
return withRunLocks(repoRoot, runId, () => {
|
|
929
|
-
const workspace = createWorkspace({ repoRoot, runId, base });
|
|
1002
|
+
const workspace = withRepoGitLock(repoRoot, () => createWorkspace({ repoRoot, runId, base }));
|
|
930
1003
|
const context = makeContext(options, ledger, workspace);
|
|
931
1004
|
const now = context.now;
|
|
932
1005
|
const supersession =
|
|
@@ -974,7 +1047,7 @@ export function startRun(options) {
|
|
|
974
1047
|
superseded: supersession.superseded,
|
|
975
1048
|
supersedeSkipped: supersession.skipped,
|
|
976
1049
|
};
|
|
977
|
-
});
|
|
1050
|
+
}, { workId, parallel: options.parallel === true });
|
|
978
1051
|
}
|
|
979
1052
|
|
|
980
1053
|
function resumeStepFromTransition(transition) {
|
|
@@ -1038,7 +1111,9 @@ export function resumeRun(options) {
|
|
|
1038
1111
|
// decided again on a ledger read under the locks: another session may
|
|
1039
1112
|
// have approved, resumed or stopped the run in between, and writing
|
|
1040
1113
|
// through the earlier read would fork the hash chain.
|
|
1041
|
-
const
|
|
1114
|
+
const outer = openLedgerFor(repoRoot, runId);
|
|
1115
|
+
const outerLedger = outer.ledger;
|
|
1116
|
+
const outside = resumeTarget(options, outer);
|
|
1042
1117
|
if (outside.early) {
|
|
1043
1118
|
return outside.early;
|
|
1044
1119
|
}
|
|
@@ -1216,5 +1291,5 @@ export function resumeRun(options) {
|
|
|
1216
1291
|
drive(context, startStep);
|
|
1217
1292
|
}
|
|
1218
1293
|
return { runId, ledgerPath, state: ledger.state, resumed: true, reason: null };
|
|
1219
|
-
});
|
|
1294
|
+
}, { workId: outerLedger.state.run.work, parallel: options.parallel === true });
|
|
1220
1295
|
}
|
|
@@ -214,19 +214,78 @@ export function acquireLock(repoRoot, runId) {
|
|
|
214
214
|
return lockPath;
|
|
215
215
|
}
|
|
216
216
|
|
|
217
|
-
//
|
|
218
|
-
//
|
|
217
|
+
// Locks that belong to no single run: the repository-wide active-run lock,
|
|
218
|
+
// and names starting with "@" (per-work, parallel-run marker, repo-git),
|
|
219
|
+
// which no run id can contain.
|
|
220
|
+
export function isRunLockName(name) {
|
|
221
|
+
return name !== "active-run" && !name.startsWith("@");
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Run ids currently holding a lock in this repository: who a blocked
|
|
225
|
+
// `start` is queued behind.
|
|
219
226
|
export function listHeldRunLocks(repoRoot) {
|
|
220
227
|
const lockDir = join(repoRoot, ".buildbeat", "runtime", "locks");
|
|
221
228
|
if (!existsSync(lockDir)) {
|
|
222
229
|
return [];
|
|
223
230
|
}
|
|
224
231
|
return readdirSync(lockDir)
|
|
225
|
-
.filter((entry) => entry.endsWith(".lock")
|
|
232
|
+
.filter((entry) => entry.endsWith(".lock"))
|
|
226
233
|
.map((entry) => entry.slice(0, -".lock".length))
|
|
234
|
+
.filter(isRunLockName)
|
|
227
235
|
.sort();
|
|
228
236
|
}
|
|
229
237
|
|
|
238
|
+
// Parallel-run markers (@parallel.<RUN>) whose owner is alive or cannot be
|
|
239
|
+
// judged; markers of dead owners are reclaimed on the way and not returned.
|
|
240
|
+
export function liveParallelMarkers(repoRoot) {
|
|
241
|
+
const lockDir = join(repoRoot, ".buildbeat", "runtime", "locks");
|
|
242
|
+
if (!existsSync(lockDir)) {
|
|
243
|
+
return [];
|
|
244
|
+
}
|
|
245
|
+
const live = [];
|
|
246
|
+
for (const entry of readdirSync(lockDir).sort()) {
|
|
247
|
+
if (!entry.startsWith("@parallel.") || !entry.endsWith(".lock")) {
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
const lockPath = join(lockDir, entry);
|
|
251
|
+
const seen = inspectLock(lockPath);
|
|
252
|
+
if (seen.state === "dead" && reclaimStaleLock(lockPath, seen.owner)) {
|
|
253
|
+
rmSync(lockPath, { recursive: true, force: true });
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
live.push({ run: entry.slice("@parallel.".length, -".lock".length), ...seen });
|
|
257
|
+
}
|
|
258
|
+
return live;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function pause(ms) {
|
|
262
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Writes to the repository's shared git state (worktree add/remove, branch
|
|
266
|
+
// create/delete, .git/config) are serialised: with parallel runs two drivers
|
|
267
|
+
// could otherwise race on .git/config.lock or index.lock. Held for
|
|
268
|
+
// milliseconds, so a busy lock is waited for (bounded), not reported.
|
|
269
|
+
export function withRepoGitLock(repoRoot, fn, { waitMs = 10_000 } = {}) {
|
|
270
|
+
const deadline = Date.now() + waitMs;
|
|
271
|
+
for (;;) {
|
|
272
|
+
try {
|
|
273
|
+
acquireLock(repoRoot, "@repo-git");
|
|
274
|
+
break;
|
|
275
|
+
} catch (error) {
|
|
276
|
+
if (!error.lock || Date.now() >= deadline) {
|
|
277
|
+
throw error;
|
|
278
|
+
}
|
|
279
|
+
pause(100);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
try {
|
|
283
|
+
return fn();
|
|
284
|
+
} finally {
|
|
285
|
+
releaseLock(repoRoot, "@repo-git");
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
230
289
|
export function releaseLock(repoRoot, runId) {
|
|
231
290
|
rmSync(lockPathFor(repoRoot, runId), { recursive: true, force: true });
|
|
232
291
|
}
|
package/templates/v2/CLAUDE.md
CHANGED
|
@@ -3,5 +3,5 @@
|
|
|
3
3
|
本工作区的会话路由、协作规则、红线,**单点在同目录的 [`AGENTS.md`](AGENTS.md)** —— 请立即读取那份。
|
|
4
4
|
|
|
5
5
|
> 本文件只为兼容「只认 `CLAUDE.md` 这个文件名的工具」而存在,**永远保持这几行**。
|
|
6
|
-
> 往这里复制任何规则 = 两份文档必然漂移(上游 `lessons.md
|
|
6
|
+
> 往这里复制任何规则 = 两份文档必然漂移(上游 `lessons.md`「SSOT 腐烂)。
|
|
7
7
|
> 也不要改成符号链接:Windows 上 git 默认 `core.symlinks=false`,clone 出来会静默退化成一个内容是路径字符串的普通文件,装载即失效。
|