@heyquilt/seam 0.1.1 → 0.17.0

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/bin/seam.js CHANGED
@@ -8,13 +8,13 @@ import { fileURLToPath } from 'node:url'
8
8
  import { readAccount, login, logout, resume } from '../lib/account.js'
9
9
 
10
10
  const here = path.dirname(fileURLToPath(import.meta.url))
11
- // The published package ships the client in lib/quilts (scripts/build.mjs); in the repo it is
12
- // read straight from the server's src/quilts.
13
- const QUILTS = fs.existsSync(path.join(here, '../lib/quilts/cli.js')) ? '../lib/quilts' : '../../src/quilts'
11
+ // In the repo the client is read straight from the server's src/quilts (never a stale build);
12
+ // the published package has no src/, and ships it in lib/quilts (scripts/build.mjs).
13
+ const QUILTS = fs.existsSync(path.join(here, '../../src/quilts/cli.js')) ? '../../src/quilts' : '../lib/quilts'
14
14
  const quilts = (file) => import(`${QUILTS}/${file}`)
15
15
  const { version } = JSON.parse(fs.readFileSync(path.join(here, '../package.json'), 'utf8'))
16
16
 
17
- const HELP = `seam ${version}: Quilt Source, source control for people and their agents
17
+ const HELP = `seam ${version}: Seam, source control for people and their agents
18
18
  Docs: https://seam.heyquilt.com/docs
19
19
 
20
20
  seam login [--no-browser] Sign in with your heyquilt.com account
@@ -24,9 +24,23 @@ Docs: https://seam.heyquilt.com/docs
24
24
  seam square open "<title>" --quilt <q> [--dir <folder>]
25
25
  seam checkout <sq:id | revision> --quilt <q> [--dir <folder>] [-- paths]
26
26
  seam status | diff | commit -m "<message>" in a block folder
27
+ seam restore <paths> | reset [rev] | clean [-f] throw away uncommitted edits
28
+ seam live [sq] | live share | live clear uncommitted work others read as sq:<id>:live
27
29
  seam merge [sq:id] [--into main] seam rebase | revert <patch:n> | cherry-pick <rev>
28
30
  seam resolve <path>... --ours | --theirs | --base | --side n | --delete
29
31
  seam log [rev] [-n 20] [--oneline] seam show <rev> seam blame <path>[@rev] seam cat | ls
32
+ seam key [add [--name n] | remove <key>] seam commit --sign | sign [rev] seam log --show-signature
33
+ seam line [new <name> [--from rev] | freeze | unfreeze] Lines: main, release/<name>
34
+ seam tag [<name> [rev]] seam release [<name> [rev] -m <notes>] Read as tag:<name>
35
+ seam commit --amend [-m "<message>"] change your square's last commit
36
+ seam square open "<title>" --on sq:<id> a square stacked on another
37
+ seam claim <path|glob> [--note why] | unclaim <id> files your square is working on
38
+ seam review approve|request-changes|comment|dismiss <sq> seam line require <line> [--approvals n] [--checks a,b]
39
+ seam check <name> green|red [--square sq | --patch n] report a check (a red one on a patch reverts it)
40
+ seam resolutions [forget <key>] conflicts settled before, settled the same way again
41
+ seam bisect <bad> <good> --run "<check>" seam grep <pattern> [rev] [--all] seam describe [rev] seam merge-base <a> <b>
42
+ seam log --follow <path> | log -S <text>
43
+ seam token [new <name> [--level read|write] | revoke <name>] a key for one quilt, for CI
30
44
  seam events [--webhook <url>] What happened to your squares
31
45
  seam export git <dir> Back to git
32
46
  seam mcp The same commands as MCP tools, for your AI tool
@@ -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
+ }
package/lib/quilts/cli.js CHANGED
@@ -4,16 +4,18 @@
4
4
  // --quilt and --square. It is not part of the Quilt app's `quilt` command.
5
5
  import path from 'node:path'
6
6
  import fs from 'node:fs'
7
+ import os from 'node:os'
7
8
  import { quiltClient } from './client.js'
8
- import { findBlock, checkout, status as blockStatus, commitFolder, syncTo, localDiff, saveBlock } from './block.js'
9
- import { describeLog, describeShow, describeDiff, describeBlame, describeStatus, describeMerge, describeWrite, describeSquare, describeSquareStatus, describeEvents } from './describe.js'
9
+ import { findBlock, checkout, status as blockStatus, commitFolder, amendFolder, syncTo, localDiff, saveBlock, restore, reset, clean, localChanges } from './block.js'
10
+ import { describeLog, describeShow, describeDiff, describeBlame, describeStatus, describeMerge, describeWrite, describeSquare, describeSquareStatus, describeEvents, describeTag, describeRelease, describeImport, describeRestore, describeReset, describeClean, describeLive, describeClaims, describeResolutions, describeLiveSet, describeMergeBase, describeGrep, describeBisect, describeCheck, describeReviews, describeReviewed, describeSquareCheck, describePolicy, describeKeys, describeTokens, describeNewToken } from './describe.js'
11
+ import { signWithLocalKey, localKey, makeLocalKey, signingKeyFile } from './signing.js'
10
12
 
11
13
  export const API_URL = 'https://api.heyquilt.com'
12
14
 
13
15
  /** The verbs `seam` answers. */
14
- export const SEAM_VERBS = new Set(['quilts', 'square', 'checkout', 'status', 'commit', 'log', 'show', 'diff', 'merge', 'rebase', 'revert', 'cherry-pick', 'blame', 'resolve', 'cat', 'ls', 'import', 'export', 'events'])
16
+ export const SEAM_VERBS = new Set(['quilts', 'square', 'checkout', 'status', 'commit', 'log', 'show', 'diff', 'merge', 'rebase', 'revert', 'cherry-pick', 'blame', 'resolve', 'cat', 'ls', 'import', 'export', 'events', 'tag', 'release', 'line', 'restore', 'reset', 'clean', 'live', 'bisect', 'grep', 'describe', 'merge-base', 'claim', 'unclaim', 'check', 'resolutions', 'review', 'sign', 'key', 'token'])
15
17
 
16
- const VALUE_FLAGS = new Set(['-m', '--message', '-n', '--max-count', '--into', '--onto', '--on-conflict', '--quilt', '--square', '--dir', '--line', '--task', '--level', '--title', '--slug', '--format', '--expect-tip', '--side', '--file', '--batch', '--to', '--note', '--after', '--kinds', '--webhook', '--secret', '--branch'])
18
+ const VALUE_FLAGS = new Set(['-m', '--quilts', '--message', '-n', '--max-count', '--into', '--onto', '--on-conflict', '--quilt', '--square', '--dir', '--line', '--task', '--level', '--title', '--slug', '--format', '--expect-tip', '--side', '--file', '--batch', '--to', '--note', '--after', '--kinds', '--webhook', '--secret', '--branch', '--from', '-S', '--run', '--marks', '--patch', '--on', '--path', '--checks', '--name', '--public-key'])
17
19
 
18
20
  export function parse (argv) {
19
21
  const pos = []; const flags = {}; const paths = []
@@ -61,6 +63,43 @@ export async function runSeamCommand (cmd, argv, { cwd = process.cwd(), token, o
61
63
  const list = await client.quilts()
62
64
  return print(list, list.length ? list.map((q) => `${q.slug} ${q.id} (${q.level})`).join('\n') : 'No quilts yet.')
63
65
  }
66
+ case 'line': {
67
+ // seam line [list] | new release/<name> [--from <rev>] | freeze <line> | unfreeze <line>
68
+ const sub = pos[0] || 'list'
69
+ const describeLine = (l) => `${l.name}: ${l.patch_count} patches, tip ${l.tip ? l.tip.slice(0, 12) : '(empty)'}${l.frozen ? ', frozen' : ''}`
70
+ if (sub === 'list') { const ls = await client.lines(needQuilt()); return print(ls, ls.map(describeLine).join('\n')) }
71
+ if (sub === 'new') {
72
+ if (!pos[1]) throw new Error('Name the line: seam line new release/<name> [--from <rev>]')
73
+ const l = await client.newLine(needQuilt(), { name: pos[1], from: flags['--from'] })
74
+ return print(l, `Made line ${describeLine(l)}.\nOpen a square on it with: seam square open "<title>" --line ${l.name}`)
75
+ }
76
+ if (sub === 'freeze' || sub === 'unfreeze') {
77
+ if (!pos[1]) throw new Error(`Which line? seam line ${sub} <line>`)
78
+ const l = await (sub === 'freeze' ? client.freezeLine : client.unfreezeLine)(needQuilt(), pos[1])
79
+ return print(l, sub === 'freeze' ? `${l.name} is frozen: merges into it are refused until seam line unfreeze ${l.name}.` : `${l.name} takes merges again.`)
80
+ }
81
+ if (sub === 'require') {
82
+ if (!pos[1]) throw new Error('Which line? seam line require <line> [<approvals>] [--checks ci,lint]')
83
+ const checks = flags['--checks'] === undefined ? undefined : String(flags['--checks']).split(',').map((c) => c.trim()).filter(Boolean)
84
+ if (pos[2] === undefined && checks === undefined) { const p = await client.linePolicy(needQuilt(), pos[1]); return print(p, describePolicy(p)) }
85
+ const p = await client.setLinePolicy(needQuilt(), pos[1], { ...(pos[2] !== undefined ? { approvals: Number(pos[2]) } : {}), ...(checks ? { checks } : {}) })
86
+ return print(p, describePolicy(p))
87
+ }
88
+ throw new Error('seam line [list] | new release/<name> [--from <rev>] | freeze <line> | unfreeze <line> | require <line> [<approvals>] [--checks ci,lint]')
89
+ }
90
+ case 'tag':
91
+ case 'release': {
92
+ // seam tag [<name> [rev]] | seam release [<name> [rev] -m <notes>]: no name lists them.
93
+ const where = (x) => `${x.short}${x.patch ? ` (${x.patch.line}:${x.patch.patch_no})` : ''}`
94
+ if (!pos[0]) {
95
+ if (cmd === 'tag') { const ts = await client.tags(needQuilt()); return print(ts, ts.length ? ts.map(describeTag).join('\n') : 'No tags yet.') }
96
+ const rs = await client.releases(needQuilt())
97
+ return print(rs, rs.length ? rs.map(describeRelease).join('\n') : 'No releases yet.')
98
+ }
99
+ if (cmd === 'tag') { const x = await client.createTag(needQuilt(), { name: pos[0], rev: pos[1] }); return print(x, `Tagged ${x.name} at ${where(x)}. Read it as tag:${x.name}.`) }
100
+ const x = await client.createRelease(needQuilt(), { name: pos[0], rev: pos[1], notes: flags['-m'] ?? flags['--message'] ?? '' })
101
+ return print(x, `Released ${x.name} at ${where(x)}. Read it as tag:${x.name}.`)
102
+ }
64
103
  case 'events': {
65
104
  // seam events [--after n] [--kinds a,b] | --webhook <url> [--secret s] [--kinds a,b] | --webhook off
66
105
  const kinds = flags['--kinds'] ? String(flags['--kinds']).split(',') : undefined
@@ -75,7 +114,7 @@ export async function runSeamCommand (cmd, argv, { cwd = process.cwd(), token, o
75
114
  case 'square': {
76
115
  const sub = pos[0] || 'list'
77
116
  if (sub === 'open') {
78
- const sq = await client.openSquare(needQuilt(), { title: pos.slice(1).join(' ') || flags['--title'] || 'untitled', line: flags['--line'], task_id: flags['--task'] })
117
+ const sq = await client.openSquare(needQuilt(), { title: pos.slice(1).join(' ') || flags['--title'] || 'untitled', line: flags['--line'], task_id: flags['--task'], ...(flags['--on'] ? { on: flags['--on'] } : {}) })
79
118
  let text = describeSquare(sq)
80
119
  if (flags['--dir']) {
81
120
  const dir = path.resolve(cwd, flags['--dir'])
@@ -130,20 +169,166 @@ export async function runSeamCommand (cmd, argv, { cwd = process.cwd(), token, o
130
169
  const st = await blockStatus(client, found.dir, block)
131
170
  return print(st, describeStatus(st))
132
171
  }
172
+ case 'review': {
173
+ // seam review [sq] (the reviews and where the square stands)
174
+ // seam review approve|request-changes|comment|dismiss [sq] [-m <text>] [--path <file> [--line <n>]]
175
+ const VERDICT = { approve: 'approve', 'request-changes': 'request_changes', comment: 'comment', dismiss: 'dismiss' }
176
+ const kind = VERDICT[pos[0]]
177
+ const sq = (kind ? pos[1] : pos[0]) || needSquare()
178
+ if (!kind) { const r = await client.reviews(needQuilt(), sq); return print(r, describeReviews(r)) }
179
+ const r = await client.review(needQuilt(), sq, { kind, body: message || '', ...(flags['--path'] ? { path: flags['--path'] } : {}), ...(flags['--line'] ? { line: Number(flags['--line']) } : {}) })
180
+ return print(r, describeReviewed(r))
181
+ }
182
+ case 'claim': {
183
+ // seam claim (lists the quilt's claims) | seam claim <pattern>... [--note <why>] (claims them for your square)
184
+ if (!pos.length) { const cs = await client.claims(needQuilt(), flags['--square']); return print(cs, describeClaims(cs)) }
185
+ const sq = needSquare()
186
+ const made = []
187
+ for (const p of pos) made.push(await client.claim(needQuilt(), sq, { pattern: p, note: flags['--note'] || message }))
188
+ return print(made, made.map((c) => `Claimed ${c.pattern} for ${c.square}: writes to it from other squares on ${c.line} are refused until the square merges or you run seam unclaim ${c.pattern}.`).join('\n'))
189
+ }
190
+ case 'check': {
191
+ // seam check <name> red|green|flaky [--line main] [--patch n] | seam check <name> green|red --square <sq>
192
+ const [name, status] = pos
193
+ if (!name || !['red', 'green', 'flaky'].includes(status)) throw new Error('seam check <name> red|green|flaky [--line <line>] [--patch <n>]')
194
+ if (flags['--square']) {
195
+ // seam check <name> green|red --square <sq>: a result for a square's latest commit (a line may require it before a merge)
196
+ const r = await client.reportSquareCheck(needQuilt(), flags['--square'], { check: name, status })
197
+ return print(r, describeSquareCheck(r))
198
+ }
199
+ const r = await client.reportCheck(needQuilt(), flags['--line'] || 'main', { check: name, status, ...(flags['--patch'] != null ? { patch: Number(flags['--patch']) } : {}) })
200
+ return print(r, describeCheck(r))
201
+ }
202
+ case 'unclaim': {
203
+ // seam unclaim <pattern|claim id>...
204
+ if (!pos.length) throw new Error('Which claim? seam unclaim <pattern>')
205
+ const sq = needSquare()
206
+ const out = []
207
+ for (const p of pos) out.push(await client.unclaim(needQuilt(), sq, p))
208
+ return print(out, out.map((r) => `Let go of ${r.released.pattern}.`).join('\n'))
209
+ }
210
+ case 'live': {
211
+ // seam live [sq] | live share (a block folder's uncommitted edits) | live clear
212
+ const sub = pos[0]
213
+ if (sub === 'share' || sub === 'clear') {
214
+ const sqRef = flags['--square'] || block?.square
215
+ if (!sqRef) throw new Error(`seam live ${sub} works in a block folder on a square (or pass --square sq:<id>).`)
216
+ if (sub === 'share' && !found) throw new Error('seam live share sends a block folder\'s uncommitted edits: run it in a folder made by seam checkout.')
217
+ const changes = sub === 'clear' ? [] : localChanges(found.dir, block).map((c) => c.status === 'deleted' ? { path: c.path, op: 'delete' } : { path: c.path, op: 'put', content: c.bytes.toString('base64'), mode: c.mode })
218
+ const r = await client.setLive(needQuilt(), sqRef, { changes })
219
+ return print(r, describeLiveSet(r))
220
+ }
221
+ const r = await client.live(needQuilt(), sub || needSquare())
222
+ return print(r, describeLive(r))
223
+ }
224
+ case 'restore':
225
+ case 'reset':
226
+ case 'clean': {
227
+ if (!found) throw new Error(`seam ${cmd} works on a block folder; run it in a folder made by seam checkout.`)
228
+ const files = [...(cmd === 'reset' ? pos.slice(1) : pos), ...paths].map((p) => path.resolve(cwd, p))
229
+ if (cmd === 'restore') { const r = await restore(client, found.dir, block, { paths: files }); return print(r, describeRestore(r)) }
230
+ if (cmd === 'reset') { const r = await reset(client, found.dir, block, { rev: pos[0] }); return print(r, describeReset(r, block)) }
231
+ const r = clean(found.dir, block, { paths: files.length ? files : [cwd], dryRun: !flags['-f'] && !flags['--force'] })
232
+ return print(r, describeClean(r, 'Run it again as seam clean -f to remove them.'))
233
+ }
133
234
  case 'commit': {
134
235
  if (!found) {
135
236
  throw new Error('seam commit seals a block folder\'s changes; run it in a folder made by seam checkout.')
136
237
  }
238
+ // --sign: signs the new commit with this computer's key (seam key add).
239
+ const signed = async (r) => {
240
+ if (!flags['--sign'] || !r?.result?.id) return ''
241
+ const s = await signWithLocalKey(client, needQuilt(), r.result.id)
242
+ r.signature = s.signature
243
+ return `\nSigned with ${s.signature.key}.`
244
+ }
245
+ if (flags['--amend']) {
246
+ const r = await amendFolder(client, found.dir, block, { message, paths: [...pos, ...paths].map((p) => path.resolve(cwd, p)) })
247
+ const s = await signed(r)
248
+ return print(r, describeWrite(r, `Amended ${r.amended.slice(0, 12)} as`) + s)
249
+ }
137
250
  if (!message) throw new Error('Say what changed: seam commit -m "message" [paths...]')
138
251
  const r = await commitFolder(client, found.dir, block, { message, paths: [...pos, ...paths].map((p) => path.resolve(cwd, p)) })
139
252
  if (r.nothing) return print(r, 'Nothing to commit.')
140
- return print(r, describeWrite(r, 'Committed'))
253
+ const s = await signed(r)
254
+ return print(r, describeWrite(r, 'Committed') + s)
255
+ }
256
+ case 'sign': {
257
+ // seam sign [rev]: signs a commit you made (default: your square's head, or main's tip).
258
+ const r = await signWithLocalKey(client, needQuilt(), pos[0] || (block?.square ? block.square_short : block?.line) || 'main')
259
+ return print(r, `Signed ${r.short} with ${r.signature.key}.`)
260
+ }
261
+ case 'token': {
262
+ // seam token [list] | new <name> [--level read|write] | revoke <id or name>
263
+ const sub = pos[0] || 'list'
264
+ if (sub === 'list') { const r = await client.tokens(needQuilt()); return print(r, describeTokens(r)) }
265
+ if (sub === 'new') {
266
+ if (!pos[1]) throw new Error('seam token new <name> [--level read|write]')
267
+ const r = await client.createToken(needQuilt(), { name: pos[1], level: flags['--level'] || 'read' })
268
+ return print(r, describeNewToken(r))
269
+ }
270
+ if (sub === 'revoke') {
271
+ if (!pos[1]) throw new Error('seam token revoke <id or name>')
272
+ const r = await client.revokeToken(needQuilt(), pos[1])
273
+ return print(r, `Revoked token ${r.name} (${r.id}): it no longer reaches the quilt.`)
274
+ }
275
+ throw new Error('seam token [list] | new <name> [--level read|write] | revoke <id or name>')
276
+ }
277
+ case 'key': {
278
+ // seam key [list [member]] | add [--name <n>] [--public-key <file>] | remove <key>
279
+ const sub = pos[0] || 'list'
280
+ if (sub === 'list') { const r = await client.signingKeys({ member: pos[1] }); return print(r, describeKeys(r)) }
281
+ if (sub === 'remove') {
282
+ if (!pos[1]) throw new Error('seam key remove <key>')
283
+ const r = await client.removeKey(pos[1])
284
+ return print(r, `Removed ${r.key}: commits it signed now show "Signature by a removed key".`)
285
+ }
286
+ if (sub !== 'add') throw new Error('seam key [list [member]] | add [--name <name>] [--public-key <file>] | remove <key>')
287
+ let pub = flags['--public-key'] ? fs.readFileSync(path.resolve(cwd, flags['--public-key']), 'utf8') : null
288
+ let made = false
289
+ if (!pub) { const k = localKey(); if (k) pub = k.public_key; else { pub = makeLocalKey(); made = true } }
290
+ const r = await client.addKey({ public_key: pub, name: flags['--name'] || os.hostname() })
291
+ return print(r, `${made ? `Made a signing key at ${signingKeyFile()} and added` : 'Added'} ${r.key}. Sign with seam commit --sign or seam sign <rev>.`)
141
292
  }
142
293
  case 'log': {
143
294
  const rev = pos[0] || (block?.square ? block.square_short : block?.line) || 'main'
144
- const r = await client.log(needQuilt(), { rev, paths, limit: flags['-n'] || flags['--max-count'] || 20 })
295
+ const r = await client.log(needQuilt(), { rev, paths, limit: flags['-n'] || flags['--max-count'] || 20, follow: flags['--follow'] ? true : undefined, show_signature: flags['--show-signature'] ? true : undefined, search: typeof flags['-S'] === 'string' ? flags['-S'] : undefined })
145
296
  return print(r, describeLog(r, { oneline: !!flags['--oneline'] }))
146
297
  }
298
+ case 'describe': {
299
+ const r = await client.describe(needQuilt(), pos[0] || (block?.square ? block.square_short : block?.line) || 'main')
300
+ return print(r, r.describe)
301
+ }
302
+ case 'merge-base': {
303
+ if (pos.length !== 2) throw new Error('seam merge-base <revision> <revision>')
304
+ const r = await client.mergeBase(needQuilt(), pos[0], pos[1])
305
+ return print(r, describeMergeBase(r))
306
+ }
307
+ case 'grep': {
308
+ // seam grep <pattern> [revision] [-- paths]: -i ignores case, -F takes the pattern as plain text, -l lists files.
309
+ if (!pos[0]) throw new Error('seam grep <pattern> [revision] [-i] [-F] [-l] [--all] [-- paths]')
310
+ if (flags['--all']) {
311
+ // --all: every quilt you can read (or --quilts a,b), at the revision (default main).
312
+ const r = await client.search({ pattern: pos[0], rev: pos[1] || 'main', quilts: flags['--quilts'] ? String(flags['--quilts']).split(',') : undefined, paths, ignore_case: flags['-i'] ? 1 : undefined, fixed: flags['-F'] ? 1 : undefined, limit: flags['--max-count'] })
313
+ return print(r, describeGrep(r, { filesOnly: !!flags['-l'] }))
314
+ }
315
+ const rev = pos[1] || (block?.square ? block.square_short : block?.line) || 'main'
316
+ const rel = (p) => found ? path.relative(found.dir, path.resolve(cwd, p)).split(path.sep).join('/') : p
317
+ const r = await client.grep(needQuilt(), { pattern: pos[0], rev, paths: paths.map(rel), ignore_case: flags['-i'] ? 1 : undefined, fixed: flags['-F'] ? 1 : undefined, limit: flags['--max-count'] })
318
+ return print(r, describeGrep(r, { filesOnly: !!flags['-l'] }))
319
+ }
320
+ case 'bisect': {
321
+ // seam bisect <bad> <good> --run "<check>": exit 0 good, 125 skip, else bad. Without --run, one step (--marks id:good,id:bad).
322
+ if (pos.length !== 2) throw new Error('seam bisect <bad> <good> --run "<check command>" (or --marks <commit>:good,<commit>:bad for one step)')
323
+ const marks = Object.fromEntries(String(flags['--marks'] || '').split(',').filter(Boolean).map((x) => x.split(':')))
324
+ if (typeof flags['--run'] === 'string') {
325
+ const { bisectRun } = await import('./bisect-run.js')
326
+ const r = await bisectRun(client, { quilt: needQuilt(), bad: pos[0], good: pos[1], run: flags['--run'], marks, api, log: (s) => { if (!json) out(s) } })
327
+ return print(r, describeBisect(r))
328
+ }
329
+ const r = await client.bisect(needQuilt(), { bad: pos[0], good: pos[1], marks })
330
+ return print(r, describeBisect(r) + (r.done ? '' : `\nThen: seam bisect ${pos[0]} ${pos[1]} --marks ${[...Object.entries(marks).map(([k, v]) => `${k}:${v}`), `${r.next.short}:<good|bad|skip>`].join(',')}`))
331
+ }
147
332
  case 'show': {
148
333
  const rev = pos[0] || (block?.square ? block.square_short : 'main')
149
334
  const format = flags['--stat'] ? 'stat' : flags['--name-only'] || flags['--name-status'] ? 'names' : 'unified'
@@ -206,13 +391,23 @@ export async function runSeamCommand (cmd, argv, { cwd = process.cwd(), token, o
206
391
  }
207
392
  case 'resolve': {
208
393
  const choose = flags['--ours'] ? 'ours' : flags['--theirs'] ? 'theirs' : flags['--base'] ? 'base' : flags['--delete'] ? 'delete' : flags['--suggestion'] ? 'suggestion' : flags['--side'] != null ? Number(flags['--side']) : null
209
- if (choose === null || !pos.length) throw new Error('seam resolve <path>... --ours | --theirs | --base | --suggestion | --side n | --delete (or edit the file and commit it without the markers)')
394
+ if (choose === null || !pos.length) throw new Error('seam resolve <path>... --ours | --theirs | --base | --suggestion | --side n | --delete [--no-remember] (or edit the file and commit it without the markers)')
210
395
  const rel = (p) => found ? path.relative(found.dir, path.resolve(cwd, p)).split(path.sep).join('/') : p
211
- const r = await client.resolve(needQuilt(), needSquare(), { ...(found ? { expect_head: block.head } : {}), entries: pos.map((p) => ({ path: rel(p), choose })) })
396
+ const r = await client.resolve(needQuilt(), needSquare(), { ...(found ? { expect_head: block.head } : {}), entries: pos.map((p) => ({ path: rel(p), choose })), ...(flags['--no-remember'] ? { remember: false } : {}) })
212
397
  let text = describeWrite(r, 'Resolved')
213
398
  if (found) { const s = await syncTo(client, found.dir, block, r.square.head, { square: r.square }); text += `\n${s.written} file(s) updated in the folder.` }
214
399
  return print(r, text)
215
400
  }
401
+ case 'resolutions': {
402
+ // seam resolutions [path] (the remembered answers) | seam resolutions forget <key>
403
+ if (pos[0] === 'forget') {
404
+ if (!pos[1]) throw new Error('Which one? seam resolutions forget <key>')
405
+ const r = await client.forgetResolution(needQuilt(), pos[1])
406
+ return print(r, `Forgot ${r.forgotten.short} (${r.forgotten.path}): that conflict will be asked about again.`)
407
+ }
408
+ const rows = await client.resolutions(needQuilt(), pos[0])
409
+ return print(rows, describeResolutions(rows))
410
+ }
216
411
  case 'blame': {
217
412
  const arg = pos[0]
218
413
  if (!arg) throw new Error('seam blame <path>[@revision]')
@@ -236,12 +431,12 @@ export async function runSeamCommand (cmd, argv, { cwd = process.cwd(), token, o
236
431
  return print(r, r.entries.map((e) => `${e.mode}\t${e.blob ? e.blob.slice(0, 12) : e.term.slice(0, 12)}\t${e.path}`).join('\n'))
237
432
  }
238
433
  case 'import': {
239
- if (pos[0] !== 'git' || !pos[1]) throw new Error('seam import git <path> [--into <new-quilt-slug>]')
434
+ if (pos[0] !== 'git' || !pos[1]) throw new Error('seam import git <path> [--into <quilt-slug>] [--validate]')
240
435
  const { importGit } = await import('./import-git.js')
241
436
  const repo = path.resolve(cwd, pos[1])
242
437
  if (!fs.existsSync(path.join(repo, '.git')) && !fs.existsSync(path.join(repo, 'HEAD'))) throw new Error(`${repo} is not a git repository`)
243
438
  const r = await importGit(client, { repo, slug: flags['--into'] || path.basename(repo).toLowerCase().replace(/[^a-z0-9-]/g, '-'), log: (s) => { if (!json) out(s) }, validate: !!flags['--validate'], batch: Number(flags['--batch']) || 100 })
244
- return print(r, `Imported ${r.commits} commits (${r.patches} patches on main) into ${r.slug} (${r.quilt}).${r.validated != null ? ` Validated ${r.validated} trees against git.` : ''}`)
439
+ return print(r, describeImport(r))
245
440
  }
246
441
  case 'export': {
247
442
  if (pos[0] !== 'git' || !pos[1]) throw new Error('seam export git <dir> [--line main] [--branch <name>]')
@@ -45,6 +45,7 @@ export function quiltClient ({ api, token, fetch: fetchImpl = globalThis.fetch,
45
45
  openSquare: (q, b) => call('POST', `${Q(q)}/squares`, b),
46
46
  square: (q, s) => call('GET', `${Q(q)}/squares/${enc(s)}`),
47
47
  commit: (q, s, b) => call('POST', `${Q(q)}/squares/${enc(s)}/commit`, b),
48
+ amend: (q, s, b) => call('POST', `${Q(q)}/squares/${enc(s)}/amend`, b),
48
49
  handoff: (q, s, b) => call('POST', `${Q(q)}/squares/${enc(s)}/handoff`, b),
49
50
  abandon: (q, s, b = {}) => call('POST', `${Q(q)}/squares/${enc(s)}/abandon`, b),
50
51
  reopen: (q, s) => call('POST', `${Q(q)}/squares/${enc(s)}/reopen`, {}),
@@ -54,6 +55,16 @@ export function quiltClient ({ api, token, fetch: fetchImpl = globalThis.fetch,
54
55
  rebase: (q, s, b) => call('POST', `${Q(q)}/squares/${enc(s)}/rebase`, b),
55
56
  cherryPick: (q, s, b) => call('POST', `${Q(q)}/squares/${enc(s)}/cherry-pick`, b),
56
57
  resolve: (q, s, b) => call('POST', `${Q(q)}/squares/${enc(s)}/resolve`, b),
58
+ lines: async (q) => (await call('GET', `${Q(q)}/lines`)).lines,
59
+ newLine: (q, b) => call('POST', `${Q(q)}/lines`, b),
60
+ reportCheck: (q, line, b) => call('POST', `${Q(q)}/lines/${enc(line)}/checks`, b),
61
+ freezeLine: (q, line) => call('POST', `${Q(q)}/lines/${enc(line)}/freeze`, {}),
62
+ unfreezeLine: (q, line) => call('POST', `${Q(q)}/lines/${enc(line)}/unfreeze`, {}),
63
+ tags: async (q) => (await call('GET', `${Q(q)}/tags`)).tags,
64
+ tag: (q, name) => call('GET', `${Q(q)}/tags/${name.split('/').map(enc).join('/')}`),
65
+ createTag: (q, b) => call('POST', `${Q(q)}/tags`, b),
66
+ releases: async (q) => (await call('GET', `${Q(q)}/releases`)).releases,
67
+ createRelease: (q, b) => call('POST', `${Q(q)}/releases`, b),
57
68
  merge: (q, line, b) => call('POST', `${Q(q)}/lines/${enc(line)}/merge`, b),
58
69
  revert: (q, b) => call('POST', `${Q(q)}/revert`, b),
59
70
  events: (q, o = {}) => call('GET', `${Q(q)}/events${qs(o)}`),
@@ -61,15 +72,40 @@ export function quiltClient ({ api, token, fetch: fetchImpl = globalThis.fetch,
61
72
  show: (q, rev, o = {}) => call('GET', `${Q(q)}/show/${enc(rev)}${qs(o)}`),
62
73
  exportPatches: (q, o = {}) => call('GET', `${Q(q)}/export/git${qs(o)}`),
63
74
  log: (q, o = {}) => call('GET', `${Q(q)}/log${qs(o)}`),
75
+ sign: (q, rev, b) => call('POST', `${Q(q)}/commits/${enc(rev)}/signatures`, b),
76
+ tokens: (q) => call('GET', `${Q(q)}/tokens`),
77
+ createToken: (q, b) => call('POST', `${Q(q)}/tokens`, b),
78
+ revokeToken: (q, id) => call('DELETE', `${Q(q)}/tokens/${enc(id)}`),
79
+ signingKeys: (o = {}) => call('GET', `/v1/signing-keys${qs(o)}`),
80
+ addKey: (b) => call('POST', '/v1/signing-keys', b),
81
+ removeKey: (key) => call('DELETE', `/v1/signing-keys/${enc(key)}`),
82
+ describe: (q, rev) => call('GET', `${Q(q)}/describe${qs({ rev })}`),
83
+ mergeBase: (q, a, b) => call('GET', `${Q(q)}/merge-base${qs({ a, b })}`),
84
+ grep: (q, o = {}) => call('GET', `${Q(q)}/grep${qs(o)}`),
85
+ search: (o = {}) => call('GET', `/v1/search${qs(o)}`),
86
+ bisect: (q, b) => call('POST', `${Q(q)}/bisect`, b),
64
87
  linePatches: (q, line = 'main', o = {}) => call('GET', `${Q(q)}/lines/${encodeURIComponent(line)}/patches${qs(o)}`),
65
88
  diff: (q, o = {}) => call('GET', `${Q(q)}/diff${qs(o)}`),
89
+ resolutions: async (q, p) => (await call('GET', `${Q(q)}/resolutions${p ? `?path=${enc(p)}` : ''}`)).resolutions,
90
+ forgetResolution: (q, key) => call('DELETE', `${Q(q)}/resolutions/${enc(key)}`),
91
+ reviews: (q, sq) => call('GET', `${Q(q)}/squares/${enc(sq)}/reviews`),
92
+ review: (q, sq, b) => call('POST', `${Q(q)}/squares/${enc(sq)}/reviews`, b),
93
+ reportSquareCheck: (q, sq, b) => call('POST', `${Q(q)}/squares/${enc(sq)}/checks`, b),
94
+ linePolicy: (q, line) => call('GET', `${Q(q)}/lines/${enc(line)}/policy`),
95
+ setLinePolicy: (q, line, b) => call('PUT', `${Q(q)}/lines/${enc(line)}/policy`, b),
96
+ claims: async (q, square) => (await call('GET', `${Q(q)}/claims${square ? `?square=${enc(square)}` : ''}`)).claims,
97
+ claim: (q, sq, b) => call('POST', `${Q(q)}/squares/${enc(sq)}/claims`, b),
98
+ unclaim: (q, sq, which) => call('DELETE', `${Q(q)}/squares/${enc(sq)}/claims/${enc(which)}`),
99
+ live: (q, sq) => call('GET', `${Q(q)}/squares/${enc(sq)}/live`),
100
+ setLive: (q, sq, body) => call('PUT', `${Q(q)}/squares/${enc(sq)}/live`, body),
66
101
  tree: (q, rev, paths) => call('GET', `${Q(q)}/tree/${enc(rev || 'main')}${qs({ paths })}`),
67
102
  cat: (q, rev, path, o = {}) => call('GET', `${Q(q)}/blob/${enc(rev || 'main')}/${pathPart(path)}${qs(o)}`),
68
103
  blame: (q, rev, path) => call('GET', `${Q(q)}/blame/${enc(rev || 'main')}/${pathPart(path)}`),
69
104
  have: (q, ids) => call('POST', `${Q(q)}/have`, { ids }),
70
105
  blobs: (q, ids) => call('POST', `${Q(q)}/blobs`, { ids }),
71
106
  uploadPack: (q, bytes) => call('POST', `${Q(q)}/packs`, bytes, { raw: true }),
72
- importCommits: (q, items) => call('POST', `${Q(q)}/import/commits`, { items }),
107
+ importCommits: (q, items, lines = []) => call('POST', `${Q(q)}/import/commits`, { items, ...(lines.length ? { lines } : {}) }),
108
+ importKnown: (q, shas) => call('POST', `${Q(q)}/import/known`, { shas }),
73
109
 
74
110
  /**
75
111
  * Uploads objects the server does not have yet ([{ kind, id, bytes }]), in packs of at most