heyquilt 0.3.118 → 0.3.120

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.
Files changed (44) hide show
  1. package/RELEASES.md +25 -0
  2. package/package.json +1 -1
  3. package/src/api/server.js +10 -1
  4. package/src/quilts/archiver.js +8 -1
  5. package/src/quilts/autorevert.js +49 -0
  6. package/src/quilts/bisect-run.js +31 -0
  7. package/src/quilts/block.js +133 -14
  8. package/src/quilts/bucket.js +25 -0
  9. package/src/quilts/claims.js +144 -0
  10. package/src/quilts/cli.js +206 -11
  11. package/src/quilts/client.js +37 -1
  12. package/src/quilts/describe.js +149 -3
  13. package/src/quilts/dogfood.js +1 -1
  14. package/src/quilts/errors.js +8 -0
  15. package/src/quilts/events.js +19 -5
  16. package/src/quilts/export-git.js +5 -1
  17. package/src/quilts/fastmerge.js +2 -2
  18. package/src/quilts/hash.js +1 -1
  19. package/src/quilts/history.js +266 -0
  20. package/src/quilts/import-git.js +154 -56
  21. package/src/quilts/import.js +56 -20
  22. package/src/quilts/index.js +34 -2
  23. package/src/quilts/lines.js +49 -0
  24. package/src/quilts/live.js +50 -0
  25. package/src/quilts/merge-smart.js +114 -0
  26. package/src/quilts/merge.js +75 -27
  27. package/src/quilts/objects.js +17 -12
  28. package/src/quilts/records.js +2 -1
  29. package/src/quilts/resolutions.js +69 -0
  30. package/src/quilts/reviews.js +195 -0
  31. package/src/quilts/routes.js +51 -14
  32. package/src/quilts/service.js +133 -25
  33. package/src/quilts/setup.js +2 -2
  34. package/src/quilts/signing.js +149 -0
  35. package/src/quilts/store-memory.js +152 -0
  36. package/src/quilts/store-rpc.js +16 -1
  37. package/src/quilts/tags.js +64 -0
  38. package/src/quilts/tokens.js +66 -0
  39. package/src/quilts/tools.js +195 -19
  40. package/src/quilts/webhook.js +116 -0
  41. package/src/server.js +2 -2
  42. package/src/session.js +4 -0
  43. package/src/{quilts → source-live}/live-block.js +4 -3
  44. package/src/{quilts → source-live}/live-square.js +2 -1
package/RELEASES.md CHANGED
@@ -11,6 +11,31 @@ Format: `## <version> — <YYYY-MM-DD>`, an optional one-line summary, then bull
11
11
  Lead each bullet with a short bold phrase. Inline `code` and **bold** are rendered;
12
12
  nothing else is.
13
13
 
14
+ ## 0.3.120 — 2026-10-26
15
+
16
+ Seam gets nearly everything its docs said was missing.
17
+
18
+ - **Seam release lines**: `seam line new 1.0 --from patch:780` makes `release/1.0` from any revision; squares open on it (`--line`) and merge into it apart from `main`; `seam line freeze` stops merges into a line until it is unfrozen. Also as `seam_line_*` tools and `GET`/`POST /lines`.
19
+ - **Seam tags and releases**: `seam tag v1.0 patch:780` names a commit for good (read it as `tag:v1.0` anywhere a revision goes); `seam release v1.1 -m "notes"` is a tag with release notes. `seam tag` and `seam release` alone list them. Also as `seam_tag`, `seam_release` and their `_list` tools, and `GET`/`POST /tags` and `/releases`.
20
+ - **Seam imports all of a git repo**: `seam import git` now brings every branch (as a line with the branch's name, remote-only branches of a clone too) and every tag, keeps submodules as gitlink entries and LFS pointer files as they are, and run again into the same quilt adds only the new commits. Also as the `seam_import_git` tool.
21
+ - **Seam block folders: restore, reset, clean**: `seam restore <paths>` throws away uncommitted edits, `seam reset [rev]` makes the folder's files those of a revision (commit the difference to undo a square's last commit), and `seam clean [-f]` removes files nobody committed. Every `.gitignore` in a block folder is read now, not only the top one, and a committed file is never ignored. Also as `seam_restore`, `seam_reset` and `seam_clean` tools.
22
+ - **Seam live work and `^` on squares**: `seam live share` puts a block folder's uncommitted edits on the server so anyone can read them as `sq:<id>:live` (`seam ls`, `cat`, `diff`) before they are committed; `seam live` shows them and `seam live clear` takes them back. `sq:<id>^` and `sq:<id>^2` name a square head's parents. Also as `seam_live` / `seam_live_set` and `GET`/`PUT /squares/:id/live`.
23
+ - **Seam history tools**: `seam bisect <bad> <good> --run "<check>"` finds the commit that broke something (each step checked out and tested in a fresh folder); `seam grep` searches the files at any revision; `seam log --follow` follows a file through renames and `seam log -S <text>` finds where text came or went; `seam describe` and `seam merge-base` as in git. The same as `seam_bisect`, `seam_grep`, `seam_describe`, `seam_merge_base` and HTTP
24
+ - **Seam amend**: `seam commit --amend [-m "message"]` changes your square's last commit instead of adding one (a forgotten file, a better message); `seam_commit` with `amend: true` and `POST /squares/<s>/amend` do the same
25
+ - **Seam claims**: `seam claim "src/api/**" --note "why"` claims files, folders or globs for your square, so other squares' commits to them on the same line are refused (`claim_refused`) until it merges or `seam unclaim`; `seam claim` lists them. `seam_claim`, `seam_claims`, `seam_unclaim` and `/claims` routes do the same
26
+ - **Seam reverts a patch that broke a check**: `seam check ci red --patch 784` (or `seam_check`, or `POST /lines/main/checks` with `patch`) reverts that patch on the line by itself and hands its holder a square holding the change again to fix; they get a `patch.reverted` event. Without `--patch` a red check only halves the merge window, as before
27
+ - **Seam remembers how a conflict was settled**: settle a conflict once (by hand or with `seam resolve`) and the same conflict coming back in a later rebase or merge is settled the same way, naming who settled it. `seam resolutions` lists them, `seam resolutions forget <key>` drops one, `--no-remember` skips it (also `seam_resolutions`, `seam_forget_resolution`, `GET /resolutions`)
28
+ - **Seam stacks squares**: `seam square open <title> --on sq:…` (or `seam_open_square { on }`, `POST /squares { on }`) starts your square from another open square's work. It can't merge before that one, and when that one merges yours is moved onto the line by itself
29
+ - **Seam review**: `seam review approve|request-changes|comment|dismiss <square>` reviews a square (a comment can sit on one line of a file), `seam review <square>` shows the reviews, and `seam line require main 2` makes a line refuse merges (`428 policy`) until a square's latest commit has two approvals from people other than its holder and nobody's request for changes stands. Also as `seam_review`, `seam_reviews`, `seam_line_require` and `/squares/<s>/reviews`, `/lines/<line>/policy`.
30
+ - **Seam required checks**: `seam line require main --checks ci,lint` makes a line refuse merges (`428 policy`) until each named check is green on a square's latest commit; CI reports with `seam check ci green --square <sq>` (or `seam_check { square }`, `POST /squares/<s>/checks`). Seam doesn't run the checks, the runner reports them.
31
+ - **Seam signed commits**: `seam key add` makes an Ed25519 key on your computer and adds it to your account, `seam commit --sign` (or `seam sign <rev>`) signs a commit you made, and `seam log --show-signature` checks each one: good, bad, removed key or none. Also as `seam_key_add`, `seam_sign`, `seam_commit { sign }`, `/v1/signing-keys` and `POST /commits/<rev>/signatures`.
32
+ - **Seam code search**: `seam grep --all <pattern>` searches every quilt you can read at once (`--quilts a,b` for some), at main or any revision, and prints the quilt before each file. Also as `seam_search` and `GET /v1/search`.
33
+ - **Seam quilt tokens**: `seam token new ci --level write` makes a key for one quilt (read or write) for a CI job or script; its `sqt_` secret is shown once and works as `SEAM_TOKEN` or `Authorization: Bearer`. `seam token` lists them and `seam token revoke ci` ends one at once. Also as `seam_token_new`, `seam_token_list`, `seam_token_revoke` and `/tokens` routes.
34
+
35
+ ## 0.3.119 — 2026-10-25
36
+
37
+ - **Your cloud AI no longer answers old messages again.** Each time its machine started, your cloud AI took messages already written to it for new ones (it learnt its name only after looking), so it did old work again, sometimes for half an hour, while new messages waited. It now answers only what was written while it was away.
38
+
14
39
  ## 0.3.118 — 2026-10-25
15
40
 
16
41
  - **A new name for your AI shows at once.** Renaming your cloud AI (or changing its color) now makes it join its sessions again under the new name within seconds, once it isn't in the middle of a run, so @its new name reaches it. Before, it kept its old name for up to five minutes and then dropped out of the session's people list.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "heyquilt",
3
- "version": "0.3.118",
3
+ "version": "0.3.120",
4
4
  "description": "Real-time, tool-agnostic pair vibe coding: sync a project folder live between collaborators using any AI coding tool.",
5
5
  "author": {
6
6
  "name": "Daniel Carmichael",
package/src/api/server.js CHANGED
@@ -48,6 +48,7 @@ import { voiceRoutes } from './routes/voice.js'
48
48
  import { mailRoutes } from './routes/mail.js'
49
49
  import { agentWakeRoutes } from './routes/agent-wake.js'
50
50
  import { quiltRoutes } from '../quilts/routes.js'
51
+ import { workspaceReach } from './workspace-reach.js'
51
52
  import { leader, LEADER_ONLY } from './leader.js'
52
53
  import { autoscale } from './autoscale.js'
53
54
  import { scaler as machineScaler } from './scaler.js'
@@ -416,7 +417,15 @@ export function startApi ({ port = 0, host = '127.0.0.1', store, verifyUser, sit
416
417
  // How Quilt wakes each agent (routes/agent-wake.js): step 2 of joining.
417
418
  routes.push(...agentWakeRoutes({ ...ctx, passKey, mail, mailFetch, makeLimiter }))
418
419
  // Quilt source control (src/quilts/, plans/quilt-source-control/): off unless startApi gets `quilts`.
419
- routes.push(...quiltRoutes({ ...ctx, passKey, quilts }))
420
+ // Seam takes who is calling from us, as a quilt member: { member_id, kind, name, tool?, on_behalf_of? }.
421
+ const quiltCaller = quilts ? workspaceReach({ ...ctx, passKey }) : null
422
+ const identifyQuiltMember = async (req) => {
423
+ const c = await quiltCaller.caller(req)
424
+ if (c.agent) return { member_id: c.account, kind: 'agent', name: c.agent.name || 'agent', ...(c.agent.provider ? { tool: String(c.agent.provider).toLowerCase() } : {}), ...(c.agent.ownerUserId ? { on_behalf_of: `person:${c.agent.ownerUserId}` } : {}) }
425
+ const p = c.userId && store.profile ? await store.profile(c.userId).catch(() => null) : null
426
+ return { member_id: c.account, kind: 'person', name: (p && p.name) || 'person' }
427
+ }
428
+ routes.push(...quiltRoutes({ quilts, identify: identifyQuiltMember }))
420
429
 
421
430
  async function openLink (code) {
422
431
  const userCode = normalizeUserCode(code)
@@ -158,9 +158,16 @@ export async function replaySegments (objects, q) {
158
158
  if (p && p.hidden_seq == null) p.hidden_seq = c.seq
159
159
  }
160
160
  for (const row of log) {
161
+ // A line made from a commit, and a line frozen or unfrozen (policy rows naming the line).
162
+ if (row.kind === 'policy' && row.line && row.payload) {
163
+ const op = row.payload.op
164
+ if (op === 'line') lines.set(row.line, { name: row.line, tip: row.payload.tip || null, tip_seq: row.seq, patch_count: 0, frozen: false })
165
+ else if ((op === 'freeze' || op === 'unfreeze') && lines.has(row.line)) lines.get(row.line).frozen = op === 'freeze'
166
+ continue
167
+ }
161
168
  if (row.kind !== 'merge') continue
162
169
  const id = row.commit || row.to || row.commit_id
163
- lines.set(row.line, { name: row.line, tip: id, tip_seq: row.seq, patch_count: row.patch_no })
170
+ lines.set(row.line, { ...lines.get(row.line), name: row.line, tip: id, tip_seq: row.seq, patch_count: row.patch_no })
164
171
  const c = commits.get(id)
165
172
  if (c) { c.patch = { line: row.line, patch_no: row.patch_no }; if (row.snapshot_root) c.snapshot_root = row.snapshot_root }
166
173
  }
@@ -0,0 +1,49 @@
1
+ // The automatic revert (plans/quilt-source-control/07 "The post-merge pipeline", steps 3 and 4).
2
+ // A red post-merge check that names the patch it failed at (`patch`, found by the runner, for
3
+ // example with `seam bisect`) reverts that patch on the line by itself: a revert square is opened
4
+ // and merged as the reporter, and a fix square holding the change again is opened for the
5
+ // patch's holder, so fixing forward is "edit, commit, merge". The patch stays in history. The
6
+ // revert is an ordinary revert and merge, so a frozen line or a full merge window refuses it like
7
+ // any merge and the revert square is left open; a revert that conflicts with later patches is
8
+ // left open too, with its terms.
9
+ import { QuiltError } from './service.js'
10
+
11
+ export function autoRevertOps (svc, { revert, mergeIntoLine, cherryPick, openSquare, handoff }) {
12
+ const { store, quiltFor, resolve, getCommit, squareView } = svc.internals
13
+
14
+ async function revertCulprit (me, ref, line, check, n) {
15
+ const q = await quiltFor(me, ref, 'write')
16
+ const row = await store.patch(q.id, line, n)
17
+ if (!row || !row.commit) throw new QuiltError(404, 'not_found', `${line} has no patch ${n}`)
18
+ const culprit = await resolve(q.id, `${line}:${n}`)
19
+ const x = await getCommit(q.id, culprit)
20
+ const first = String(x.message || '').split('\n')[0]
21
+ let r
22
+ try {
23
+ r = await revert(me, ref, { rev: `${line}:${n}`, line, message: `Revert patch ${line}:${n}: ${check} failed\n\nThis reverts patch ${line}:${n} (${first}).` })
24
+ } catch (err) {
25
+ if (err.code === 'bad_request') return { reverted: false, patch_no: n, refused: { code: 'nothing_to_revert', detail: err.message } }
26
+ throw err
27
+ }
28
+ const out = { reverted: false, patch_no: n, square: r.square }
29
+ if (r.conflicted) return { ...out, refused: { code: 'conflict', detail: `the revert conflicts with later patches in ${r.terms.map((t) => t.path).join(', ')}; resolve and merge the revert square` } }
30
+ try {
31
+ const m = await mergeIntoLine(me, ref, line, { square: r.square.id }, { reverts: n })
32
+ if (!m.ok) return { ...out, square: m.square || r.square, refused: m.refused }
33
+ Object.assign(out, { reverted: true, revert_patch_no: m.merged.patch_no, square: m.square })
34
+ } catch (err) {
35
+ if (!err.code || !err.status) throw err
36
+ return { ...out, refused: { code: err.code, detail: err.message } }
37
+ }
38
+ // The fix square: the change again over the tip after the revert, held by the patch's holder.
39
+ const orig = row.square ? await store.getSquare(row.square) : null
40
+ const holder = orig && !orig.import ? orig.holder : null
41
+ const fix = await openSquare(me, ref, { title: `Fix ${first || `patch ${line}:${n}`}`.slice(0, 200), line })
42
+ await cherryPick(me, ref, fix.id, { from: culprit, expect_head: null, message: x.message })
43
+ if (holder && holder !== me.member_id) await handoff(me, ref, fix.id, { to: holder, context: `Patch ${line}:${n} was reverted: ${check} failed. This square holds the change again: fix it, commit, merge.` })
44
+ out.fix_square = await squareView(await store.getSquare(fix.id))
45
+ return out
46
+ }
47
+
48
+ return { revertCulprit }
49
+ }
@@ -0,0 +1,31 @@
1
+ // The check side of bisect (the server narrows, history.js): each commit the server names is
2
+ // checked out into a fresh folder and the check command runs there. Exit 0 is good, 125 is skip,
3
+ // 1 to 127 is bad, anything else (a signal, 128 and up) stops, as git bisect run does.
4
+ import fs from 'node:fs'
5
+ import os from 'node:os'
6
+ import path from 'node:path'
7
+ import { spawn } from 'node:child_process'
8
+ import { checkout } from './block.js'
9
+
10
+ const runIn = (cmd, cwd) => new Promise((resolve, reject) => {
11
+ const child = spawn(cmd, { cwd, shell: true, stdio: 'ignore' })
12
+ child.on('error', reject)
13
+ child.on('exit', (code, signal) => resolve(signal ? null : code))
14
+ })
15
+
16
+ export async function bisectRun (client, { quilt, bad, good, run, marks = {}, paths = [''], api, log = () => {} }) {
17
+ const m = { ...marks }
18
+ for (let i = 0; i < 200; i++) {
19
+ const r = await client.bisect(quilt, { bad, good, marks: m })
20
+ if (r.done) return { ...r, marks: m }
21
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'seam-bisect-'))
22
+ try {
23
+ await checkout(client, { quilt, rev: r.next.id, paths, dir, api })
24
+ const code = await runIn(run, dir)
25
+ if (code == null || code > 127) throw new Error(`The check stopped on ${r.next.short} (exit ${code ?? 'by a signal'}): bisect stopped.`)
26
+ m[r.next.id] = code === 0 ? 'good' : code === 125 ? 'skip' : 'bad'
27
+ log(`${r.next.short} ${m[r.next.id]}`)
28
+ } finally { fs.rmSync(dir, { recursive: true, force: true }) }
29
+ }
30
+ throw new Error('bisect took more than 200 steps')
31
+ }
@@ -35,29 +35,57 @@ export function saveBlock (dir, block) {
35
35
 
36
36
  const inBlock = (block, p) => block.paths.some((x) => under(p, x))
37
37
 
38
- function ignorer (dir) {
39
- const ig = ignore().add(ALWAYS_IGNORED)
40
- try { ig.add(fs.readFileSync(path.join(dir, '.gitignore'), 'utf8')) } catch {}
41
- return ig
38
+ /** The folders that hold committed files: they are walked even when a .gitignore names them. */
39
+ function trackedDirs (block) {
40
+ const out = new Set()
41
+ for (const p of Object.keys(block.files)) for (let i = p.indexOf('/'); i > 0; i = p.indexOf('/', i + 1)) out.add(p.slice(0, i))
42
+ return out
43
+ }
44
+
45
+ /** Reads `rel`/.gitignore into the stack of rules in force there (deepest last). */
46
+ function rulesIn (dir, rel, stack) {
47
+ try { return [...stack, { base: rel, ig: ignore().add(fs.readFileSync(path.join(dir, rel, '.gitignore'), 'utf8')) }] } catch { return stack }
48
+ }
49
+
50
+ /** As git decides: the deepest .gitignore with a rule for the path wins (a `!` rule re-includes). */
51
+ function ignoredBy (stack, r, isDir) {
52
+ for (let i = stack.length - 1; i >= 0; i--) {
53
+ const { base, ig } = stack[i]
54
+ const rel = base ? r.slice(base.length + 1) : r
55
+ const res = ig.test(isDir ? rel + '/' : rel)
56
+ if (res.ignored) return true
57
+ if (res.unignored) return false
58
+ }
59
+ return false
42
60
  }
43
61
 
44
- /** Every file under the block's paths on disk: Map path → { abs, stat }. */
62
+ const ALWAYS = ignore().add(ALWAYS_IGNORED)
63
+
64
+ /**
65
+ * Every file under the block's paths on disk: Map path → { abs, stat }. Every .gitignore on the
66
+ * way down is read, as git does; a committed file is never ignored.
67
+ */
45
68
  function scan (dir, block) {
46
- const ig = ignorer(dir)
47
69
  const out = new Map()
48
- const walk = (rel) => {
70
+ const dirs = trackedDirs(block)
71
+ const walk = (rel, stack) => {
49
72
  let names
50
73
  try { names = fs.readdirSync(path.join(dir, rel), { withFileTypes: true }) } catch { return }
51
74
  for (const d of names) {
52
75
  const r = rel ? `${rel}/${d.name}` : d.name
53
- if (ig.ignores(d.isDirectory() ? r + '/' : r)) continue
54
- if (d.isDirectory()) { if (block.paths.some((x) => under(r, x) || under(x, r))) walk(r); continue }
76
+ const n = r.normalize('NFC')
77
+ const isDir = d.isDirectory()
78
+ if (ALWAYS.ignores(isDir ? r + '/' : r)) continue
79
+ if (!(isDir ? dirs.has(n) : block.files[n]) && ignoredBy(stack, r, isDir)) continue
80
+ // A submodule's folder stands for its gitlink entry: what is inside belongs to the submodule.
81
+ if (isDir && block.files[n]?.mode === 'gitlink') { out.set(n, { abs: path.join(dir, r), gitlink: true }); continue }
82
+ if (isDir) { if (block.paths.some((x) => under(r, x) || under(x, r))) walk(r, rulesIn(dir, r, stack)); continue }
55
83
  if (!(d.isFile() || d.isSymbolicLink()) || !inBlock(block, r)) continue
56
84
  const abs = path.join(dir, r)
57
- out.set(r.normalize('NFC'), { abs, stat: fs.lstatSync(abs) })
85
+ out.set(n, { abs, stat: fs.lstatSync(abs) })
58
86
  }
59
87
  }
60
- walk('')
88
+ walk('', rulesIn(dir, '', []))
61
89
  return out
62
90
  }
63
91
 
@@ -75,8 +103,8 @@ export function localChanges (dir, block, { paths = [] } = {}) {
75
103
  const wanted = (p) => !only.length || only.some((x) => under(p, x))
76
104
  const files = scan(dir, block)
77
105
  const out = []
78
- for (const [p, { abs, stat }] of files) {
79
- if (!wanted(p)) continue
106
+ for (const [p, { abs, stat, gitlink }] of files) {
107
+ if (!wanted(p) || gitlink) continue
80
108
  const known = block.files[p]
81
109
  if (known && known.size === stat.size && known.mtimeMs === stat.mtimeMs) continue
82
110
  const { bytes, mode } = readFileEntry(abs, stat)
@@ -94,6 +122,8 @@ async function writeEntry (dir, e, content) {
94
122
  const abs = path.join(dir, ...e.path.split('/'))
95
123
  fs.mkdirSync(path.dirname(abs), { recursive: true })
96
124
  try { fs.rmSync(abs, { force: true }) } catch {}
125
+ // A git submodule is an empty folder, as git leaves one it has not cloned.
126
+ if (e.mode === 'gitlink') { fs.mkdirSync(abs, { recursive: true }); return { blob: e.blob, mode: 'gitlink', size: e.size, mtimeMs: 0 } }
97
127
  if (e.mode === 'symlink') fs.symlinkSync(content.toString('utf8'), abs)
98
128
  else fs.writeFileSync(abs, content, { mode: e.mode === 'exec' ? 0o755 : 0o644 })
99
129
  const st = fs.lstatSync(abs)
@@ -156,7 +186,11 @@ export async function syncTo (client, dir, block, to, { square } = {}) {
156
186
  for (const f of d.files) {
157
187
  if (local.has(f.path)) { kept.push(f.path); continue }
158
188
  const e = byPath.get(f.path)
159
- if (!e) { fs.rmSync(path.join(dir, ...f.path.split('/')), { force: true }); delete block.files[f.path]; written++; continue }
189
+ if (!e) {
190
+ const abs = path.join(dir, ...f.path.split('/'))
191
+ if (block.files[f.path]?.mode === 'gitlink') { try { fs.rmdirSync(abs) } catch {} } else fs.rmSync(abs, { force: true })
192
+ delete block.files[f.path]; written++; continue
193
+ }
160
194
  block.files[f.path] = await writeEntry(dir, e, await get(e))
161
195
  written++
162
196
  }
@@ -213,6 +247,25 @@ export async function commitFolder (client, dir, block, { message, paths = [] })
213
247
  return r
214
248
  }
215
249
 
250
+ /** Amends the square's last commit with the folder's changes (paths: only these) and, if given, a new message. */
251
+ export async function amendFolder (client, dir, block, { message, paths = [] }) {
252
+ if (!block.square) throw new Error('This folder is a read-only checkout of a line; there is no commit of yours to amend.')
253
+ const changes = localChanges(dir, block, { paths })
254
+ const blobs = changes.filter((c) => c.bytes).map((c) => ({ kind: 'blob', id: c.blob, bytes: c.bytes }))
255
+ if (blobs.length) await client.upload(block.quilt, blobs)
256
+ const list = changes.map((c) => c.status === 'deleted' ? { path: c.path, op: 'delete' } : { path: c.path, op: 'put', blob: c.blob, mode: c.mode || 'file' })
257
+ const r = await client.amend(block.quilt, block.square, { expect_head: block.head, ...(message ? { message } : {}), changes: list })
258
+ for (const c of changes) {
259
+ if (c.status === 'deleted') { delete block.files[c.path]; continue }
260
+ const st = fs.lstatSync(path.join(dir, ...c.path.split('/')))
261
+ block.files[c.path] = { blob: c.blob, mode: c.mode, size: st.size, mtimeMs: st.mtimeMs }
262
+ }
263
+ block.at = r.square.head
264
+ block.head = r.square.head
265
+ saveBlock(dir, block)
266
+ return r
267
+ }
268
+
216
269
  /** The folder's changes as a unified diff against `at` (diff with no revision, in a block). */
217
270
  export async function localDiff (client, dir, block, { paths = [] } = {}) {
218
271
  const changes = localChanges(dir, block, { paths })
@@ -226,3 +279,69 @@ export async function localDiff (client, dir, block, { paths = [] } = {}) {
226
279
  }
227
280
  return { changes: changes.map(({ bytes, ...c }) => c), text: out.join('') }
228
281
  }
282
+
283
+ const absOf = (dir, p) => path.join(dir, ...p.split('/'))
284
+ const removeEntry = (dir, p, mode) => { const abs = absOf(dir, p); if (mode === 'gitlink') { try { fs.rmdirSync(abs) } catch {} } else fs.rmSync(abs, { force: true }) }
285
+
286
+ /** Takes away the folders a removed file leaves empty, up to the block folder. */
287
+ function pruneEmpty (dir, p) {
288
+ for (let d = path.dirname(absOf(dir, p)); d.startsWith(dir + path.sep); d = path.dirname(d)) {
289
+ try { if (fs.readdirSync(d).length) return; fs.rmdirSync(d) } catch { return }
290
+ }
291
+ }
292
+
293
+ /**
294
+ * restore: puts the named paths back as they were at the folder's last commit (`at`), throwing
295
+ * away their uncommitted edits and deletions. Files nobody committed are left (clean removes them).
296
+ */
297
+ export async function restore (client, dir, block, { paths }) {
298
+ if (!paths?.length) throw new Error('Which files? seam restore <paths> (. for everything here)')
299
+ const changes = localChanges(dir, block, { paths }).filter((c) => c.status !== 'added')
300
+ const entries = changes.map((c) => ({ path: c.path, ...block.files[c.path] }))
301
+ const get = await contentsFor(client, block.quilt, `c:${block.at}`, entries)
302
+ for (const e of entries) block.files[e.path] = { ...await writeEntry(dir, e, await get(e)), ...(e.term ? { term: e.term } : {}) }
303
+ saveBlock(dir, block)
304
+ return { restored: entries.map((e) => e.path) }
305
+ }
306
+
307
+ /**
308
+ * reset: makes the folder's files those of `rev` (default: the folder's last commit, throwing
309
+ * away every uncommitted edit). The folder stays on its square or line: what differs from there
310
+ * shows as uncommitted changes, so a commit makes the square match `rev`. Files nobody committed
311
+ * stay unless `rev` has them.
312
+ */
313
+ export async function reset (client, dir, block, { rev } = {}) {
314
+ if (!rev) {
315
+ const r = await restore(client, dir, block, { paths: [dir] })
316
+ return { rev: block.at, written: r.restored.length, removed: 0, changes: 0 }
317
+ }
318
+ const t = await client.tree(block.quilt, rev, block.paths)
319
+ const local = new Map(localChanges(dir, block).map((c) => [c.path, c]))
320
+ const want = new Map(t.entries.map((e) => [e.path, e]))
321
+ const toWrite = t.entries.filter((e) => {
322
+ const known = block.files[e.path]
323
+ return local.has(e.path) || !known || known.blob !== e.blob || known.mode !== e.mode || (e.term || null) !== (known.term || null)
324
+ })
325
+ const get = await contentsFor(client, block.quilt, rev, toWrite)
326
+ let removed = 0
327
+ for (const e of toWrite) {
328
+ const rec = await writeEntry(dir, e, await get(e))
329
+ const known = block.files[e.path]
330
+ // Same as the folder's commit: clean again. Otherwise the manifest keeps the commit's record,
331
+ // so the file shows as changed.
332
+ if (known && known.blob === e.blob && known.mode === e.mode) block.files[e.path] = rec
333
+ }
334
+ for (const [p, f] of Object.entries(block.files)) {
335
+ if (want.has(p) || !fs.existsSync(absOf(dir, p))) continue
336
+ removeEntry(dir, p, f.mode); pruneEmpty(dir, p); removed++
337
+ }
338
+ saveBlock(dir, block)
339
+ return { rev: t.commit, written: toWrite.length, removed, changes: localChanges(dir, block).length }
340
+ }
341
+
342
+ /** clean: removes the files under `paths` that nobody committed and no .gitignore names. */
343
+ export function clean (dir, block, { paths = [], dryRun = false } = {}) {
344
+ const added = localChanges(dir, block, { paths }).filter((c) => c.status === 'added').map((c) => c.path)
345
+ if (!dryRun) for (const p of added) { removeEntry(dir, p, 'file'); pruneEmpty(dir, p) }
346
+ return { removed: added, dry_run: dryRun }
347
+ }
@@ -0,0 +1,25 @@
1
+ // A private Supabase Storage bucket for a quilt's packs: write-once signed uploads, short-lived
2
+ // signed downloads, listing. Ids come from the content, so an upload never replaces what is there.
3
+ import { createClient } from '@supabase/supabase-js'
4
+
5
+ const LINK_S = 10 * 60
6
+
7
+ export class SupabaseBucket {
8
+ constructor ({ url, key, bucket }) {
9
+ this.bucket = createClient(url, key, { auth: { persistSession: false, autoRefreshToken: false } }).storage.from(bucket)
10
+ }
11
+
12
+ // Supabase signed uploads can't bound size: the bucket's file_size_limit caps each object.
13
+ async uploadTarget (room, name) {
14
+ const { data, error } = await this.bucket.createSignedUploadUrl(`${room}/${name}`, { upsert: false })
15
+ if (error && (String(error.statusCode) === '409' || /exists|duplicate/i.test(String(error.message)))) return { exists: true }
16
+ if (error) throw new Error(`storage: ${error.message}`)
17
+ return { method: 'PUT', url: data.signedUrl }
18
+ }
19
+
20
+ async downloadTarget (room, name) {
21
+ const { data, error } = await this.bucket.createSignedUrl(`${room}/${name}`, LINK_S)
22
+ if (error) throw new Error(`storage: ${error.message}`)
23
+ return { url: data.signedUrl }
24
+ }
25
+ }
@@ -0,0 +1,144 @@
1
+ // Claims on a square's files (plans/quilt-source-control/06, "Claims on squares"): a claim says
2
+ // "this square is working on these paths" so nobody else spends work on them. A write under a
3
+ // claimed pattern from another open square on the same line is refused (412 claim_refused) and the
4
+ // writer is told who holds it and why. A claim lasts while its square is open: merging, abandoning
5
+ // or archiving the square lets go of it. Merges never check claims; overlapping work that was
6
+ // written before a claim existed still meets at the merge (07).
7
+ import { QuiltError, squareShort } from './errors.js'
8
+ import { uuidv7 } from './ids.js'
9
+
10
+ export const MAX_PATTERN = 500
11
+ export const MAX_NOTE = 300
12
+ export const MAX_CLAIMS = 200 // per square
13
+
14
+ /** A path, folder or simple glob (*, **, ?) as a matcher over file paths. */
15
+ export function globMatcher (pattern) {
16
+ const p = String(pattern).trim().replace(/^\.\//, '')
17
+ if (!/[*?]/.test(p)) {
18
+ const base = p.replace(/\/+$/, '')
19
+ return (rel) => rel === base || rel.startsWith(base + '/')
20
+ }
21
+ let re = ''
22
+ for (let i = 0; i < p.length; i++) {
23
+ const c = p[i]
24
+ if (c === '*' && p[i + 1] === '*') {
25
+ re += '.*'
26
+ i++
27
+ if (p[i + 1] === '/') i++
28
+ } else if (c === '*') re += '[^/]*'
29
+ else if (c === '?') re += '[^/]'
30
+ else re += c.replace(/[.+^${}()|[\]\\]/g, '\\$&')
31
+ }
32
+ const rx = new RegExp(`^${re}$`)
33
+ return (rel) => rx.test(rel)
34
+ }
35
+
36
+ /** Whether two patterns can cover the same file (paths: files that exist now). */
37
+ export function patternsOverlap (a, b, paths = []) {
38
+ if (a === b) return true
39
+ const ma = globMatcher(a)
40
+ const mb = globMatcher(b)
41
+ if (ma(b) || mb(a) || paths.some((p) => ma(p) && mb(p))) return true
42
+ // A glob reaches every file under the text before its first wildcard: when one pattern's stem
43
+ // is inside the other's, they may meet on a file that does not exist yet.
44
+ const stem = (p) => p.split(/[*?]/)[0]
45
+ const [sa, sb] = [stem(a), stem(b)]
46
+ if (sa === a && sb === b) return false
47
+ const [short, long] = sa.length <= sb.length ? [sa, sb] : [sb, sa]
48
+ return short !== '' && (long.startsWith(short.endsWith('/') ? short : short + '/') || long === short)
49
+ }
50
+
51
+ /** A claim pattern: a relative path, folder or glob, no `..`. */
52
+ export function claimPattern (raw) {
53
+ const p = String(raw || '').trim().replace(/^\.\//, '')
54
+ if (!p || p.length > MAX_PATTERN || p.startsWith('/') || p.split('/').includes('..') || p.includes('\0') || p.includes('\\')) {
55
+ throw new QuiltError(400, 'bad_request', `a claim is a relative path, folder or glob (*, **, ?), up to ${MAX_PATTERN} characters`)
56
+ }
57
+ return p
58
+ }
59
+
60
+ const ACTIVE = new Set(['open', 'merging'])
61
+
62
+ /** The claims that still hold: those of open squares. */
63
+ export async function liveClaims (store, q) {
64
+ const rows = await store.claims(q)
65
+ if (!rows.length) return []
66
+ const squares = new Map()
67
+ const out = []
68
+ for (const c of rows) {
69
+ if (!squares.has(c.square_id)) squares.set(c.square_id, await store.getSquare(c.square_id))
70
+ const sq = squares.get(c.square_id)
71
+ if (sq && ACTIVE.has(sq.state)) out.push({ ...c, line: sq.line })
72
+ }
73
+ return out
74
+ }
75
+
76
+ /** Refuses a write to `paths` from square `sq` when another open square on its line claims one. */
77
+ export async function guardClaims (store, q, sq, paths) {
78
+ if (!paths.length) return
79
+ // A stacked square writes under its parents' claims (06): it builds on their work.
80
+ const mine = new Set([sq.id])
81
+ for (let p = sq; p.parent_square && mine.size <= 10;) { mine.add(p.parent_square); p = await store.getSquare(p.parent_square); if (!p) break }
82
+ const others = (await liveClaims(store, q)).filter((c) => !mine.has(c.square_id) && c.line === sq.line)
83
+ for (const c of others) {
84
+ const m = globMatcher(c.pattern)
85
+ const hit = paths.find((p) => m(p))
86
+ if (hit) {
87
+ throw new QuiltError(412, 'claim_refused', `${hit} is claimed by ${c.holder} in ${squareShort(c.square_id)}${c.note ? ` (${c.note})` : ''}: wait for it, ask them to hand it over, or work on other files`,
88
+ { path: hit, claim: view(c) })
89
+ }
90
+ }
91
+ }
92
+
93
+ const view = (c) => ({ id: c.id, pattern: c.pattern, square: squareShort(c.square_id), square_id: c.square_id, line: c.line, holder: c.holder, note: c.note || '', at: c.at })
94
+
95
+ export function claimOps (svc) {
96
+ const { store, now, quiltFor, squareByRef, writable, entriesOf, snapshot, fromStore } = svc.internals
97
+
98
+ /** GET /claims[?square=]: the claims that hold now, oldest first. */
99
+ async function claims (me, ref, { square } = {}) {
100
+ const q = await quiltFor(me, ref, 'read')
101
+ let rows = await liveClaims(store, q.id)
102
+ if (square) { const sq = await squareByRef(q.id, square); rows = rows.filter((c) => c.square_id === sq.id) }
103
+ return { claims: rows.sort((a, b) => a.at - b.at).map(view) }
104
+ }
105
+
106
+ /** POST /squares/:id/claims { pattern, note? }: claims paths for the square. */
107
+ async function claim (me, ref, sqRef, body = {}) {
108
+ const q = await quiltFor(me, ref, 'write')
109
+ const sq = await writable(me, q, sqRef)
110
+ if (sq.state !== 'open') throw new QuiltError(409, 'square_closed', `the square is ${sq.state}`)
111
+ const pattern = claimPattern(body.pattern)
112
+ const note = String(body.note || '')
113
+ if (note.length > MAX_NOTE) throw new QuiltError(400, 'bad_request', `a claim's note is up to ${MAX_NOTE} characters`)
114
+ const all = await liveClaims(store, q.id)
115
+ const mine = all.filter((c) => c.square_id === sq.id)
116
+ const same = mine.find((c) => c.pattern === pattern)
117
+ if (same) return view(same)
118
+ if (mine.length >= MAX_CLAIMS) throw new QuiltError(400, 'bad_request', `a square holds up to ${MAX_CLAIMS} claims`)
119
+ const others = all.filter((c) => c.square_id !== sq.id && c.line === sq.line)
120
+ if (others.length) {
121
+ const tip = (await store.getLine(q.id, sq.line))?.tip || sq.base
122
+ const paths = tip ? (await entriesOf(q.id, await snapshot(q.id, tip))).map((e) => e.path) : []
123
+ const taken = others.find((c) => patternsOverlap(c.pattern, pattern, paths))
124
+ if (taken) throw new QuiltError(409, 'claim_taken', `${taken.pattern} is already claimed by ${taken.holder} in ${squareShort(taken.square_id)}${taken.note ? ` (${taken.note})` : ''}`, { claim: view(taken) })
125
+ }
126
+ try {
127
+ const row = await store.createClaim({ quilt_id: q.id, id: uuidv7(now()), square_id: sq.id, pattern, holder: me.member_id, note, at: now() })
128
+ return view({ ...row, line: sq.line })
129
+ } catch (err) { throw fromStore(err) }
130
+ }
131
+
132
+ /** DELETE /squares/:id/claims/:claim (its id or its pattern): lets go of a claim. */
133
+ async function unclaim (me, ref, sqRef, which) {
134
+ const q = await quiltFor(me, ref, 'write')
135
+ const sq = await writable(me, q, sqRef)
136
+ const key = String(which || '').trim().replace(/^\.\//, '')
137
+ const c = (await store.claims(q.id)).find((x) => x.square_id === sq.id && (x.id === key || x.pattern === key))
138
+ if (!c) throw new QuiltError(404, 'not_found', `${squareShort(sq.id)} has no claim ${key}`)
139
+ try { await store.deleteClaim({ quilt_id: q.id, id: c.id }) } catch (err) { throw fromStore(err) }
140
+ return { ok: true, released: view({ ...c, line: sq.line }) }
141
+ }
142
+
143
+ return { claims, claim, unclaim }
144
+ }