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.
- package/RELEASES.md +25 -0
- package/package.json +1 -1
- package/src/api/server.js +10 -1
- package/src/quilts/archiver.js +8 -1
- package/src/quilts/autorevert.js +49 -0
- package/src/quilts/bisect-run.js +31 -0
- package/src/quilts/block.js +133 -14
- package/src/quilts/bucket.js +25 -0
- package/src/quilts/claims.js +144 -0
- package/src/quilts/cli.js +206 -11
- package/src/quilts/client.js +37 -1
- package/src/quilts/describe.js +149 -3
- package/src/quilts/dogfood.js +1 -1
- package/src/quilts/errors.js +8 -0
- package/src/quilts/events.js +19 -5
- package/src/quilts/export-git.js +5 -1
- package/src/quilts/fastmerge.js +2 -2
- package/src/quilts/hash.js +1 -1
- package/src/quilts/history.js +266 -0
- package/src/quilts/import-git.js +154 -56
- package/src/quilts/import.js +56 -20
- package/src/quilts/index.js +34 -2
- package/src/quilts/lines.js +49 -0
- package/src/quilts/live.js +50 -0
- package/src/quilts/merge-smart.js +114 -0
- package/src/quilts/merge.js +75 -27
- package/src/quilts/objects.js +17 -12
- package/src/quilts/records.js +2 -1
- package/src/quilts/resolutions.js +69 -0
- package/src/quilts/reviews.js +195 -0
- package/src/quilts/routes.js +51 -14
- package/src/quilts/service.js +133 -25
- package/src/quilts/setup.js +2 -2
- package/src/quilts/signing.js +149 -0
- package/src/quilts/store-memory.js +152 -0
- package/src/quilts/store-rpc.js +16 -1
- package/src/quilts/tags.js +64 -0
- package/src/quilts/tokens.js +66 -0
- package/src/quilts/tools.js +195 -19
- package/src/quilts/webhook.js +116 -0
- package/src/server.js +2 -2
- package/src/session.js +4 -0
- package/src/{quilts → source-live}/live-block.js +4 -3
- 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
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
|
-
|
|
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)
|
package/src/quilts/archiver.js
CHANGED
|
@@ -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
|
+
}
|
package/src/quilts/block.js
CHANGED
|
@@ -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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
54
|
-
|
|
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(
|
|
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) {
|
|
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
|
+
}
|