heyquilt 0.3.122 → 0.3.140
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 +279 -17
- package/bin/quilt.js +62 -152
- package/desktop/main.js +16 -2
- package/package.json +3 -2
- package/src/account.js +0 -29
- package/src/agent-task-workflow.js +2 -2
- package/src/agents/common.js +13 -0
- package/src/ai-vm.js +9 -1
- package/src/api/activity.js +5 -0
- package/src/api/memory-store.js +16 -38
- package/src/api/notify.js +134 -0
- package/src/api/routes/browsers.js +29 -0
- package/src/api/routes/relay.js +26 -3
- package/src/api/routes/session-migrate.js +7 -4
- package/src/api/routes/sessions.js +73 -1
- package/src/api/routes/teams.js +0 -23
- package/src/api/server.js +17 -25
- package/src/api/supabase-store.js +43 -56
- package/src/autowork.js +182 -16
- package/src/branch-view.js +6 -131
- package/src/connection.js +36 -9
- package/src/control.js +131 -48
- package/src/duties.js +1 -1
- package/src/force-date.js +27 -0
- package/src/fsutil.js +13 -3
- package/src/github-commit.js +3 -315
- package/src/history.js +13 -3
- package/src/mcp.js +93 -167
- package/src/merge3.js +11 -3
- package/src/now-summary.js +113 -0
- package/src/pathrules.js +12 -0
- package/src/protocol.js +10 -2
- package/src/quilt-default.js +84 -0
- package/src/quilt-history.js +106 -0
- package/src/quilt-remote.js +78 -0
- package/src/relay-ask.js +3 -66
- package/src/relay-cluster.js +3 -5
- package/src/relay-lease.js +6 -2
- package/src/relay-mcp.js +154 -432
- package/src/relay-prune.js +6 -145
- package/src/relay-turn-sync.js +1 -1
- package/src/release-words.js +2 -2
- package/src/runner.js +20 -7
- package/src/seam-access.js +97 -0
- package/src/seam-claims.js +53 -0
- package/src/seam-url.js +15 -0
- package/src/server.js +1516 -1383
- package/src/session-follow.js +461 -27
- package/src/session-layout3.js +410 -4
- package/src/session-migrate.js +15 -11
- package/src/session-quilt.js +1 -1
- package/src/session-seam.js +233 -20
- package/src/session.js +745 -3438
- package/src/source-live/bring-in.js +283 -0
- package/src/source-live/live-block.js +504 -33
- package/src/source-live/live-square.js +16 -5
- package/src/status.js +7 -58
- package/src/turn-sync.js +15 -2
- package/src/ui/agent-guide.js +1 -1
- package/src/ui/app.css +85 -20
- package/src/ui/app.js +4 -1
- package/src/ui/changes.js +5 -14
- package/src/ui/chat.js +9 -4
- package/src/ui/cloud-ai-model.js +13 -0
- package/src/ui/cloud-ai.js +10 -2
- package/src/ui/common.js +6 -4
- package/src/ui/fileview.js +20 -7
- package/src/ui/force-words.js +19 -0
- package/src/ui/git-rule.js +9 -28
- package/src/ui/home.js +3 -223
- package/src/ui/loom-model.js +15 -1
- package/src/ui/quiltview.js +213 -0
- package/src/ui/seam-model.js +251 -0
- package/src/ui/session.js +377 -489
- package/src/ui/tree.js +2 -8
- package/src/ui/voice-call.js +83 -14
- package/src/ui/voice-model.js +39 -0
- package/src/ui/voice.js +167 -17
- package/src/ui/workspaces.js +2 -122
- package/src/ui-server.js +81 -66
- package/src/update-check.js +25 -1
- package/src/voice.js +13 -6
- package/src/api/routes/github.js +0 -393
- package/src/branches.js +0 -194
- package/src/clash.js +0 -128
- package/src/commit-request.js +0 -59
- package/src/commit.js +0 -213
- package/src/git-hook.js +0 -48
- package/src/git.js +0 -147
- package/src/gitignore.js +0 -49
- package/src/gitstate.js +0 -1033
- package/src/quilts/AGENTS.md +0 -1
- package/src/quilts/CLAUDE.md +0 -1
- package/src/quilts/accounts.js +0 -136
- package/src/quilts/affected.js +0 -132
- package/src/quilts/archiver.js +0 -175
- package/src/quilts/autorevert.js +0 -49
- package/src/quilts/bisect-run.js +0 -31
- package/src/quilts/blame.js +0 -103
- package/src/quilts/block.js +0 -347
- package/src/quilts/bucket.js +0 -25
- package/src/quilts/cbor.js +0 -92
- package/src/quilts/checks.js +0 -308
- package/src/quilts/claims.js +0 -144
- package/src/quilts/cli.js +0 -536
- package/src/quilts/client.js +0 -137
- package/src/quilts/conflict-format.js +0 -190
- package/src/quilts/describe.js +0 -296
- package/src/quilts/dogfood.js +0 -82
- package/src/quilts/errors.js +0 -8
- package/src/quilts/events.js +0 -171
- package/src/quilts/export-git.js +0 -130
- package/src/quilts/export-remote.js +0 -156
- package/src/quilts/export.js +0 -81
- package/src/quilts/fastmerge.js +0 -41
- package/src/quilts/hash.js +0 -44
- package/src/quilts/history.js +0 -266
- package/src/quilts/ids.js +0 -12
- package/src/quilts/import-follow.js +0 -236
- package/src/quilts/import-git.js +0 -294
- package/src/quilts/import.js +0 -196
- package/src/quilts/index.js +0 -94
- package/src/quilts/keys.js +0 -186
- package/src/quilts/limits.js +0 -67
- package/src/quilts/lines.js +0 -49
- package/src/quilts/live.js +0 -50
- package/src/quilts/merge-smart.js +0 -114
- package/src/quilts/merge.js +0 -501
- package/src/quilts/oauth.js +0 -202
- package/src/quilts/objects.js +0 -144
- package/src/quilts/pack.js +0 -118
- package/src/quilts/records.js +0 -211
- package/src/quilts/resolutions.js +0 -69
- package/src/quilts/reviews.js +0 -208
- package/src/quilts/routes.js +0 -186
- package/src/quilts/runner.js +0 -95
- package/src/quilts/server.js +0 -144
- package/src/quilts/service.js +0 -963
- package/src/quilts/setup.js +0 -41
- package/src/quilts/signing.js +0 -149
- package/src/quilts/squares.js +0 -239
- package/src/quilts/store-memory.js +0 -844
- package/src/quilts/store-rpc.js +0 -90
- package/src/quilts/store-supabase.js +0 -13
- package/src/quilts/tags.js +0 -64
- package/src/quilts/textdiff.js +0 -96
- package/src/quilts/tokens.js +0 -66
- package/src/quilts/tools.js +0 -403
- package/src/quilts/tree.js +0 -351
- package/src/quilts/webhook.js +0 -116
- package/src/quilts/window.js +0 -121
- package/src/quilts/xdiff.js +0 -435
- package/src/relay-ci.js +0 -111
- package/src/relay-clash.js +0 -214
- package/src/relay-commit.js +0 -918
- package/src/relay-filekey.js +0 -46
- package/src/relay-git.js +0 -274
- package/src/relay-githttp.js +0 -166
- package/src/relay-github.js +0 -109
- package/src/relay-outbound.js +0 -1295
- package/src/relay-repo-moves.js +0 -43
- package/src/relay-sync.js +0 -795
- package/src/relay-upstream.js +0 -889
- package/src/sync-words.js +0 -42
- package/src/ui/branches.js +0 -111
- package/src/upstream.js +0 -192
package/src/quilts/fastmerge.js
DELETED
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
// A fast front for merge-smart.js's mergeSmart (whose node-diff3 LCS is quadratic: seconds on a
|
|
2
|
-
// 7,000-line file). A clean line merge comes from the Myers hunks in textdiff.js (`nway`, the
|
|
3
|
-
// same N-way merge the conflict format uses); only a clash goes to mergeSmart's ladder, and then
|
|
4
|
-
// with the lines all three versions share at the start and end cut off, so it reads only the
|
|
5
|
-
// region that differs. Same answer, same `how`, as mergeSmart for a clean merge; a clash is
|
|
6
|
-
// mergeSmart's own answer on the middle, put back between the shared lines.
|
|
7
|
-
import { mergeSmart } from './merge-smart.js'
|
|
8
|
-
import { nway } from './conflict-format.js'
|
|
9
|
-
|
|
10
|
-
export function fastMerge (base, ours, theirs) {
|
|
11
|
-
const [o, a, b] = [base.split('\n'), ours.split('\n'), theirs.split('\n')]
|
|
12
|
-
const regions = nway(o, [a, b])
|
|
13
|
-
if (regions.every((r) => r.ok)) return { text: regions.flatMap((r) => r.ok).join('\n'), conflicts: [], how: 'lines' }
|
|
14
|
-
// Every clash is two inserts at one place the base had nothing (07's `additive` step): ours
|
|
15
|
-
// then theirs, shared leading lines once. Shared trailing lines are kept on both sides, not
|
|
16
|
-
// once as 07 says: two added functions share their closing `}`, and taking it once dropped
|
|
17
|
-
// one function's closing brace (found by real agents adding functions side by side).
|
|
18
|
-
if (regions.every((r) => r.ok || (!r.base.length && r.sides.length === 2))) {
|
|
19
|
-
return { text: regions.flatMap((r) => r.ok || additive(r.sides[0], r.sides[1])).join('\n'), conflicts: [], how: 'additive' }
|
|
20
|
-
}
|
|
21
|
-
// Shared lines at both ends, leaving at least one line in each middle.
|
|
22
|
-
const min = Math.min(o.length, a.length, b.length) - 1
|
|
23
|
-
let p = 0
|
|
24
|
-
while (p < min && o[p] === a[p] && o[p] === b[p]) p++
|
|
25
|
-
let s = 0
|
|
26
|
-
while (s < min - p && o[o.length - 1 - s] === a[a.length - 1 - s] && o[o.length - 1 - s] === b[b.length - 1 - s]) s++
|
|
27
|
-
if (!p && !s) return mergeSmart(base, ours, theirs)
|
|
28
|
-
const mid = (x) => x.slice(p, x.length - s).join('\n')
|
|
29
|
-
const m = mergeSmart(mid(o), mid(a), mid(b))
|
|
30
|
-
if (m.text === '') return mergeSmart(base, ours, theirs) // zero lines or one empty line: ambiguous
|
|
31
|
-
return { ...m, text: [...o.slice(0, p), m.text, ...o.slice(o.length - s)].join('\n') }
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/** Two inserts at one place: ours, then what theirs adds after the leading lines both share. */
|
|
35
|
-
function additive (a, b) {
|
|
36
|
-
let pre = 0
|
|
37
|
-
while (pre < a.length && pre < b.length && a[pre] === b[pre]) pre++
|
|
38
|
-
if (pre === b.length) return a
|
|
39
|
-
if (pre === a.length) return b
|
|
40
|
-
return [...a, ...b.slice(pre)]
|
|
41
|
-
}
|
package/src/quilts/hash.js
DELETED
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
// Ids for Quilt source control (plans/quilt-source-control/03): BLAKE3-256 with a
|
|
2
|
-
// domain-separation context per kind of object, so bytes of one kind can never be presented
|
|
3
|
-
// as another. Ids travel as 64 hex characters.
|
|
4
|
-
import { blake3 } from '@noble/hashes/blake3.js'
|
|
5
|
-
|
|
6
|
-
const enc = new TextEncoder()
|
|
7
|
-
const contexts = new Map()
|
|
8
|
-
|
|
9
|
-
/** The context bytes for a kind and format version, e.g. 'commit' → "quilt commit v1". */
|
|
10
|
-
function context (kind) {
|
|
11
|
-
let c = contexts.get(kind)
|
|
12
|
-
if (!c) { c = enc.encode(`quilt ${kind} v1`); contexts.set(kind, c) }
|
|
13
|
-
return c
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export const KINDS = ['commit', 'blob', 'tree', 'chunk', 'pack', 'term', 'manifest']
|
|
17
|
-
|
|
18
|
-
/** The id of `bytes` as an object of `kind`: BLAKE3 derive-key mode, 32 bytes, as hex. */
|
|
19
|
-
// Not object kinds: keys made with their own contexts so they can never equal an object id.
|
|
20
|
-
const KEY_KINDS = ['idempotency', 'resolution']
|
|
21
|
-
|
|
22
|
-
/** A key (not an object id) in its own context, e.g. 'idempotency' → "quilt idempotency v1". */
|
|
23
|
-
export function keyOf (kind, bytes) {
|
|
24
|
-
if (!KEY_KINDS.includes(kind)) throw new Error(`unknown key kind ${kind}`)
|
|
25
|
-
return Buffer.from(blake3(bytes, { context: context(kind) })).toString('hex')
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export function idOf (kind, bytes) {
|
|
29
|
-
if (!KINDS.includes(kind)) throw new Error(`unknown object kind ${kind}`)
|
|
30
|
-
return Buffer.from(blake3(bytes, { context: context(kind) })).toString('hex')
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export const ID = /^[0-9a-f]{64}$/
|
|
34
|
-
|
|
35
|
-
/** Whether `s` looks like an id (64 lowercase hex). */
|
|
36
|
-
export const isId = (s) => typeof s === 'string' && ID.test(s)
|
|
37
|
-
|
|
38
|
-
/** An id as its 32 bytes. */
|
|
39
|
-
export const idBytes = (hex) => Buffer.from(hex, 'hex')
|
|
40
|
-
|
|
41
|
-
/** A quilt's shard (03): blake3 of its id's 16 bytes (plain hash mode), first byte mod 64. */
|
|
42
|
-
export function shardOf (quiltId) {
|
|
43
|
-
return blake3(Buffer.from(String(quiltId).replace(/-/g, ''), 'hex'))[0] % 64
|
|
44
|
-
}
|
package/src/quilts/history.js
DELETED
|
@@ -1,266 +0,0 @@
|
|
|
1
|
-
// History tools (plans/quilt-source-control/08 and 12): log --follow (a file's history through its
|
|
2
|
-
// renames), log -S (commits that changed how often a text appears), describe (the nearest tag),
|
|
3
|
-
// merge-base (where two revisions last shared history), grep (lines at a revision) and bisect (the
|
|
4
|
-
// server narrows good..bad; whoever runs the check, the seam CLI or an agent, marks each step).
|
|
5
|
-
import { cleanPath, cleanPrefix, under } from './records.js'
|
|
6
|
-
import { isText, lineStat } from './textdiff.js'
|
|
7
|
-
import { QuiltError } from './service.js'
|
|
8
|
-
|
|
9
|
-
const MAX_WALK = 100000
|
|
10
|
-
const MAX_GREP_BYTES = 64 * 1024 * 1024
|
|
11
|
-
const MAX_GREP_FILE = 2 * 1024 * 1024
|
|
12
|
-
const MAX_SEARCH_QUILTS = 100
|
|
13
|
-
|
|
14
|
-
/** A grep pattern as a RegExp, refusing the shapes that can backtrack for ever on one line. */
|
|
15
|
-
export function grepPattern (pattern, { fixed = false, ignoreCase = false } = {}) {
|
|
16
|
-
const p = String(pattern ?? '')
|
|
17
|
-
if (!p) throw new QuiltError(400, 'bad_request', 'give a pattern')
|
|
18
|
-
if (p.length > 500) throw new QuiltError(400, 'bad_request', 'a pattern is up to 500 characters')
|
|
19
|
-
const src = fixed ? p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') : p
|
|
20
|
-
if (!fixed && (/\((?:[^()\\]|\\.)*[+*}](?:[^()\\]|\\.)*\)\s*[+*{]/.test(p) || /\\[1-9]/.test(p))) {
|
|
21
|
-
throw new QuiltError(400, 'bad_request', `the pattern ${p} could run for ever (a repeated group that repeats, or a back-reference): simplify it, or pass fixed for plain text`)
|
|
22
|
-
}
|
|
23
|
-
try { return new RegExp(src, ignoreCase ? 'i' : '') } catch (err) { throw new QuiltError(400, 'bad_request', `bad pattern: ${err.message}`) }
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
const count = (text, needle) => { let n = 0; let i = 0; while ((i = text.indexOf(needle, i)) >= 0) { n++; i += needle.length } return n }
|
|
27
|
-
|
|
28
|
-
export function historyOps (svc, { mergeBase: commonBase }) {
|
|
29
|
-
const { quiltFor, resolve, rootAt, getCommit, snapshot, reader, readBlob, commitView, store } = svc.internals
|
|
30
|
-
const baseLog = svc.log
|
|
31
|
-
|
|
32
|
-
async function entryAt (q, commitId, p) {
|
|
33
|
-
if (!commitId) return null
|
|
34
|
-
return reader(q).lookup(await snapshot(q, commitId), p)
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
async function textOf (q, e, cache) {
|
|
38
|
-
if (!e || !e.blob) return null
|
|
39
|
-
if (cache.has(e.blob)) return cache.get(e.blob)
|
|
40
|
-
const b = await readBlob(q, e.blob)
|
|
41
|
-
const t = isText(b) ? b.toString('utf8') : null
|
|
42
|
-
cache.set(e.blob, t)
|
|
43
|
-
return t
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/** The path `p` had before commit `c` added it: copy_from, a deleted file with the same content, or the most alike deleted text file. */
|
|
47
|
-
async function renamedFrom (q, c, ch, texts) {
|
|
48
|
-
if (ch.copy_from?.path) return ch.copy_from.path
|
|
49
|
-
const parent = c.parents[0] || null
|
|
50
|
-
if (!parent || await entryAt(q, parent, ch.path)) return null
|
|
51
|
-
const gone = c.changes.filter((x) => x.op === 'delete')
|
|
52
|
-
if (!gone.length) return null
|
|
53
|
-
const before = await Promise.all(gone.map((x) => entryAt(q, parent, x.path)))
|
|
54
|
-
const same = gone.find((x, i) => before[i]?.blob === ch.blob)
|
|
55
|
-
if (same) return same.path
|
|
56
|
-
const now = await textOf(q, { blob: ch.blob }, texts)
|
|
57
|
-
if (now == null) return null
|
|
58
|
-
let best = null; let score = 0.5
|
|
59
|
-
for (let i = 0; i < gone.length; i++) {
|
|
60
|
-
const was = await textOf(q, before[i], texts)
|
|
61
|
-
if (was == null) continue
|
|
62
|
-
const st = lineStat(was, now)
|
|
63
|
-
const total = Math.max(was.split('\n').length, now.split('\n').length, 1)
|
|
64
|
-
const kept = 1 - Math.max(st.add, st.del) / total
|
|
65
|
-
if (kept >= score) { best = gone[i].path; score = kept }
|
|
66
|
-
}
|
|
67
|
-
return best
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/** Whether commit `c` changed how many times `needle` appears in the changed files (a rename keeps the count). */
|
|
71
|
-
async function changesCount (q, c, needle, prefixes, texts) {
|
|
72
|
-
const parent = c.parents[0] || null
|
|
73
|
-
let before = 0; let after = 0
|
|
74
|
-
for (const ch of c.changes) {
|
|
75
|
-
if (prefixes.length && !prefixes.some((p) => under(ch.path, p))) continue
|
|
76
|
-
const was = await textOf(q, await entryAt(q, parent, ch.path), texts)
|
|
77
|
-
const now = ch.op === 'put' ? await textOf(q, { blob: ch.blob }, texts) : null
|
|
78
|
-
if (was) before += count(was, needle)
|
|
79
|
-
if (now) after += count(now, needle)
|
|
80
|
-
}
|
|
81
|
-
return before !== after
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* GET /log with follow (one file, through its renames) or search (-S): a walk over first parents.
|
|
86
|
-
* Without either, the indexed log (service.js).
|
|
87
|
-
*/
|
|
88
|
-
async function log (me, ref, opts = {}) {
|
|
89
|
-
const { rev = 'main', paths = [], limit = 20, follow = false, search } = opts
|
|
90
|
-
if (!follow && !search) return baseLog(me, ref, opts)
|
|
91
|
-
const q = await quiltFor(me, ref)
|
|
92
|
-
if (follow && (paths || []).length !== 1) throw new QuiltError(400, 'bad_request', 'log --follow takes one file')
|
|
93
|
-
if (search != null && (!String(search).length || String(search).length > 500)) throw new QuiltError(400, 'bad_request', 'log -S takes a text of 1 to 500 characters')
|
|
94
|
-
let range = String(rev || 'main')
|
|
95
|
-
let stop = null
|
|
96
|
-
if (range.includes('..')) {
|
|
97
|
-
const [a, b] = range.split('..')
|
|
98
|
-
stop = a ? await resolve(q.id, a) : null
|
|
99
|
-
range = b || 'main'
|
|
100
|
-
}
|
|
101
|
-
let id = await resolve(q.id, range)
|
|
102
|
-
let cur = follow ? cleanPath(paths[0]) : null
|
|
103
|
-
const prefixes = follow ? [] : (paths || []).map(cleanPrefix)
|
|
104
|
-
const max = Math.min(Number(limit) || 20, 1000)
|
|
105
|
-
const texts = new Map()
|
|
106
|
-
const out = []
|
|
107
|
-
let walked = 0
|
|
108
|
-
while (id && id !== stop && out.length < max && walked++ < MAX_WALK) {
|
|
109
|
-
const c = await getCommit(q.id, id)
|
|
110
|
-
if (follow) {
|
|
111
|
-
const ch = c.changes.find((x) => x.path === cur)
|
|
112
|
-
if (ch && (search == null || await changesCount(q.id, c, String(search), [cur], texts))) out.push({ ...commitView(c, { changes: false }), path: cur })
|
|
113
|
-
if (ch && ch.op === 'put') { const from = await renamedFrom(q.id, c, ch, texts); if (from) cur = from }
|
|
114
|
-
} else {
|
|
115
|
-
const touched = !prefixes.length || c.changes.some((x) => prefixes.some((p) => under(x.path, p)))
|
|
116
|
-
if (touched && await changesCount(q.id, c, String(search), prefixes, texts)) out.push(commitView(c, { changes: false }))
|
|
117
|
-
}
|
|
118
|
-
id = c.parents[0] || null
|
|
119
|
-
}
|
|
120
|
-
return { commits: out, more: !!id && id !== stop && out.length >= max, ...(follow ? { follow: cleanPath(paths[0]) } : {}) }
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/** Every ancestor of `id` (all parents) with its distance, nearest first, up to MAX_WALK. */
|
|
124
|
-
async function ancestors (q, id, { until } = {}) {
|
|
125
|
-
const seen = new Map()
|
|
126
|
-
if (!id) return seen
|
|
127
|
-
let frontier = [id]
|
|
128
|
-
seen.set(id, 0)
|
|
129
|
-
for (let d = 1; frontier.length && seen.size < MAX_WALK; d++) {
|
|
130
|
-
const next = []
|
|
131
|
-
for (const x of frontier) {
|
|
132
|
-
if (until && until(x)) continue
|
|
133
|
-
for (const p of (await getCommit(q, x)).parents) if (!seen.has(p)) { seen.set(p, d); next.push(p) }
|
|
134
|
-
}
|
|
135
|
-
frontier = next
|
|
136
|
-
}
|
|
137
|
-
return seen
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
async function patchOf (q, id) { return id ? ((await store.getCommit(q, id))?.patch || null) : null }
|
|
141
|
-
|
|
142
|
-
/** GET /describe?rev=: the nearest tag behind a revision, as <tag>-<commits past it>-<commit>. */
|
|
143
|
-
async function describe (me, ref, rev = 'main') {
|
|
144
|
-
const q = await quiltFor(me, ref)
|
|
145
|
-
const id = await resolve(q.id, rev || 'main')
|
|
146
|
-
if (!id) throw new QuiltError(404, 'not_found', `${rev || 'main'} has no commits yet`)
|
|
147
|
-
const byCommit = new Map()
|
|
148
|
-
for (const t of await store.tags(q.id)) {
|
|
149
|
-
const had = byCommit.get(t.commit)
|
|
150
|
-
if (!had || (t.seq || 0) > (had.seq || 0)) byCommit.set(t.commit, t)
|
|
151
|
-
}
|
|
152
|
-
const short = id.slice(0, 12)
|
|
153
|
-
let hit = null
|
|
154
|
-
if (byCommit.size) {
|
|
155
|
-
const dist = await ancestors(q.id, id, { until: (x) => byCommit.has(x) })
|
|
156
|
-
for (const [cid, d] of dist) if (byCommit.has(cid) && (!hit || d < hit.d)) hit = { d, tag: byCommit.get(cid).name }
|
|
157
|
-
}
|
|
158
|
-
if (!hit) return { commit: id, short, tag: null, distance: null, describe: short }
|
|
159
|
-
return { commit: id, short, tag: hit.tag, distance: hit.d, describe: hit.d ? `${hit.tag}-${hit.d}-${short}` : hit.tag }
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
/** GET /merge-base?a=&b=: where two revisions last shared history (null if never). */
|
|
163
|
-
async function mergeBaseOf (me, ref, a, b) {
|
|
164
|
-
const q = await quiltFor(me, ref)
|
|
165
|
-
if (!a || !b) throw new QuiltError(400, 'bad_request', 'merge-base takes two revisions (a and b)')
|
|
166
|
-
const { base } = await commonBase(q.id, await resolve(q.id, a), await resolve(q.id, b))
|
|
167
|
-
return { commit: base, short: base ? base.slice(0, 12) : null, patch: await patchOf(q.id, base) }
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
/** GET /grep: the lines matching a pattern in the files at a revision (sq:<id>:live too). */
|
|
171
|
-
async function grep (me, ref, { pattern, rev = 'main', paths = [], ignore_case: ignoreCase = false, fixed = false, limit = 200 } = {}) {
|
|
172
|
-
const q = await quiltFor(me, ref)
|
|
173
|
-
const re = grepPattern(pattern, { fixed, ignoreCase })
|
|
174
|
-
const { id, root } = await rootAt(q.id, rev || 'main')
|
|
175
|
-
const prefixes = (paths && paths.length ? paths : ['']).map(cleanPrefix)
|
|
176
|
-
const max = Math.min(Math.max(Number(limit) || 200, 1), 2000)
|
|
177
|
-
const matches = []
|
|
178
|
-
const files = []
|
|
179
|
-
let bytes = 0; let more = false; let skipped = 0
|
|
180
|
-
for (const e of await reader(q.id).list(root, prefixes)) {
|
|
181
|
-
if (!e.blob || e.mode === 'gitlink' || e.mode === 'symlink') continue
|
|
182
|
-
if ((e.size || 0) > MAX_GREP_FILE || bytes + (e.size || 0) > MAX_GREP_BYTES) { skipped++; continue }
|
|
183
|
-
const buf = await readBlob(q.id, e.blob)
|
|
184
|
-
bytes += buf.length
|
|
185
|
-
if (!isText(buf)) continue
|
|
186
|
-
const lines = buf.toString('utf8').split('\n')
|
|
187
|
-
let hit = false
|
|
188
|
-
for (let i = 0; i < lines.length; i++) {
|
|
189
|
-
const text = lines[i].length > 4000 ? lines[i].slice(0, 4000) : lines[i]
|
|
190
|
-
if (!re.test(text)) continue
|
|
191
|
-
if (matches.length >= max) { more = true; break }
|
|
192
|
-
hit = true
|
|
193
|
-
matches.push({ path: e.path, line: i + 1, text: text.replace(/\r$/, '') })
|
|
194
|
-
}
|
|
195
|
-
if (hit) files.push(e.path)
|
|
196
|
-
if (more) break
|
|
197
|
-
}
|
|
198
|
-
return { commit: id, matches, files, more, ...(skipped ? { skipped } : {}) }
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
/**
|
|
202
|
-
* GET /v1/search: grep across every quilt the caller can read (or the ones named), at one
|
|
203
|
-
* revision (main by default). A quilt without that revision is skipped; limit counts all matches.
|
|
204
|
-
*/
|
|
205
|
-
async function search (me, { pattern, rev = 'main', quilts = [], paths = [], ignore_case: ignoreCase = false, fixed = false, limit = 200 } = {}) {
|
|
206
|
-
grepPattern(pattern, { fixed, ignoreCase })
|
|
207
|
-
const max = Math.min(Math.max(Number(limit) || 200, 1), 2000)
|
|
208
|
-
const named = (quilts || []).filter(Boolean)
|
|
209
|
-
const targets = named.length ? await Promise.all(named.map((ref) => quiltFor(me, ref))) : await store.quiltsFor(me.member_id)
|
|
210
|
-
if (targets.length > MAX_SEARCH_QUILTS) throw new QuiltError(400, 'bad_request', `search reads at most ${MAX_SEARCH_QUILTS} quilts at once: name some with quilts`)
|
|
211
|
-
const matches = []; const files = []; const searched = []
|
|
212
|
-
let more = false; let skipped = 0
|
|
213
|
-
for (const q of targets) {
|
|
214
|
-
if (matches.length >= max) { more = true; break }
|
|
215
|
-
let r
|
|
216
|
-
try { r = await grep(me, q.slug, { pattern, rev, paths, ignore_case: ignoreCase, fixed, limit: max - matches.length }) } catch (e) {
|
|
217
|
-
if (e instanceof QuiltError && e.status === 404) continue
|
|
218
|
-
throw e
|
|
219
|
-
}
|
|
220
|
-
searched.push(q.slug)
|
|
221
|
-
for (const m of r.matches) matches.push({ quilt: q.slug, ...m })
|
|
222
|
-
for (const f of r.files) files.push(`${q.slug}:${f}`)
|
|
223
|
-
skipped += r.skipped || 0
|
|
224
|
-
if (r.more) { more = true; break }
|
|
225
|
-
}
|
|
226
|
-
return { rev: rev || 'main', searched, matches, files, more, ...(skipped ? { skipped } : {}) }
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
/**
|
|
230
|
-
* POST /bisect { bad, good, marks }: the first-parent commits after good up to bad, narrowed by
|
|
231
|
-
* marks ({ <commit>: good | bad | skip }). Answers the next commit to test, or the first bad one.
|
|
232
|
-
*/
|
|
233
|
-
async function bisect (me, ref, { bad = 'main', good, marks = {} } = {}) {
|
|
234
|
-
const q = await quiltFor(me, ref)
|
|
235
|
-
if (!good) throw new QuiltError(400, 'bad_request', 'bisect needs a good revision (one without the problem)')
|
|
236
|
-
const [b, g] = [await resolve(q.id, bad), await resolve(q.id, good)]
|
|
237
|
-
if (!b || !g) throw new QuiltError(404, 'not_found', 'bisect needs two revisions with commits')
|
|
238
|
-
const behindGood = await ancestors(q.id, g)
|
|
239
|
-
const list = []
|
|
240
|
-
let id = b
|
|
241
|
-
while (id && !behindGood.has(id) && list.length < MAX_WALK) { list.push(id); id = (await getCommit(q.id, id)).parents[0] || null }
|
|
242
|
-
if (!id || !list.length) throw new QuiltError(400, 'bad_request', `${good} is not an ancestor of ${bad}: give a good revision older than the bad one`)
|
|
243
|
-
list.reverse() // oldest first; the last is bad
|
|
244
|
-
const mark = new Map()
|
|
245
|
-
for (const [k, v] of Object.entries(marks || {})) {
|
|
246
|
-
if (!['good', 'bad', 'skip'].includes(v)) throw new QuiltError(400, 'bad_request', `a mark is good, bad or skip, not ${v}`)
|
|
247
|
-
const full = list.find((x) => x.startsWith(String(k).replace(/^c:/, '')))
|
|
248
|
-
if (full) mark.set(full, v)
|
|
249
|
-
}
|
|
250
|
-
let hi = list.length - 1
|
|
251
|
-
for (let i = 0; i < list.length; i++) if (mark.get(list[i]) === 'bad') { hi = i; break }
|
|
252
|
-
let lo = -1
|
|
253
|
-
for (let i = hi - 1; i >= 0; i--) if (mark.get(list[i]) === 'good') { lo = i; break }
|
|
254
|
-
const left = []
|
|
255
|
-
for (let i = lo + 1; i < hi; i++) if (mark.get(list[i]) !== 'skip') left.push(i)
|
|
256
|
-
const view = async (cid) => commitView(await getCommit(q.id, cid), { changes: false })
|
|
257
|
-
if (!left.length) {
|
|
258
|
-
const skipped = list.slice(lo + 1, hi)
|
|
259
|
-
return { done: true, first_bad: await view(list[hi]), ...(skipped.length ? { maybe: await Promise.all(skipped.map(view)) } : {}), tested: mark.size }
|
|
260
|
-
}
|
|
261
|
-
const next = left[Math.floor((left.length - 1) / 2)]
|
|
262
|
-
return { done: false, next: await view(list[next]), remaining: left.length, steps: Math.ceil(Math.log2(left.length + 1)) }
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
return { log, describe, mergeBaseOf, grep, search, bisect }
|
|
266
|
-
}
|
package/src/quilts/ids.js
DELETED
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
// Ids shared by the quilt server and seam's client (the git import makes its squares' ids).
|
|
2
|
-
import crypto from 'node:crypto'
|
|
3
|
-
|
|
4
|
-
/** A UUIDv7: 48 bits of milliseconds, then random (README §3.3: time-sortable, never collides). */
|
|
5
|
-
export function uuidv7 (ms = Date.now()) {
|
|
6
|
-
const b = crypto.randomBytes(16)
|
|
7
|
-
b.writeUIntBE(ms, 0, 6)
|
|
8
|
-
b[6] = (b[6] & 0x0f) | 0x70
|
|
9
|
-
b[8] = (b[8] & 0x3f) | 0x80
|
|
10
|
-
const h = b.toString('hex')
|
|
11
|
-
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`
|
|
12
|
-
}
|
|
@@ -1,236 +0,0 @@
|
|
|
1
|
-
// The remote driver of a git import, and the follow (plans/quilt-source-control/14, "Who runs
|
|
2
|
-
// it and where" and "Importing a live repository"; 16 step 1). POST /v1/imports makes a new
|
|
3
|
-
// quilt and imports a git remote into it from a mirror in scratch, with the same code as the
|
|
4
|
-
// local driver (import-git.js). With follow: true the import then keeps following the remote's
|
|
5
|
-
// main and release/* every `interval` seconds (default 600, at least 60), or at once on a poke
|
|
6
|
-
// (POST /v1/imports/:id/poke, with the import's secret, so a git hook or a cron job can send it
|
|
7
|
-
// without a member's key). While it follows, those lines are frozen with reason import_follow:
|
|
8
|
-
// merges into them are refused with the import's id, because git is the authority. A rewritten
|
|
9
|
-
// ref stops the follow (state failed, error 'ref rewritten'). One import per quilt, never
|
|
10
|
-
// reopened (D19): the import's id is its quilt's id.
|
|
11
|
-
import fs from 'node:fs'
|
|
12
|
-
import os from 'node:os'
|
|
13
|
-
import path from 'node:path'
|
|
14
|
-
import crypto from 'node:crypto'
|
|
15
|
-
import { execFile } from 'node:child_process'
|
|
16
|
-
import { QuiltError } from './service.js'
|
|
17
|
-
import { buildPack } from './pack.js'
|
|
18
|
-
import { importGit } from './import-git.js'
|
|
19
|
-
|
|
20
|
-
export const POKE_PREFIX = 'sip_'
|
|
21
|
-
const hash = (s) => crypto.createHash('sha256').update(String(s)).digest('hex')
|
|
22
|
-
const followed = (name) => name === 'main' || name.startsWith('release/')
|
|
23
|
-
const LEASE = ' import' // a line lease no line can hold: one pass at a time across servers
|
|
24
|
-
|
|
25
|
-
/** Who may read a remote: a URL a server may fetch (never a path on the server unless allowLocal). */
|
|
26
|
-
export function remoteUrl (raw, { allowLocal = false } = {}) {
|
|
27
|
-
const s = String(raw || '').trim()
|
|
28
|
-
if (/^(https?|ssh|git):\/\/[^\s]+$/i.test(s) || /^[\w.-]+@[\w.-]+:[^\s]+$/.test(s)) return s
|
|
29
|
-
if (allowLocal && (path.isAbsolute(s) || /^file:\/\//.test(s))) return s
|
|
30
|
-
throw new QuiltError(400, 'bad_request', 'url: a git remote (https://, ssh:// or user@host:path)')
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export function run (args, { env = {}, cwd } = {}) {
|
|
34
|
-
return new Promise((resolve, reject) => {
|
|
35
|
-
execFile('git', args, { cwd, env: { ...process.env, GIT_NO_REPLACE_OBJECTS: '1', GIT_TERMINAL_PROMPT: '0', ...env }, maxBuffer: 64 << 20 }, (err, stdout, stderr) => {
|
|
36
|
-
if (err) return reject(new Error(String(stderr || err.message).trim().split('\n').pop()))
|
|
37
|
-
resolve(stdout)
|
|
38
|
-
})
|
|
39
|
-
})
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/** The import's credential as git config: `user:token`, or a bare token as x-access-token's. */
|
|
43
|
-
export function authArgs (secret) {
|
|
44
|
-
if (!secret) return []
|
|
45
|
-
const pair = secret.includes(':') ? secret : `x-access-token:${secret}`
|
|
46
|
-
return ['-c', `http.extraHeader=Authorization: Basic ${Buffer.from(pair).toString('base64')}`]
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/** importGit's client, in this process: the same operations the HTTP routes call. */
|
|
50
|
-
export function localClient (api, me) {
|
|
51
|
-
return {
|
|
52
|
-
quilt: (q) => api.quiltInfo(me, q),
|
|
53
|
-
createQuilt: (b) => api.createQuilt(me, b),
|
|
54
|
-
importKnown: (q, shas) => api.importKnown(me, q, { shas }),
|
|
55
|
-
lines: async (q) => (await api.lines(me, q)).lines,
|
|
56
|
-
tags: async (q) => (await api.tags(me, q)).tags,
|
|
57
|
-
createTag: (q, b) => api.createTag(me, q, b),
|
|
58
|
-
tree: (q, rev, paths) => api.tree(me, q, rev, { paths: paths || [''] }),
|
|
59
|
-
exportPatches: (q, o) => api.exportPatches(me, q, o),
|
|
60
|
-
blobs: (q, ids) => api.blobs(me, q, ids),
|
|
61
|
-
importCommits: (q, items, lines = []) => api.importBatch(me, q, JSON.parse(JSON.stringify({ items, lines }))),
|
|
62
|
-
async upload (q, objs) {
|
|
63
|
-
const missing = new Set()
|
|
64
|
-
for (let i = 0; i < objs.length; i += 10000) for (const id of (await api.have(me, q, objs.slice(i, i + 10000).map((o) => o.id))).missing) missing.add(id)
|
|
65
|
-
let batch = []; let size = 0
|
|
66
|
-
const flush = async () => { if (batch.length) await api.uploadPack(me, q, buildPack(batch).bytes); batch = []; size = 0 }
|
|
67
|
-
for (const o of objs.filter((x) => missing.has(x.id))) {
|
|
68
|
-
if (size + o.bytes.length > 48 * 1024 * 1024) await flush()
|
|
69
|
-
batch.push(o); size += o.bytes.length
|
|
70
|
-
}
|
|
71
|
-
await flush()
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
export function importFollowOps (svc, api, { scratch = path.join(os.tmpdir(), 'seam-imports'), allowLocal = false, secret = (name) => process.env[`SEAM_SECRET_${String(name).toUpperCase().replace(/[^A-Z0-9]/g, '_')}`] || null, holder = `follower:${process.pid}:${crypto.randomBytes(4).toString('hex')}`, tickMs = 30_000 } = {}) {
|
|
77
|
-
const { store, now, quiltFor, fromStore } = svc.internals
|
|
78
|
-
const running = new Map() // quilt id → the pass in flight
|
|
79
|
-
const lastPass = new Map() // quilt id → when its last pass started, on this server
|
|
80
|
-
let timer = null
|
|
81
|
-
|
|
82
|
-
const view = (rec) => { const { poke_hash: _, ...rest } = rec; return { ...rest, quilt_id: rec.id } }
|
|
83
|
-
const save = async (qid, rec, opts = {}) => { try { return await store.setImport({ quilt_id: qid, import: rec, actor: rec.by.member_id, at: now(), ...opts }) } catch (err) { throw fromStore(err) } }
|
|
84
|
-
|
|
85
|
-
async function record (qid) {
|
|
86
|
-
const q = await store.getQuilt(qid)
|
|
87
|
-
return q?.settings?.import || null
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/** One follow pass: fetch the remote into the mirror, import what is new, freeze what is followed. */
|
|
91
|
-
async function pass (qid) {
|
|
92
|
-
const rec = await record(qid)
|
|
93
|
-
if (!rec || !['reading', 'following'].includes(rec.state)) return
|
|
94
|
-
const lease = await store.takeLease({ kind: 'line', quilt_id: qid, line: LEASE, holder, ttl_ms: 30 * 60_000, at: now() })
|
|
95
|
-
if (!lease.held) return
|
|
96
|
-
lastPass.set(qid, now())
|
|
97
|
-
const was = rec.state
|
|
98
|
-
try {
|
|
99
|
-
const mirror = path.join(scratch, qid)
|
|
100
|
-
const auth = authArgs(rec.credential ? await secret(rec.credential, { quilt_id: qid }) : null)
|
|
101
|
-
const env = { GIT_ALLOW_PROTOCOL: allowLocal ? 'https:http:ssh:git:file' : 'https:http:ssh:git' }
|
|
102
|
-
if (!fs.existsSync(path.join(mirror, 'HEAD'))) {
|
|
103
|
-
fs.mkdirSync(scratch, { recursive: true })
|
|
104
|
-
fs.rmSync(mirror, { recursive: true, force: true })
|
|
105
|
-
await run([...auth, 'clone', '--quiet', '--mirror', rec.source.url, mirror], { env })
|
|
106
|
-
} else {
|
|
107
|
-
await run([...auth, '-C', mirror, 'fetch', '--quiet', '--prune', 'origin'], { env })
|
|
108
|
-
}
|
|
109
|
-
const r = await importGit(localClient(api, rec.by), { repo: mirror, slug: qid, follow: true })
|
|
110
|
-
const lines = []
|
|
111
|
-
for (const l of await store.lines(qid)) {
|
|
112
|
-
if (!followed(l.name)) continue
|
|
113
|
-
lines.push({ line: l.name, patches: l.patch_count, tip_sha: l.tip ? await store.gitShaOf(qid, l.tip) : null })
|
|
114
|
-
}
|
|
115
|
-
const next = { ...rec, lines, passes: (rec.passes || 0) + 1, last_pass_at: now(), commits: (rec.commits || 0) + r.commits }
|
|
116
|
-
delete next.error
|
|
117
|
-
if (r.rewritten.length) Object.assign(next, { state: 'failed', error: 'ref rewritten', rewritten: r.rewritten, finished_at: now() })
|
|
118
|
-
else if (!rec.follow) Object.assign(next, { state: 'done', finished_at: now() })
|
|
119
|
-
else next.state = 'following'
|
|
120
|
-
// While git leads, every followed line stays frozen (a stopped follow too: nobody merges
|
|
121
|
-
// over git's history until the owner decides).
|
|
122
|
-
await save(qid, next, { freeze: rec.follow ? true : null, lines: lines.map((l) => l.line), expect_state: was })
|
|
123
|
-
} catch (err) {
|
|
124
|
-
// A first pass that fails ends the import; a later one keeps following and says why.
|
|
125
|
-
const cur = await record(qid)
|
|
126
|
-
if (cur && cur.state === was) await save(qid, was === 'reading' ? { ...cur, state: 'failed', error: err.message, finished_at: now() } : { ...cur, error: err.message }, { expect_state: was }).catch(() => {})
|
|
127
|
-
} finally {
|
|
128
|
-
await store.giveLease({ kind: 'line', quilt_id: qid, line: LEASE, holder }).catch(() => {})
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
/** Runs a pass now, or joins the one in flight. */
|
|
133
|
-
function kick (qid) {
|
|
134
|
-
if (!running.has(qid)) running.set(qid, pass(qid).catch(() => {}).finally(() => running.delete(qid)))
|
|
135
|
-
return running.get(qid)
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/** POST /v1/imports { url, slug, follow?, interval?, credential? } → the import, and its poke secret (shown once). */
|
|
139
|
-
async function startImport (me, { url, slug, follow = false, interval = 600, credential = null } = {}) {
|
|
140
|
-
const source = remoteUrl(url, { allowLocal })
|
|
141
|
-
const s = String(slug || '').toLowerCase()
|
|
142
|
-
let existing = null
|
|
143
|
-
try { existing = await quiltFor(me, s) } catch (err) { if (err.status !== 404) throw err }
|
|
144
|
-
if (existing) throw new QuiltError(409, existing.settings?.import ? 'import_closed' : 'slug_taken', existing.settings?.import ? `${s} already has its import (one import per quilt, never reopened)` : `you already have a quilt called ${s}`)
|
|
145
|
-
if (credential != null && !/^[A-Za-z0-9_.-]{1,100}$/.test(String(credential))) throw new QuiltError(400, 'bad_request', 'credential: the name of a secret, not the secret')
|
|
146
|
-
const q = await api.createQuilt(me, { slug: s, name: s, import: true })
|
|
147
|
-
const poke = POKE_PREFIX + crypto.randomBytes(24).toString('base64url')
|
|
148
|
-
const rec = { id: q.id, source: { url: source }, ...(credential ? { credential: String(credential) } : {}), follow: !!follow, interval_s: Math.max(60, Math.floor(Number(interval) || 600)), state: 'reading', lines: [], passes: 0, commits: 0, poke_hash: hash(poke), by: { member_id: me.member_id, kind: me.kind || 'person', name: me.name || null }, started_at: now() }
|
|
149
|
-
// main is frozen before anything is imported, so nothing merges ahead of git.
|
|
150
|
-
await save(q.id, rec, { freeze: rec.follow ? true : null, lines: ['main'], expect_state: null })
|
|
151
|
-
kick(q.id)
|
|
152
|
-
return { ...view(rec), poke_secret: poke }
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
async function importStatus (me, id) {
|
|
156
|
-
const q = await quiltFor(me, id, 'read')
|
|
157
|
-
const rec = q.settings?.import
|
|
158
|
-
if (!rec) throw new QuiltError(404, 'not_found', 'this quilt has no import')
|
|
159
|
-
return view(rec)
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
/** POST /v1/imports/:id/poke: a member who can write, or anyone with the import's secret. */
|
|
163
|
-
async function pokeImport (me, id, { secret: given } = {}) {
|
|
164
|
-
let rec
|
|
165
|
-
if (given) {
|
|
166
|
-
const q = /^[0-9a-f-]{36}$/i.test(String(id)) ? await store.getQuilt(String(id)) : null
|
|
167
|
-
rec = q?.settings?.import
|
|
168
|
-
if (!rec || !rec.poke_hash || !crypto.timingSafeEqual(Buffer.from(rec.poke_hash), Buffer.from(hash(given)))) throw new QuiltError(401, 'bad_token', 'this poke secret is not the import\'s')
|
|
169
|
-
} else {
|
|
170
|
-
const q = await quiltFor(me, id, 'write')
|
|
171
|
-
rec = q.settings?.import
|
|
172
|
-
if (!rec) throw new QuiltError(404, 'not_found', 'this quilt has no import')
|
|
173
|
-
}
|
|
174
|
-
if (rec.state !== 'following') throw new QuiltError(409, 'not_following', `the import is ${rec.state}, not following`)
|
|
175
|
-
kick(rec.id)
|
|
176
|
-
return { ok: true, id: rec.id, queued: true }
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
/**
|
|
180
|
-
* POST /v1/imports/:id/cutover (admin; 14 step 3, 16 step 3): a last follow pass, the remote's
|
|
181
|
-
* tips checked against the imported tips, then in one go: the follow ended for good (state
|
|
182
|
-
* done, closed), the followed lines unfrozen, and the export of main to the source turned on.
|
|
183
|
-
* Refuses (remote_moved, or conflict when a line moved in Seam) and leaves the follow as it was.
|
|
184
|
-
*/
|
|
185
|
-
async function cutover (me, id, { credential } = {}) {
|
|
186
|
-
const q = await quiltFor(me, id, 'admin')
|
|
187
|
-
if (!q.settings?.import) throw new QuiltError(404, 'not_found', 'this quilt has no import')
|
|
188
|
-
if (q.settings.import.state !== 'following') throw new QuiltError(409, 'not_following', `the import is ${q.settings.import.state}, not following`)
|
|
189
|
-
await (running.get(q.id) || Promise.resolve())
|
|
190
|
-
await kick(q.id)
|
|
191
|
-
const rec = await record(q.id)
|
|
192
|
-
if (rec.state !== 'following') throw new QuiltError(409, 'not_following', `the last follow pass ended the import: ${rec.error || rec.state}`)
|
|
193
|
-
const auth = authArgs(rec.credential ? await secret(rec.credential, { quilt_id: q.id }) : null)
|
|
194
|
-
const env = { GIT_ALLOW_PROTOCOL: allowLocal ? 'https:http:ssh:git:file' : 'https:http:ssh:git' }
|
|
195
|
-
const remote = new Map()
|
|
196
|
-
for (const row of (await run([...auth, 'ls-remote', rec.source.url, 'HEAD', 'refs/heads/release/*'], { env })).split('\n').filter(Boolean)) {
|
|
197
|
-
const [sha, ref] = row.split('\t')
|
|
198
|
-
remote.set(ref === 'HEAD' ? 'main' : ref.replace(/^refs\/heads\//, ''), sha)
|
|
199
|
-
}
|
|
200
|
-
const have = new Map(rec.lines.map((l) => [l.line, l.tip_sha]))
|
|
201
|
-
for (const [line, sha] of remote) if (have.get(line) !== sha) throw new QuiltError(409, 'remote_moved', `the remote's ${line} is ${sha.slice(0, 12)}, the import has ${have.has(line) ? String(have.get(line)).slice(0, 12) : 'no such line'}: stop pushes to the remote (branch protection), then cut over again`, { line, sha })
|
|
202
|
-
const tips = {}
|
|
203
|
-
for (const l of await store.lines(q.id)) if (followed(l.name)) tips[l.name] = l.tip
|
|
204
|
-
const done = { ...rec, state: 'done', closed: true, finished_at: now() }
|
|
205
|
-
delete done.error
|
|
206
|
-
await save(q.id, done, { freeze: false, lines: Object.keys(tips), expect_state: 'following', expect_tips: tips })
|
|
207
|
-
let exp
|
|
208
|
-
try {
|
|
209
|
-
exp = await api.setExport(me, q.id, { target: rec.source.url, credential: credential || rec.credential || null, lines: ['main'], releases: false })
|
|
210
|
-
} catch (err) {
|
|
211
|
-
// The export must be on before anything merges in Seam: put the follow back as it was.
|
|
212
|
-
await save(q.id, rec, { freeze: true, lines: Object.keys(tips), expect_state: 'done' }).catch(() => {})
|
|
213
|
-
throw err
|
|
214
|
-
}
|
|
215
|
-
return { ok: true, import: view(done), export: exp }
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
const follower = {
|
|
219
|
-
/** Every tick, a pass for each following import whose interval is up (on this server). */
|
|
220
|
-
start () {
|
|
221
|
-
if (timer) return
|
|
222
|
-
const tick = async () => {
|
|
223
|
-
let list = []
|
|
224
|
-
try { list = await store.importsFollowing() } catch { return }
|
|
225
|
-
for (const { quilt_id: qid, import: rec } of list) if (now() - (lastPass.get(qid) || 0) >= rec.interval_s * 1000) kick(qid)
|
|
226
|
-
api.exportTick?.().catch(() => {})
|
|
227
|
-
}
|
|
228
|
-
timer = setInterval(tick, tickMs)
|
|
229
|
-
timer.unref?.()
|
|
230
|
-
tick()
|
|
231
|
-
},
|
|
232
|
-
stop () { if (timer) clearInterval(timer); timer = null }
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
return { startImport, importStatus, pokeImport, cutover, importIdle: (id) => running.get(id) || Promise.resolve(), follower, importPass: kick }
|
|
236
|
-
}
|