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.
@@ -0,0 +1,286 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Read-only git facts.
5
+ *
6
+ * The one place the workspace layer talks to git. Rules:
7
+ * - Queries only. A call here never mutates a repo — GIT_OPTIONAL_LOCKS=0
8
+ * keeps even `status` from refreshing the index on disk.
9
+ * - Observation never executes repo-configured programs, and repo config
10
+ * never reshapes what is observed. GIT_CONFIG_* below outranks any
11
+ * repo/user/system setting for exactly those keys: core.fsmonitor is a
12
+ * command git would otherwise run during `status`, and
13
+ * status.showUntrackedFiles could hide untracked files from the count.
14
+ * - No shell, pinned cwd, no prompts, C locale.
15
+ * - Failure is an answer. "Not a repo" and "bare" are classification
16
+ * results the caller matches on; anything unexpected throws GitError
17
+ * carrying the full command and stderr.
18
+ */
19
+
20
+ const fs = require('fs');
21
+ const path = require('path');
22
+ const { execFile } = require('child_process');
23
+ const { promisify } = require('util');
24
+
25
+ const execFileAsync = promisify(execFile);
26
+
27
+ const GIT_ENV = {
28
+ GIT_OPTIONAL_LOCKS: '0',
29
+ GIT_TERMINAL_PROMPT: '0',
30
+ LC_ALL: 'C',
31
+ GIT_CONFIG_COUNT: '2',
32
+ GIT_CONFIG_KEY_0: 'core.fsmonitor',
33
+ GIT_CONFIG_VALUE_0: 'false',
34
+ GIT_CONFIG_KEY_1: 'status.showUntrackedFiles',
35
+ GIT_CONFIG_VALUE_1: 'normal',
36
+ };
37
+
38
+ class GitError extends Error {
39
+ constructor(args, cwd, exitCode, stderr) {
40
+ super(`git ${args.join(' ')} in ${cwd} exited ${exitCode}: ${stderr.trim()}`);
41
+ this.name = 'GitError';
42
+ this.args = args;
43
+ this.cwd = cwd;
44
+ this.exitCode = exitCode;
45
+ this.stderr = stderr;
46
+ }
47
+ }
48
+
49
+ async function git(args, cwd, { okCodes = [0] } = {}) {
50
+ try {
51
+ const { stdout } = await execFileAsync('git', args, {
52
+ cwd,
53
+ env: { ...process.env, ...GIT_ENV },
54
+ timeout: 15_000,
55
+ maxBuffer: 32 * 1024 * 1024,
56
+ });
57
+ return { stdout, exitCode: 0 };
58
+ } catch (error) {
59
+ const exitCode = typeof error.code === 'number' ? error.code : 1;
60
+ if (okCodes.includes(exitCode)) {
61
+ return { stdout: error.stdout ?? '', exitCode };
62
+ }
63
+ throw new GitError(args, cwd, exitCode, String(error.stderr ?? error.message));
64
+ }
65
+ }
66
+
67
+ const NOT_A_REPO_RE = /not a git repository/i;
68
+
69
+ /**
70
+ * What is this directory, in git's own eyes?
71
+ *
72
+ * Returns one of:
73
+ * { kind: 'not-a-repo', path }
74
+ * { kind: 'bare', path }
75
+ * { kind: 'worktree', path, toplevel, gitDir, commonDir,
76
+ * linked: boolean, superproject: string | null }
77
+ *
78
+ * `path` is always the realpath of the input. `linked` means a linked
79
+ * worktree (`git worktree add`); a non-empty `superproject` means the
80
+ * toplevel is a submodule of that superproject.
81
+ */
82
+ async function classifyDirectory(dirPath) {
83
+ const realPath = await fs.promises.realpath(dirPath);
84
+
85
+ let probe;
86
+ try {
87
+ probe = await git(['-C', realPath, 'rev-parse', '--is-inside-work-tree', '--is-bare-repository'], realPath);
88
+ } catch (error) {
89
+ if (error instanceof GitError && NOT_A_REPO_RE.test(error.stderr)) {
90
+ return { kind: 'not-a-repo', path: realPath };
91
+ }
92
+ throw error;
93
+ }
94
+ const [insideWorkTree, bare] = probe.stdout.trim().split('\n');
95
+ if (bare === 'true') return { kind: 'bare', path: realPath };
96
+ if (insideWorkTree !== 'true') {
97
+ // Inside a .git directory itself; the containing repo owns this path.
98
+ return { kind: 'not-a-repo', path: realPath };
99
+ }
100
+
101
+ const { stdout } = await git(
102
+ ['-C', realPath, 'rev-parse', '--show-toplevel', '--absolute-git-dir', '--git-common-dir'],
103
+ realPath,
104
+ );
105
+ const [toplevel, gitDir, commonDirRaw] = stdout.trim().split('\n');
106
+ const commonDir = path.resolve(realPath, commonDirRaw); // --git-common-dir prints relative to the cwd
107
+
108
+ const superprojectOut = await git(['-C', realPath, 'rev-parse', '--show-superproject-working-tree'], realPath);
109
+ const superproject = superprojectOut.stdout.trim() || null;
110
+
111
+ return {
112
+ kind: 'worktree',
113
+ path: realPath,
114
+ toplevel,
115
+ gitDir,
116
+ commonDir,
117
+ linked: gitDir !== commonDir && !superproject,
118
+ superproject,
119
+ };
120
+ }
121
+
122
+ /**
123
+ * All worktrees of the repo that owns `worktreeRoot`, from
124
+ * `git worktree list --porcelain`. Bare placeholder entries are dropped —
125
+ * they have no working files to card. Prunable entries (registered but
126
+ * gone from disk) are reported as git states them; callers decide.
127
+ */
128
+ async function listWorktrees(worktreeRoot) {
129
+ const { stdout } = await git(['-C', worktreeRoot, 'worktree', 'list', '--porcelain'], worktreeRoot);
130
+ const worktrees = [];
131
+ let current = null;
132
+ for (const line of stdout.split('\n')) {
133
+ if (line.startsWith('worktree ')) {
134
+ current = {
135
+ path: line.slice('worktree '.length),
136
+ bare: false,
137
+ detached: false,
138
+ branch: null,
139
+ head: null,
140
+ prunable: false,
141
+ locked: false,
142
+ };
143
+ worktrees.push(current);
144
+ } else if (line === 'bare') {
145
+ current.bare = true;
146
+ } else if (line === 'detached') {
147
+ current.detached = true;
148
+ } else if (line === 'prunable' || line.startsWith('prunable ')) {
149
+ current.prunable = true;
150
+ } else if (line === 'locked' || line.startsWith('locked ')) {
151
+ current.locked = true;
152
+ } else if (line.startsWith('HEAD ')) {
153
+ current.head = line.slice('HEAD '.length);
154
+ } else if (line.startsWith('branch ')) {
155
+ current.branch = line.slice('branch '.length).replace(/^refs\/heads\//, '');
156
+ }
157
+ }
158
+ return worktrees.filter((worktree) => !worktree.bare);
159
+ }
160
+
161
+ /**
162
+ * HEAD position and working-tree cleanliness in one `git status
163
+ * --porcelain=v2 -z --branch` call.
164
+ *
165
+ * head.oid and head.branch are null for an unborn branch and a detached
166
+ * HEAD respectively. ahead/behind are 0 whenever there is no upstream.
167
+ *
168
+ * Dirty counts are git status entries, exactly as git reports them: an
169
+ * untracked directory of many files is one entry, and a file that is
170
+ * staged and then edited again counts once as staged and once as
171
+ * unstaged. Counts answer "is there uncommitted work, roughly how much" —
172
+ * they are not a file census.
173
+ */
174
+ async function headAndDirty(worktreeRoot) {
175
+ const { stdout } = await git(['-C', worktreeRoot, 'status', '--porcelain=v2', '-z', '--branch'], worktreeRoot);
176
+
177
+ const head = { branch: null, oid: null, detached: false, upstream: null, ahead: 0, behind: 0 };
178
+ const dirty = { staged: 0, unstaged: 0, untracked: 0, conflicted: 0, submodulesChanged: 0 };
179
+
180
+ const tokens = stdout.split('\0');
181
+ for (let i = 0; i < tokens.length; i++) {
182
+ const token = tokens[i];
183
+ if (token.startsWith('# branch.oid ')) {
184
+ const oid = token.slice('# branch.oid '.length);
185
+ head.oid = oid === '(initial)' ? null : oid;
186
+ } else if (token.startsWith('# branch.head ')) {
187
+ const branch = token.slice('# branch.head '.length);
188
+ head.detached = branch === '(detached)';
189
+ head.branch = head.detached ? null : branch;
190
+ } else if (token.startsWith('# branch.upstream ')) {
191
+ head.upstream = token.slice('# branch.upstream '.length);
192
+ } else if (token.startsWith('# branch.ab ')) {
193
+ const [ahead, behind] = token.slice('# branch.ab '.length).split(' ');
194
+ head.ahead = Number(ahead.slice(1));
195
+ head.behind = Number(behind.slice(1));
196
+ } else if (token.startsWith('1 ') || token.startsWith('2 ')) {
197
+ const [, xy, sub] = token.split(' ');
198
+ if (xy[0] !== '.') dirty.staged += 1;
199
+ if (xy[1] !== '.') dirty.unstaged += 1;
200
+ if (sub[0] === 'S') dirty.submodulesChanged += 1;
201
+ if (token.startsWith('2 ')) i += 1; // rename records carry origPath in the next token
202
+ } else if (token.startsWith('u ')) {
203
+ dirty.conflicted += 1;
204
+ } else if (token.startsWith('? ')) {
205
+ dirty.untracked += 1;
206
+ }
207
+ }
208
+ return { head, dirty };
209
+ }
210
+
211
+ /** HEAD's commit right now, or null on an unborn branch. The coherence probe. */
212
+ async function headOid(worktreeRoot) {
213
+ const result = await git(['-C', worktreeRoot, 'rev-parse', '--verify', '--quiet', 'HEAD'], worktreeRoot, {
214
+ okCodes: [0, 1],
215
+ });
216
+ return result.exitCode === 0 ? result.stdout.trim() : null;
217
+ }
218
+
219
+ const OPERATION_MARKERS = [
220
+ ['rebase-merge', 'rebase'],
221
+ ['rebase-apply', 'rebase'],
222
+ ['MERGE_HEAD', 'merge'],
223
+ ['CHERRY_PICK_HEAD', 'cherry-pick'],
224
+ ['REVERT_HEAD', 'revert'],
225
+ ['BISECT_LOG', 'bisect'],
226
+ ];
227
+
228
+ /** Which multi-step git operation is mid-flight, if any. */
229
+ async function operationState(worktreeRoot) {
230
+ const markers = OPERATION_MARKERS.map(([marker]) => marker);
231
+ const { stdout } = await git(
232
+ ['-C', worktreeRoot, 'rev-parse', ...markers.flatMap((marker) => ['--git-path', marker])],
233
+ worktreeRoot,
234
+ );
235
+ const resolved = stdout.trim().split('\n');
236
+ for (let i = 0; i < OPERATION_MARKERS.length; i++) {
237
+ if (fs.existsSync(path.resolve(worktreeRoot, resolved[i]))) return OPERATION_MARKERS[i][1];
238
+ }
239
+ return 'none';
240
+ }
241
+
242
+ /** The configured origin URL, or null when the repo has no origin. */
243
+ async function originUrl(worktreeRoot) {
244
+ const result = await git(['-C', worktreeRoot, 'config', '--get', 'remote.origin.url'], worktreeRoot, {
245
+ okCodes: [0, 1],
246
+ });
247
+ return result.exitCode === 0 ? result.stdout.trim() : null;
248
+ }
249
+
250
+ /**
251
+ * The repo's root commit — the lexically first parentless commit, a stable
252
+ * identity for "same history" across clones. Only call on a born HEAD.
253
+ */
254
+ async function rootCommit(worktreeRoot) {
255
+ const { stdout } = await git(['-C', worktreeRoot, 'rev-list', '--max-parents=0', 'HEAD'], worktreeRoot);
256
+ return stdout.trim().split('\n').sort()[0];
257
+ }
258
+
259
+ /**
260
+ * Does git own this file? A file is git-owned when the worktree around it
261
+ * tracks it (`ls-files --error-unmatch`). Untracked files inside a repo
262
+ * and files outside any repo have no git owner.
263
+ * Returns { toplevel } for a git-owned file, null otherwise.
264
+ */
265
+ async function gitOwnerOf(filePath) {
266
+ const classified = await classifyDirectory(path.dirname(filePath));
267
+ if (classified.kind !== 'worktree') return null;
268
+ const tracked = await git(
269
+ ['ls-files', '--error-unmatch', '--', path.basename(filePath)],
270
+ path.dirname(filePath),
271
+ { okCodes: [0, 1] },
272
+ );
273
+ return tracked.exitCode === 0 ? { toplevel: classified.toplevel } : null;
274
+ }
275
+
276
+ module.exports = {
277
+ GitError,
278
+ classifyDirectory,
279
+ gitOwnerOf,
280
+ headAndDirty,
281
+ headOid,
282
+ listWorktrees,
283
+ operationState,
284
+ originUrl,
285
+ rootCommit,
286
+ };
@@ -0,0 +1,88 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Who observes, and which checkout is observed.
5
+ *
6
+ * machineId is the enrolled computer identity this runtime runs as — the
7
+ * same id the platform knows this machine by. replicaId is minted once
8
+ * per checkout and keyed by the checkout's own git directory, the one
9
+ * path git itself dedicates to exactly that checkout: two worktrees of a
10
+ * repo are two replicas, and a moved checkout is a new replica. Ids live
11
+ * only in our database; nothing is ever written into a repo.
12
+ */
13
+
14
+ const crypto = require('crypto');
15
+ const fs = require('fs');
16
+ const os = require('os');
17
+ const path = require('path');
18
+ const { AMALGM_COMPUTER_ID, AMALGM_DIR } = require('../config');
19
+ const { cachedStatement, openLocalDb } = require('../state/db');
20
+
21
+ let resolvedMachineId = null;
22
+ let resolvedUserId;
23
+
24
+ /**
25
+ * The identity other machines know this one by: the container/computer id
26
+ * from the environment, the enrolled computer record, or — on a machine
27
+ * that was never enrolled — a visibly local hostname identity.
28
+ */
29
+ function machineId() {
30
+ if (resolvedMachineId) return resolvedMachineId;
31
+ if (AMALGM_COMPUTER_ID) {
32
+ resolvedMachineId = AMALGM_COMPUTER_ID;
33
+ return resolvedMachineId;
34
+ }
35
+ try {
36
+ const record = JSON.parse(fs.readFileSync(path.join(AMALGM_DIR, 'computer.json'), 'utf8'));
37
+ if (record?.computer_id) {
38
+ resolvedMachineId = String(record.computer_id);
39
+ return resolvedMachineId;
40
+ }
41
+ } catch {
42
+ // Not enrolled; fall through to the visibly-local identity.
43
+ }
44
+ resolvedMachineId = `unenrolled:${os.hostname()}`;
45
+ return resolvedMachineId;
46
+ }
47
+
48
+ /**
49
+ * The account this machine is enrolled to, or null on a machine that was
50
+ * never enrolled. Cloud-facing features key on this; without it they
51
+ * simply do not start.
52
+ */
53
+ function userId() {
54
+ if (resolvedUserId !== undefined) return resolvedUserId;
55
+ try {
56
+ const record = JSON.parse(fs.readFileSync(path.join(AMALGM_DIR, 'computer.json'), 'utf8'));
57
+ resolvedUserId = record?.user_id ? String(record.user_id) : null;
58
+ } catch {
59
+ resolvedUserId = null;
60
+ }
61
+ return resolvedUserId;
62
+ }
63
+
64
+ /**
65
+ * The replica record for a checkout, minted on first sight.
66
+ *
67
+ * rootCommit is cached here the first time the repo's history has one —
68
+ * it is the repo's permanent identity anchor, so it is computed once per
69
+ * replica, never re-walked on refresh.
70
+ */
71
+ function replicaForGitDir(gitDir) {
72
+ const db = openLocalDb();
73
+ const existing = cachedStatement(db, 'SELECT * FROM repo_replicas WHERE git_dir = ?').get(gitDir);
74
+ if (existing) return { replicaId: existing.replica_id, rootCommit: existing.root_commit };
75
+ const replicaId = `rpl_${crypto.randomBytes(9).toString('base64url')}`;
76
+ cachedStatement(db, `
77
+ INSERT INTO repo_replicas (git_dir, replica_id, root_commit, created_at)
78
+ VALUES (?, ?, NULL, ?)
79
+ `).run(gitDir, replicaId, new Date().toISOString());
80
+ return { replicaId, rootCommit: null };
81
+ }
82
+
83
+ function cacheRootCommit(gitDir, rootCommitOid) {
84
+ cachedStatement(openLocalDb(), 'UPDATE repo_replicas SET root_commit = ? WHERE git_dir = ?')
85
+ .run(rootCommitOid, gitDir);
86
+ }
87
+
88
+ module.exports = { cacheRootCommit, machineId, replicaForGitDir, userId };
@@ -14,6 +14,9 @@ const {
14
14
  renameWorkspace,
15
15
  upsertWorkspace,
16
16
  } = require('./store');
17
+ const { listRepoCards, refreshRepoCards, repoCardServiceStats } = require('./cards');
18
+ const { machineId } = require('./identity');
19
+ const { readWall, wallStats } = require('./wall');
17
20
 
18
21
  const execFileAsync = promisify(execFile);
19
22
  const MAX_BUFFER = 10 * 1024 * 1024;
@@ -625,6 +628,63 @@ async function handleWorktreeDelete(body, sendJson) {
625
628
  sendJson(200, { success: true });
626
629
  }
627
630
 
631
+ function handleRepoCards(sendJson) {
632
+ try {
633
+ sendJson(200, { cards: listRepoCards() });
634
+ } catch (error) {
635
+ sendJson(500, { error: error instanceof Error ? error.message : 'Failed to list repo cards' });
636
+ }
637
+ }
638
+
639
+ async function handleRepoCardsRefresh(sendJson) {
640
+ try {
641
+ sendJson(200, await refreshRepoCards());
642
+ } catch (error) {
643
+ sendJson(500, { error: error instanceof Error ? error.message : 'Failed to refresh repo cards' });
644
+ }
645
+ }
646
+
647
+ function handleWorkspaceSyncStats(sendJson) {
648
+ try {
649
+ const cards = listRepoCards();
650
+ const health = { healthy: 0, missing: 0, broken: 0, inaccessible: 0 };
651
+ for (const card of cards) health[card.health] = (health[card.health] || 0) + 1;
652
+ sendJson(200, {
653
+ machine: machineId(),
654
+ cards: { total: cards.length, ...health },
655
+ service: repoCardServiceStats(),
656
+ wall: wallStats(),
657
+ });
658
+ } catch (error) {
659
+ sendJson(500, { error: error instanceof Error ? error.message : 'Failed to read sync stats' });
660
+ }
661
+ }
662
+
663
+ /**
664
+ * The whole wall: this machine's cards straight from the live store, every
665
+ * peer's cards as last published. One call answers "what code does each of
666
+ * my machines have right now".
667
+ */
668
+ function handleWorkspaceMachines(sendJson) {
669
+ try {
670
+ const self = machineId();
671
+ const machines = [
672
+ { machine: self, self: true, publishedAt: null, cards: listRepoCards() },
673
+ ...readWall()
674
+ .filter((entry) => entry.machine !== self)
675
+ .map((entry) => ({
676
+ machine: entry.machine,
677
+ self: false,
678
+ publishedAt: entry.publishedAt,
679
+ cards: entry.cards,
680
+ })),
681
+ ];
682
+ sendJson(200, { self, machines });
683
+ } catch (error) {
684
+ sendJson(500, { error: error instanceof Error ? error.message : 'Failed to read the machine wall' });
685
+ }
686
+ }
687
+
628
688
  module.exports = {
629
689
  getWorkspaceRoot,
630
690
  handleProjects,
@@ -639,4 +699,8 @@ module.exports = {
639
699
  handleWorktreeList,
640
700
  handleWorktreeCreate,
641
701
  handleWorktreeDelete,
702
+ handleRepoCards,
703
+ handleRepoCardsRefresh,
704
+ handleWorkspaceMachines,
705
+ handleWorkspaceSyncStats,
642
706
  };