amalgm 0.1.158 → 0.1.159
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/package.json +1 -1
- package/runtime/scripts/amalgm-mcp/fs/rest.js +1 -1
- package/runtime/scripts/amalgm-mcp/index.js +2 -0
- package/runtime/scripts/amalgm-mcp/server/routes/state.js +4 -0
- package/runtime/scripts/amalgm-mcp/server/routes/workspace.js +16 -0
- package/runtime/scripts/amalgm-mcp/state/attachments.js +43 -0
- package/runtime/scripts/amalgm-mcp/state/db.js +35 -0
- package/runtime/scripts/amalgm-mcp/state/presence.js +175 -0
- package/runtime/scripts/amalgm-mcp/state/promotions.js +10 -0
- package/runtime/scripts/amalgm-mcp/state/rest.js +41 -0
- package/runtime/scripts/amalgm-mcp/state/shared-rest.js +20 -1
- package/runtime/scripts/amalgm-mcp/tests/state-presence.test.js +130 -0
- package/runtime/scripts/amalgm-mcp/tests/state-promotion-ownership.test.js +86 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-cards-store.test.js +326 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-cards.test.js +401 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-wall.test.js +148 -0
- package/runtime/scripts/amalgm-mcp/tools/shared-live-smoke.js +227 -0
- package/runtime/scripts/amalgm-mcp/workspace/card.js +199 -0
- package/runtime/scripts/amalgm-mcp/workspace/cards.js +437 -0
- package/runtime/scripts/amalgm-mcp/workspace/git.js +286 -0
- package/runtime/scripts/amalgm-mcp/workspace/identity.js +88 -0
- package/runtime/scripts/amalgm-mcp/workspace/rest.js +64 -0
- package/runtime/scripts/amalgm-mcp/workspace/wall.js +308 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Live smoke for the shared realtime rail (Wave 3) against RUNNING runtimes.
|
|
6
|
+
*
|
|
7
|
+
* The CI tiers in local-live-runbook.md (unit / loopback / full-route e2e)
|
|
8
|
+
* all run against sandboxed engines. This tool is the fourth tier: it drives
|
|
9
|
+
* the real installed runtime on this machine, through the real local
|
|
10
|
+
* gateway, onto the real cloud rail. Nothing is stubbed.
|
|
11
|
+
*
|
|
12
|
+
* Commands:
|
|
13
|
+
* status shared replicas + runtime health for this machine
|
|
14
|
+
* send [--path <doc>] append a marker line to a shared doc through the
|
|
15
|
+
* normal doc path, then wait for the official cloud
|
|
16
|
+
* ack (applied_version advance + sync_status synced)
|
|
17
|
+
* watch --for <text> wait until <text> arrives IN THE FILE on disk —
|
|
18
|
+
* i.e. a remote machine's edit crossed the rail and
|
|
19
|
+
* materialized here
|
|
20
|
+
*
|
|
21
|
+
* Cross-machine proof = `send` on machine A, `watch --for <its marker>` on
|
|
22
|
+
* machine B, then the reverse. Run from a checkout (node
|
|
23
|
+
* runtime/scripts/amalgm-mcp/tools/shared-live-smoke.js …) or from an
|
|
24
|
+
* installed runtime package (same relative path under the install dir).
|
|
25
|
+
*
|
|
26
|
+
* Options: --user <email> (default: sole entry under ~/amalgm/users),
|
|
27
|
+
* --branch <label> (default: main), --timeout <seconds> (default: 90).
|
|
28
|
+
* Exit code 0 on proof, 1 on failure/timeout — usable from scripts.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const fs = require('fs');
|
|
32
|
+
const os = require('os');
|
|
33
|
+
const path = require('path');
|
|
34
|
+
|
|
35
|
+
function fail(msg) {
|
|
36
|
+
console.error(`FAIL: ${msg}`);
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function parseArgs(argv) {
|
|
41
|
+
const args = { _: [] };
|
|
42
|
+
for (let i = 0; i < argv.length; i++) {
|
|
43
|
+
const a = argv[i];
|
|
44
|
+
if (a.startsWith('--')) {
|
|
45
|
+
args[a.slice(2)] = argv[i + 1];
|
|
46
|
+
i++;
|
|
47
|
+
} else {
|
|
48
|
+
args._.push(a);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return args;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function expandHome(p) {
|
|
55
|
+
if (!p) return p;
|
|
56
|
+
return p.startsWith('~') ? path.join(os.homedir(), p.slice(1)) : p;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function resolveUser(args) {
|
|
60
|
+
if (args.user) return args.user;
|
|
61
|
+
const usersDir = path.join(os.homedir(), 'amalgm', 'users');
|
|
62
|
+
const entries = fs.readdirSync(usersDir).filter((e) => e.includes('@'));
|
|
63
|
+
if (entries.length === 1) return entries[0];
|
|
64
|
+
fail(`multiple users under ${usersDir} — pass --user <email> (found: ${entries.join(', ')})`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function loadRuntime(user, branch) {
|
|
68
|
+
const base = path.join(os.homedir(), 'amalgm', 'users', user);
|
|
69
|
+
const runtimeDir = path.join(base, 'runtimes', branch);
|
|
70
|
+
const statePath = path.join(runtimeDir, 'runtime-state.json');
|
|
71
|
+
if (!fs.existsSync(statePath)) {
|
|
72
|
+
fail(`no runtime state at ${statePath} — is the ${branch} runtime installed for ${user}?`);
|
|
73
|
+
}
|
|
74
|
+
const state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
|
|
75
|
+
const token = JSON.parse(fs.readFileSync(path.join(runtimeDir, 'runtime-token.json'), 'utf8')).token;
|
|
76
|
+
const dbPath = path.join(base, 'amalgm.db');
|
|
77
|
+
return { base, gatewayUrl: state.gateway_url, version: state.version, token, dbPath };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function openDb(dbPath) {
|
|
81
|
+
const Database = require('better-sqlite3');
|
|
82
|
+
return new Database(dbPath, { readonly: true, fileMustExist: true });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function readReplicas(db) {
|
|
86
|
+
return db
|
|
87
|
+
.prepare(
|
|
88
|
+
`SELECT resource_id, local_path, applied_version, snapshot_version,
|
|
89
|
+
sync_status, last_error, updated_at
|
|
90
|
+
FROM shared_resource_replicas ORDER BY updated_at DESC`
|
|
91
|
+
)
|
|
92
|
+
.all();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function replicaFor(db, docPath) {
|
|
96
|
+
return db
|
|
97
|
+
.prepare(`SELECT * FROM shared_resource_replicas WHERE local_path = ?`)
|
|
98
|
+
.get(docPath);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function api(rt, route, body) {
|
|
102
|
+
const res = await fetch(`${rt.gatewayUrl}${route}`, {
|
|
103
|
+
method: 'POST',
|
|
104
|
+
headers: {
|
|
105
|
+
'content-type': 'application/json',
|
|
106
|
+
'x-amalgm-runtime-token': rt.token,
|
|
107
|
+
},
|
|
108
|
+
body: JSON.stringify(body),
|
|
109
|
+
});
|
|
110
|
+
const text = await res.text();
|
|
111
|
+
if (!res.ok) fail(`${route} → HTTP ${res.status}: ${text.slice(0, 300)}`);
|
|
112
|
+
return JSON.parse(text);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function checkHealth(rt) {
|
|
116
|
+
let res;
|
|
117
|
+
try {
|
|
118
|
+
res = await fetch(`${rt.gatewayUrl}/healthz`, { signal: AbortSignal.timeout(5000) });
|
|
119
|
+
} catch (err) {
|
|
120
|
+
fail(`runtime unreachable at ${rt.gatewayUrl} (${err.cause?.code || err.message}) — is it running?`);
|
|
121
|
+
}
|
|
122
|
+
if (!res.ok) fail(`healthz → HTTP ${res.status}`);
|
|
123
|
+
return res.json();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function pickDocPath(args, db) {
|
|
127
|
+
if (args.path) return path.resolve(expandHome(args.path));
|
|
128
|
+
const rows = readReplicas(db);
|
|
129
|
+
if (rows.length === 1) return rows[0].local_path;
|
|
130
|
+
if (rows.length === 0) fail('no shared replicas on this machine — share a doc first, or pass --path');
|
|
131
|
+
fail(`multiple shared replicas — pass --path. Known:\n ${rows.map((r) => r.local_path).join('\n ')}`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
135
|
+
|
|
136
|
+
async function cmdStatus(rt) {
|
|
137
|
+
const health = await checkHealth(rt);
|
|
138
|
+
console.log(`runtime ${health.runtime_label} v${rt.version} at ${rt.gatewayUrl} — ${health.status}`);
|
|
139
|
+
const db = openDb(rt.dbPath);
|
|
140
|
+
const rows = readReplicas(db);
|
|
141
|
+
if (rows.length === 0) {
|
|
142
|
+
console.log('no shared replicas.');
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
for (const r of rows) {
|
|
146
|
+
console.log(
|
|
147
|
+
` ${r.resource_id} v${r.applied_version} (snapshot v${r.snapshot_version}) ${r.sync_status}` +
|
|
148
|
+
`${r.last_error ? ` ERROR: ${r.last_error}` : ''}\n ${r.local_path} (updated ${r.updated_at})`
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function cmdSend(rt, args, timeoutMs) {
|
|
154
|
+
const Y = require('yjs');
|
|
155
|
+
await checkHealth(rt);
|
|
156
|
+
const db = openDb(rt.dbPath);
|
|
157
|
+
const docPath = pickDocPath(args, db);
|
|
158
|
+
const before = replicaFor(db, docPath);
|
|
159
|
+
if (!before) fail(`${docPath} is not a shared replica on this machine`);
|
|
160
|
+
if (before.sync_status !== 'synced') {
|
|
161
|
+
console.log(`note: replica starts in '${before.sync_status}' (v${before.applied_version})`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const marker = `[smoke ${new Date().toISOString()} host=${os.hostname()} n=${Math.random().toString(36).slice(2, 8)}]`;
|
|
165
|
+
const opened = await api(rt, '/state/docs/open', { path: docPath });
|
|
166
|
+
const doc = new Y.Doc();
|
|
167
|
+
Y.applyUpdate(doc, Buffer.from(opened.state, 'base64'));
|
|
168
|
+
const beforeVector = Y.encodeStateVector(doc);
|
|
169
|
+
const text = doc.getText('content');
|
|
170
|
+
doc.transact(() => text.insert(text.length, `\n${marker}\n`));
|
|
171
|
+
const update = Buffer.from(Y.encodeStateAsUpdate(doc, beforeVector)).toString('base64');
|
|
172
|
+
await api(rt, '/state/docs/update', { path: docPath, updates: [update] });
|
|
173
|
+
console.log(`sent marker through doc path: ${marker}`);
|
|
174
|
+
console.log(`waiting for cloud ack (baseline v${before.applied_version})…`);
|
|
175
|
+
|
|
176
|
+
const start = Date.now();
|
|
177
|
+
while (Date.now() - start < timeoutMs) {
|
|
178
|
+
const row = replicaFor(db, docPath);
|
|
179
|
+
if (row.sync_status === 'rejected' || row.sync_status === 'conflicted') {
|
|
180
|
+
fail(`replica entered '${row.sync_status}': ${row.last_error || '(no error recorded)'}`);
|
|
181
|
+
}
|
|
182
|
+
if (row.applied_version > before.applied_version && row.sync_status === 'synced') {
|
|
183
|
+
const materialized = fs.readFileSync(docPath, 'utf8').includes(marker);
|
|
184
|
+
console.log(
|
|
185
|
+
`PASS: official v${before.applied_version} → v${row.applied_version} in ${Date.now() - start}ms; ` +
|
|
186
|
+
`file materialized: ${materialized ? 'yes' : 'pending write-back'}`
|
|
187
|
+
);
|
|
188
|
+
console.log(`cross-machine check: on the other machine run\n … watch --for "${marker}"`);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
await sleep(400);
|
|
192
|
+
}
|
|
193
|
+
fail(`no cloud ack within ${timeoutMs / 1000}s — edit is journaled locally (nothing lost); check tunnel/gateway per local-live-runbook.md`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function cmdWatch(rt, args, timeoutMs) {
|
|
197
|
+
if (!args.for) fail('watch requires --for <text>');
|
|
198
|
+
const db = openDb(rt.dbPath);
|
|
199
|
+
const docPath = pickDocPath(args, db);
|
|
200
|
+
const start = Date.now();
|
|
201
|
+
console.log(`watching ${docPath} for: ${args.for}`);
|
|
202
|
+
while (Date.now() - start < timeoutMs) {
|
|
203
|
+
if (fs.existsSync(docPath) && fs.readFileSync(docPath, 'utf8').includes(args.for)) {
|
|
204
|
+
const row = replicaFor(db, docPath);
|
|
205
|
+
console.log(`PASS: marker arrived in the file after ${Date.now() - start}ms (replica v${row?.applied_version}, ${row?.sync_status})`);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
await sleep(400);
|
|
209
|
+
}
|
|
210
|
+
fail(`marker did not arrive within ${timeoutMs / 1000}s`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function main() {
|
|
214
|
+
const args = parseArgs(process.argv.slice(2));
|
|
215
|
+
const cmd = args._[0];
|
|
216
|
+
if (!['status', 'send', 'watch'].includes(cmd)) {
|
|
217
|
+
console.error('usage: shared-live-smoke.js <status|send|watch> [--path <doc>] [--for <text>] [--user <email>] [--branch <label>] [--timeout <seconds>]');
|
|
218
|
+
process.exit(1);
|
|
219
|
+
}
|
|
220
|
+
const rt = loadRuntime(resolveUser(args), args.branch || 'main');
|
|
221
|
+
const timeoutMs = (Number(args.timeout) || 90) * 1000;
|
|
222
|
+
if (cmd === 'status') await cmdStatus(rt);
|
|
223
|
+
if (cmd === 'send') await cmdSend(rt, args, timeoutMs);
|
|
224
|
+
if (cmd === 'watch') await cmdWatch(rt, args, timeoutMs);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
main().catch((err) => fail(err.stack || String(err)));
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Repo cards: one small, typed statement of a worktree's exact state.
|
|
5
|
+
*
|
|
6
|
+
* A card carries identifiers and counts, never file contents — a
|
|
7
|
+
* 4,000-file checkout is one card change. Cards are observations; nothing
|
|
8
|
+
* in the system acts on one, and observing never writes to a repo. The
|
|
9
|
+
* contract is docs/workspace-sync.md.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const {
|
|
15
|
+
classifyDirectory,
|
|
16
|
+
headAndDirty,
|
|
17
|
+
headOid,
|
|
18
|
+
listWorktrees,
|
|
19
|
+
operationState,
|
|
20
|
+
originUrl,
|
|
21
|
+
rootCommit,
|
|
22
|
+
} = require('./git');
|
|
23
|
+
const { cacheRootCommit, machineId, replicaForGitDir } = require('./identity');
|
|
24
|
+
|
|
25
|
+
const CARD_CONTRACT = 'repo-card@2';
|
|
26
|
+
|
|
27
|
+
// A transportable identity needs a dotted host: bare names resolve through
|
|
28
|
+
// this machine's own config (ssh aliases, LAN names) and identify nothing
|
|
29
|
+
// anywhere else.
|
|
30
|
+
const HOST = String.raw`([\w-]+(?:\.[\w-]+)+)`;
|
|
31
|
+
const URL_REMOTE = new RegExp(`^(?:ssh|git|https?)://(?:[^@/]+@)?${HOST}(?::\\d+)?/(.+)$`, 'i');
|
|
32
|
+
const SCP_REMOTE = new RegExp(`^(?:[\\w.-]+@)?${HOST}:(.+)$`);
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The transportable identity of a remote, or null when it has none.
|
|
36
|
+
*
|
|
37
|
+
* Every clone spelling of one hosted repo folds onto one https URL:
|
|
38
|
+
* scp-like (git@host:owner/repo.git), ssh:// with a port, https:// with
|
|
39
|
+
* credentials — lowercased host, no userinfo, no port, no query, no
|
|
40
|
+
* fragment, no .git. Credentials and tokens embedded anywhere in a URL
|
|
41
|
+
* must never ride a card off this machine, and machine-local remotes
|
|
42
|
+
* (file://, bare paths, ssh aliases) are null: a name that only resolves
|
|
43
|
+
* on this machine identifies nothing on any other.
|
|
44
|
+
*/
|
|
45
|
+
function remoteIdentity(url) {
|
|
46
|
+
const trimmed = url.trim();
|
|
47
|
+
const match = trimmed.match(URL_REMOTE) || trimmed.match(SCP_REMOTE);
|
|
48
|
+
if (!match) return null;
|
|
49
|
+
const repoPath = match[2]
|
|
50
|
+
.replace(/[?#].*$/, '')
|
|
51
|
+
.replace(/^\/+/, '')
|
|
52
|
+
.replace(/\/+$/, '')
|
|
53
|
+
.replace(/\.git$/, '');
|
|
54
|
+
return repoPath ? `https://${match[1].toLowerCase()}/${repoPath}` : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function cardKind(classified) {
|
|
58
|
+
if (classified.superproject) return 'submodule';
|
|
59
|
+
return classified.linked ? 'linked-worktree' : 'primary';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function observeWorktree(classified) {
|
|
63
|
+
const worktreeRoot = classified.toplevel;
|
|
64
|
+
const gitDir = await fs.promises.realpath(classified.gitDir);
|
|
65
|
+
const replica = replicaForGitDir(gitDir);
|
|
66
|
+
const [{ head, dirty }, operation, remote] = await Promise.all([
|
|
67
|
+
headAndDirty(worktreeRoot),
|
|
68
|
+
operationState(worktreeRoot),
|
|
69
|
+
originUrl(worktreeRoot),
|
|
70
|
+
]);
|
|
71
|
+
let anchor = replica.rootCommit;
|
|
72
|
+
if (!anchor && head.oid) {
|
|
73
|
+
anchor = await rootCommit(worktreeRoot);
|
|
74
|
+
cacheRootCommit(gitDir, anchor);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
card: CARD_CONTRACT,
|
|
79
|
+
machine: machineId(),
|
|
80
|
+
replica: replica.replicaId,
|
|
81
|
+
path: worktreeRoot,
|
|
82
|
+
gitDir,
|
|
83
|
+
repo: {
|
|
84
|
+
remote: remote === null ? null : remoteIdentity(remote),
|
|
85
|
+
rootCommit: anchor,
|
|
86
|
+
},
|
|
87
|
+
kind: cardKind(classified),
|
|
88
|
+
head,
|
|
89
|
+
dirty,
|
|
90
|
+
operation,
|
|
91
|
+
observedAt: new Date().toISOString(),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Build the repo-card@2 for one worktree. `dirPath` may be any directory
|
|
97
|
+
* inside the worktree; the card is always for the worktree root. Throws
|
|
98
|
+
* on anything that is not a worktree — discovery filters first.
|
|
99
|
+
*
|
|
100
|
+
* Observation is not atomic — git can move between the underlying reads —
|
|
101
|
+
* so HEAD is probed again afterwards and the card rebuilt once if it
|
|
102
|
+
* moved. A repo still in motion after that simply cards again on the next
|
|
103
|
+
* doorbell; every card is a true observation of some moment.
|
|
104
|
+
*/
|
|
105
|
+
async function buildRepoCard(dirPath) {
|
|
106
|
+
const classified = await classifyDirectory(dirPath);
|
|
107
|
+
if (classified.kind !== 'worktree') {
|
|
108
|
+
throw new Error(`cannot card ${classified.path}: ${classified.kind}`);
|
|
109
|
+
}
|
|
110
|
+
const observed = await observeWorktree(classified);
|
|
111
|
+
if (observed.head.oid === await headOid(classified.toplevel)) return observed;
|
|
112
|
+
return observeWorktree(classified);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The card's identity-relevant content. Two observations of an untouched
|
|
117
|
+
* repo produce the same fingerprint; any real change produces a new one.
|
|
118
|
+
* The store bumps a card's generation exactly when the fingerprint moves.
|
|
119
|
+
*/
|
|
120
|
+
function cardFingerprint(card) {
|
|
121
|
+
const { observedAt, ...content } = card;
|
|
122
|
+
return JSON.stringify(content);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function isDirectory(candidate) {
|
|
126
|
+
const stats = await fs.promises.stat(candidate);
|
|
127
|
+
return stats.isDirectory();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Resolve a tracked folder to the worktrees it means, using git itself:
|
|
132
|
+
*
|
|
133
|
+
* - a folder inside a repo means that repo,
|
|
134
|
+
* - a plain folder means the repos sitting one level down (a folder of
|
|
135
|
+
* projects; hidden entries are not code folders),
|
|
136
|
+
* - and every discovered repo brings all of its linked worktrees along,
|
|
137
|
+
* wherever on disk they live — same repo, same tracking.
|
|
138
|
+
*
|
|
139
|
+
* Returns { worktrees, skipped } — worktree roots to card, and what was
|
|
140
|
+
* looked at but not carded, each with its reason: 'bare', 'not-a-repo',
|
|
141
|
+
* 'prunable' (registered but gone from disk), or 'broken-worktree'
|
|
142
|
+
* (present on disk but no longer answering as a repo, e.g. after its
|
|
143
|
+
* main repo moved).
|
|
144
|
+
* Discovery never descends into a repo looking for submodules; an
|
|
145
|
+
* explicitly tracked submodule path cards like any other worktree.
|
|
146
|
+
*/
|
|
147
|
+
async function discoverWorktrees(rootPath) {
|
|
148
|
+
const classified = await classifyDirectory(rootPath);
|
|
149
|
+
const skipped = [];
|
|
150
|
+
const repos = new Map(); // commonDir → one worktree root; two doors into one repo expand once
|
|
151
|
+
|
|
152
|
+
if (classified.kind === 'worktree') {
|
|
153
|
+
repos.set(classified.commonDir, classified.toplevel);
|
|
154
|
+
} else if (classified.kind === 'bare') {
|
|
155
|
+
skipped.push({ path: classified.path, reason: 'bare' });
|
|
156
|
+
} else {
|
|
157
|
+
const entries = await fs.promises.readdir(classified.path);
|
|
158
|
+
const children = entries
|
|
159
|
+
.filter((entry) => !entry.startsWith('.'))
|
|
160
|
+
.map((entry) => path.join(classified.path, entry));
|
|
161
|
+
for (const child of children) {
|
|
162
|
+
if (!(await isDirectory(child))) continue;
|
|
163
|
+
const childClassified = await classifyDirectory(child);
|
|
164
|
+
if (childClassified.kind === 'worktree') {
|
|
165
|
+
if (!repos.has(childClassified.commonDir)) repos.set(childClassified.commonDir, childClassified.toplevel);
|
|
166
|
+
} else {
|
|
167
|
+
skipped.push({ path: childClassified.path, reason: childClassified.kind });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const worktrees = new Set();
|
|
173
|
+
for (const repoRoot of repos.values()) {
|
|
174
|
+
for (const worktree of await listWorktrees(repoRoot)) {
|
|
175
|
+
if (worktree.prunable) {
|
|
176
|
+
skipped.push({ path: worktree.path, reason: 'prunable' });
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
// Being listed is not enough — after a repo moves, a registered
|
|
180
|
+
// worktree can survive on disk with a dangling pointer. Card only
|
|
181
|
+
// what answers to git as a worktree.
|
|
182
|
+
const answered = await classifyDirectory(worktree.path);
|
|
183
|
+
if (answered.kind === 'worktree') {
|
|
184
|
+
worktrees.add(answered.toplevel);
|
|
185
|
+
} else {
|
|
186
|
+
skipped.push({ path: worktree.path, reason: 'broken-worktree' });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return { worktrees: [...worktrees].sort(), skipped };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
module.exports = {
|
|
194
|
+
CARD_CONTRACT,
|
|
195
|
+
buildRepoCard,
|
|
196
|
+
cardFingerprint,
|
|
197
|
+
discoverWorktrees,
|
|
198
|
+
remoteIdentity,
|
|
199
|
+
};
|