heyquilt 0.3.138 → 0.3.141

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/RELEASES.md +10 -0
  2. package/bin/quilt.js +7 -143
  3. package/package.json +1 -1
  4. package/src/account.js +0 -29
  5. package/src/api/memory-store.js +0 -29
  6. package/src/api/routes/teams.js +0 -23
  7. package/src/api/server.js +5 -19
  8. package/src/api/supabase-store.js +0 -52
  9. package/src/branch-view.js +6 -131
  10. package/src/control.js +2 -33
  11. package/src/duties.js +1 -1
  12. package/src/github-commit.js +3 -315
  13. package/src/mcp.js +8 -157
  14. package/src/relay-ask.js +3 -66
  15. package/src/relay-cluster.js +3 -5
  16. package/src/relay-mcp.js +6 -418
  17. package/src/relay-prune.js +6 -145
  18. package/src/relay-turn-sync.js +1 -1
  19. package/src/server.js +33 -1331
  20. package/src/session-follow.js +18 -5
  21. package/src/session.js +58 -3526
  22. package/src/status.js +2 -58
  23. package/src/turn-sync.js +15 -2
  24. package/src/ui/changes.js +5 -14
  25. package/src/ui/chat.js +0 -1
  26. package/src/ui/common.js +0 -2
  27. package/src/ui/git-rule.js +2 -30
  28. package/src/ui/home.js +3 -76
  29. package/src/ui/session.js +34 -528
  30. package/src/ui/tree.js +2 -8
  31. package/src/ui/workspaces.js +2 -122
  32. package/src/ui-server.js +1 -38
  33. package/src/api/routes/github.js +0 -380
  34. package/src/branches.js +0 -194
  35. package/src/clash.js +0 -128
  36. package/src/commit-request.js +0 -59
  37. package/src/commit.js +0 -213
  38. package/src/git-hook.js +0 -48
  39. package/src/gitignore.js +0 -49
  40. package/src/gitstate.js +0 -1069
  41. package/src/relay-ci.js +0 -111
  42. package/src/relay-clash.js +0 -214
  43. package/src/relay-commit.js +0 -923
  44. package/src/relay-filekey.js +0 -46
  45. package/src/relay-git.js +0 -274
  46. package/src/relay-githttp.js +0 -166
  47. package/src/relay-github.js +0 -109
  48. package/src/relay-outbound.js +0 -1295
  49. package/src/relay-repo-moves.js +0 -43
  50. package/src/relay-sync.js +0 -795
  51. package/src/relay-upstream.js +0 -889
  52. package/src/sync-words.js +0 -42
  53. package/src/upstream.js +0 -192
package/src/session.js CHANGED
@@ -19,7 +19,7 @@ import { Connection, PUSH_REQUEST_TIMEOUT_MS, QUILT_MOVE_TIMEOUT_MS } from './co
19
19
  import { loadIdentity } from './identity.js'
20
20
  import { writePrivateJson } from './private-file.js'
21
21
  import { MAX_SHARED_FILE_BYTES, FEATURES } from './protocol.js'
22
- import { formatBytes, pullAdvice } from './status.js'
22
+ import { formatBytes } from './status.js'
23
23
  import {
24
24
  loadIgnore, IGNORE_FILES, isIgnored, isSafeRelPath, resolveInside, looksBinary, sha1, walk,
25
25
  toPosix, globMatcher, MAX_TEXT_BYTES, MAX_BINARY_BYTES, LARGE_FILE_BYTES, MAX_STORED_BINARY_BYTES, CHAT_FILES, closeWatcher
@@ -48,21 +48,12 @@ import { chatAbout, waitingOn, queuedFor, renderQueueNotice, askForIt, heldRefus
48
48
  import { makeSubscription, deliverEvents, deliverWebhook, parseWebhookUrl, webhookPayload } from './webhooks.js'
49
49
  import { MentionRunner, parseOnMention, readOnMention, writeOnMention } from './on-mention.js'
50
50
  import { pickChecklist } from './agent-task-workflow.js'
51
- import { cleanCommitFiles, ownerName, commitRequestMessage, MAX_COMMIT_DESCRIPTION } from './commit-request.js'
52
- import { cleanFiles, uncommitted, lastCommitted, commitFiles, commitMessage, editorsSince, uncommittedByPerson, requestWarning, REQUEST_SETTLE_MS, MAX_REQUEST_FILES, MAX_MESSAGE } from './commit.js'
53
51
  import { changeRefusal, TALK_REFUSED } from './session-access.js'
54
52
  import { canAdmit } from './admit-policy.js'
55
53
  import { merge3, mergeAuto, hasMarkers } from './merge3.js'
56
54
  import { sameEditClient, asClient } from './same-edit.js'
57
55
  import { openMerge, updateMerge, readMerges, pruneMerges, cleanName } from './merges.js'
58
- import { ensureQuiltIgnored } from './gitignore.js'
59
- import { urlOrigin, gitDir, headKey, headRef, gitRuns, askTwice, lastCallTimedOut, busy as gitBusy, leftoverLock, STALE_LOCK_MS, indexStamp, classify, filesAt, changesBetween, commitsBetween, treeState, branchTip, watchGit, unmergedPaths, stashStamp, upstreamAdds, pullState, SETTLE_MS, BURST_PATHS, upstreamOf, fetchUpstream, isAncestor, stagedAgainst, fastForward, resetIndex, blobAt, committedAs, hasFilesUnder, repoBranches, remoteHost, backWithGit, installGitHelpers, setPersonGit, hasCommit, pushLocal, bundleOf, adoptPublished, fetchSignedIn, movedRemoteUrl, commitTimes, pushedBase, changedSince, ownSinceFork, linksAt } from './gitstate.js'
60
- import { planCatchUp, catchUpAdvice, publishedOf, notePublished } from './upstream.js'
61
- import { agentPrefix, noreply, slug, gitForEveryoneWords } from './github-commit.js'
62
- import { parseRepo, upstreamRef } from './relay-upstream.js'
63
- import { noCredentialsText } from './relay-github.js'
64
- import { clashTaskId, readClash, clashCandidates, clashOrder, holderOf, ownerLabel, clashTitle, clashFiles, clashBrief, leaveItNotice, clashFacts } from './clash.js'
65
- import { cleanGit, branchBoard, RELAY_BY, isRelay } from './branches.js'
56
+ import { RELAY_NAME as RELAY_BY, isRelay } from './ui/chat.js'
66
57
  import { cleanTyping, TYPING_MS, AGENT_TYPING_MS } from './ui/chat.js'
67
58
  import { DEFAULT_KEY } from './branchdocs.js'
68
59
  import { boxEntry } from './boxes.js'
@@ -73,6 +64,30 @@ import { squareShort } from '@heyquilt/seam/lib/quilts/errors.js'
73
64
  export { applyTextDiff }
74
65
 
75
66
  const LOCAL = Symbol('local')
67
+
68
+ /** A short name for a branch, a task or a session: lower case, dashes, at most `max` characters. */
69
+ function slug (s, max = 40) {
70
+ return String(s || '').toLowerCase().normalize('NFKD').replace(/[^\w\s-]/g, '').trim().replace(/[\s_]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '').slice(0, max).replace(/-$/, '') || 'work'
71
+ }
72
+
73
+ /** Whether `root` is a git checkout (a .git folder, or a worktree's pointer file). */
74
+ function hasGitDir (root) {
75
+ try { fs.statSync(path.join(root, '.git')); return true } catch { return false }
76
+ }
77
+
78
+ /** The branch a git checkout in `root` is on, read from .git/HEAD (no git run); null when none or detached. */
79
+ function headBranch (root) {
80
+ try {
81
+ let dir = path.join(root, '.git')
82
+ if (fs.statSync(dir).isFile()) {
83
+ const m = /^gitdir:\s*(.+)$/m.exec(fs.readFileSync(dir, 'utf8'))
84
+ if (!m) return null
85
+ dir = path.resolve(root, m[1].trim())
86
+ }
87
+ const m = /^ref:\s*refs\/heads\/(.+)$/m.exec(fs.readFileSync(path.join(dir, 'HEAD'), 'utf8'))
88
+ return m ? m[1].trim() : null
89
+ } catch { return null }
90
+ }
76
91
  const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904' // git's empty tree, the base of a branch pushed from nothing
77
92
 
78
93
  /**
@@ -258,10 +273,8 @@ export class Session extends EventEmitter {
258
273
  this.chat = this.doc.getArray('chat') // { by, text, ts }
259
274
  this.activity = this.doc.getArray('activity') // { by, path, kind, detail, ts }
260
275
  this.agentFeed = this.doc.getArray('agentFeed') // { id, by, tool, conv, kind, text, ts, summary?, title? }
261
- this.commitRequests = this.doc.getMap('commitRequests') // id -> { id, by, message, ts, state: 'open'|'done', doneBy, hash }
262
276
  this.tasks = this.doc.getMap('tasks') // id -> { id, title, column, by, assignee, forAi, tool, files, conv, verified, qaNotes, recurring, cron, order, ts }
263
277
  this.taskComments = this.doc.getMap('taskComments') // task id -> [{ id, by, text, ts }] (task-comments.js)
264
- this.clashes = this.doc.getMap('clashes') // git branch -> { task, sha, upstream }: the open clash task for that branch (clash.js)
265
278
  // Mentions, direct messages and tasks handed to this member (or their AI), for agents to wake on.
266
279
  this.inboxTracker = new Inbox()
267
280
  // Shared by every AI session working as this member (each runs its own `quilt mcp`): messages
@@ -457,32 +470,6 @@ export class Session extends EventEmitter {
457
470
  this.emit('identity', { name, kind, aiOf })
458
471
  }
459
472
 
460
- /**
461
- * A git folder's .gitignore ignores .quilt/ (gitignore.js), so git leaves this
462
- * session's state alone. Before the watcher starts: the line is shared like any
463
- * edit of .gitignore, and partners' git ignores it too.
464
- */
465
- ignoreQuiltState ({ share = false } = {}) {
466
- // An edit we may not make (a viewer, or an editor kept to other folders)
467
- // would only be refused (and kept aside) at every start.
468
- if (this.access && this.access.state === 'approved' ? this.writeRefusal('.gitignore') : this.savedRole === 'viewer') return
469
- const r = ensureQuiltIgnored(this.root)
470
- if (r.added) {
471
- this.ig = loadIgnore(this.root)
472
- if (share) {
473
- // Shared like any edit of .gitignore, but it is Quilt's, not this person's or their AI's: never claimed for them.
474
- const d = this.readDisk('.gitignore')
475
- this.quiltOwnWrite = '.gitignore'; this.quiltOwnKey = d ? d.key : null
476
- this.queue('.gitignore') // the watcher's first scan takes the folder as it is
477
- }
478
- if (!this.quiltIgnoreSaid) this.log('Added .quilt/ to .gitignore so git leaves Quilt\'s state alone.')
479
- this.quiltIgnoreSaid = true
480
- } else if (r.error && !this.quiltIgnoreSaid) {
481
- this.quiltIgnoreSaid = true
482
- this.log(`⚠️ couldn't add .quilt/ to .gitignore (${r.error.message}); git stash -u or git clean could take Quilt's state away`)
483
- }
484
- }
485
-
486
473
  log (msg) { this.logs.push(msg); if (this.logs.length > 200) this.logs.shift(); this.emit('log', msg) }
487
474
 
488
475
  /**
@@ -540,79 +527,12 @@ export class Session extends EventEmitter {
540
527
  /** The folder's commit, for an entry's first version in a diff branch: while seeding, the branch's start; else HEAD. */
541
528
  entryBase () { return (this.seeding && this.seedBase) || (this.git && this.git.sha) || null }
542
529
 
543
- /** For a first join of a diff branch: what the folder has that differs from HEAD, or null. */
544
- async gitNowForJoin () {
545
- return this.diffForm && this.git && this.git.sha ? await changedSince(this.root, this.git.sha).catch(() => null) : null
546
- }
547
-
548
- /** What the join of a branch asks for: a diff document measured from the newest commit a remote has (a new branch only). */
549
- async diffJoinFields () {
550
- if (!this.git || !this.git.sha || (this.features && !this.features.split(',').includes('branch-diffs'))) return {}
551
- const base = await pushedBase(this.root).catch(() => null)
552
- if (!base) return {}
553
- // Which GitHub repository the folder is a clone of: a session new to it learns it from here.
554
- const up = await upstreamOf(this.root).catch(() => null)
555
- const url = (up && up.url) || await this.gitOut(['remote', 'get-url', 'origin'])
556
- const m = /github\.com[:/]([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?\/?$/i.exec(String(url || ''))
557
- return { form: 'diff', diffBase: base, ...(m ? { repo: `github.com/${m[1]}/${m[2]}` } : {}) }
558
- }
559
-
560
- /**
561
- * The repository this folder's origin names was renamed or moved to another owner on GitHub (the relay
562
- * says so: its record's `was`, from GitHub's webhook or another member's folder): origin is pointed at
563
- * the new name, so fetches and pushes go there without GitHub's redirect, and the relay never hears the
564
- * old name from here again.
565
- */
566
- async followRepoMove () {
567
- const mine = (this.branchList || []).find((b) => b.key === this.branch)
568
- const r = mine && mine.relay
569
- if (!this.git || !r || !r.repo || !Array.isArray(r.was) || !r.was.length) return
570
- const seen = `${r.repo}\0${r.was.join(',')}`
571
- if (this.repoMoveSeen === seen) return
572
- this.repoMoveSeen = seen
573
- const up = await upstreamOf(this.root)
574
- if (!up || !up.remote || up.remote === '.' || !up.url) return
575
- const next = movedRemoteUrl(up.url, r.was, r.repo)
576
- if (!next || next === up.url) return
577
- const ok = await this.gitOut(['remote', 'set-url', up.remote, next])
578
- if (ok === null) { this.repoMoveSeen = null; return }
579
- this.log(`🔀 GitHub says the repository moved to ${r.repo.replace(/^github\.com\//, '')}: ${up.remote} points there now`)
580
- this.checkUpstreamSoon({ fetch: true })
581
- }
582
-
583
530
  /** The relay's list of the session's branches. */
584
531
  setBranches (list) {
585
- const rs = this.relaySha()
586
532
  this.branchList = (Array.isArray(list) ? list : []).filter((b) => b && typeof b.key === 'string')
587
- // GitHub renamed or moved the repository: this folder's origin follows it.
588
- this.followRepoMove().catch((err) => this.emit('debug', `following a moved repository: ${err.message}`))
589
- // The relay's record for this branch moved: what this folder says about it (upstreamFactsFor) is looked at again.
590
- if (this.relaySha() !== rs) {
591
- this.relayShaAt = Date.now()
592
- if (this.upstreamFacts) { this.shareGit(); this.checkUpstreamSoon() }
593
- }
594
- // An agent's folder without git, now that the relay says which repository and commit its branch is at;
595
- // a person's too, once git is on for everyone here (git for everyone).
596
- if (this.ready && this.gitBacking && (this.kind === 'agent' || this.personGitOn()) && !this.git && !this.backing && this.relayEntryFor(this.branch)) {
597
- this.maybeBackWithGit({ late: true }).catch((err) => this.emit('debug', `git backing: ${err.message}`))
598
- }
599
- // Whether the session's GitHub connection covers this person's repository may have changed: their git helper follows.
600
- this.syncPersonGitSoon()
601
- // Large files on a branch that syncs with GitHub, once the owner agreed: the relay gets its wrap of each key (plan §10).
602
533
  if (this.relayKeys && this.relayEntryFor(this.branch)?.fileKey) this.shareKeysWithRelay()
603
- // An agent's folder that is a GitHub checkout already: its origin becomes the relay's git once the relay says it serves it (plan §9).
604
- if (this.kind === 'agent' && this.git && !this.movedOrigin && !this.stopped && this.relayEntryFor(this.branch)?.git) {
605
- this.movedOrigin = true
606
- this.moveOriginToRelay().catch((err) => this.emit('debug', `git origin: ${err.message}`))
607
- }
608
534
  // This folder's branch went over the size limit for one branch: said once, here and to its AI.
609
535
  const mine = this.branchList.find((b) => b.key === this.branch)
610
- // An agent's folder on a branch that was merged goes back to the branch it merged into, so the finished branch
611
- // can leave the session (relay-prune.js) and the next task starts from there. People choose for themselves.
612
- if (this.kind === 'agent' && this.ready && mine && mine.merged && !mine.default && this.git && this.git.branch === mine.key && this.leftMerged !== mine.key && !this.stopped) {
613
- this.leftMerged = mine.key
614
- this.leaveMergedBranch(mine).catch((err) => this.emit('debug', `leaving the merged ${mine.key}: ${err.message}`))
615
- }
616
536
  if (mine && mine.full && this.fullSaid !== mine.key) {
617
537
  this.fullSaid = mine.key
618
538
  const msg = `${mine.key} is over the session's size limit for one branch, so the relay takes no new changes on it; they stay in this folder. Other branches, chat and tasks carry on as usual. To keep syncing, start a fresh copy with \`git checkout -b <new name>\`: your changes since are kept in this folder.`
@@ -660,7 +580,7 @@ export class Session extends EventEmitter {
660
580
  // As at a first join: what differs is backed up once, by reconcileFirstJoin, not again as a live clash.
661
581
  const ready = this.ready
662
582
  this.ready = false
663
- try { this.noteFirstJoin(this.reconcileFirstJoin(await this.gitNowForJoin())) } finally { this.ready = ready }
583
+ try { this.noteFirstJoin(this.reconcileFirstJoin(null)) } finally { this.ready = ready }
664
584
  this.log(`⚠️ ${msg}`)
665
585
  this.notice(msg)
666
586
  this.saveStateNow()
@@ -672,26 +592,18 @@ export class Session extends EventEmitter {
672
592
  this.loadWebhook()
673
593
  this.loadOnMention()
674
594
  const hadState = this.loadState()
675
- // Back in a folder we synced before: the line is an offline edit, merged once synced. A first
676
- // join adds it after taking the room's files (reconcileFirstJoin), which may replace .gitignore.
677
- if (hadState) this.ignoreQuiltState()
595
+ // A folder never synced and empty as the session starts: in a session that is a layout-3 quilt already,
596
+ // whatever it gets before its block opens is the read-only branch document's older copy, never its
597
+ // member's edits (session-follow.js openHere; 2026-11-01). Cleared by a first word of the quilt that is
598
+ // not layout 3, and once the block opens.
599
+ this.joinedEmpty = !hadState && !walk(this.root, this.ig).some((rel) => this.syncable(rel))
678
600
  // Restarted while the folder follows its square: its .git is set aside (session-follow.js) and stays so; its branch is the note's.
679
- this.gitAside = gitDir(this.root) ? null : gitAsideOf(this.root)
680
- this.git = await headKey(this.root)
681
- this.gitSeen = this.git
682
- // A repo whose git can't be run (not on the PATH of an app started from the Dock, say) or
683
- // didn't answer in time, as against a branch with no commits yet, which git reads fine.
684
- const headTimedOut = !this.git && lastCallTimedOut(this.root)
685
- const gitUnreadable = !this.git && !!gitDir(this.root) && (headTimedOut || !(headRef(this.root) && await gitRuns(this.root)))
601
+ this.gitAside = hasGitDir(this.root) ? null : gitAsideOf(this.root)
686
602
  if (hadState) this.loadClaims()
687
- // Restarted on another branch, or mid-hold: nothing in this tree is the session's offline work (see resumeHold).
688
- const resumed = hadState && this.resumeHold()
689
- // The branch document this folder syncs: the branch it syncs in git (still the one it was paused off, until
690
- // the folder follows git), ∅ (the room's default) without git.
691
- this.branch = this.git ? this.git.key : (gitDir(this.root) && headRef(this.root)) || (this.gitAside && this.gitAside.branch) || DEFAULT_KEY
603
+ // The branch whose square this folder follows: the checkout's branch name, the set-aside note's, else the room's default.
604
+ this.branch = headBranch(this.root) || (this.gitAside && this.gitAside.branch) || DEFAULT_KEY
692
605
  // The form saved is the saved branch's; another branch's is the relay's to say when it is joined.
693
606
  if (this.savedBranch !== this.branch) { this.branchForm = null; this.diffBase = null }
694
- const diffJoin = await this.diffJoinFields()
695
607
 
696
608
  this.conn = new Connection({
697
609
  server: this.server,
@@ -708,7 +620,7 @@ export class Session extends EventEmitter {
708
620
  doc: this.doc,
709
621
  // An app's saved document from before branch documents is the room's old one: the relay kept it as that branch's.
710
622
  // base: the commit a branch new to the session starts from (a partner without it locally creates it there).
711
- branch: { key: this.branch, doc: this.bdoc, ...diffJoin, ...(this.git && this.git.sha ? { base: this.git.sha } : {}), ...(this.legacyState && this.savedGit ? { adopt: this.savedGit.key } : {}), ...(this.branchEpoch && this.savedBranch === this.branch ? { epoch: this.branchEpoch } : {}) },
623
+ branch: { key: this.branch, doc: this.bdoc, ...(this.branchEpoch && this.savedBranch === this.branch ? { epoch: this.branchEpoch } : {}) },
712
624
  // state.bin is a copy of an older document of this branch (it was removed and started again): never merged.
713
625
  onStaleBranch: (reply) => this.staleBranch(reply),
714
626
  beforeRemote: () => { if (this.ready) this.flushPending() }
@@ -731,6 +643,7 @@ export class Session extends EventEmitter {
731
643
  // Seam (Seam's plans/quilt-source-control/16): the relay says when the session is a quilt, and which.
732
644
  this.conn.on('quilt', (q) => {
733
645
  this.quilt = q
646
+ if (!(q && q.layout === 3)) this.joinedEmpty = false
734
647
  this.emit('quilt', q)
735
648
  // At layout 3 this folder follows its branch's square by itself (session-follow.js).
736
649
  if (!this.squareFollower) this.squareFollower = new SquareFollower(this)
@@ -751,22 +664,13 @@ export class Session extends EventEmitter {
751
664
  else if (!m && was) this.log(`🧵 the move of the session's code ${was.kind === 'back' ? 'back to its git branches' : 'into Seam'} is over.`)
752
665
  this.scheduleStatusWrite()
753
666
  })
754
- // GitHub moved this folder's branch: fetch and bring it in now, not at the next minute's look.
755
- this.conn.on('pushed', (p) => {
756
- if (!this.git || !this.git.branch || p.branch !== this.branchKey() || this.stopped) return
757
- if (p.sha && this.upstream && this.upstream.sha === p.sha && !this.upstream.behind) return
758
- // A look with a fetch, as soon as the folder is quiet (it asks again by itself while files are still changing).
759
- this.checkUpstreamSoon({ fetch: true, delay: 250 })
760
- })
761
- // A move's own join is the move's to finish (finishMove): this.branch changes only once the folder is there.
762
667
  this.conn.on('branch-joined', (r) => {
763
- if (typeof r.branch !== 'string' || !r.branch || (this.hold && this.hold.kind === 'switching')) return
668
+ if (typeof r.branch !== 'string' || !r.branch) return
764
669
  if (typeof r.epoch === 'string' && r.epoch && !this.staleRejoin) this.branchEpoch = r.epoch
765
670
  // An older relay says no form: its documents are all full ones.
766
671
  this.branchForm = r.form === 'diff' ? 'diff' : 'full'
767
672
  if (r.form === 'diff' && typeof r.diffBase === 'string') this.diffBase = r.diffBase
768
673
  this.branch = r.branch
769
- this.announceBranch()
770
674
  this.scheduleStatusWrite()
771
675
  })
772
676
  this.conn.on('branch-refused', (why) => this.log(`⚠️ the relay refused this folder's branch: ${why}`))
@@ -778,20 +682,13 @@ export class Session extends EventEmitter {
778
682
  // We've synced this folder before: hold what was edited while we were
779
683
  // away, let the relay tell us what the others did, then merge the two.
780
684
  const marks = historyMarks(this.history.entries()) // what we had seen: the catch-up is the rest
781
- if (gitUnreadable) this.saysGitUnreadable(resumed)
782
- const gitNow = this.diffForm && !resumed && this.git && this.git.sha ? await changedSince(this.root, this.git.sha).catch(() => null) : null
783
- const offline = resumed ? { entries: [], take: [], downloads: [] } : this.captureOffline(gitNow)
685
+ const offline = this.captureOffline(null)
784
686
  this.goLive()
785
687
  // A folder that follows its square (its .git set aside) is compared with the branch document it last synced, which the
786
688
  // square's work has moved past: its block's first look says what went in, so nothing is said here.
787
689
  if (offline.entries.length && !gitAsideOf(this.root)) this.log(`${offline.entries.length} file(s) changed while you were away; merging once the relay has synced…`)
788
690
  const synced = this.conn.waitForSync()
789
- // An agent's folder synced before folders were git-backed: once the relay has synced, with its
790
- // branch's repository known, it is made a checkout, and git is watched from then on.
791
- synced.then(() => this.maybeBackWithGit({ late: true })).catch(() => {})
792
691
  synced.then(() => this.mergeOffline(offline)).then((mine) => {
793
- // A diff branch: files git had that were deleted while away are deleted for everyone.
794
- for (const rel of offline.gone || []) if (this.followsGit(rel) && !this.onDisk(rel)) { this.lastKnown.set(rel, ''); this.ingest(rel) }
795
692
  if (mine) this.noteCatchUp({ ...awayChanges(this.history.entries(), marks, this.name), since: this.savedSeenAt, mine })
796
693
  }).catch((err) => {
797
694
  // Never synced (the relay refused us): nothing can be merged, so nothing stays held.
@@ -799,21 +696,7 @@ export class Session extends EventEmitter {
799
696
  this.awayBackups = null
800
697
  this.emit('debug', `offline merge did not run: ${err && err.message}`)
801
698
  })
802
- if (resumed) {
803
- // The doc loaded from disk is the room as it was at the last stop: the hold settles
804
- // against the room as it is now. Never synced: the hold stays on and nothing is shared.
805
- this.holdAwaitsSync = true
806
- synced.then(() => {
807
- this.holdAwaitsSync = false
808
- // Restarted on another branch: the folder follows git there, now that it has the room's work on the old one.
809
- const away = this.resumeAway
810
- this.resumeAway = null
811
- if (away) this.followHead(away)
812
- else this.settleSoon()
813
- }, () => {})
814
- }
815
699
  } else {
816
- if (gitUnreadable) this.saysGitUnreadable(false)
817
700
  this.log('waiting for relay…')
818
701
  const sync = this.conn.waitForSync()
819
702
  // In a session with an owner we may have to wait for them to let us in.
@@ -829,10 +712,8 @@ export class Session extends EventEmitter {
829
712
  if (first === 'pending') {
830
713
  // Finish joining in the background once let in.
831
714
  this.admitted = sync.then(async () => {
832
- this.noteFirstJoin(this.reconcileFirstJoin(await this.gitNowForJoin()))
715
+ this.noteFirstJoin(this.reconcileFirstJoin(null))
833
716
  this.goLive()
834
- this.ignoreQuiltState({ share: true })
835
- await this.maybeBackWithGit()
836
717
  await this.startWatcher()
837
718
  this.log('✅ you were let in')
838
719
  this.emit('status-changed')
@@ -843,10 +724,8 @@ export class Session extends EventEmitter {
843
724
  clearTimeout(timer)
844
725
  this.conn.off('access', onAccess)
845
726
  }
846
- this.noteFirstJoin(this.reconcileFirstJoin(await this.gitNowForJoin()))
727
+ this.noteFirstJoin(this.reconcileFirstJoin(null))
847
728
  this.goLive()
848
- this.ignoreQuiltState({ share: true })
849
- await this.maybeBackWithGit()
850
729
  }
851
730
  await this.startWatcher()
852
731
  // git was set aside while following a square, and the session is not one at layout 3 now (or Seam is off here): it comes back.
@@ -854,62 +733,6 @@ export class Session extends EventEmitter {
854
733
  return this
855
734
  }
856
735
 
857
- /**
858
- * An agent's folder that Quilt syncs but git doesn't know (no .git) is made a checkout of the
859
- * session branch's repository, at the commit the session's files are measured from, with every
860
- * file left as it is: git log and blame work, the folder follows the branch like any clone, and
861
- * git signs in through Quilt (installGitHelpers): fetches always, pushes within the owner's policy
862
- * (quilt_agent_commits), so the agent commits and pushes like anyone, or with quilt_commit.
863
- * Returns whether it was done.
864
- */
865
- async maybeBackWithGit ({ late = false } = {}) {
866
- // A person's folder too, while git is on for everyone here and a connection covers the repository (git for everyone): with GitHub as origin.
867
- if (!this.gitBacking || (this.kind !== 'agent' && !this.personGitOn()) || this.git || gitDir(this.root) || this.gitAside || this.inSquare || this.stopped || this.backing) return false
868
- // The relay's record of the branch (its repository and the commit the session is at) comes with its
869
- // branch list; when it isn't here yet, setBranches tries again as it arrives (no wait at start).
870
- const relay = this.relayEntryFor(this.branch)
871
- if (!relay) return false
872
- const repo = parseRepo(`https://${relay.repo}`)
873
- const ref = upstreamRef(relay.upstream)
874
- const key = this.branch === DEFAULT_KEY ? (this.branchList.find((b) => b.default) || {}).key : this.branch
875
- if (!repo || !repo.github || !ref || !key) return false
876
- this.backing = true // claimed before anything waits: a second call meanwhile (a late branch list) does nothing
877
- try {
878
- if (!(await gitRuns(this.root))) return false
879
- // The relay's git (plan §9) when it serves the session's branches: no GitHub token on this machine, pushes checked and published by the relay.
880
- const viaRelay = this.kind === 'agent' && !!relay.git && !!this.relayGitUrl()
881
- const url = viaRelay ? this.relayGitUrl() : this.gitRemote ? this.gitRemote(relay.repo) : `https://${relay.repo}.git`
882
- this.log(`🌱 making this folder a git checkout of ${relay.repo} at ${relay.sha.slice(0, 7)} (${key})${viaRelay ? ', through the relay' : ''}…`)
883
- let r = viaRelay
884
- ? await backWithGit(this.root, { url, ref, branch: key, sha: relay.sha, token: await this.passString(), user: 'quilt', helpers: this.gitHelpers() })
885
- : await backWithGit(this.root, { url, ref, branch: key, sha: relay.sha, helpers: this.gitHelpers() })
886
- if (!r.ok && !viaRelay) {
887
- const token = await this.gitAuth()
888
- if (token) r = await backWithGit(this.root, { url, ref, branch: key, sha: relay.sha, token, helpers: this.gitHelpers() })
889
- }
890
- if (!r.ok) { this.log(`⚠️ could not make this folder a git checkout (${r.error}); it syncs without git, and this is tried again at the next start`); return false }
891
- if (this.stopped) return false
892
- this.git = await headKey(this.root)
893
- this.gitSeen = this.git
894
- this.gitIndex = indexStamp(this.root)
895
- if (this.branch === DEFAULT_KEY) this.branch = key // the folder is on the branch by name now
896
- this.log(`✅ this folder is a git checkout of ${relay.repo} on ${key}: git log, blame, commit and push work (git signs in through Quilt, within the session's git setting), and quilt_commit works too`)
897
- if (this.kind !== 'agent') { this.personGitSig = null; this.syncPersonGitSoon() }
898
- if (late && this.watcher) this.startGitWatch()
899
- this.announceBranch()
900
- this.scheduleStatusWrite()
901
- this.saveStateNow()
902
- return true
903
- } finally { this.backing = false }
904
- }
905
-
906
- /** Whether agents can commit on this branch themselves: the owner lets them, and a member's GitHub connection covers the repository (from the relay's branch list). */
907
- canCommitHere () {
908
- const policy = (this.access && this.access.agentCommits) || 'branches'
909
- // The relay says (access.agentsCanCommit: the room's GitHub connection or token, whatever branch this folder is on); older relays only through the branch list.
910
- return policy !== 'off' && !!(this.access && (this.access.agentsCanCommit === true || (this.access.agentsCanCommit === undefined && this.branchList.some((b) => b && b.relay && b.relay.github && b.relay.github.state === 'ok'))))
911
- }
912
-
913
736
  /** This folder's branch as the relay names it (the room's default branch's own name, for a folder on ∅). */
914
737
  branchKey () { return this.branch === DEFAULT_KEY ? (this.branchList.find((b) => b.default) || {}).key || this.branch : this.branch }
915
738
 
@@ -921,384 +744,11 @@ export class Session extends EventEmitter {
921
744
  return r && r.sha && r.repo && r.upstream ? r : null
922
745
  }
923
746
 
924
- /**
925
- * A token for this branch's repository from the session's GitHub connection, or null; kept until
926
- * near its expiry. `access` 'read' fetches; 'write' (an agent's folder pushing) is given by the relay
927
- * within the owner's policy, and a read token already here is exchanged for it.
928
- */
929
- async gitAuth ({ access = 'read', repo = null } = {}) {
930
- const t = this.gitToken
931
- // Kept: a token that can write, or one that answered the same ask (the relay gave read for a write ask: no point asking again until something changes).
932
- if (t && t.token && t.expiresAt - Date.now() > 60 * 1000 && (access === 'read' || t.access === 'write' || t.asked === access)) return t.token
933
- if (t && !t.token && Date.now() - t.at < 10 * 60 * 1000 && (t.asked || 'read') === access) return null
934
- try {
935
- if (!this.conn || !this.conn.adminRequest) return null
936
- const r = await this.conn.adminRequest({ op: 'gitToken', access, ...(repo ? { repo } : {}) })
937
- if (r && r.git && r.git.token) { this.gitToken = { token: r.git.token, expiresAt: Number(r.git.expiresAt) || Date.now() + 30 * 60 * 1000, access: r.git.access === 'write' ? 'write' : 'read', asked: access }; return r.git.token }
938
- this.gitToken = { no: (r && r.git && r.git.state) || 'error', at: Date.now(), asked: access }
939
- } catch { this.gitToken = { no: 'error', at: Date.now(), asked: access } }
940
- return null
941
- }
942
-
943
- /** The token already here and good for a minute more, or null (gitAuth asks for one). */
944
- gitTokenNow () { const t = this.gitToken; return t && t.token && t.expiresAt - Date.now() > 60 * 1000 ? t.token : null }
945
-
946
- /**
947
- * What git in this folder signs in to `host` with (the credential helper, `quilt git-credential`):
948
- * { username, password } from the session's GitHub connection, write access while git is on for
949
- * everyone here, or {} (git then fails as it would without Quilt; a person's helper asks their own
950
- * helpers then). A person's folder: only for the session's repository, where Quilt put its helper
951
- * (syncPersonGit), with a token of their own.
952
- */
953
- async gitCredential ({ host = '' } = {}) {
954
- if (!this.git) return {}
955
- const want = String(host || '').toLowerCase()
956
- if (this.kind !== 'agent') {
957
- const pg = this.personGit
958
- if (!pg || !pg.helper || !pg.repo) return {}
959
- let urlHost = ''
960
- try { urlHost = new URL(pg.url).host.toLowerCase() } catch {}
961
- if (want !== 'github.com' && want !== urlHost) return {}
962
- if (!this.personGitOn()) return {}
963
- const token = await this.gitAuth({ access: 'write', repo: pg.repo })
964
- return token ? { username: 'x-access-token', password: token } : {}
965
- }
966
- // The relay's git (plan §9): signed in with this agent's Quilt pass; no GitHub token reaches this machine.
967
- const relay = this.relayGitUrl()
968
- if (relay && want === new URL(relay).host.toLowerCase()) { const pass = await this.passString(); return pass ? { username: 'quilt', password: pass } : {} }
969
- if (want !== 'github.com') return {}
970
- // The repository is the folder's remote's, whatever branch is checked out (a new feature branch has no
971
- // record on the relay yet); a remote that isn't a GitHub URL (a mirror, a local path) leaves it to the relay's record.
972
- const repo = await this.originRepo()
973
- // Write when the owner lets agents push (the relay gives read when it can't); read otherwise.
974
- const policy = (this.access && this.access.agentCommits) || 'branches'
975
- const token = await this.gitAuth({ access: policy !== 'off' ? 'write' : 'read', repo: repo && repo.github ? `${repo.owner}/${repo.name}` : null })
976
- return token ? { username: 'x-access-token', password: token } : {}
977
- }
978
-
979
- /** Where the relay serves this session's git (plan §9): http(s)://<relay>/git/<room>.git, or null. */
980
- relayGitUrl () {
981
- try {
982
- const u = new URL(String(this.server || ''))
983
- if (!/^wss?:$/.test(u.protocol)) return null
984
- return `${u.protocol === 'wss:' ? 'https:' : 'http:'}//${u.host}/git/${encodeURIComponent(this.room)}.git`
985
- } catch { return null }
986
- }
987
-
988
- /** This member's current Quilt pass (what git signs in to the relay with), or null. */
989
- async passString () {
990
- try { return this.passes ? String(await this.passes.get()) : null } catch { return null }
991
- }
992
-
993
- /**
994
- * An agent's folder whose origin is GitHub, in a session whose relay serves git (plan §9): origin
995
- * becomes the relay, so no GitHub token is needed here and pushes are checked and published by the
996
- * relay. Resolves whether it changed.
997
- */
998
- async moveOriginToRelay () {
999
- if (this.kind !== 'agent' || !this.git) return false
1000
- const relay = this.relayEntryFor(this.branch)
1001
- const to = this.relayGitUrl()
1002
- if (!relay || !relay.git || !to) return false
1003
- // As configured, not as insteadOf rewrites it (git remote get-url would).
1004
- const url = await this.gitOut(['config', '--get', 'remote.origin.url'])
1005
- if (!url || url === to) return false
1006
- const repo = parseRepo(url)
1007
- if (!repo || !repo.github || `github.com/${repo.owner}/${repo.name}`.toLowerCase() !== String(relay.repo).toLowerCase()) return false
1008
- if ((await this.gitOut(['remote', 'set-url', 'origin', to])) === null) return false
1009
- const helpers = this.gitHelpers()
1010
- if (helpers) { try { await installGitHelpers(this.root, { ...helpers, host: new URL(to).hostname, origin: urlOrigin(to) }) } catch {} }
1011
- this.log(`🔁 this folder's git goes through the relay now (origin ${to}): pushes are checked and put on GitHub there, with no GitHub token on this machine`)
1012
- return true
1013
- }
1014
-
1015
- /**
1016
- * After a push through the relay that GitHub had moved past, the relay put the commits on its head
1017
- * as new commits (replayed): this folder's branch then follows GitHub's, its own commits kept under
1018
- * refs/quilt/original/<branch>/, when every one of them is there (git cherry). Resolves whether it moved.
1019
- */
1020
- async followReplayed ({ up, at }) {
1021
- if (this.kind !== 'agent' || !this.git || !this.git.branch || !up || !up.sha) return false
1022
- const url = await this.gitOut(['config', '--get', `remote.${up.remote || 'origin'}.url`])
1023
- if (!url || url !== this.relayGitUrl()) return false
1024
- // The commit the relay put the session at, when GitHub has moved past it since (publishLocal says why): the rest comes in as any of GitHub's commits do.
1025
- const rs = this.relaySha()
1026
- const to = !this.memberAt(up.sha) && rs && rs !== at && await isAncestor(this.root, rs, up.sha) ? rs : up.sha
1027
- const cherry = await this.gitOut(['cherry', to, at])
1028
- if (cherry === null || !cherry || cherry.split('\n').some((l) => !l.startsWith('-'))) return false
1029
- if (!(await this.sessionOnDisk(at, to))) { this.askRelayFor(up.sha); this.checkUpstreamSoon({ delay: 500 }); return false }
1030
- const r = await adoptPublished(this.root, this.git.branch, at, to)
1031
- if (!r.moved) return false
1032
- this.gitSeen = { key: this.git.branch, branch: this.git.branch, sha: to }
1033
- this.gitIndex = indexStamp(this.root)
1034
- this.log(`⬆️ the relay put your commits on GitHub's newer ${this.git.branch} (${to.slice(0, 7)}); this folder follows it (yours are kept under refs/quilt/original/${this.git.branch}/)`)
1035
- const behind = to === up.sha ? 0 : ((await commitsBetween(this.root, to, up.sha)) ?? 1)
1036
- this.setUpstream({ ...up, behind, ahead: 0 })
1037
- if (behind) this.checkUpstreamSoon()
1038
- return true
1039
- }
1040
-
1041
- /** The repository this folder's git pushes to (parseRepo of the branch's remote, or origin), or null. */
1042
- async originRepo () {
1043
- const branch = this.git && this.git.branch
1044
- const remote = (branch && await this.gitOut(['config', '--get', `branch.${branch}.remote`])) || 'origin'
1045
- const url = await this.gitOut(['remote', 'get-url', remote])
1046
- // Through the relay's git (plan §9): the session's repository, not the relay's address.
1047
- if (url && url === this.relayGitUrl()) { const e = this.relayEntryFor(this.branch); return e && e.repo ? parseRepo(`https://${e.repo}`) : null }
1048
- return url ? parseRepo(url) : null
1049
- }
1050
-
1051
- /**
1052
- * Whether a push from this folder may go ahead (the pre-push hook, `quilt git-pre-push`): `refs`
1053
- * are [{ localRef, localSha, remoteRef, remoteSha }] for `remote`. Agents push within the owner's
1054
- * policy: 'off' nothing, 'branches' only quilt/<agent>/… branches, 'any' anything; and never a push
1055
- * that rewrites what is on GitHub (a force-push: the branch there must be an ancestor of what is
1056
- * pushed). A push that may go is remembered, and once git's remote-tracking branch shows it, the
1057
- * session records what was published and tells the chat (notePushed). People's pushes are their own: always ok.
1058
- */
1059
- async pushCheck ({ remote = 'origin', refs = [] } = {}) {
1060
- const list = (Array.isArray(refs) ? refs : []).filter((r) => r && typeof r.remoteRef === 'string')
1061
- // A session that is a quilt lands work through Seam, and GitHub takes only Seam's export: an
1062
- // agent's push goes nowhere, and a person's to the session's repository is told the Seam way.
1063
- if (this.quilt && this.quilt.id && (this.kind === 'agent' || await this.isSessionRemote(remote))) return { ok: false, why: pushRefusal(this.quilt.slug || this.quilt.id) }
1064
- if (this.kind !== 'agent') return this.personPushCheck({ remote })
1065
- const policy = (this.access && this.access.agentCommits) || 'branches'
1066
- if (policy === 'off') return { ok: false, why: `${gitForEveryoneWords({ policy: 'off', by: this.offBy() })} Ask a person for the commit with quilt_request_commit.` }
1067
- if (policy === 'branches') {
1068
- const prefix = agentPrefix(this.name)
1069
- const wrong = list.filter((r) => !r.remoteRef.startsWith(`refs/heads/${prefix}`))
1070
- if (wrong.length) {
1071
- const name = (r) => r.remoteRef.replace(/^refs\/heads\//, '')
1072
- return { ok: false, why: `The session owner lets agents push only to branches of their own here: ${prefix}<topic> (not ${wrong.map(name).join(', ')}). Push to one (git push origin HEAD:${prefix}<topic>) and ask a person to open a pull request from it to the session's branch, or ask the owner to allow any branch (quilt_agent_commits any).` }
1073
- }
1074
- }
1075
- // Pushing to GitHub needs credentials this folder gets from the session: when there are none, say why now, instead of git's "could not read Username".
1076
- const repo = await this.originRepo()
1077
- // Through the relay's git (plan §9) the relay signs in to GitHub itself: no token is asked for here.
1078
- const viaRelay = (await this.gitOut(['config', '--get', `remote.${remote || 'origin'}.url`])) === this.relayGitUrl()
1079
- if (repo && repo.github && list.length && !viaRelay) {
1080
- const token = await this.gitAuth({ access: policy !== 'off' ? 'write' : 'read', repo: `${repo.owner}/${repo.name}` })
1081
- if (!token) return { ok: false, why: `This folder can't sign in to GitHub to push: ${noCredentialsText({ state: (this.gitToken && this.gitToken.no) || 'error' }, `${repo.owner}/${repo.name}`, { verb: 'push to' })}` }
1082
- }
1083
- const sha = (s) => /^[0-9a-f]{40}$/.test(s || '') && !/^0+$/.test(s)
1084
- for (const r of list) {
1085
- if (!sha(r.localSha) || !sha(r.remoteSha)) continue // a new branch, or a deletion: nothing to rewrite
1086
- if ((await this.gitOut(['merge-base', '--is-ancestor', r.remoteSha, r.localSha])) === null) {
1087
- return { ok: false, why: `That push would rewrite ${r.remoteRef.replace(/^refs\/heads\//, '')} on GitHub (what is there isn't an ancestor of what you push). Quilt never rewrites or force-pushes history, and neither do agents: fetch, then rebase or merge onto the branch as it is, and push without --force.` }
1088
- }
1089
- }
1090
- const now = Date.now()
1091
- for (const r of list) {
1092
- if (!sha(r.localSha)) continue // a deletion: nothing published
1093
- if (!r.remoteRef.startsWith('refs/heads/')) continue
1094
- this.pendingPushes.push({ remote: String(remote || 'origin'), branch: r.remoteRef.slice('refs/heads/'.length), sha: r.localSha, was: sha(r.remoteSha) ? r.remoteSha : null, at: now, tries: 0 })
1095
- }
1096
- if (this.pendingPushes.length) this.notePushedSoon(1500)
1097
- return { ok: true }
1098
- }
1099
-
1100
- /**
1101
- * A person's push (git for everyone): while git is on they push any branch; off is off for everyone,
1102
- * so a push to the session's repository is refused, saying who turned it off. Other remotes (a fork)
1103
- * are their own business.
1104
- */
1105
- async personPushCheck ({ remote = 'origin' } = {}) {
1106
- if (this.gitPolicy() !== 'off') return { ok: true }
1107
- const name = String(remote || 'origin')
1108
- const url = /^[a-z][a-z0-9+.-]*:\/\/|^[^/\s]+@[^/\s]+:/i.test(name) ? name : await this.gitOut(['config', '--get', `remote.${name}.url`])
1109
- const repo = url ? parseRepo(url) : null
1110
- if (!repo || !repo.github || !this.sessionRepos().has(`github.com/${repo.owner}/${repo.name}`.toLowerCase())) return { ok: true }
1111
- const by = this.offBy()
1112
- return { ok: false, why: `${gitForEveryoneWords({ policy: 'off', by })} ${by ? 'Its admins turn it back on in its settings.' : 'The session owner turns it back on in the session\'s settings (under Version control, This session\'s source control).'}` }
1113
- }
1114
-
1115
- /** Whether `remote` (a name or URL) is the session's GitHub repository: its own folder's origin, or one its branches follow. */
1116
- async isSessionRemote (remote = 'origin') {
1117
- const name = String(remote || 'origin')
1118
- const url = /^[a-z][a-z0-9+.-]*:\/\/|^[^/\s]+@[^/\s]+:/i.test(name) ? name : await this.gitOut(['config', '--get', `remote.${name}.url`])
1119
- if (url && url === this.relayGitUrl()) return true
1120
- const repo = url ? parseRepo(url) : null
1121
- if (!repo || !repo.github) return false
1122
- return name === 'origin' || this.sessionRepos().has(`github.com/${repo.owner}/${repo.name}`.toLowerCase())
1123
- }
1124
-
1125
- /** The rule over the session that turned git off ({ by, name }), or null when the owner did (or it is on). */
1126
- offBy () {
1127
- const by = this.access && this.access.agentCommitsBy
1128
- return by && by.mode === 'off' && this.gitPolicy() === 'off' ? by : null
1129
- }
1130
-
1131
- /** Git for this session ('off', 'branches', 'any'), as the relay last said. */
1132
- gitPolicy () { return (this.access && this.access.agentCommits) || 'branches' }
1133
-
1134
- /** The GitHub repositories the session's branches follow, as 'github.com/owner/name' in lower case. */
1135
- sessionRepos () {
1136
- const out = new Set()
1137
- for (const b of this.branchList) if (b && b.relay && /^github\.com\//i.test(String(b.relay.repo || ''))) out.add(String(b.relay.repo).toLowerCase())
1138
- return out
1139
- }
1140
-
1141
- /** The GitHub account whose connection covers `repo` ('github.com/owner/name') for this session, or '' (none does). */
1142
- connectedLogin (repo = null) {
1143
- const want = repo ? String(repo).toLowerCase() : null
1144
- const e = this.branchList.find((b) => b && b.relay && b.relay.github && b.relay.github.state === 'ok' && (!want || String(b.relay.repo || '').toLowerCase() === want))
1145
- return e ? String(e.relay.github.login || '') || 'connected' : ''
1146
- }
1147
-
1148
- /** Whether git is on for everyone here and a connection covers the session's repository (a person's folder gets Quilt's helper then). */
1149
- personGitOn () { return this.kind !== 'agent' && !!(this.access && this.access.state === 'approved') && this.gitPolicy() !== 'off' && !!this.connectedLogin() }
1150
-
1151
- /** Git for everyone, as this member's app shows it: { on, words, login }. */
1152
- gitForEveryone () {
1153
- const policy = this.gitPolicy()
1154
- const login = this.connectedLogin()
1155
- return { on: policy !== 'off' && !!login, policy, words: gitForEveryoneWords({ policy, by: this.offBy(), login: login === 'connected' ? '' : login }), ...(login ? { login } : {}) }
1156
- }
1157
-
1158
- /** syncPersonGit, when what it depends on changed since it last ran. */
1159
- syncPersonGitSoon () {
1160
- if (this.kind === 'agent' || !this.cli || this.stopped || !this.git) return
1161
- const sig = `${this.gitPolicy()}|${this.personGitOn()}|${[...this.sessionRepos()].sort().join(',')}|${this.connectedLogin()}`
1162
- if (sig === this.personGitSig) return
1163
- this.personGitSig = sig
1164
- this.syncPersonGit().catch((err) => this.emit('debug', `git helpers: ${err.message}`))
1165
- }
1166
-
1167
- /**
1168
- * Git for everyone in a person's own folder: while it is a checkout of the session's GitHub repository
1169
- * over https, Quilt's pre-push hook is in it, and while git is on and a connection covers the repository,
1170
- * Quilt's credential helper for that repository's URL too (gitstate.js setPersonGit). Off takes the
1171
- * helper away; `leaving` takes both away. One at a time; a call meanwhile runs again after.
1172
- */
1173
- async syncPersonGit ({ leaving = false } = {}) {
1174
- if (this.kind === 'agent' || !this.cli) return
1175
- if (this.personGitBusy) { if (!leaving) { this.personGitAgain = true; return } await this.personGitBusy }
1176
- let done
1177
- this.personGitBusy = new Promise((resolve) => { done = resolve })
1178
- try {
1179
- do {
1180
- this.personGitAgain = false
1181
- if (!gitDir(this.root)) return
1182
- const configured = await this.gitOut(['config', '--get', 'remote.origin.url'])
1183
- const repo = configured ? parseRepo(configured) : null
1184
- const full = repo && repo.github ? `github.com/${repo.owner}/${repo.name}`.toLowerCase() : ''
1185
- const ours = !!full && this.sessionRepos().has(full)
1186
- // As git asks for credentials: the remote's URL with insteadOf applied; https only (ssh signs in with keys).
1187
- const effective = ours ? await this.gitOut(['remote', 'get-url', 'origin']) : null
1188
- const url = effective && /^https?:\/\//i.test(effective) ? effective.replace(/\/\/[^/@]*@/, '//') : ''
1189
- const helper = !leaving && ours && !!url && this.personGitOn() && !!this.connectedLogin(full)
1190
- const hook = !leaving && ours
1191
- const r = await setPersonGit(this.root, { ...this.cli, url, helper, hook })
1192
- const was = this.personGit
1193
- this.personGit = { url, repo: repo && repo.github ? `${repo.owner}/${repo.name}` : null, helper: r.helper, hook: r.hook }
1194
- if (leaving) continue
1195
- if (r.helper && !(was && was.helper)) this.log(`🔑 ${gitForEveryoneWords({ policy: this.gitPolicy(), login: this.connectedLogin(full) })} (Quilt signs git in for ${repo.owner}/${repo.name} in this folder only)`)
1196
- if (!r.helper && was && was.helper) this.log(`🔒 Quilt no longer signs git in for ${was.repo} here: ${this.gitPolicy() === 'off' ? 'git is off for everyone in this session' : 'no GitHub connection covers it now'}`)
1197
- } while (this.personGitAgain && !this.stopped)
1198
- } finally { this.personGitBusy = null; done() }
1199
- }
1200
-
1201
- notePushedSoon (ms) {
1202
- if (this.pushTimer) clearTimeout(this.pushTimer)
1203
- this.pushTimer = setTimeout(() => { this.pushTimer = null; this.notePushed().catch((err) => this.emit('debug', `pushed: ${err.message}`)) }, ms)
1204
- this.pushTimer.unref()
1205
- }
1206
-
1207
- /**
1208
- * Pushes the hook announced that git's remote-tracking branch now shows went through: the files
1209
- * they changed are recorded as published (so the session's copy is kept when the branch is merged
1210
- * and comes back down, upstream.js), and the chat hears what was pushed. Looked at again for up to two minutes.
1211
- */
1212
- async notePushed () {
1213
- if (this.stopped || !this.pendingPushes.length) return
1214
- const left = []
1215
- for (const p of this.pendingPushes) {
1216
- const tip = await this.gitOut(['rev-parse', '--verify', '-q', `refs/remotes/${p.remote}/${p.branch}`])
1217
- if (tip !== p.sha) { if (Date.now() - p.at < 2 * 60 * 1000) left.push(p); continue }
1218
- // What the push added: since the branch's old tip; for a new branch, since it left the default branch (or the remote's HEAD), or everything.
1219
- const main = (this.branchList.find((b) => b && b.default) || {}).key || 'main'
1220
- const base = p.was || (await this.gitOut(['merge-base', p.sha, `refs/remotes/${p.remote}/${main}`])) || (await this.gitOut(['merge-base', p.sha, `refs/remotes/${p.remote}/HEAD`])) || EMPTY_TREE
1221
- const names = base ? (await this.gitOut(['diff', '--name-only', '--no-renames', '-z', base, p.sha]) || '').split('\0').filter(Boolean).map(toPosix) : []
1222
- const files = new Map()
1223
- for (const rel of names) {
1224
- const buf = await this.gitOut(['cat-file', 'blob', `${p.sha}:${rel}`], { buffer: true })
1225
- if (buf) files.set(rel, buf)
1226
- }
1227
- const count = base ? Number(await this.gitOut(['rev-list', '--count', `${base}..${p.sha}`])) || 1 : 1
1228
- const repo = await this.originRepo()
1229
- const full = repo && repo.owner ? `${repo.owner}/${repo.name}` : null
1230
- const where = full ? `${full} ${p.branch}` : p.branch
1231
- this.log(`⬆️ pushed ${count} commit${count === 1 ? '' : 's'} to ${where}${names.length ? ` (${names.slice(0, 8).join(', ')}${names.length > 8 ? ', …' : ''})` : ''}`)
1232
- // The relay keeps the record and tells the chat (it writes for members whose own writes are held to a folder too); without it, this folder does.
1233
- const entries = [...files].map(([rel, buf]) => ({ path: rel, id: crypto.createHash('sha1').update(buf).digest('hex') }))
1234
- let told = false
1235
- if (this.conn && this.conn.adminRequest) {
1236
- const subject = (await this.gitOut(['log', '-1', '--format=%s', p.sha])) || ''
1237
- try { told = !!(await this.conn.adminRequest({ op: 'pushed', branch: p.branch, sha: p.sha, count, files: entries, subject })) } catch (err) { this.emit('debug', `pushed: ${err.message}`) }
1238
- }
1239
- if (!told) {
1240
- if (files.size) notePublished(this.doc, files, LOCAL)
1241
- this.postGitNote(`${this.name} pushed ${count} commit${count === 1 ? '' : 's'} to ${where}${names.length ? `: ${names.slice(0, 8).join(', ')}${names.length > 8 ? ` and ${names.length - 8} more` : ''}` : ''}${repo && repo.github ? ` https://github.com/${full}/commit/${p.sha}` : ''}`)
1242
- }
1243
- }
1244
- const landed = this.pendingPushes.length - left.length
1245
- this.pendingPushes = left
1246
- // A push landed: it may have set the branch's upstream (git push -u) or moved it. A fetch now tells the relay, not the next poll.
1247
- if (landed > 0) this.checkUpstreamSoon({ fetch: true, delay: 500 })
1248
- if (left.length) this.notePushedSoon(Math.min(30000, 2000 * 2 ** Math.min(4, ++left[0].tries)))
1249
- }
1250
-
1251
- /** git's standard output in this folder (trimmed, or a Buffer with `buffer`), or null when it fails. */
1252
- gitOut (args, { buffer = false } = {}) {
1253
- return new Promise((resolve) => {
1254
- execFile('git', args, { cwd: this.root, encoding: buffer ? 'buffer' : 'utf8', maxBuffer: 64 * 1024 * 1024, timeout: 20000 }, (err, out) => resolve(err ? null : buffer ? out : String(out).trim()))
1255
- })
1256
- }
1257
-
1258
- /**
1259
- * This folder's branch has no upstream, but its remote has a branch of the same name (a partner
1260
- * pushed it): fetched, and set as its upstream, as `git push -u` would. Returns upstreamOf after, or null.
1261
- */
1262
- async trackRemoteBranch () {
1263
- const branch = this.git && this.git.branch
1264
- if (!branch) return null
1265
- const main = (this.branchList.find((b) => b && b.default) || {}).key || 'main'
1266
- const remote = (await this.gitOut(['config', '--get', `branch.${main}.remote`])) || 'origin'
1267
- const url = await this.gitOut(['remote', 'get-url', remote])
1268
- if (!url) return null
1269
- const f = await fetchSignedIn(this.root, { name: `${remote}/${branch}`, remote, url, sha: null }, { kept: this.gitTokenNow(), fresh: () => this.gitAuth(), drop: () => { this.gitToken = null }, retry: remoteHost(url) === 'github.com' })
1270
- if (!f.ok) return null
1271
- if (!(await this.gitOut(['rev-parse', '-q', '--verify', `refs/remotes/${remote}/${branch}^{commit}`]))) return null
1272
- if ((await this.gitOut(['branch', `--set-upstream-to=${remote}/${branch}`, branch])) === null) return null
1273
- this.log(`🔗 ${branch} follows ${remote}/${branch} now (a partner pushed it), so its commits come into this folder`)
1274
- return upstreamOf(this.root)
1275
- }
1276
-
1277
- /** How git in this agent's folder runs Quilt, and whom its commits are credited to (installGitHelpers), or null. */
1278
- gitHelpers () { return this.cli && this.kind === 'agent' ? { ...this.cli, identity: { name: this.name, email: noreply(this.name) } } : null }
1279
-
1280
- /** Puts the credential helper and pre-push hook in an agent's folder that git already knows (a clone, or one backed before they existed). */
1281
- async ensureGitHelpers () {
1282
- const helpers = this.gitHelpers()
1283
- if (!helpers || !this.git || this.helpersDone) return
1284
- this.helpersDone = true
1285
- try {
1286
- // The host git signs in to: the remote's, or GitHub's when the session follows a GitHub repository and the remote is a path (a mirror).
1287
- if (await this.moveOriginToRelay()) return
1288
- const up = await upstreamOf(this.root)
1289
- const host = (up && up.url && remoteHost(up.url)) || (this.branchList.some((b) => b && b.relay && /^github\.com\//.test(String(b.relay.repo || ''))) ? 'github.com' : null)
1290
- await installGitHelpers(this.root, { ...helpers, host, origin: up && up.url ? urlOrigin(up.url) : '' })
1291
- } catch (err) { this.emit('debug', `git helpers: ${err.message}`) }
1292
- }
1293
-
1294
747
  // --------------------------------------------------------------- access --
1295
748
 
1296
749
  setAccess (a) {
1297
750
  const was = this.access
1298
751
  this.access = a
1299
- // What agents may push changed: the token here may be the wrong one now (or taken back). Ask again.
1300
- if (was && was.agentCommits !== a.agentCommits) this.gitToken = null
1301
- this.syncPersonGitSoon() // git for everyone: a person's helper follows git on or off
1302
752
  if (a.state === 'approved' && a.owner) this.sendStartName()
1303
753
  if (this.force && this.force.migrate && a.state === 'approved' && a.owner) this.onForce(this.force)
1304
754
  // A new session becomes a quilt as its owner (in a room with no owner, the relay lets any editor: quiltPlanRefusal).
@@ -1362,17 +812,6 @@ export class Session extends EventEmitter {
1362
812
  * the folders they may change.
1363
813
  */
1364
814
  approve (key, { role, scopes, typeId, access } = {}) { return this.conn.adminRequest({ op: 'approve', key, role, scopes, typeId, access }) }
1365
- /** The owner's "Sync with GitHub" switch for this session (GitHub as a node). */
1366
- setGithubSync (on) { return this.conn.adminRequest({ op: 'githubSync', on: on !== false }) }
1367
- /** The owner lets the relay read large files on branches that sync with GitHub, so it can commit them (plan §10). */
1368
- setLargeToRelay (on) { return this.conn.adminRequest({ op: 'largeToRelay', on: on === true }) }
1369
- /** A person takes an agent's branch into theirs, pushed as one commit (relay-outbound.js adoptBranch). */
1370
- async adoptBranch (from) { return (await this.conn.adminRequest({ op: 'adopt', from: String(from || '') }, { timeoutMs: PUSH_REQUEST_TIMEOUT_MS })).adopt }
1371
- /** quilt_clash: a file held because the session and GitHub changed the same lines (relay-clash.js), resolved through the relay. */
1372
- async clash ({ action, path: rel = '', content = null, checks = null, force = false } = {}) {
1373
- if (!this.conn || !this.conn.adminRequest) throw new Error('not connected')
1374
- return this.conn.adminRequest({ op: 'clash', action, path: rel, ...(typeof content === 'string' ? { content } : {}), ...(checks ? { checks } : {}), ...(force ? { force: true } : {}) })
1375
- }
1376
815
  deny (key) { return this.conn.adminRequest({ op: 'deny', key }) }
1377
816
  setMember (key, { role, scopes, access } = {}) { return this.conn.adminRequest({ op: 'set', key, role, scopes, access }) }
1378
817
  removeMember (key) { return this.conn.adminRequest({ op: 'remove', key }) }
@@ -1382,21 +821,8 @@ export class Session extends EventEmitter {
1382
821
  /** Owner only: names the session for everyone in it (1 to 80 characters). */
1383
822
  rename (name) { return this.conn.adminRequest({ op: 'name', name }) }
1384
823
 
1385
- /**
1386
- * Owner only: the read-only GitHub token the relay uses to bring commits in from a private
1387
- * repository while no folder is online (relay-upstream.js); '' clears it. It goes to the relay
1388
- * and stays there: never in presence, the branch list or this app's status (only whether one is set).
1389
- */
1390
- async setGithubToken (token) {
1391
- if (!this.conn) throw new Error('not connected to the relay')
1392
- if (!this.isOwner) throw new Error('only the session owner can set the GitHub token')
1393
- const r = await this.conn.adminRequest({ op: 'githubToken', token: String(token ?? '').trim() })
1394
- return { ok: true, githubToken: !!r.githubToken }
1395
- }
1396
-
1397
824
  /** A new session is named after its folder, once, as soon as the relay lets us in as its owner. */
1398
825
  sendStartName () {
1399
- this.sendStartAgentCommits()
1400
826
  this.sendWorkspace()
1401
827
  if (!this.startName || this.startNameSent) return
1402
828
  this.startNameSent = true
@@ -1429,15 +855,6 @@ export class Session extends EventEmitter {
1429
855
  this.sendWorkspace()
1430
856
  }
1431
857
 
1432
- /** A new room starts with the relay's default (branches); your setting, when it differs, is put on the room once. */
1433
- sendStartAgentCommits () {
1434
- const mode = this.startAgentCommits
1435
- if (!mode || this.startAgentCommitsSent) return
1436
- this.startAgentCommitsSent = true
1437
- if (mode === ((this.access && this.access.agentCommits) || 'branches')) return
1438
- this.setAgentCommits(mode).catch((err) => this.log(`couldn't set what agents may commit: ${err.message}`))
1439
- }
1440
-
1441
858
  goLive () {
1442
859
  this.observeBranch()
1443
860
  this.shareKeysWithViewers()
@@ -1469,25 +886,10 @@ export class Session extends EventEmitter {
1469
886
  })
1470
887
  this.activity.observe(() => this.scheduleStatusWrite())
1471
888
  this.tasks.observe((ev, tr) => {
1472
- this.scanInbox({ quiet: tr.origin === LOCAL || tr.origin === CLASH })
1473
- // A clash task moved, reassigned or taken off the board by someone else: looked at again.
1474
- if (tr.origin !== CLASH) {
1475
- const clashTasks = new Set([...this.clashes.values()].map((r) => r && r.task))
1476
- if ([...ev.keysChanged].some((k) => clashTasks.has(k))) this.reviewClashSoon()
1477
- }
889
+ this.scanInbox({ quiet: tr.origin === LOCAL })
1478
890
  this.scheduleStatusWrite()
1479
891
  })
1480
- this.clashes.observe((ev, tr) => { if (tr.origin !== CLASH) this.reviewClashSoon() })
1481
892
  this.taskComments.observe(() => this.scheduleStatusWrite())
1482
- this.commitRequests.observe((ev, tr) => {
1483
- for (const [id, change] of ev.changes.keys) {
1484
- const r = this.commitRequests.get(id)
1485
- if (tr.origin === LOCAL || !r) continue
1486
- if (change.action === 'add') { this.log(`📌 ${r.by} asked for a commit: ${r.message}`); this.reviewCommitsSoon() }
1487
- else if (r.state === 'done') this.log(`✅ ${r.doneBy || 'the host'} committed ${r.hash ? r.hash.slice(0, 7) : ''} (${r.message})`)
1488
- }
1489
- this.emit('status-changed')
1490
- })
1491
893
  this.agentFeed.observe((ev) => {
1492
894
  const added = []
1493
895
  for (const item of ev.changes.added) for (const e of item.content.getContent()) if (e && e.id) added.push(e)
@@ -1497,11 +899,6 @@ export class Session extends EventEmitter {
1497
899
  this.ready = true
1498
900
  this.scanInbox({ quiet: true }) // take stock: what is already here wakes nobody
1499
901
  this.fetchMissedFiles()
1500
- // Commit requests: those waiting are looked at once this folder has settled, and again now and then
1501
- // (a file someone held is let go of, a request that couldn't be committed by itself is retried).
1502
- this.reviewCommitsSoon(20000)
1503
- this.reviewCommitsTimer2 = setInterval(() => this.reviewCommitsSoon(0), 2 * 60 * 1000)
1504
- if (this.reviewCommitsTimer2.unref) this.reviewCommitsTimer2.unref()
1505
902
  this.scheduleStateSave()
1506
903
  this.scheduleStatusWrite()
1507
904
  }
@@ -1657,7 +1054,7 @@ export class Session extends EventEmitter {
1657
1054
  }
1658
1055
  // When we were last in touch with the session: "you left 3h ago" on the next catch-up.
1659
1056
  if (this.conn && this.conn.connected) this.savedSeenAt = Date.now()
1660
- fs.writeFileSync(path.join(this.stateDir, 'state.json'), JSON.stringify({ room: this.room, server: this.server, layout: 2, branch: this.branch, ...(this.branchEpoch ? { epoch: this.branchEpoch } : {}), ...(this.branchForm ? { form: this.branchForm } : {}), ...(this.diffBase ? { diffBase: this.diffBase } : {}), storedOnDisk: Object.fromEntries(this.storedOnDisk), known, ...(this.boxBase.size ? { boxBase: Object.fromEntries(this.boxBase) } : {}), ...(Object.keys(behind).length ? { behind } : {}), ...this.gitState(), ...this.roleState(), ...(this.savedSeenAt ? { seenAt: this.savedSeenAt } : {}) }))
1057
+ fs.writeFileSync(path.join(this.stateDir, 'state.json'), JSON.stringify({ room: this.room, server: this.server, layout: 2, branch: this.branch, ...(this.branchEpoch ? { epoch: this.branchEpoch } : {}), ...(this.branchForm ? { form: this.branchForm } : {}), ...(this.diffBase ? { diffBase: this.diffBase } : {}), storedOnDisk: Object.fromEntries(this.storedOnDisk), known, ...(this.boxBase.size ? { boxBase: Object.fromEntries(this.boxBase) } : {}), ...(Object.keys(behind).length ? { behind } : {}), ...this.roleState(), ...(this.savedSeenAt ? { seenAt: this.savedSeenAt } : {}) }))
1661
1058
  }
1662
1059
 
1663
1060
  /** This member's role, so the next start knows it before the relay says (ignoreQuiltState). */
@@ -1666,18 +1063,6 @@ export class Session extends EventEmitter {
1666
1063
  return role ? { role } : {}
1667
1064
  }
1668
1065
 
1669
- /**
1670
- * What state.json keeps of git: the branch this session syncs, the commit
1671
- * it was last seen at there, and whether a hold was on. The next start
1672
- * reads them in resumeHold.
1673
- */
1674
- gitState () {
1675
- if (!this.git) return this.savedGit ? { gitKey: this.savedGit.key, gitSha: this.savedGit.sha, gitHeld: this.savedGit.held } : {}
1676
- const seen = this.hold ? this.hold.prevHead : this.gitSeen
1677
- // A burst still being classified counts as held: a crash then restarts held, never sharing what git did.
1678
- return { gitKey: this.git.key, gitSha: seen && seen.key === this.git.key ? seen.sha : null, gitHeld: this.held() }
1679
- }
1680
-
1681
1066
  // ------------------------------------------------------------ reconcile --
1682
1067
 
1683
1068
  sharedPaths () {
@@ -1871,7 +1256,6 @@ export class Session extends EventEmitter {
1871
1256
 
1872
1257
  /** Merges one captured path. Returns what happened, or null when nothing needed doing. */
1873
1258
  async mergeOne (e) {
1874
- if (e.carried) return this.mergeCarried(e) // work a checkout carried from another branch
1875
1259
  const { rel, base, via = null } = e
1876
1260
  const release = () => this.merging.delete(rel)
1877
1261
  const disk = this.readDisk(rel)
@@ -1915,14 +1299,6 @@ export class Session extends EventEmitter {
1915
1299
  release()
1916
1300
  return 'merged'
1917
1301
  }
1918
- // A pull of GitHub's commits on a branch that syncs live with GitHub, that the relay didn't bring in first
1919
- // (relayHasPull): GitHub's commits come into the session combined line by line, as the relay brings them
1920
- // (relay-sync.js); the rule below is for changes between people. Lines both changed are still left to settle.
1921
- if (via === 'pull' && !conflicts.length && this.branchLive()) {
1922
- this.applyMerged(rel, text, 'with GitHub\'s changes', { pulled })
1923
- release()
1924
- return 'merged'
1925
- }
1926
1302
  // Otherwise (offline, a pull) both sides changed it: Quilt combines them itself.
1927
1303
  return this.autoSettle({ rel, base, ours, theirs, theirsBy, disk, binary: false, via })
1928
1304
  }
@@ -2041,14 +1417,9 @@ export class Session extends EventEmitter {
2041
1417
  applyTextDiff(ytext, text)
2042
1418
  this.recordActivity(rel, 'merged', detail, { before, after: text }, { pulled }) // the chronology keeps the merge's diff
2043
1419
  }, LOCAL)
2044
- if (this.held()) {
2045
- // git is at work on the folder: the merge reaches the disk when the hold ends (lastKnown, the base then, stays).
2046
- this.heldPaths.add(rel)
2047
- } else {
2048
- fs.mkdirSync(path.dirname(abs), { recursive: true })
2049
- this.writeFile(rel, abs, text)
2050
- this.lastKnown.set(rel, text)
2051
- }
1420
+ fs.mkdirSync(path.dirname(abs), { recursive: true })
1421
+ this.writeFile(rel, abs, text)
1422
+ this.lastKnown.set(rel, text)
2052
1423
  this.setOnDisk(rel, null)
2053
1424
  this.noteMyEdit(rel, { pulled })
2054
1425
  this.log(`🧵 merged ${rel} ${detail}`)
@@ -2238,7 +1609,7 @@ export class Session extends EventEmitter {
2238
1609
  // The folder follows its square: the block sees every change; git moving HEAD may take it to another branch's square.
2239
1610
  // The app hears of each change on disk all the same (its Files list and an open file read the folder then: squareFiles).
2240
1611
  if (this.syncStopped) return // live sync stopped at the forced date (onForce): the folder is plain git
2241
- if (this.inSquare) { if (rel === HEAD_CHANGED) this.squareHeadMoved(); else this.emit('file-changed', { path: rel }); return }
1612
+ if (this.inSquare) { this.emit('file-changed', { path: rel }); return }
2242
1613
  this.pending.add(rel)
2243
1614
  if (!this.flushTimer) this.flushTimer = setTimeout(() => this.flushPending(), FLUSH_MS)
2244
1615
  }
@@ -2248,22 +1619,6 @@ export class Session extends EventEmitter {
2248
1619
  this.flushTimer = null
2249
1620
  const paths = [...this.pending]
2250
1621
  this.pending.clear()
2251
- if (this.classifying) {
2252
- // A burst is being classified: these wait behind it, and are looked at together once it's known.
2253
- for (const rel of paths) this.classifying.behind.add(rel)
2254
- return
2255
- }
2256
- if (this.hold) {
2257
- for (const rel of paths) if (rel !== HEAD_CHANGED) this.heldPaths.add(rel)
2258
- // Only a change here puts the settle off: the flush before each update from the room
2259
- // (beforeRemote) has none, and a partner typing must not hold this folder for good.
2260
- // A move to another branch ends on its own (finishMove).
2261
- if (paths.length && this.hold.kind !== 'switching') this.settleSoon()
2262
- // A move that didn't finish follows HEAD wherever git takes it next (back to the old branch resumes it).
2263
- else if (this.hold.kind === 'switching' && paths.includes(HEAD_CHANGED) && !this.following) this.recheckSoon()
2264
- return
2265
- }
2266
- if (this.git && this.isBurst(paths)) return this.startClassify(paths)
2267
1622
  this.ingestAll(paths)
2268
1623
  }
2269
1624
 
@@ -2273,152 +1628,10 @@ export class Session extends EventEmitter {
2273
1628
  }
2274
1629
  }
2275
1630
 
2276
- // ------------------------------------------------------------------ git --
2277
- // A git operation in a synced folder (stash, reset, pull, rebase, checkout)
2278
- // rewrites files in a burst. Those are not edits: the folder's sync is held
2279
- // until git is done, then the room's work is put back (discard), the new
2280
- // commits are merged into it (advance), or the folder pauses (switch).
2281
-
2282
- /** Did git just touch this folder? (index written, HEAD moved, an operation in progress, or a flood of paths) */
2283
- isBurst (paths) {
2284
- const stamp = indexStamp(this.root)
2285
- const indexChanged = stamp !== this.gitIndex
2286
- this.gitIndex = stamp
2287
- this.burstByIndex = indexChanged
2288
- return indexChanged || Date.now() - this.headChangedAt < 2000 || !!this.gitBusy() || paths.length >= BURST_PATHS ||
2289
- // A session file gone from here: git is asked first whether a fetched pull brings it (making way, not a deletion).
2290
- paths.some((rel) => this.syncable(rel) && this.sharedKey(rel) !== undefined && !this.onDisk(rel))
2291
- }
2292
-
2293
- /** The git operation under way in this folder, or null; a leftover index.lock doesn't count. */
2294
- gitBusy () { return gitBusy(this.root, undefined, this.leftoverLock) }
2295
-
2296
- /** Whether the folder's sync is held: git is at work on it, or a burst of changes is being classified. (A move's own writes go through: arrive.) */
2297
- held () { return !!((this.hold && !this.arriving) || this.classifying) }
2298
-
2299
- /**
2300
- * Runs git work in this folder one piece at a time, in order (a burst's
2301
- * classification, a settle, a look at HEAD), each skipped once stopped.
2302
- */
2303
- gitTask (fn) {
2304
- const run = this.gitChain.then(() => this.stopped ? undefined : fn())
2305
- this.gitChain = run.catch(() => {})
2306
- return run
2307
- }
2308
-
2309
- /**
2310
- * Asks git what a burst was, off the event loop. Until it's known the
2311
- * folder is held as during a hold: nothing is shared or written, and later
2312
- * flushes wait behind it.
2313
- */
2314
- startClassify (paths) {
2315
- const c = this.classifying = { behind: new Set() }
2316
- this.gitTask(() => this.classifyBurst(paths, c)).catch((err) => {
2317
- this.log(`could not read git: ${err.message}`)
2318
- if (this.classifying !== c || this.stopped) return
2319
- // Never held for good: held as git being busy, and asked again every SETTLE_MS.
2320
- const behind = this.endClassify(c)
2321
- for (const rel of paths) if (rel !== HEAD_CHANGED) this.heldPaths.add(rel)
2322
- this.setHold('busy')
2323
- this.settleSoon()
2324
- this.requeue(behind)
2325
- })
2326
- }
2327
-
2328
- /** The burst is known: the folder is no longer held for it. Returns the paths that waited behind it. */
2329
- endClassify (c) {
2330
- if (this.classifying !== c) return []
2331
- this.classifying = null
2332
- return [...c.behind]
2333
- }
2334
-
2335
1631
  requeue (paths) {
2336
1632
  if (!this.stopped) for (const rel of paths) this.queue(rel)
2337
1633
  }
2338
1634
 
2339
- async classifyBurst (all, c) {
2340
- let paths = all.filter((p) => p !== HEAD_CHANGED)
2341
- let again = false
2342
- let r
2343
- for (;;) {
2344
- await this.noteCommits()
2345
- r = await classify(this.root, { changed: paths, before: this.gitSeen, leftover: this.leftoverLock, indexWrote: this.burstByIndex || again })
2346
- if (this.stopped) return
2347
- // git wrote the index, yet HEAD hasn't moved and the paths aren't clean: a checkout or a pull
2348
- // can be in the instant between writing the index and moving the branch. Asked once more, a flush later.
2349
- if (r.kind !== 'edit' || !r.head || !paths.length || !this.burstByIndex || r.putBack.length || again || this.hold) break
2350
- again = true
2351
- await new Promise((resolve) => setTimeout(resolve, FLUSH_MS))
2352
- if (this.stopped) return
2353
- const more = [...c.behind].filter((p) => p !== HEAD_CHANGED)
2354
- c.behind.clear()
2355
- paths = [...new Set([...paths, ...more])]
2356
- this.isBurst(paths) // the index as the next flush would see it
2357
- }
2358
- const runs = r.head ? true : await gitRuns(this.root) // asked while still held
2359
- if (this.stopped) return
2360
- // Removed files the fetched upstream adds: making way for a pull (git won't pull over untracked files), kept for everyone.
2361
- if (r.head && (r.kind === 'edit' || r.kind === 'discard')) r.awaitPull = await this.makingWay(paths)
2362
- if (this.stopped) return
2363
- const behind = this.endClassify(c)
2364
- try { this.settleBurst(r, paths, again, runs) } finally { this.requeue(behind) }
2365
- }
2366
-
2367
- /** Acts on what git says a burst was (classifyBurst). Nothing here waits on git. */
2368
- settleBurst (r, paths, again, runs) {
2369
- if (r.awaitPull && r.awaitPull.length) {
2370
- this.waitForPull(r.awaitPull, r.kind === 'discard')
2371
- const away = new Set(r.awaitPull)
2372
- paths = paths.filter((rel) => !away.has(rel))
2373
- }
2374
- if (this.hold) {
2375
- // git's watcher started a hold meanwhile: the burst is held with the rest.
2376
- for (const rel of paths) this.heldPaths.add(rel)
2377
- if (this.hold.kind !== 'switching') this.settleSoon()
2378
- return
2379
- }
2380
- if (!r.head) return this.headless(paths, runs) // this.git is set, so this is a repo
2381
- this.gitFailures = 0
2382
- this.gitSeen = r.head
2383
- if (r.kind === 'edit') {
2384
- // git wrote the index and put some of these paths back (a stash in the same flush as an
2385
- // untracked file's save): held, so the settle restores those and shares only the rest.
2386
- if (paths.length && (this.burstByIndex || again) && r.putBack.length) {
2387
- for (const rel of paths) this.heldPaths.add(rel)
2388
- this.setHold('settling', { prevHead: r.prevHead })
2389
- this.settleSoon()
2390
- return
2391
- }
2392
- // What changed while git was asked (from the room, or a retried write) was held. A path
2393
- // outside the burst whose disk is as Quilt left it gets the room's version as it is; the
2394
- // rest merge against the version both sides last had, as a settle merges what changed during a hold.
2395
- const late = [...this.heldPaths]
2396
- this.heldPaths.clear()
2397
- const waited = new Set(late)
2398
- const burst = new Set(paths)
2399
- this.ingestAll(paths.filter((rel) => !waited.has(rel)))
2400
- const entries = []
2401
- for (const rel of late) {
2402
- if (!this.syncable(rel) || this.merging.has(rel)) continue
2403
- if (!burst.has(rel) && this.untouchedHere(rel)) this.tryWrite(rel)
2404
- else entries.push({ rel, base: this.lastKnown.get(rel), via: 'hold' })
2405
- }
2406
- if (entries.length) this.mergeHeld(entries).catch((err) => this.log(`could not merge: ${err.message}`))
2407
- return
2408
- }
2409
- for (const rel of paths) this.heldPaths.add(rel)
2410
- if (r.kind === 'busy') {
2411
- this.setHold('busy', { prevHead: r.prevHead })
2412
- if (r.conflict) this.noteConflict(r.conflict)
2413
- this.settleSoon() // polls until the operation is over, in case its end goes unseen
2414
- } else if (r.kind === 'switch') {
2415
- this.followHead(r.head.key, r.prevHead)
2416
- } else { // discard, advance
2417
- this.setHold('settling', { prevHead: r.prevHead })
2418
- this.settleSoon()
2419
- }
2420
- }
2421
-
2422
1635
  /** The disk at rel is as Quilt last wrote or read it (or still absent, for a path it never had). */
2423
1636
  untouchedHere (rel) {
2424
1637
  const disk = this.readDisk(rel)
@@ -2426,1168 +1639,10 @@ export class Session extends EventEmitter {
2426
1639
  return (disk ? disk.key : undefined) === this.lastKnown.get(rel)
2427
1640
  }
2428
1641
 
2429
- // ------------------------------------------------------------ branches --
2430
- // A folder is on one branch of the session at a time, the one git has it on.
2431
- // Quilt never switches branches itself: when a person or an agent checks out
2432
- // another branch in git, the folder moves to that branch's document, and the
2433
- // branch it left keeps its work in the session. Nothing is synced branch on
2434
- // branch. Uncommitted work git carries over in the checkout is the old
2435
- // branch's (the session has it there): on a branch the session already has,
2436
- // Quilt writes that branch's version over it; a branch new to the session
2437
- // starts from the folder as git left it, carried work included.
2438
-
2439
- /**
2440
- * git moved HEAD to branch `to` (a checkout in a terminal or by an agent, or while Quilt was
2441
- * stopped): the folder moves there. `prevHead`: where HEAD was before git moved it.
2442
- */
2443
- followHead (to, prevHead = null) {
2444
- if (!to || this.stopped || this.following || this.pendingMove || to === this.branch) return
2445
- this.following = to
2446
- this.setHold('switching', { to, ...(prevHead ? { prevHead } : {}) }) // nothing more is shared on the old branch from here
2447
- this.gitTask(() => this.moveTo(to))
2448
- // The branch it failed on: a move can retarget on the way (moveTo, abandonMove).
2449
- .catch((err) => this.moveFailed((this.pendingMove && this.pendingMove.to) || (this.hold && this.hold.to) || to, err))
2450
- .finally(() => { this.following = null })
2451
- }
2452
-
2453
- /**
2454
- * A move that didn't finish: the folder stays held (nothing of either branch reaches the
2455
- * other) and recheckMove tries again, less and less often, or follows git wherever it goes
2456
- * next. A refusal the relay will repeat (a viewer starting a branch, the branch limit) is
2457
- * said once, with what to do; checking the old branch out again resumes sync there.
2458
- */
2459
- moveFailed (to, err) {
2460
- if (this.stopped || !this.hold || this.hold.kind !== 'switching') return
2461
- const msg = (err && err.message) || String(err)
2462
- const connected = !!(this.conn && this.conn.connected)
2463
- const refused = connected && !/did not answer|not connected|disconnected/.test(msg)
2464
- // Offline is no try: reconnecting tries again at once (start: the 'connected' status clears moveRetryAt).
2465
- if (connected) this.moveTries = (this.moveTries || 0) + 1
2466
- this.moveRetryAt = !connected ? 0 : Date.now() + (refused ? MOVE_RETRY_MAX_MS : Math.min(MOVE_RETRY_MAX_MS, RETRY_MS * 2 ** (this.moveTries - 1)))
2467
- this.hold.waiting = refused ? 'refused' : 'retrying'
2468
- this.emit('hold', this.hold)
2469
- this.scheduleStatusWrite()
2470
- const said = `${to}\0${refused ? msg : ''}`
2471
- if (this.moveSaid === said) return
2472
- this.moveSaid = said
2473
- if (refused) {
2474
- const text = `The session can't take ${to}: ${msg}. This folder isn't syncing while it's on ${to}; check out ${this.branch} again in git to keep syncing there.`
2475
- this.log(`⚠️ ${text}`)
2476
- this.notice(text)
2477
- } else {
2478
- this.log(`⚠️ You're on ${to} in git, but its work in the session can't be loaded yet (${msg}). This folder waits and tries again.`)
2479
- }
2480
- }
2481
-
2482
- /** While a move is unfinished: where is HEAD now? Back on the folder's branch resumes it; elsewhere is followed (backing off on the same branch). */
2483
- async recheckMove () {
2484
- if (!this.hold || this.hold.kind !== 'switching' || this.following || this.stopped) return null
2485
- const at = await this.headNow()
2486
- if (this.stopped || !at || this.following) return null
2487
- const m = this.pendingMove
2488
- const target = m ? m.to : this.hold.to
2489
- if (at !== this.branch && at === target && Date.now() < (this.moveRetryAt || 0)) return null // tried a moment ago
2490
- this.following = at
2491
- try {
2492
- if (m) return await (m.to === at ? this.finishMove(m) : this.abandonMove(m, at))
2493
- if (at === this.branch) return this.settleBack()
2494
- this.hold.to = at
2495
- return await this.moveTo(at)
2496
- } finally { this.following = null }
2497
- }
2498
-
2499
- /** Where HEAD is: its branch, `@<sha>` when detached, or a branch with no commits yet; null when git can't say. */
2500
- async headNow () {
2501
- const head = await headKey(this.root)
2502
- return head ? head.key : headRef(this.root)
2503
- }
2504
-
2505
- /** recheckMove, as git work; what fails is a move that failed again. */
2506
- recheckSoon () {
2507
- this.gitTask(() => this.recheckMove()).catch((err) => this.moveFailed(this.pendingMove ? this.pendingMove.to : this.hold && this.hold.to, err))
2508
- }
2509
-
2510
- /** HEAD is back on the folder's branch before it moved: the trip is settled like any git work. */
2511
- settleBack () {
2512
- this.moveTries = 0
2513
- this.moveSaid = null
2514
- this.hold = { kind: 'settling', since: this.hold ? this.hold.since : Date.now(), prevHead: this.hold ? this.hold.prevHead : this.gitSeen }
2515
- this.emit('hold', this.hold)
2516
- this.scheduleStatusWrite()
2517
- this.settleSoon()
2518
- return null
2519
- }
2520
-
2521
- /**
2522
- * Moves this folder to branch `to`, which git has already checked out: every change on the
2523
- * branch it leaves is confirmed in the room where it can be, that branch's document is kept
2524
- * aside here (parkLocalState), and finishMove joins the new one. Runs as git work.
2525
- */
2526
- async moveTo (to) {
2527
- const from = this.branch
2528
- const fromSha = this.git ? this.git.sha : null
2529
- const at = await this.headNow()
2530
- if (this.stopped) return null
2531
- if (at === from) return this.settleBack()
2532
- if (at && at !== to) { to = at; if (this.hold) this.hold.to = at }
2533
- const oldKeys = new Map() // path -> the old branch's version (as lastKnown keys it), to tell what git carried over
2534
- for (const rel of this.sharedPaths()) if (this.syncable(rel)) oldKeys.set(rel, this.sharedKey(rel) ?? null)
2535
- // What doesn't reach the relay now stays in `from`'s document kept here, and goes out when the folder is back.
2536
- try { await this.conn.confirmBranch() } catch (err) { this.emit('debug', `confirming ${from} before moving: ${err.message}`) }
2537
- if (this.stopped) return null
2538
- await this.releaseAutoClaims().catch(() => {}) // claims that followed edits on `from` don't come along
2539
- const carried = await this.carriedFrom(oldKeys, fromSha)
2540
- if (this.stopped) return null
2541
- this.parkLocalState(from)
2542
- const claims = this.claims
2543
- const doc = new Y.Doc()
2544
- const left = this.unparkLocalState(to, doc)
2545
- return this.finishMove({ from, to, doc, left, epoch: left ? this.parkedEpoch(to) : null, carried, claims })
2546
- }
2547
-
2548
- /**
2549
- * Joins the new branch's document, writes it onto the folder and goes live there. HEAD is
2550
- * read again before and after the join: if git has moved on (a quick checkout back, or a
2551
- * retry long after), this move is dropped without writing anything and the folder follows
2552
- * HEAD from where it is. Tried again (recheckMove) when the relay can't load the branch yet.
2553
- */
2554
- async finishMove (m) {
2555
- const { from, to, carried } = m
2556
- let { doc, left } = m
2557
- let at = await this.headNow()
2558
- if (this.stopped) return null
2559
- if (at !== to) return this.abandonMove(m, at)
2560
- let head = await headKey(this.root)
2561
- let reply
2562
- let fresh = null // the relay's document of `to` is newer than the one this folder kept (P27)
2563
- try {
2564
- // A branch new to the session is kept as differences from git when the relay can (branch-view.js).
2565
- const diff = await this.diffJoinFields()
2566
- reply = await this.conn.joinBranch(to, doc, { ...(head && head.sha ? { base: head.sha } : {}), ...diff, epoch: m.epoch || null, onStale: () => (fresh = new Y.Doc()) })
2567
- await this.conn.waitForBranchSync()
2568
- } catch (err) {
2569
- if (fresh) fresh.destroy()
2570
- this.pendingMove = m
2571
- throw err
2572
- }
2573
- if (fresh) {
2574
- // `to` was removed from the session and started again since this folder left it: what it
2575
- // kept of the old document is dropped, and the folder arrives as one that was never there.
2576
- doc.destroy()
2577
- m.doc = doc = fresh
2578
- m.left = left = null
2579
- m.epoch = null
2580
- const said = `${to} was removed from the session (nobody was on it for 30 days) and has been started again since, so what this folder had kept of it is not brought back; the session's files there are on disk.`
2581
- this.log(`⚠️ ${said}`)
2582
- this.notice(said)
2583
- }
2584
- if (this.stopped) return null
2585
- head = await headKey(this.root)
2586
- at = head ? head.key : headRef(this.root)
2587
- if (this.stopped) return null
2588
- if (at !== to) return this.abandonMove(m, at)
2589
- this.pendingMove = null
2590
- this.moveTries = 0
2591
- this.moveSaid = null
2592
- const old = this.bdoc
2593
- this.bindBranchDoc(doc)
2594
- old.destroy()
2595
- this.branch = reply.branch || to
2596
- this.branchEpoch = typeof reply.epoch === 'string' && reply.epoch ? reply.epoch : null
2597
- this.branchForm = reply.form === 'diff' ? 'diff' : 'full'
2598
- this.diffBase = reply.form === 'diff' && typeof reply.diffBase === 'string' ? reply.diffBase : null
2599
- this.git = head || { key: to, branch: to, sha: null } // a branch with no commits yet has no sha
2600
- this.gitSeen = this.git
2601
- this.rejoin = false
2602
- this.observeBranch()
2603
- this.announceBranch()
2604
- let entries = []
2605
- let movedOn = null
2606
- try { entries = await this.arrive({ created: !!reply.created, left, carried }) } finally {
2607
- // git moved again while the branch was written: still held, and the folder follows on (below).
2608
- const ref = headRef(this.root)
2609
- if (ref && ref !== this.branch && !this.stopped && this.hold) { movedOn = ref; this.hold.to = ref }
2610
- if (!movedOn) {
2611
- // What git and the move wrote is accounted for: the next flush is an edit unless git moves again.
2612
- const late = [...this.heldPaths]
2613
- this.heldPaths.clear()
2614
- this.releaseHold()
2615
- this.gitIndex = indexStamp(this.root)
2616
- this.headChangedAt = 0
2617
- this.stashSeen = stashStamp(this.root)
2618
- this.scanDisk({ baseline: true })
2619
- this.requeue(late.filter((rel) => this.syncable(rel) && !this.untouchedHere(rel)))
2620
- }
2621
- }
2622
- if (this.stopped) return null
2623
- if (entries.length && !movedOn) {
2624
- this.mergeHeld(entries).then(({ conflicts }) => {
2625
- if (conflicts) this.log(`🔀 ${conflicts} file${conflicts === 1 ? '' : 's'} you changed on ${to} clash with the session's; see Merges.`)
2626
- }).catch((err) => this.log(`could not merge: ${err.message}`))
2627
- } else for (const e of entries) this.merging.delete(e.rel)
2628
- this.saveStateNow()
2629
- fs.rmSync(this.branchStateDir(to), { recursive: true, force: true }) // in state.bin from here
2630
- // The upstream is the new branch's now.
2631
- this.upstream = null
2632
- this.fetchState = null
2633
- this.upstreamSaid = ''
2634
- this.upstreamDeferred = null
2635
- this.indexLate = null
2636
- this.shareGit()
2637
- this.refreshPull()
2638
- this.checkUpstreamSoon({ fetch: true })
2639
- const note = this.moveNote({ from, to: this.branch, created: !!reply.created, carried: [...carried.keys()] })
2640
- this.log(`🔀 ${note}`)
2641
- // Agents working in this folder hear it with their next answer; the room sees it in the activity log.
2642
- this.notice(`This folder moved from ${from} to ${this.branch} (a checkout in git): its files are ${this.branch}'s in the session now, and ${from}'s work stays in the session on ${from}.`)
2643
- this.doc.transact(() => {
2644
- this.activity.push([{ by: this.name, path: '', kind: 'switched', detail: '', branch: this.branch, from, ts: Date.now() }])
2645
- if (this.activity.length > 300) this.activity.delete(0, this.activity.length - 300)
2646
- }, LOCAL)
2647
- this.emit('branch', this.branch)
2648
- this.scheduleStatusWrite()
2649
- if (movedOn) { this.following = movedOn; return this.moveTo(movedOn) }
2650
- return { branch: this.branch, note }
2651
- }
2652
-
2653
- /**
2654
- * git moved on before a move finished: nothing of it was written. The connection goes back
2655
- * to the folder's branch if the join had moved it, and the folder settles there (HEAD came
2656
- * back) or moves to wherever HEAD is now.
2657
- */
2658
- async abandonMove (m, at) {
2659
- this.pendingMove = null
2660
- this.claims = m.claims
2661
- m.doc.destroy()
2662
- if (this.conn.branchKey !== this.branch) {
2663
- this.conn.setBranch(this.branch, this.bdoc) // a reconnect meanwhile joins it again by itself
2664
- this.conn.branchEpoch = this.branchEpoch // the document it holds, for that join (not the one it left)
2665
- try {
2666
- await this.conn.joinBranch(this.branch, this.bdoc)
2667
- await this.conn.waitForBranchSync()
2668
- } catch (err) { this.emit('debug', `rejoining ${this.branch}: ${err.message}`) }
2669
- }
2670
- if (this.stopped) return null
2671
- if (!at || at === this.branch) return this.settleBack()
2672
- if (this.hold) this.hold.to = at
2673
- return this.moveTo(at)
2674
- }
2675
-
2676
- /**
2677
- * Writes the branch document onto a folder git has just put on that branch. A document new
2678
- * to the session (or empty) is made from the folder as git left it instead, carried work
2679
- * included. Otherwise what git carried over from the old branch (`carried`: path -> the
2680
- * old branch's version, still on disk) gets this branch's version, or HEAD's when the
2681
- * document hasn't the path: the old branch's work never enters this one. Any other path git
2682
- * shows as changed (an edit made on this branch while Quilt wasn't looking) is merged into
2683
- * the document: against the version this folder had when it last left the branch (`left`),
2684
- * else HEAD's; returned as entries for mergeHeld. A file HEAD has that the document hasn't
2685
- * is left as git has it, and not shared.
2686
- */
2687
- /**
2688
- * A branch new to the session, kept as differences from git (branch-view.js): only what the folder
2689
- * has that differs from the branch's first commit (its edits, new files, local commits) goes in,
2690
- * deletions into `gone`; everything else is git's, and stays on disk as git put it. When git can't
2691
- * say what differs, every file goes in (a diff document may hold more than it needs; never less).
2692
- */
2693
- async seedDiff () {
2694
- const base = this.diffBase
2695
- const diff = base ? await changedSince(this.root, base).catch(() => null) : null
2696
- if (this.stopped) return []
2697
- this.arriving = true
2698
- this.seeding = true
2699
- this.seedBase = base
2700
- try {
2701
- for (const rel of walk(this.root, this.ig)) {
2702
- if (!diff || diff.changed.has(rel)) { this.ingest(rel); continue }
2703
- const disk = this.readDisk(rel)
2704
- if (disk && !disk.skip && !disk.tooLarge) { this.lastKnown.set(rel, disk.key); this.gitOnly.set(rel, disk.key) }
2705
- }
2706
- if (diff) {
2707
- const gone = [...diff.deleted].filter((rel) => this.syncable(rel) && !this.onDisk(rel))
2708
- if (gone.length) {
2709
- this.transact(() => {
2710
- for (const rel of gone) {
2711
- this.gone.set(rel, { at: Date.now(), by: this.name })
2712
- if (!this.fileBases.has(rel)) this.fileBases.set(rel, base)
2713
- }
2714
- }, LOCAL)
2715
- }
2716
- }
2717
- } finally { this.seeding = false; this.arriving = false; this.seedBase = null }
2718
- return []
2719
- }
2720
-
2721
- async arrive ({ created, left = null, carried = new Map() }) {
2722
- this.lastKnown.clear()
2723
- this.storedOnDisk.clear()
2724
- this.known = null
2725
- this.writeFailed.clear()
2726
- this.retry.clear()
2727
- this.gitOnly.clear()
2728
- if (created && this.diffForm) return this.seedDiff()
2729
- if (created || (!this.files.size && !this.blobs.size && !(this.diffForm && this.gone.size))) {
2730
- // What the folder brings to the room is its starting point, not a change anyone made.
2731
- this.arriving = true
2732
- this.seeding = true
2733
- try { for (const rel of walk(this.root, this.ig)) this.ingest(rel) } finally { this.seeding = false; this.arriving = false }
2734
- return []
2735
- }
2736
- // Carried, and still as git carried it (an edit made since is this branch's, and merges).
2737
- const away = new Set([...carried].filter(([rel, key]) => { const d = this.readDisk(rel); return (d ? d.key : null) === key }).map(([rel]) => rel))
2738
- const tree = await treeState(this.root, [])
2739
- if (this.stopped) return []
2740
- // git can't say what it changed: every path that differs from the session's is merged (a record at worst).
2741
- const dirty = (tree
2742
- ? [...tree.dirty].filter((rel) => this.syncable(rel))
2743
- : [...new Set([...this.sharedPaths(), ...walk(this.root, this.ig)])].filter((rel) => {
2744
- if (!this.syncable(rel)) return false
2745
- const disk = this.readDisk(rel)
2746
- return !(disk && (disk.skip || disk.tooLarge)) && (disk ? disk.key : undefined) !== this.sharedKey(rel)
2747
- })).filter((rel) => !away.has(rel))
2748
- const fromHead = [...away].filter((rel) => this.sharedKey(rel) === undefined) // the document hasn't it: HEAD's file
2749
- // Carried, then edited since the checkout: only that edit is this branch's. Merged against the carried version.
2750
- const editedSince = new Set(dirty.filter((rel) => carried.has(rel)))
2751
- const ask = [...dirty, ...fromHead]
2752
- const atHead = this.git && this.git.sha && ask.length ? await filesAt(this.root, this.git.sha, ask) : null
2753
- const bins = new Map()
2754
- for (const rel of [...fromHead, ...editedSince]) {
2755
- const k = atHead ? atHead.get(rel) : null
2756
- if (typeof k === 'string' && k.startsWith('bin:')) bins.set(rel, await blobAt(this.root, this.git.sha, rel))
2757
- }
2758
- if (this.stopped) return []
2759
- const changed = new Set(dirty)
2760
- const shared = this.sharedPaths()
2761
- this.arriving = true
2762
- try {
2763
- this.writeBackAll([...shared].filter((rel) => this.syncable(rel) && !changed.has(rel)))
2764
- // A diff branch: what the session deleted goes from the folder too (a change of the folder's own to one merges below).
2765
- if (this.diffForm) this.writeBackAll([...this.gone.keys()].filter((rel) => this.syncable(rel) && !changed.has(rel) && !this.files.has(rel) && !this.blobs.has(rel)))
2766
- for (const rel of fromHead) this.putHeadVersion(rel, atHead ? atHead.get(rel) : null, bins.get(rel))
2767
- // HEAD's files the document hasn't: git's, not this folder's change, so not shared.
2768
- for (const rel of walk(this.root, this.ig)) {
2769
- if (shared.has(rel) || changed.has(rel) || this.lastKnown.has(rel) || !this.syncable(rel)) continue
2770
- const disk = this.readDisk(rel)
2771
- if (disk && !disk.skip && !disk.tooLarge) { this.lastKnown.set(rel, disk.key); this.gitOnly.set(rel, disk.key) }
2772
- }
2773
- } finally { this.arriving = false }
2774
- const baseOf = (rel) => left && left.has(rel) ? left.get(rel) : atHead ? atHead.get(rel) ?? undefined : undefined
2775
- const entries = []
2776
- for (const rel of dirty) {
2777
- if (!editedSince.has(rel)) { entries.push({ rel, base: baseOf(rel), via: 'hold' }); continue }
2778
- // Merged with this branch's version (HEAD's file when its document hasn't the path): see mergeCarried.
2779
- entries.push({ rel, base: carried.get(rel), via: 'hold', carried: true, head: atHead ? atHead.get(rel) : undefined, headBuf: bins.get(rel) })
2780
- }
2781
- for (const e of entries) this.merging.add(e.rel)
2782
- return entries
2783
- }
2784
-
2785
- /**
2786
- * A path git carried over from the old branch, edited after the checkout: only that edit is
2787
- * this branch's. Merged three-way (base: the carried version; ours: the disk; theirs: this
2788
- * branch's version, or HEAD's file when its document hasn't the path). Anything that doesn't
2789
- * merge cleanly (a clash, a binary, a file this branch hasn't) is set aside: never a shared
2790
- * merge record, which would carry the old branch's text into this one.
2791
- */
2792
- async mergeCarried ({ rel, base, head, headBuf }) {
2793
- this.merging.delete(rel)
2794
- const disk = this.readDisk(rel)
2795
- if (disk && (disk.skip || disk.tooLarge)) return null
2796
- const ours = disk ? disk.key : null
2797
- const inDoc = this.sharedKey(rel) !== undefined
2798
- if (!inDoc && head === undefined) {
2799
- // git couldn't read HEAD's file (too large, a filter): left as it is, and not shared.
2800
- if (disk) this.lastKnown.set(rel, disk.key)
2801
- return null
2802
- }
2803
- const theirs = inDoc ? this.sharedKey(rel) : head // null: neither this branch nor its commit has it
2804
- if (ours === theirs) { if (ours === null) this.lastKnown.delete(rel); else this.lastKnown.set(rel, ours); return null }
2805
- if (ours === base) { this.putBranchVersion(rel, { inDoc, head, headBuf }); return null } // not edited after all
2806
- if (theirs === null) { this.setAsideCarried(rel, { inDoc, head, headBuf, clash: false }); return null } // not on this branch
2807
- // Edited here and changed on this branch too: combined by Quilt, as mergeOne does.
2808
- const binary = [base, ours, theirs].some((k) => typeof k === 'string' && k.startsWith('bin:'))
2809
- if (inDoc) return this.autoSettle({ rel, base, ours, theirs, theirsBy: this.lastEditorOf(rel), disk, binary })
2810
- // This branch's commit has it, its document doesn't yet: the merge goes onto the disk, and is shared from there.
2811
- if (ours === null) { this.putHeadVersion(rel, head, headBuf); return null }
2812
- if (binary) return null // ours, edited after the checkout, is the newer version: it stays, and is shared from the disk
2813
- const r = mergeAuto(base || '', ours, theirs, { oursNewer: true })
2814
- this.writeFile(rel, resolveInside(this.root, rel), r.text)
2815
- this.log(`🧵 ${rel}: your edit after switching to ${this.branch} is merged with ${this.branch}'s own change to it`)
2816
- return 'merged'
2817
- }
2818
-
2819
- /** This branch's version of rel onto the disk: its document's, else HEAD's file, else none. */
2820
- putBranchVersion (rel, { inDoc, head, headBuf }) {
2821
- if (!inDoc) return this.putHeadVersion(rel, head, headBuf)
2822
- const disk = this.readDisk(rel)
2823
- if (disk && !disk.skip && !disk.tooLarge) this.lastKnown.set(rel, disk.key) // replaced without a copy here: setAsideCarried keeps one
2824
- this.tryWrite(rel)
2825
- }
2826
-
2827
- /** A carried path edited after the checkout that can't merge: the folder's copy under .quilt/conflicts, this branch's version on disk, said once. */
2828
- setAsideCarried (rel, { inDoc, head, headBuf, clash }) {
2829
- let dest = null
2830
- try {
2831
- if (this.onDisk(rel)) {
2832
- dest = path.join(this.stateDir, 'conflicts', `${Date.now()}`, ...rel.split('/'))
2833
- fs.mkdirSync(path.dirname(dest), { recursive: true })
2834
- fs.copyFileSync(resolveInside(this.root, rel), dest)
2835
- }
2836
- } catch (err) { this.log(`could not keep a copy of ${rel}: ${err.message}`); return }
2837
- this.putBranchVersion(rel, { inDoc, head, headBuf })
2838
- const copy = dest ? `; your copy is in ${path.relative(this.root, dest).split(path.sep).join('/')}` : ''
2839
- const text = clash
2840
- ? `Your edit to ${rel} after switching to ${this.branch} and ${this.branch}'s own change to it are not combined on their own, so ${this.branch}'s version is on disk${copy}.`
2841
- : `${rel} came over from the old branch in the checkout and isn't on ${this.branch}, so it's off the disk${copy}.`
2842
- this.log(`📦 ${text}`)
2843
- this.notice(text)
2844
- }
2845
-
2846
- /** A carried path the branch's document hasn't: HEAD's file goes back (`key` from filesAt, `buf` for a binary), or none when HEAD hasn't it either. */
2847
- putHeadVersion (rel, key, buf) {
2848
- if (key === undefined) return // git couldn't read it (too large, a filter): left as it is
2849
- try {
2850
- const abs = resolveInside(this.root, rel)
2851
- if (key === null) {
2852
- fs.rmSync(abs, { force: true })
2853
- removeEmptyParents(this.root, path.dirname(abs))
2854
- this.lastKnown.delete(rel)
2855
- return
2856
- }
2857
- const data = key.startsWith('bin:') ? buf : key
2858
- if (data === null || data === undefined) return
2859
- fs.mkdirSync(path.dirname(abs), { recursive: true })
2860
- this.writeFile(rel, abs, data)
2861
- this.lastKnown.set(rel, key)
2862
- if (this.sharedKey(rel) === undefined) this.gitOnly.set(rel, key)
2863
- } catch (err) { this.log(`could not put ${rel} back to your last commit: ${err.message}`) }
2864
- }
2865
-
2866
- /**
2867
- * The paths git carried over from the old branch (path -> the old branch's version): changed
2868
- * on disk, where the old branch's document differs from its HEAD. (git keeps uncommitted
2869
- * changes across a checkout; Quilt wrote the session's work there as such.)
2870
- */
2871
- async carriedFrom (oldKeys, fromSha) {
2872
- const out = new Map()
2873
- const tree = await treeState(this.root, [])
2874
- if (!tree) return out
2875
- // .gitignore's .quilt/ line is Quilt's own, carried on every checkout: not the session's work.
2876
- const dirty = [...tree.dirty].filter((rel) => this.syncable(rel) && rel !== '.gitignore')
2877
- const was = fromSha && dirty.length ? await filesAt(this.root, fromSha, dirty) : null
2878
- for (const rel of dirty) {
2879
- const disk = this.readDisk(rel)
2880
- if (disk && (disk.skip || disk.tooLarge)) continue
2881
- // The old branch had uncommitted work here, and git kept a change across the checkout. (The disk may
2882
- // have been edited again since: arrive merges that edit against the old branch's version.)
2883
- const head = was ? was.get(rel) : undefined // undefined: git couldn't read it
2884
- if (oldKeys.has(rel)) { if (oldKeys.get(rel) !== head) out.set(rel, oldKeys.get(rel)) } else if (!disk && head !== null && head !== undefined) out.set(rel, null) // a deletion the old branch made
2885
- }
2886
- return out
2887
- }
2888
-
2889
- /** The session's version of each path onto the disk, over what git put there (git has that: no copy is kept). */
2890
- writeBackAll (rels) {
2891
- for (const rel of rels) {
2892
- const disk = this.readDisk(rel)
2893
- if (disk && (disk.skip || disk.tooLarge)) continue
2894
- if (disk) this.lastKnown.set(rel, disk.key); else this.lastKnown.delete(rel)
2895
- this.tryWrite(rel)
2896
- }
2897
- }
2898
-
2899
- /** Where the saved state of branch `key` waits while this folder is on another branch. */
2900
- branchStateDir (key) { return path.join(this.stateDir, 'branches', Buffer.from(key, 'utf8').toString('base64url')) }
2901
-
2902
- /** Keeps the branch this folder leaves: its document as this app has it (anything the relay hasn't got yet included) and its claims. */
2903
- parkLocalState (key) {
2904
- const dir = this.branchStateDir(key)
2905
- try {
2906
- fs.mkdirSync(dir, { recursive: true })
2907
- fs.writeFileSync(path.join(dir, 'state.bin'), Y.encodeStateAsUpdate(this.bdoc))
2908
- fs.writeFileSync(path.join(dir, 'claims.json'), JSON.stringify([...this.claims.values()]))
2909
- if (this.branchEpoch) fs.writeFileSync(path.join(dir, 'epoch'), this.branchEpoch)
2910
- else fs.rmSync(path.join(dir, 'epoch'), { force: true })
2911
- } catch (err) { this.log(`could not keep ${key}'s state: ${err.message}`) }
2912
- }
2913
-
2914
- /** Which document of branch `key` its kept state is a copy of (parkLocalState), or null. */
2915
- parkedEpoch (key) {
2916
- try { return fs.readFileSync(path.join(this.branchStateDir(key), 'epoch'), 'utf8').trim() || null } catch { return null }
2917
- }
2918
-
2919
- /**
2920
- * The saved document of a branch this folder was on before (into `doc`), and its claims until
2921
- * the relay sends them. Returns what the folder had of each path when it left (path -> key),
2922
- * or null when it was never on that branch here.
2923
- */
2924
- unparkLocalState (key, doc) {
2925
- const dir = this.branchStateDir(key)
2926
- let had = false
2927
- try { Y.applyUpdate(doc, fs.readFileSync(path.join(dir, 'state.bin')), LOCAL); had = true } catch {}
2928
- this.claims = new Map()
2929
- try { for (const c of JSON.parse(fs.readFileSync(path.join(dir, 'claims.json'), 'utf8'))) this.claims.set(c.pattern, c) } catch {}
2930
- if (!had) return null
2931
- const left = new Map()
2932
- for (const rel of [...doc.getMap('files').keys(), ...doc.getMap('blobs').keys()]) left.set(rel, docKey(doc, rel))
2933
- return left
2934
- }
2935
-
2936
- /** Partners see which branch this folder is on (presence). */
2937
- announceBranch () {
2938
- if (this.conn && this.conn.awareness.getLocalState()) this.conn.awareness.setLocalStateField('branch', this.branch)
2939
- }
2940
-
2941
- /** What a move says in the log. */
2942
- moveNote ({ from, to, created, carried = [] }) {
2943
- const head = `You're on ${to} now (you switched in git)`
2944
- if (created) return `${head}. ${to} is new to the session: this folder's files are its starting point, and ${from}'s work stays in the session on ${from}.`
2945
- const list = carried.slice(0, 3).join(', ') + (carried.length > 3 ? ', …' : '')
2946
- const brought = carried.length ? ` git had carried ${from}'s uncommitted work in ${list} over: that stays in the session on ${from}, and ${to}'s version is on disk.` : ''
2947
- return `${head}; the session's work there is on disk.${brought}`
2948
- }
2949
-
2950
- /**
2951
- * HEAD can't be read as a commit. A branch with no commits yet (`git
2952
- * checkout --orphan`, git itself running: `runs`) is a switch to it, and
2953
- * once the folder is on it, a change there is an edit (there is no commit
2954
- * to tell git's work from). Otherwise git can't be asked here.
2955
- */
2956
- headless (paths = [], runs = false) {
2957
- const ref = headRef(this.root)
2958
- if (ref && this.git && runs && ref !== this.git.key) {
2959
- for (const rel of paths) this.heldPaths.add(rel)
2960
- this.followHead(ref)
2961
- return
2962
- }
2963
- if (ref && this.git && runs && !this.git.sha) {
2964
- const late = [...this.heldPaths]
2965
- this.heldPaths.clear()
2966
- if (this.hold) this.releaseHold()
2967
- const burst = new Set(paths)
2968
- this.ingestAll(paths)
2969
- for (const rel of late) {
2970
- if (burst.has(rel) || !this.syncable(rel) || this.merging.has(rel)) continue
2971
- if (this.untouchedHere(rel)) this.tryWrite(rel); else this.ingestAll([rel])
2972
- }
2973
- return
2974
- }
2975
- this.gitUnreadable(paths)
2976
- }
2977
-
2978
- /**
2979
- * git can't be run (missing, or HEAD unreadable) in a folder that is a repo:
2980
- * whatever happened can't be told apart from an edit, so the folder stays
2981
- * held and nothing is shared. Asked again every SETTLE_MS, for good.
2982
- */
2983
- gitUnreadable (paths = []) {
2984
- for (const rel of paths) this.heldPaths.add(rel)
2985
- if (++this.gitFailures === GIT_FAILURES_TO_SAY) this.log('⚠️ Quilt can\'t read git here; this folder stays paused')
2986
- this.setHold('busy') // a switching hold stays as it is
2987
- this.settleSoon()
2988
- }
2989
-
2990
- /**
2991
- * Commits made here of work the room already has (`git commit` touches no
2992
- * file, so no burst saw them) move gitSeen forward, so a later pull or
2993
- * rebase is measured from them: otherwise the room's text, now committed,
2994
- * would look like a change of the room's against the older commit and clash
2995
- * with what the operation made of it. Read from the branch, not HEAD: a
2996
- * rebase has already moved HEAD away when its first files land.
2997
- */
2998
- async noteCommits () {
2999
- const seen = this.gitSeen
3000
- if (!seen || !seen.branch) return
3001
- const tip = await branchTip(this.root, seen.branch)
3002
- if (!tip || tip === seen.sha) return
3003
- const changes = await changesBetween(this.root, seen.sha, tip)
3004
- if (!changes) return // git could not say: gitSeen stays, and the burst merges against it
3005
- const changed = [...changes.keys()].filter((rel) => this.syncable(rel))
3006
- const now = await filesAt(this.root, tip, changed)
3007
- if (!now) return
3008
- for (const rel of changed) if ((now.get(rel) ?? undefined) !== this.lastKnown.get(rel)) return // new content: the burst merges it
3009
- const next = { ...seen, sha: tip }
3010
- if (this.gitSeen === seen) this.gitSeen = next // unless a newer head was seen meanwhile
3011
- // A hold that started from this head (setHold) measures from the commits too.
3012
- if (this.hold && this.hold.prevHead === seen) this.hold.prevHead = next
3013
- }
3014
-
3015
- setHold (kind, extra = {}) {
3016
- // A move to another branch ends only once the folder is there (finishMove): git at work meanwhile doesn't change it.
3017
- if (this.hold && (this.hold.kind === kind || this.hold.kind === 'switching')) return
3018
- // The git watcher can start a hold before any burst is classified: commits of the room's work
3019
- // move its starting point on, asked before it can settle (gitTask runs in order).
3020
- if (!this.hold) this.gitTask(() => this.noteCommits()).catch(() => {})
3021
- this.settleBoxesSoon() // a commit may have taken boxed work in
3022
- // since: when the folder was first held (the app's "git is busy" note waits on it), kept across kinds.
3023
- this.hold = { kind, since: this.hold ? this.hold.since : Date.now(), ...(this.hold ? { prevHead: this.hold.prevHead } : {}), ...extra }
3024
- if (!this.hold.prevHead) this.hold.prevHead = this.gitSeen
3025
- this.emit('hold', this.hold)
3026
- this.scheduleStatusWrite()
3027
- // state.json's gitHeld at once: a crash a moment later must restart held, not as an offline rejoin.
3028
- this.saveStateNow()
3029
- }
3030
-
3031
- releaseHold () {
3032
- this.hold = null
3033
- this.heldConflicts.clear()
3034
- clearTimeout(this.settleTimer); this.settleTimer = null
3035
- this.emit('hold', null)
3036
- this.scheduleStatusWrite()
3037
- // Not at once: the settle writes back and merges just after this, and a crash before
3038
- // those finish must still restart held (and settle again) rather than as an offline rejoin.
3039
- this.scheduleStateSave()
3040
- const later = this.upstreamAfterHold
3041
- this.upstreamAfterHold = null
3042
- if (later) this.checkUpstreamSoon({ fetch: later.fetch })
3043
- }
3044
-
3045
- /** (Re)arms the settle timer: the hold ends SETTLE_MS after the last file or git event. */
3046
- settleSoon () {
3047
- if (!this.hold || this.hold.kind === 'switching' || this.stopped || this.holdAwaitsSync || this.inSquare) return
3048
- clearTimeout(this.settleTimer)
3049
- const gen = ++this.settleGen
3050
- this.settleTimer = setTimeout(() => {
3051
- this.settleTimer = null
3052
- this.gitTask(() => this.onSettled(gen)).catch((err) => {
3053
- this.log(`could not settle: ${err.message}`)
3054
- this.settleSoon() // every hold has a way out: asked again
3055
- })
3056
- }, SETTLE_MS)
3057
- this.settleTimer.unref()
3058
- }
3059
-
3060
- /** Whether a hold is on that a settle may end now. */
3061
- settleable () {
3062
- return !!this.hold && this.hold.kind !== 'switching' && !this.stopped && !this.holdAwaitsSync
3063
- }
3064
-
3065
- /** `gen`: the settleSoon that armed it. A later one (a file or git event since) means not settled yet. */
3066
- async onSettled (gen = this.settleGen) {
3067
- if (!this.settleable()) return
3068
- let busy = this.gitBusy()
3069
- if (busy === 'index-lock' && Date.now() - this.lastFileEventAt >= STALE_LOCK_MS) {
3070
- // Only the index lock, there a while, and the tree quiet as long (a long checkout writes files
3071
- // all along): a git that crashed may have left it.
3072
- const stamp = await leftoverLock(this.root)
3073
- if (!this.settleable()) return
3074
- if (stamp) {
3075
- this.leftoverLock = stamp
3076
- this.log('⚠️ a leftover .git/index.lock is being ignored; delete it if git complains')
3077
- busy = this.gitBusy()
3078
- }
3079
- }
3080
- if (busy) { this.setHold('busy'); this.settleSoon(); return } // still mid-operation: look again later
3081
- // git left a conflict for the person (a `stash pop` that clashed has no marker file): their
3082
- // resolution is shared once git has it (git add), never git's conflict markers.
3083
- const conflicts = await unmergedPaths(this.root)
3084
- if (!this.settleable()) return
3085
- if (conflicts && conflicts.size) { this.setHold('busy'); this.noteConflict([...conflicts]); this.settleSoon(); return }
3086
- const head = await headKey(this.root)
3087
- if (!this.settleable()) return
3088
- if (!head) {
3089
- const runs = await gitRuns(this.root)
3090
- return this.settleable() ? this.headless([], runs) : undefined
3091
- }
3092
- if (this.git && head.key !== this.git.key) {
3093
- // Landed on another branch while settling: the folder follows it there.
3094
- this.followHead(head.key)
3095
- return
3096
- }
3097
- const plan = await this.planSettle(head)
3098
- if (!this.settleable()) return
3099
- // A file or git event since this settle's timer was armed (it armed another), or a path the room
3100
- // changed that the plan never saw: the folder hasn't settled. Asked again, the hold on meanwhile.
3101
- if (gen !== this.settleGen || (plan && [...this.heldPaths].some((rel) => !plan.considered.has(rel)))) { this.settleSoon(); return }
3102
- if (!plan) return this.gitUnreadable()
3103
- const from = this.hold.prevHead // where HEAD was before git moved it: the pull's commits are from..head
3104
- // Out of normal sync from here on: a partner's edit arriving
3105
- // during a merge must not land on a pulled file before it is merged.
3106
- for (const e of plan.advance) this.merging.add(e.rel)
3107
- this.heldPaths.clear()
3108
- this.rejoin = false
3109
- this.gitFailures = 0
3110
- this.gitSeen = head
3111
- // What git wrote is accounted for here: the next flush is an edit unless git moves again.
3112
- this.gitIndex = indexStamp(this.root)
3113
- this.headChangedAt = 0
3114
- const stash = stashStamp(this.root)
3115
- const why = stash !== this.stashSeen ? 'stash' : 'reset'
3116
- this.stashSeen = stash
3117
- this.releaseHold()
3118
- // Made way for a pull that has landed now: those files are the pull's (merged in plan.advance).
3119
- for (const rel of [...this.pullWait.keys()]) if (this.onDisk(rel)) this.endPullWait(rel)
3120
- if (plan.awaitPull && plan.awaitPull.length) {
3121
- this.waitForPull(plan.awaitPull.filter((a) => a.stash).map((a) => a.rel), true)
3122
- this.waitForPull(plan.awaitPull.filter((a) => !a.stash).map((a) => a.rel), false)
3123
- }
3124
- this.writeBack(plan.discarded, why)
3125
- if (plan.advance.length) this.pullWaitOver.clear() // new commits: what was waited for is a different question now
3126
- this.refreshPull()
3127
- // Not awaited: merging (an AI merge can take a while) never holds up the next git work.
3128
- this.mergeSettled(plan, { from, to: head }).catch((err) => this.log(`could not merge: ${err.message}`))
3129
- this.settleBoxesSoon() // what git did may have committed (or put back) boxed work
3130
- }
3131
-
3132
- async mergeSettled (plan, { from = null, to = null } = {}) {
3133
- await this.mergeHeld(plan.edited)
3134
- const { conflicts, changed } = await this.mergeHeld(plan.advance)
3135
- const n = plan.advance.length
3136
- if (n) this.log(`🔀 Merged the commits you pulled into the session's work (${n} file${n === 1 ? '' : 's'}${conflicts ? `, ${conflicts} need${conflicts === 1 ? 's' : ''} merging` : ''})`)
3137
- // Commits that only moved HEAD over work the room already has (your own commit) change nothing: no event.
3138
- if (changed) await this.notePull({ from, to, files: changed })
3139
- }
3140
-
3141
- /**
3142
- * One line for the whole pull in the activity log ("pulled 45 commits from main · 269 files"),
3143
- * in place of an entry per file: the pull brought other people's work, not edits of ours.
3144
- */
3145
- async notePull ({ from, to, files }) {
3146
- let commits = null
3147
- if (from && from.sha && to && to.sha && from.sha !== to.sha) commits = await commitsBetween(this.root, from.sha, to.sha)
3148
- const branch = (to && to.branch) || null
3149
- const what = [commits ? `${commits} commit${commits === 1 ? '' : 's'}` : 'commits', branch ? `from ${branch}` : ''].filter(Boolean).join(' ')
3150
- const detail = `${what} · ${files} file${files === 1 ? '' : 's'}`
3151
- this.log(`⬇️ shared as one pull: ${detail}`)
3152
- // Someone who may not post can't add lines of their own to the log (the relay would undo it).
3153
- if (this.stopped || !this.mayTalk()) return
3154
- this.doc.transact(() => {
3155
- this.activity.push([{ by: this.name, path: '', kind: 'pulled', detail, ts: Date.now() }])
3156
- if (this.activity.length > 300) this.activity.delete(0, this.activity.length - 300)
3157
- }, LOCAL)
3158
- }
3159
-
3160
- /**
3161
- * What ending the hold does, all asked of git before anything changes, in a
3162
- * handful of git calls whatever the number of paths. Null only when git
3163
- * can't be run (the hold stays on); a git call that fails is retried once
3164
- * (one that timed out is not), then the settle goes on with less (never
3165
- * parks the folder for good).
3166
- * - advance: each path the new commits changed, settled into the
3167
- * doc: base = the file at the old commit, ours = the disk, theirs = the room.
3168
- * - discarded: held paths git put back (clean, and tracked or gone from
3169
- * disk: stash, reset, restore). The room's version goes back on disk.
3170
- * - edited: every other held path, changed here or in the room while held.
3171
- * Merged against the version both sides last had, so an edit made during
3172
- * a hold (a commit, an agent's `git status`) is shared, never reverted.
3173
- * - considered: every path the plan looked at. One held while git was being
3174
- * asked that isn't among them means the folder hasn't settled (onSettled).
3175
- */
3176
- async planSettle (head) {
3177
- const prev = this.hold.prevHead
3178
- const paths = new Set(this.heldPaths)
3179
- // Restarted held: changes made while stopped were never seen, so every path is checked.
3180
- if (this.rejoin) for (const rel of [...this.sharedPaths(), ...walk(this.root, this.ig)]) paths.add(rel)
3181
- const free = (rel) => this.syncable(rel) && !this.merging.has(rel)
3182
- const twice = (ask) => askTwice(this.root, ask) // not again after a timeout: that would stall the app as long again
3183
- let changes = new Map()
3184
- // A reset that dropped commits made here for another history (git reset --hard origin/main): only what that
3185
- // history brought since the two met is what came in. What the dropped commits changed was this folder's own work,
3186
- // which the session already has; measured from them, it would be taken away from everyone. (A rebase is measured
3187
- // from them: its new commits carry this folder's work, as the person resolved it.)
3188
- let from = prev && prev.sha
3189
- const last = from && from !== head.sha ? await this.gitOut(['reflog', '-1', '--format=%H%x00%gs', 'HEAD']) : null
3190
- const reset = !!last && last.split('\0')[0] === head.sha && /^reset: /.test(last.split('\0')[1] || '')
3191
- if (reset && !(await isAncestor(this.root, from, head.sha))) {
3192
- const met = await this.gitOut(['merge-base', from, head.sha])
3193
- if (met && /^[0-9a-f]{40}$/.test(met)) from = met
3194
- }
3195
- if (from && from !== head.sha) {
3196
- changes = await twice(() => changesBetween(this.root, from, head.sha))
3197
- if (!changes) {
3198
- if (!(await gitRuns(this.root))) return null
3199
- // Each held path is then checked against HEAD below: the pulled ones look discarded and get the room's version.
3200
- this.log('⚠️ git could not list what the new commits changed; the session\'s version of those files is kept')
3201
- changes = new Map()
3202
- }
3203
- }
3204
- const changed = [...changes.keys()].filter(free)
3205
- let old = await filesAt(this.root, from, changed.filter((rel) => changes.get(rel) !== 'A'))
3206
- if (!old) return null
3207
- // A pull of GitHub's commits on a branch that syncs live: the relay brings the same commits into the session
3208
- // (bringIn). Once it is at the pulled commit or past it, the pulled files are measured from that commit, so the
3209
- // pull's change isn't shared a second time on top of the relay's. Nothing to wait for when the session has the
3210
- // pulled versions already (a commit made here, its edits shared as they were made).
3211
- if (changed.length && this.followsRelay && this.branchLive()) {
3212
- const atHead = await filesAt(this.root, head.sha, changed)
3213
- const has = () => atHead && changed.every((rel) => this.sharedKey(rel) === (atHead.get(rel) ?? undefined))
3214
- if (atHead && !atHead.failed && (has() || await this.relayHasPull(head.sha, has))) old = this.pulledBases(changed, old, atHead)
3215
- }
3216
- if (old.failed) this.log(`⚠️ git could not read ${old.failed} file${old.failed === 1 ? '' : 's'} at the old commit; merged without a base, so where they differ the newer version stays`)
3217
- // A file git made the person resolve (stash pop, rebase) holds their merge of the commits and the
3218
- // session's work: merged against the session's version this disk had, it is taken as resolved.
3219
- const advance = changed.map((rel) => ({ rel, base: this.heldConflicts.has(rel) && this.lastKnown.has(rel) ? this.lastKnown.get(rel) : old.get(rel) ?? undefined, via: 'pull' }))
3220
- const rest = [...paths].filter((rel) => !changes.has(rel) && free(rel))
3221
- const tree = rest.length ? await twice(() => treeState(this.root, rest)) : { dirty: new Set(), tracked: new Set() }
3222
- if (!tree) {
3223
- if (!(await gitRuns(this.root))) return null
3224
- // git can't say which paths it put back. Each is merged against the file at HEAD: a discarded
3225
- // one (the disk is HEAD's) takes the room's version, an edited one merges three-way. Never
3226
- // against lastKnown: after a stash that is the room's version, and the stashed-away disk would win.
3227
- const atHead = await filesAt(this.root, head.sha, rest)
3228
- if (!atHead) return null
3229
- this.log(`⚠️ git could not say what it changed here; ${rest.length} held file${rest.length === 1 ? '' : 's'} merged against your last commit`)
3230
- return { advance, discarded: [], edited: rest.map((rel) => ({ rel, base: atHead.get(rel) ?? undefined, via: this.rejoin ? undefined : 'hold' })), considered: new Set([...paths, ...changes.keys()]) }
3231
- }
3232
- const discarded = []; const edited = []
3233
- for (const rel of rest) {
3234
- // git calls an untracked or ignored file clean too, but nothing put it back to a commit: that is an edit.
3235
- if (!tree.dirty.has(rel) && (tree.tracked.has(rel) || !this.onDisk(rel))) discarded.push(rel)
3236
- else edited.push({ rel, base: this.lastKnown.get(rel), via: this.rejoin ? undefined : 'hold' })
3237
- }
3238
- const gone = [...discarded, ...edited.map((e) => e.rel)].filter((rel) => !changes.has(rel))
3239
- const away = new Set(await this.makingWay(gone))
3240
- return {
3241
- advance,
3242
- discarded: discarded.filter((rel) => !away.has(rel)),
3243
- edited: edited.filter((e) => !away.has(e.rel)),
3244
- awaitPull: [...away].map((rel) => ({ rel, stash: discarded.includes(rel) })),
3245
- considered: new Set([...paths, ...changes.keys()])
3246
- }
3247
- }
3248
-
3249
- /**
3250
- * The base of each pulled path once the relay is at the pulled commit (planSettle): the file at that commit,
3251
- * so the pull's change isn't shared again on top of the relay's. Not for a file the session doesn't have at
3252
- * that commit (the relay holds GitHub's change while someone holds the file) whose disk doesn't have the
3253
- * pull's change either (git moved the branch and left the file, as `git reset` does): measured from the
3254
- * commit, the disk would look like it took the pull's change away. That one is measured from the old commit.
3255
- */
3256
- pulledBases (rels, old, atHead) {
3257
- const out = new Map(atHead)
3258
- out.failed = 0
3259
- const text = (k) => typeof k === 'string' && !k.startsWith('bin:')
3260
- for (const rel of rels) {
3261
- const pulled = atHead.get(rel) ?? undefined
3262
- const was = old.get(rel) ?? undefined
3263
- if (pulled === was || this.sharedKey(rel) === pulled) continue
3264
- const disk = this.readDisk(rel)
3265
- const ours = disk && !disk.skip && !disk.tooLarge ? disk.key : null
3266
- if (ours === pulled) continue
3267
- // The disk has the pull's change when combining the pull into it changes nothing.
3268
- const hasPull = text(ours) && text(was) && text(pulled) && (() => { const m = merge3(was, ours, pulled); return !m.conflicts.length && m.text === ours })()
3269
- if (!hasPull) out.set(rel, old.get(rel))
3270
- }
3271
- return out
3272
- }
3273
-
3274
1642
  onDisk (rel) {
3275
1643
  try { fs.lstatSync(path.join(this.root, ...rel.split('/'))); return true } catch { return false }
3276
1644
  }
3277
1645
 
3278
- /**
3279
- * At start, before captureOffline: the folder is on another branch than the
3280
- * one this session synced, or it stopped mid-hold. Its tree is then not the
3281
- * session's work plus offline edits (it is another branch, or what git left
3282
- * half-done), so nothing is captured from it. On another branch the folder
3283
- * moves there once the relay has synced (start: followHead); otherwise the
3284
- * hold carries on, and when it settles every path is restored or merged
3285
- * against the version the session had when it stopped.
3286
- */
3287
- resumeHold () {
3288
- const saved = this.savedGit
3289
- if (!saved) return false
3290
- const head = this.git // null: git can't say where HEAD is (an unborn branch, or git not running)
3291
- if (!head && !gitDir(this.root)) return false
3292
- const at = head ? head.key : headRef(this.root) // null: unreadable, and not on a named branch
3293
- const away = !!at && at !== saved.key
3294
- if (head && !away && !saved.held) return false
3295
- this.git = { key: saved.key, branch: saved.key.startsWith('@') ? null : saved.key, sha: saved.sha }
3296
- this.gitSeen = head
3297
- // An unfinished offline merge's bases are no bases for this hold; left, the next start would read them.
3298
- fs.rmSync(this.heldBasesFile, { force: true })
3299
- // The base for merging what the folder holds once it's back: the session's version at the last stop.
3300
- for (const rel of this.sharedPaths()) {
3301
- const k = this.sharedKey(rel)
3302
- if (this.syncable(rel) && k !== undefined) this.lastKnown.set(rel, k)
3303
- }
3304
- this.rejoin = true
3305
- // Back on the branch but git can't be read: held as busy, asked again every SETTLE_MS (see gitUnreadable).
3306
- const kind = away ? 'switching' : head ? 'settling' : 'busy'
3307
- this.hold = { kind, since: Date.now(), prevHead: saved.sha ? { ...this.git } : null, ...(away ? { to: at } : {}) }
3308
- if (away) this.resumeAway = at
3309
- return true
3310
- }
3311
-
3312
- /** Said once at start: the folder is a repo, but git can't be read here. */
3313
- saysGitUnreadable (held) {
3314
- if (held) {
3315
- this.gitFailures = GIT_FAILURES_TO_SAY // said here: gitUnreadable doesn't say it again
3316
- this.log('⚠️ Quilt can\'t read git here; this folder stays paused')
3317
- } else {
3318
- this.log('⚠️ Quilt can\'t read git here, so a git command in this folder is shared like any edit')
3319
- }
3320
- }
3321
-
3322
- /**
3323
- * Of these paths, the ones gone from this folder that the fetched upstream
3324
- * adds, and that the session has: they were moved out of the way of a pull.
3325
- */
3326
- async makingWay (paths) {
3327
- const gone = paths.filter((rel) => this.syncable(rel) && !this.onDisk(rel) && this.sharedKey(rel) !== undefined && !this.pullWaitOver.has(rel))
3328
- if (!gone.length) return []
3329
- const up = await upstreamAdds(this.root, gone)
3330
- return up ? [...up.keys()] : []
3331
- }
3332
-
3333
- /** rel is in the way of a fetched pull (as last asked of git), and the session has it. */
3334
- blocksPull (rel) {
3335
- return !!(this.pull && this.pull.adds.some((a) => a.path === rel && !a.waiting) && this.sharedKey(rel) !== undefined && !this.pullWaitOver.has(rel))
3336
- }
3337
-
3338
- /** Keeps files removed (or stashed: `stash`) to make way for a pull for everyone, until it lands or PULL_WAIT_MS passes. */
3339
- waitForPull (rels, stash) {
3340
- const fresh = rels.filter((rel) => !this.pullWait.has(rel))
3341
- if (!fresh.length) return
3342
- for (const rel of fresh) {
3343
- const timer = setTimeout(() => this.pullWaitEnded(rel), this.pullWaitMs)
3344
- timer.unref()
3345
- this.pullWait.set(rel, { since: Date.now(), stash, timer })
3346
- }
3347
- this.log(`⏳ ${fresh.join(', ')} out of the way for a pull: kept for everyone until it lands.`)
3348
- this.refreshPull()
3349
- }
3350
-
3351
- endPullWait (rel) {
3352
- const w = this.pullWait.get(rel)
3353
- if (!w) return
3354
- clearTimeout(w.timer)
3355
- this.pullWait.delete(rel)
3356
- }
3357
-
3358
- /** No pull came in time: a removal was meant (shared as a deletion), a stash puts the session's copy back. */
3359
- pullWaitEnded (rel) {
3360
- const w = this.pullWait.get(rel)
3361
- if (!w || this.stopped) return
3362
- if (this.held()) { w.timer = setTimeout(() => this.pullWaitEnded(rel), SETTLE_MS); w.timer.unref(); return } // after git is done
3363
- this.pullWait.delete(rel)
3364
- if (this.onDisk(rel)) return
3365
- this.pullWaitOver.add(rel)
3366
- if (w.stash) {
3367
- this.log(`↩️ No pull came: ${rel} is back from the session.`)
3368
- this.writeBack([rel])
3369
- } else {
3370
- this.log(`🗑️ No pull came: ${rel} is deleted for everyone.`)
3371
- try { this.ingest(rel) } catch (err) { this.log(`could not sync ${rel}: ${err.message}`) } // as a deletion, not asked of git again
3372
- }
3373
- this.refreshPull()
3374
- }
3375
-
3376
- /** Asks git what a pull would bring over files the session put here (after a fetch, a settle, a start). */
3377
- refreshPull () {
3378
- if (!this.git || this.stopped) return
3379
- this.gitTask(async () => {
3380
- const st = await pullState(this.root)
3381
- if (!this.stopped) this.setPull(st)
3382
- }).catch(() => {})
3383
- }
3384
-
3385
- setPull (st) {
3386
- const adds = []
3387
- for (const [rel, key] of st ? st.adds : []) {
3388
- const waiting = this.pullWait.has(rel)
3389
- if (!waiting && !this.onDisk(rel)) continue // not in the way
3390
- if (this.sharedKey(rel) === undefined) continue // not the session's: git's usual advice applies
3391
- const disk = waiting ? null : this.readDisk(rel)
3392
- const here = disk && disk.key !== undefined ? disk.key : this.sharedKey(rel)
3393
- adds.push({ path: rel, same: here === key, waiting })
3394
- }
3395
- adds.sort((a, b) => a.path < b.path ? -1 : 1)
3396
- this.pull = st ? { upstream: st.upstream, behind: st.behind, adds } : null
3397
- // Told once per set of files in the way (at the top of the AI's next quilt answer, and in the log).
3398
- const said = adds.filter((a) => !a.waiting).map((a) => `${a.path}:${a.same}`).join('|')
3399
- if (said && said !== this.pullSaid) {
3400
- const text = pullAdvice(this.pull)
3401
- this.notice(text)
3402
- this.log(`⬇️ ${text}`)
3403
- }
3404
- this.pullSaid = said
3405
- this.scheduleStatusWrite()
3406
- }
3407
-
3408
- // ------------------------------------------------------------ upstream --
3409
- // Commits made outside the session (pushed from a worktree, merged on GitHub,
3410
- // or another worktree moving this branch) come in without anyone pulling:
3411
- // the folder's branch moves forward to them, with the session's uncommitted
3412
- // work merged into their files (upstream.js). Never a history merge: a branch
3413
- // that has commits of its own the upstream lacks is left to a person or AI.
3414
-
3415
- /** Looks again shortly (a branch moved, a fetch landed); several asks in a row make one look. */
3416
- checkUpstreamSoon ({ fetch = false, delay = SETTLE_MS } = {}) {
3417
- if (!this.git || !this.git.branch || this.stopped || this.inSquare || this.syncStopped) return
3418
- this.upstreamFetch = this.upstreamFetch || fetch
3419
- if (this.upstreamTimer) return // one look is already on its way: it takes this ask too
3420
- this.upstreamTimer = setTimeout(() => {
3421
- this.upstreamTimer = null
3422
- const f = this.upstreamFetch
3423
- this.upstreamFetch = false
3424
- this.gitTask(() => this.checkUpstream({ fetch: f })).catch((err) => this.log(`could not check for new commits: ${err.message}`))
3425
- }, delay)
3426
- this.upstreamTimer.unref()
3427
- }
3428
-
3429
- /** Asks again once it's quiet: when the hold ends if held (a push's look must not wait for the next poll), else shortly. */
3430
- upstreamLater (fetch) {
3431
- if (!this.hold) { this.checkUpstreamSoon({ fetch }); return }
3432
- this.upstreamAfterHold = { fetch: !!(fetch || (this.upstreamAfterHold && this.upstreamAfterHold.fetch)) }
3433
- }
3434
-
3435
- /** Nothing under way here: no hold, no burst, no file changes waiting, no git operation. */
3436
- quietForUpstream () {
3437
- return !this.stopped && !!this.git && !!this.git.branch && !this.held() && !this.pending.size &&
3438
- Date.now() - this.lastFileEventAt >= SETTLE_MS && !this.gitBusy() && this.ready !== false
3439
- }
3440
-
3441
- /**
3442
- * The look itself (in gitTask order). Asks git where the branch and its upstream are,
3443
- * fetching first when asked, and catches up when the branch is behind. Returns the state.
3444
- */
3445
- async checkUpstream ({ fetch = false, now = false } = {}) {
3446
- if (!this.git || !this.git.branch || this.stopped || this.inSquare || this.syncStopped) return this.upstream
3447
- if (!this.quietForUpstream()) {
3448
- this.upstreamLater(fetch) // busy for a moment: asked again once it's quiet
3449
- return this.upstream
3450
- }
3451
- const head = await headKey(this.root)
3452
- if (!head || head.key !== this.git.key) return this.upstream
3453
- await this.fixIndex()
3454
- let up = await upstreamOf(this.root)
3455
- // A diff branch's unchanged files come from git (branch-view.js): a branch made here follows its GitHub branch once there is one.
3456
- if (!up && fetch && this.diffForm) up = await this.trackRemoteBranch()
3457
- if (up && fetch && up.remote && up.remote !== '.') {
3458
- const started = Date.now()
3459
- // The session's GitHub connection reads the repository when the folder can't sign in itself (an
3460
- // agent's folder, or a person's): relay-github.js, read only. A kept token that stopped working is dropped.
3461
- const f = await fetchSignedIn(this.root, up, { kept: this.gitTokenNow(), fresh: () => this.gitAuth(), drop: () => { this.gitToken = null }, retry: remoteHost(up.url) === 'github.com' })
3462
- this.noteFetch(f, up)
3463
- if (f.ok) { up = await upstreamOf(this.root); this.fetchedFrom = started }
3464
- }
3465
- this.refreshRepo()
3466
- // Whether the upstream descends from the commit the relay records for this branch: the relay
3467
- // only moves its record forward on a folder's word (relay-upstream.js noteFolders).
3468
- const rs = this.relaySha()
3469
- const past = rs && up && up.sha ? (rs === up.sha || await isAncestor(this.root, rs, up.sha) ? rs : null) : null
3470
- // Whether this folder's own commit is on the upstream (at or behind its tip), or a local commit not pushed
3471
- // yet: the relay records the former as the branch's commit, and the upstream's tip for the latter.
3472
- const mine = this.reportedHead()
3473
- const headPushed = mine && up && up.sha ? (mine === up.sha || await isAncestor(this.root, mine, up.sha)) : null
3474
- // Whether this folder has the relay's recorded commit at all: a folder without it that fetched after it heard of
3475
- // the record says the record is a commit the upstream never had. One that hasn't fetched since says nothing.
3476
- const relayKnown = rs ? (await hasCommit(this.root, rs) ? true : (this.fetchedFrom || 0) >= (this.relayShaAt || 0) ? false : null) : null
3477
- // Each said only along with what it was found for (gitSummary): the head, the upstream's commit and the relay's record.
3478
- this.upstreamFacts = { head: mine, up: (up && up.sha) || null, rs, past, headPushed, relayKnown }
3479
- // Files changed here during the fetch (the relay syncing, an edit): looked at again once it's quiet.
3480
- if (this.stopped) return this.upstream
3481
- if (!this.quietForUpstream()) { this.upstreamLater(false); return this.upstream }
3482
- // The branch moved without this folder's files moving with it: another worktree of the
3483
- // repository moved it (a commit made here was taken in by noteCommits instead).
3484
- const seen = this.gitSeen
3485
- if (this.bringInUpstream && seen && seen.sha && seen.key === head.key && seen.sha !== head.sha) {
3486
- await this.noteCommits()
3487
- if (this.gitSeen && this.gitSeen.sha !== head.sha && await isAncestor(this.root, seen.sha, head.sha)) {
3488
- await this.bringIn(seen.sha, head.sha, { moveRef: false, up, now: true })
3489
- }
3490
- }
3491
- if (!up || !up.sha) { this.setUpstream(up ? { ...up, behind: 0, ahead: 0 } : null); return this.upstream }
3492
- if (!this.bringInUpstream) return this.upstream
3493
- const at = (this.gitSeen && this.gitSeen.sha) || head.sha
3494
- if (up.sha === at) { this.setUpstream({ ...up, behind: 0, ahead: 0 }); return this.upstream }
3495
- const behind = await commitsBetween(this.root, at, up.sha)
3496
- const ahead = await commitsBetween(this.root, up.sha, at)
3497
- if (behind === null || ahead === null) return this.upstream
3498
- if (!behind) { this.setUpstream({ ...up, behind, ahead }); if (ahead) await this.publishLocal({ up, at, behind, ahead }); return this.upstream }
3499
- if (ahead) {
3500
- if (await this.followReplayed({ up, at })) return this.upstream
3501
- // The relay has these commits on GitHub already: the branch follows once GitHub's files are here (followPublished).
3502
- if (this.adoptLater && this.adoptLater.at === at) {
3503
- if (await this.followPublished(up)) return this.upstream
3504
- if (this.adoptLater) { this.setUpstream({ ...up, behind, ahead, diverged: true }); return this.upstream }
3505
- }
3506
- this.setUpstream({ ...up, behind, ahead, diverged: true }); await this.publishLocal({ up, at, behind, ahead }); return this.upstream
3507
- }
3508
- await this.bringIn(at, up.sha, { moveRef: true, up: { ...up, behind, ahead }, now })
3509
- return this.upstream
3510
- }
3511
-
3512
- /**
3513
- * GitHub as a node (plan §6): this person's folder has local commits GitHub hasn't, on a branch
3514
- * that syncs live. Only when the person opted in (Settings: Push my local commits for me; otherwise
3515
- * they push with git themselves, and are told once per commit) do they go to GitHub: pushed with the person's own git when it can and the branch
3516
- * isn't behind or protected (a); otherwise handed to the relay as a git bundle, which puts them on
3517
- * GitHub's head with their message and author (b), replaying them when GitHub moved meanwhile
3518
- * (a diverged branch). The folder then follows GitHub's head, its own commits kept under
3519
- * refs/quilt/original/<branch>/; only the branch and the index move, never a file. A change that
3520
- * clashes with GitHub's is the session's to merge (relay-clash.js): the folder follows all the same.
3521
- * Agents' folders, and branches that don't sync live, are left as they are.
3522
- */
3523
- async publishLocal ({ up, at, behind, ahead = 1 }) {
3524
- if (this.kind === 'agent' || !this.git || !this.git.branch || !up || !up.remote || up.remote === '.') return
3525
- const entry = (this.branchList || []).find((b) => b.key === this.branch)
3526
- if (!entry || !entry.relay || !entry.relay.live) return
3527
- if (this.published === at) return
3528
- const branch = this.git.branch
3529
- if (!this.pushesLocalCommits()) {
3530
- if (this.toldLocal !== at) { this.toldLocal = at; this.log(`your ${ahead === 1 ? 'local commit' : `${ahead} local commits`} on ${branch} ${ahead === 1 ? 'isn\'t' : 'aren\'t'} on GitHub yet: push with git (or let Quilt push them for you: Settings, Push my local commits for me)`) }
3531
- return
3532
- }
3533
- const ref = upstreamRef(up.name, up.remote)
3534
- if (!ref || gitBusy(this.root) || (await stagedAgainst(this.root, at)) !== false) return
3535
- this.published = at
3536
- if (!behind && !entry.relay.protected) {
3537
- const r = await pushLocal(this.root, up.remote, at, ref)
3538
- if (r.ok) {
3539
- this.log(`⬆️ pushed your ${ahead === 1 ? 'local commit' : `${ahead} local commits`} on ${branch} to ${up.name}`)
3540
- this.checkUpstreamSoon({ fetch: true, delay: 200 })
3541
- return
3542
- }
3543
- this.emit('debug', `push from the folder: ${r.error}; handing the commits to the relay`)
3544
- }
3545
- const bundle = await bundleOf(this.root, branch, up.sha)
3546
- if (!bundle) { this.published = null; return }
3547
- let out
3548
- try { out = (await this.conn.adminRequest({ op: 'bundle', bundle: bundle.toString('base64'), head: at, ref: `refs/heads/${branch}` }, { timeoutMs: PUSH_REQUEST_TIMEOUT_MS })).bundle } catch (err) { this.published = null; this.log(`could not hand your local commits to the relay: ${err.message}`); return }
3549
- if (!out || !['pushed', 'nothing', 'clash'].includes(out.state)) { this.log(`your local commits on ${branch} stay here for now: ${(out && out.why) || 'the relay could not take them'}`); return }
3550
- const f = await fetchSignedIn(this.root, up, { kept: this.gitTokenNow(), fresh: () => this.gitAuth(), drop: () => { this.gitToken = null }, retry: remoteHost(up.url) === 'github.com' })
3551
- if (!f.ok) return
3552
- const now = await upstreamOf(this.root)
3553
- if (!now || !now.sha) return
3554
- // The folder follows the commit the relay put the session at (its push, or GitHub's head it brought in), never a
3555
- // newer one of GitHub's the fetch just found: the session has yet to bring that in, and a branch moved there with
3556
- // git alone would lack its files, so a commit made then would delete them. GitHub's newer commits come in as any do.
3557
- // (A relay before 0.3.96 says no `head` for 'nothing' and 'clash': GitHub's head then.)
3558
- const to = out.sha || out.head || now.sha
3559
- if (to === at) return
3560
- this.adoptLater = { at, to, count: out.published ? out.published.length : 0 }
3561
- await this.followPublished(now)
3562
- }
3563
-
3564
- /**
3565
- * The relay put this folder's local commits on GitHub (publishLocal): the branch follows the commit the relay
3566
- * put the session at (adoptLater.to) once the session's files are on disk (sessionOnDisk), else at a later look.
3567
- * `now` is the upstream as the folder last fetched it. Resolves whether it moved.
3568
- */
3569
- async followPublished (now) {
3570
- const later = this.adoptLater
3571
- const branch = this.git && this.git.branch
3572
- // Not on GitHub's branch any more (rewritten meanwhile): the commits are handed over again (publishLocal).
3573
- if (!later || !branch || !now || !now.sha || (now.sha !== later.to && !(await isAncestor(this.root, later.to, now.sha)))) { this.adoptLater = null; this.published = null; return false }
3574
- if (!(await this.sessionOnDisk(later.at, later.to))) {
3575
- this.askRelayFor(later.to)
3576
- this.checkUpstreamSoon({ delay: 500 })
3577
- return false
3578
- }
3579
- this.adoptLater = null
3580
- const r = await adoptPublished(this.root, branch, later.at, later.to)
3581
- if (!r.moved) { this.log(`your local commits went to GitHub; ${branch} follows it once ${r.why}`); this.published = null; return false }
3582
- this.gitSeen = { key: branch, branch, sha: later.to }
3583
- this.gitIndex = indexStamp(this.root)
3584
- this.log(`⬆️ the relay put your local commit${later.count === 1 ? '' : 's'} on ${now.name} (${later.to.slice(0, 7)}); ${branch} follows it (yours are kept under refs/quilt/original/${branch}/)`)
3585
- const behind = now.sha === later.to ? 0 : ((await commitsBetween(this.root, later.to, now.sha)) ?? 1)
3586
- this.setUpstream({ ...now, behind, ahead: 0 })
3587
- if (behind) this.checkUpstreamSoon()
3588
- return true
3589
- }
3590
-
3591
1646
  /** What the folder holds at rel, as lastKnown keys it: null when nothing is there, undefined for a file never synced (a link, too large). */
3592
1647
  diskKey (rel) {
3593
1648
  const d = this.readDisk(rel)
@@ -3595,218 +1650,6 @@ export class Session extends EventEmitter {
3595
1650
  return d.skip || d.tooLarge ? undefined : d.key
3596
1651
  }
3597
1652
 
3598
- /**
3599
- * Whether this folder's branch may move from `from` to `to` with git alone, every file left as it is: the
3600
- * session is at `to` (the relay brought it in, or a member's folder is there), and each file the commits
3601
- * between them change is on disk as the session has it. A file of GitHub's still on its way here (one
3602
- * written only once the folder's hold ends) would be missing under an index that has it, and a
3603
- * `git commit -a` just after the move would take it for a deletion and undo it on GitHub.
3604
- */
3605
- async sessionOnDisk (from, to) {
3606
- if (!this.memberAt(to) && !(await this.relayPast(to))) return false
3607
- const changes = await changesBetween(this.root, from, to)
3608
- if (!changes) return false
3609
- for (const rel of changes.keys()) if (this.syncable(rel) && (this.diskKey(rel) ?? undefined) !== this.sharedKey(rel)) return false
3610
- return true
3611
- }
3612
-
3613
- /**
3614
- * Brings commits `from`..`to` into this folder and the session: every file they changed is
3615
- * merged with the session's uncommitted work, then the branch moves to `to` (moveRef) or,
3616
- * when it already has, the index follows it. All or nothing: one file that can't be merged
3617
- * cleanly and nothing is written; the AIs are told which, and why.
3618
- */
3619
- async bringIn (from, to, { moveRef, up = null, now = false }) {
3620
- const branch = this.git.branch
3621
- const upName = up ? up.name : branch
3622
- const state = (extra) => this.setUpstream({ ...(up || { name: upName }), behind: extra.behind ?? (up ? up.behind : 0), ahead: 0, ...extra })
3623
- // A branch that syncs live with GitHub (plan §6): the relay brings GitHub's commits into the session, and this
3624
- // folder follows them with git alone. Sharing them from here as well would share them twice: two writers of one
3625
- // change, and the session's document keeps both copies. So it asks the relay to look, and waits until it is at `to`.
3626
- if (await this.relayBrings(to, up, { moveRef })) {
3627
- state({ waiting: RELAY_WAITING })
3628
- this.askRelayFor(to)
3629
- this.checkUpstreamSoon({ fetch: false, delay: 2 * 1000 })
3630
- return
3631
- }
3632
- // Another member's folder on this branch, with its own upstream, takes it first: two folders
3633
- // sharing the same commits at once would share them twice. This one waits, looking every few
3634
- // seconds, until that folder is at `to` (then it follows), leaves, or takes longer than catcherWaitMs.
3635
- // A first in line that hasn't said it is bringing `to` in gets one look, as it may never (no upstream of its own).
3636
- const ahead = !now && !this.memberAt(to) ? this.otherCatcherFirst(to) : false
3637
- if (ahead) {
3638
- if (this.upstreamDeferred !== to) { this.upstreamDeferred = to; this.upstreamDeferredAt = Date.now() }
3639
- if (Date.now() - this.upstreamDeferredAt < (ahead === 'busy' ? this.catcherWaitMs : 3 * 1000)) {
3640
- state({ waiting: CATCHER_WAITING })
3641
- this.checkUpstreamSoon({ fetch: false, delay: 3 * 1000 })
3642
- return
3643
- }
3644
- this.log(`⏱️ the member first in line hasn't brought in ${to.slice(0, 7)} after ${Math.round(this.catcherWaitMs / 1000)}s; bringing it in here`)
3645
- }
3646
- const staged = await stagedAgainst(this.root, from)
3647
- if (staged !== false) { state({ waiting: staged ? 'changes are staged for a commit here' : 'git could not say what is staged' }); return }
3648
- // Said in presence: another folder on this branch waits for this one instead of bringing `to` in too.
3649
- this.bringing = to
3650
- this.shareGit()
3651
- try { await this.bringInNow(from, to, { moveRef, up, state }) } finally { this.bringing = null; this.shareGit() }
3652
- }
3653
-
3654
- async bringInNow (from, to, { moveRef, up, state }) {
3655
- const branch = this.git.branch
3656
- const upName = up ? up.name : branch
3657
- // The relay brought the session to a commit between here and `to` while this folder was away:
3658
- // follow it first (git only, the session has those files), then bring in only what came after.
3659
- const mid = this.relaySha()
3660
- if (moveRef && mid && mid !== from && mid !== to && !this.memberAt(to) && await isAncestor(this.root, from, mid) && await isAncestor(this.root, mid, to)) {
3661
- await this.bringInNow(from, mid, { moveRef, up, state })
3662
- if (!this.gitSeen || this.gitSeen.sha !== mid || this.stopped) return
3663
- from = mid
3664
- }
3665
- const changes = await changesBetween(this.root, from, to)
3666
- if (!changes) return
3667
- // A member's folder on this branch is already at `to`, in step with the session: the session's
3668
- // files are that commit plus the session's work already (that folder brought it in, or someone
3669
- // there resolved it by hand). This folder follows: only files the session doesn't share are merged.
3670
- const following = this.memberAt(to)
3671
- // A diff branch's files the session never changed follow git (branch-view.js): no folder shares them, so
3672
- // a folder following another still writes them itself.
3673
- const own = (rel) => !following || !this.syncable(rel) || this.followsGit(rel)
3674
- const paths = [...changes.keys()].filter(own)
3675
- const scope = new Map([...changes].filter(([rel]) => own(rel)))
3676
- const [base, theirs] = [await filesAt(this.root, from, paths), await filesAt(this.root, to, paths)]
3677
- if (!base || !theirs || this.stopped || !this.quietForUpstream()) return
3678
- // Claims are checked on what would actually be written (below, after the plan): a file someone
3679
- // holds whose version the session already has (it came from the session) holds nothing up.
3680
- for (const rel of paths) {
3681
- if (this.syncable(rel) && this.writeRefusal(rel)) { state({ waiting: `you may not change ${rel} in this session` }); return }
3682
- }
3683
- // Links are git's (the session never syncs them): one the folder has as git has it at either end is that version, and one the commits bring is written as a link.
3684
- const [linksFrom, linksTo] = [await linksAt(this.root, from, paths), await linksAt(this.root, to, paths)]
3685
- if (!linksFrom || !linksTo) return
3686
- const linkKey = (rel) => {
3687
- let target
3688
- try { target = fs.readlinkSync(resolveInside(this.root, rel)) } catch { return undefined }
3689
- if (linksTo.has(rel) && theirs.get(rel) === target) return target
3690
- if (linksFrom.has(rel) && base.get(rel) === target) return target
3691
- return undefined
3692
- }
3693
- const diskKey = (rel) => { const k = this.diskKey(rel); return k === undefined ? linkKey(rel) : k }
3694
- // The session and this folder must agree first: a change still on its way waits for the next look.
3695
- // Following, that is every file the commits changed: the branch moves only once the session's versions are
3696
- // on disk, or a commit made meanwhile (git commit -a) would take a file still on its way for a deletion.
3697
- for (const rel of following ? changes.keys() : paths) {
3698
- if (!this.syncable(rel)) continue
3699
- // A file git has that the session leaves to it: in step when the folder still has git's version (an edit not shared yet waits).
3700
- const want = this.followsGit(rel) ? (this.gitOnly.has(rel) ? this.gitOnly.get(rel) : this.lastKnown.has(rel) ? this.lastKnown.get(rel) : (diskKey(rel) ?? undefined)) : this.sharedKey(rel)
3701
- if ((diskKey(rel) ?? undefined) !== want) { this.checkUpstreamSoon(); return }
3702
- }
3703
- // Files the session moved: same name, somewhere the old commit didn't have them.
3704
- const goneHere = new Set(paths.filter((rel) => base.get(rel) && diskKey(rel) === null).map((rel) => rel.slice(rel.lastIndexOf('/') + 1)))
3705
- const moved = new Map()
3706
- if (goneHere.size) {
3707
- const cands = [...this.sharedPaths()].filter((rel) => goneHere.has(rel.slice(rel.lastIndexOf('/') + 1)) && !changes.has(rel))
3708
- const atFrom = cands.length ? await filesAt(this.root, from, cands) : new Map()
3709
- if (!atFrom) return
3710
- for (const rel of cands) if (atFrom.get(rel) === null) { const k = diskKey(rel); if (k) moved.set(rel, k) }
3711
- }
3712
- // Folders the session emptied, that the new commits add files to (a reorganisation they predate).
3713
- const emptied = new Set()
3714
- for (const rel of paths) {
3715
- if (base.get(rel) !== null || theirs.get(rel) === null) continue
3716
- const dir = rel.includes('/') ? rel.slice(0, rel.lastIndexOf('/')) : ''
3717
- if (emptied.has(dir) || [...this.sharedPaths()].some((p) => (dir ? p.startsWith(dir + '/') : !p.includes('/')))) continue
3718
- if (await hasFilesUnder(this.root, from, dir)) emptied.add(dir)
3719
- }
3720
- // Where the session and the commits changed the same lines, the newer edit stays (upstream.js combine).
3721
- const times = await commitTimes(this.root, from, to)
3722
- const plan = planCatchUp({ changes: scope, base, theirs, disk: diskKey, moved, emptied, published: publishedOf(this.doc), oursNewer: (rel) => this.sessionEditedAt(rel) > ((times && times.get(rel)) || 0) + 999 }) // a commit's whole second is its own
3723
- if (this.stopped || !this.quietForUpstream()) return
3724
- if (plan.conflicts.length) {
3725
- const behind = up ? up.behind : await commitsBetween(this.root, from, to)
3726
- state({ behind, conflicts: plan.conflicts })
3727
- return
3728
- }
3729
- // Clean, but a file it would write is held for a clash's merge by someone else: it waits for them.
3730
- for (const rel of [...plan.writes.keys(), ...plan.moves.flatMap((m) => [m.from, m.to])]) {
3731
- const claim = this.syncable(rel) ? this.claimFor(rel) : null
3732
- if (claim && !this.ownClaim(claim)) { state({ waiting: `${claim.by} holds ${rel}` }); return }
3733
- }
3734
- // Under git's index lock: if the branch moved meanwhile, nothing is written. Then the files, the
3735
- // index, and last the branch (gitstate.js fastForward): a commit made meanwhile can't undo them.
3736
- let written = 0
3737
- const writeFiles = async () => {
3738
- this.gitSeen = { key: branch, branch, sha: to }
3739
- for (const [rel, key] of plan.writes) {
3740
- try {
3741
- const abs = resolveInside(this.root, rel)
3742
- if (key === null) {
3743
- fs.rmSync(abs, { force: true })
3744
- removeEmptyParents(this.root, path.dirname(abs))
3745
- } else {
3746
- const data = key.startsWith('bin:') ? await blobAt(this.root, to, rel) : key
3747
- if (data === null) throw new Error('git could not read it')
3748
- fs.mkdirSync(path.dirname(abs), { recursive: true })
3749
- if (linksTo.has(rel)) { fs.rmSync(abs, { force: true }); fs.symlinkSync(String(data), abs) } else this.writeFile(rel, abs, data)
3750
- }
3751
- written++
3752
- if (this.syncable(rel)) this.ingest(rel, { pulled: true })
3753
- } catch (err) { this.log(`could not bring in ${rel}: ${err.message}`) }
3754
- }
3755
- }
3756
- if (!(await fastForward(this.root, moveRef ? branch : null, from, to, writeFiles))) {
3757
- this.checkUpstreamSoon()
3758
- return
3759
- }
3760
- // The index follows at the next look if git couldn't set it, or if another git that read it
3761
- // just before (an AI's `git status`) writes its old copy back over it: checked for a few seconds.
3762
- this.indexLate = to
3763
- this.indexWatchUntil = Date.now() + INDEX_WATCH_MS
3764
- for (const ms of [500, 2000, INDEX_WATCH_MS]) setTimeout(() => this.gitTask(() => this.fixIndex()).catch(() => {}), ms).unref()
3765
- // What git and Quilt just wrote is accounted for: the next flush is no burst of git's.
3766
- this.gitIndex = indexStamp(this.root)
3767
- this.headChangedAt = 0
3768
- this.upstreamDeferred = null
3769
- const count = await commitsBetween(this.root, from, to)
3770
- if (following) this.log(`⬇️ ${branch} follows ${isRelay(following) ? 'the relay' : `${following}'s folder`} to ${to.slice(0, 7)} (${count ?? 'new'} commit${count === 1 ? '' : 's'} from ${upName}); the session already has ${isRelay(following) ? 'the files it brought in' : 'their files'}`)
3771
- const moves = plan.moves.length ? `; followed ${plan.moves.map((m) => `${m.from} → ${m.to}`).join(', ')}` : ''
3772
- if (!following) this.log(`⬇️ brought in ${count ?? 'new'} commit${count === 1 ? '' : 's'} from ${upName} (${written} file${written === 1 ? '' : 's'})${moves}`)
3773
- // Whoever's lines the commits' newer edit replaced is told once (a notice, nothing to do).
3774
- for (const m of following ? [] : plan.merged || []) {
3775
- const lost = (m.lost || []).filter((l) => l.side === 'ours').flatMap((l) => l.lines)
3776
- if (!lost.length) continue
3777
- const text = `Quilt brought ${upName}'s commits into ${m.path}: where the session and the commits changed the same lines, the commits' newer edit stays. The replaced lines are in the file's history (Changes):\n${lost.slice(0, 12).map((l) => `- ${l}`).join('\n')}`
3778
- const who = this.lastEditorOf(m.path)
3779
- if (who && who !== this.name) { try { this.say(text, { to: who }) } catch {} } else this.log(`🧵 ${text}`)
3780
- }
3781
- if (written && !following) await this.notePull({ from: { sha: from }, to: { sha: to, branch }, files: written })
3782
- if (plan.strays.length) {
3783
- const list = plan.strays.slice(0, 5).join(', ') + (plan.strays.length > 5 ? ', …' : '')
3784
- this.notice(`The commits Quilt just brought in from ${upName} add ${list} to a folder the session had emptied (moved elsewhere?). Check whether they belong where the session moved the rest.`)
3785
- }
3786
- // On the way to the upstream's tip (a commit the relay brought the session to first): still behind it.
3787
- const left = up && up.sha && up.sha !== to ? ((await commitsBetween(this.root, to, up.sha)) ?? 1) : 0
3788
- this.setUpstream({ ...(up || { name: upName }), sha: up ? up.sha : to, behind: left, ahead: 0, brought: { count, files: written, at: Date.now() } })
3789
- }
3790
-
3791
- /**
3792
- * Just after a bring-in, the index must be the new commit's. A git that read the index before
3793
- * and wrote it back after (status refreshing it) leaves the old commit's there, which reads as
3794
- * "the session staged a revert": set again. Only while the bring-in is recent, and only when
3795
- * HEAD is still where it put it: later staging is the person's.
3796
- */
3797
- async fixIndex () {
3798
- const sha = this.indexLate
3799
- if (!sha || this.stopped || !this.git) return
3800
- if (Date.now() > this.indexWatchUntil) { this.indexLate = null; return }
3801
- const head = await headKey(this.root)
3802
- if (!head || head.sha !== sha || this.gitBusy()) return
3803
- if (await stagedAgainst(this.root, sha) && await resetIndex(this.root, sha)) this.gitIndex = indexStamp(this.root)
3804
- }
3805
-
3806
- /**
3807
- * Asked for by an AI (quilt_sync_branch): fetch and bring commits in now, without waiting for
3808
- * the next look or another member's folder. What happened: { branch, upstream, moved, busy }.
3809
- */
3810
1653
  /**
3811
1654
  * What making this session a quilt would do (Seam's plans/quilt-source-control/16), read only: the
3812
1655
  * relay's dry run (behind QUILT_SOURCE_URL there, the owner's), as { plan, text }.
@@ -3964,9 +1807,6 @@ export class Session extends EventEmitter {
3964
1807
  try { back = putGitBack(this.root) } catch (err) { this.log(`⚠️ couldn't put git back in this folder (${err.message}); it is in .quilt/git-before-seam`); return false }
3965
1808
  if (!back) { this.log('⚠️ this folder has a new .git, so the one set aside stays in .quilt/git-before-seam'); return false }
3966
1809
  this.gitAside = null
3967
- this.git = await headKey(this.root)
3968
- this.gitSeen = this.git
3969
- if (this.git && !this.stopped) this.startGitWatch()
3970
1810
  this.log('git is back in this folder.')
3971
1811
  this.scheduleStatusWrite()
3972
1812
  return true
@@ -3998,8 +1838,6 @@ export class Session extends EventEmitter {
3998
1838
  }
3999
1839
  }
4000
1840
  this.scanDisk({ baseline: true })
4001
- this.checkUpstreamSoon({ fetch: true })
4002
- this.settleSoon()
4003
1841
  this.scheduleStatusWrite()
4004
1842
  }
4005
1843
 
@@ -4041,11 +1879,6 @@ export class Session extends EventEmitter {
4041
1879
  return !(b && b.stored)
4042
1880
  }
4043
1881
 
4044
- /** The paths this folder's branch changed since it left the default branch, { committed, uncommitted } (the square's first look keeps those as the folder's), or null when git can't say. */
4045
- branchOwnPaths () {
4046
- return ownSinceFork(this.root, gitAsideOf(this.root) ? { gitDir: path.join(this.root, GIT_ASIDE) } : {}).catch(() => null)
4047
- }
4048
-
4049
1882
  /**
4050
1883
  * The paths whose edits this folder made into its branch document but the relay does not hold
4051
1884
  * (session-follow.js unsentPaths): at layout 3 the read-only document refused them, so the
@@ -4112,9 +1945,7 @@ export class Session extends EventEmitter {
4112
1945
  if (this.boxBase.has(rel)) { const b = this.boxBase.get(rel); return b && typeof b.text === 'string' ? b.text : null }
4113
1946
  const k = this.lastKnown.get(rel)
4114
1947
  if (k !== undefined) return typeof k === 'string' && !/^bin:[0-9a-f]{40}$/.test(k) ? k : null
4115
- if (!this.followsGit(rel) || !gitAsideOf(this.root)) return null
4116
- const buf = await blobAt(this.root, 'HEAD', rel, { gitDir: path.join(this.root, GIT_ASIDE) })
4117
- return buf && !looksBinary(buf) ? buf.toString('utf8') : null
1948
+ return null
4118
1949
  }
4119
1950
 
4120
1951
  /** Whether the session never syncs anything in folder `rel` (.git, .quilt, node_modules, ignored ones): the block's walk skips it. */
@@ -4123,20 +1954,6 @@ export class Session extends EventEmitter {
4123
1954
  /** The branch the folder is on for its square: git's, once git moved it while following (squareHeadMoved). */
4124
1955
  squareBranchKey () { return this.squareBranch || this.branchKey() }
4125
1956
 
4126
- /** git moved HEAD while the folder follows a square: on another branch, the folder follows that branch's square. */
4127
- squareHeadMoved () {
4128
- clearTimeout(this.squareHeadTimer)
4129
- this.squareHeadTimer = setTimeout(async () => {
4130
- const head = await headKey(this.root).catch(() => null)
4131
- if (!head || !this.inSquare || this.stopped) return
4132
- const key = head.key === DEFAULT_KEY ? this.branchKey() : head.key
4133
- if (key === this.squareBranchKey()) return
4134
- this.squareBranch = key
4135
- if (this.squareFollower) await this.squareFollower.branchMoved(this.quilt).catch((err) => this.log(`🧵 ${err.message}`))
4136
- }, SETTLE_MS)
4137
- this.squareHeadTimer.unref?.()
4138
- }
4139
-
4140
1957
  /** Reads of the quilt for the folder's block: through the relay, with the room's read token (session-follow.js relayReader). */
4141
1958
  squareReader () { return relayReader(this.conn) }
4142
1959
 
@@ -4315,188 +2132,6 @@ export class Session extends EventEmitter {
4315
2132
  return { ...r, text: lines.join('\n') }
4316
2133
  }
4317
2134
 
4318
- async syncBranchNow () {
4319
- // A folder that can't bring commits in itself (no git here, or its fetch fails) asks the
4320
- // relay to look at GitHub for its branch now (relay-upstream.js), as a hosted agent can.
4321
- const relay = () => this.askRelaySync()
4322
- if (!this.git) return { git: false, relay: await relay() }
4323
- if (!this.git.branch) return { git: true, branch: null }
4324
- // A save or a git command a moment ago: give it a few seconds to settle first.
4325
- for (let i = 0; i < 30 && !this.quietForUpstream() && !this.hold; i++) await new Promise((r) => setTimeout(r, 200))
4326
- const before = this.gitSeen && this.gitSeen.sha
4327
- const busy = this.hold ? this.hold.kind : this.quietForUpstream() ? null : 'files are still changing here'
4328
- if (!busy) await this.gitTask(() => this.checkUpstream({ fetch: true, now: true }))
4329
- const failed = this.upstream && this.upstream.fetchOk === false
4330
- return { git: true, branch: this.git.branch, busy, upstream: this.upstream, moved: !!(this.gitSeen && before && this.gitSeen.sha !== before), ...(failed ? { relay: await relay() } : {}) }
4331
- }
4332
-
4333
- /** Asks the relay to look at GitHub for this branch now; what it said ({ state, ... }), or { error }. Never throws. */
4334
- async askRelaySync () {
4335
- try {
4336
- if (!this.conn || !this.conn.adminRequest) return { error: 'not connected to the relay' }
4337
- const r = await this.conn.adminRequest({ op: 'syncUpstream' })
4338
- return r && r.sync ? r.sync : { error: 'the relay did not say' }
4339
- } catch (err) { return { error: /not connected/.test(err.message) ? 'not connected to the relay' : err.message } }
4340
- }
4341
-
4342
- /**
4343
- * What the last git fetch of the upstream said: recorded, shared in presence (upstream.fetchOk
4344
- * false: the relay then brings commits in for this branch itself) and said once per reason.
4345
- */
4346
- noteFetch (f, up) {
4347
- if (!f || f.skipped) return
4348
- const prev = this.fetchState
4349
- this.fetchState = { ok: !!f.ok, error: f.ok ? null : (f.error || 'git fetch failed'), at: Date.now() }
4350
- // In place: a clash review under way holds this object, and a new one would make it give up.
4351
- if (this.upstream) Object.assign(this.upstream, { fetchOk: this.fetchState.ok, fetchError: this.fetchState.error, fetchAt: this.fetchState.at })
4352
- if (prev && prev.ok === this.fetchState.ok && prev.error === this.fetchState.error) return
4353
- const name = up && up.name ? up.name : 'the upstream'
4354
- if (!this.fetchState.ok) {
4355
- const said = `Couldn't fetch ${name}: ${this.fetchState.error}. Commits pushed elsewhere can't come in through this folder until it can (git needs credentials that work without a prompt); meanwhile the relay brings them in from GitHub for \`${this.git ? this.git.branch : this.branch}\` when it knows the repository.`
4356
- this.log(`⚠️ ${said}`)
4357
- this.notice(said)
4358
- } else if (prev && !prev.ok) this.log(`✅ fetching ${name} works again`)
4359
- this.shareGit()
4360
- this.scheduleStatusWrite()
4361
- }
4362
-
4363
- /**
4364
- * A member whose folder is on this branch at `sha`, in step with the session (not held), or null.
4365
- * The relay counts as one at the commit it records for the branch (relay-upstream.js): the last
4366
- * commit a folder there was at in step with the session, or the one the relay brought the
4367
- * session's files to itself while no folder was online. Either way the session's files are
4368
- * that commit plus its work, so this folder follows without merging them again.
4369
- */
4370
- memberAt (sha) {
4371
- if (!this.conn || !this.conn.awareness) return null
4372
- const me = this.conn.awareness.clientID
4373
- for (const [id, st] of this.conn.awareness.getStates()) {
4374
- if (id === me || !st || !st.git || !st.name) continue
4375
- if (st.git.branch === this.git.branch && st.git.sha === sha && !st.git.held) return st.name
4376
- }
4377
- const mine = this.branchList.find((b) => b.key === this.branch)
4378
- // Live sync says the commit the session's content is acknowledged at too (relay-sync.js syncStatus revision).
4379
- if (sha && mine && mine.relay && (mine.relay.sha === sha || (mine.relay.sync && mine.relay.sync.revision === sha))) return RELAY_BY
4380
- return null
4381
- }
4382
-
4383
- /**
4384
- * Whether commit `to` is the relay's to bring into the session, and it hasn't yet: the branch syncs live with
4385
- * GitHub, `to` is GitHub's (the upstream's tip or behind it; a commit only made here is this folder's to
4386
- * share), and neither the relay nor a member's folder is at it.
4387
- */
4388
- async relayBrings (to, up, { moveRef = true } = {}) {
4389
- if (!this.followsRelay || !this.branchLive() || this.memberAt(to)) return false
4390
- if (moveRef) return true
4391
- return !!(up && up.sha && (up.sha === to || await isAncestor(this.root, to, up.sha)))
4392
- }
4393
-
4394
- /** Whether the relay has brought the session to commit `sha` or past it (relaySha), or a member's folder is at it. */
4395
- async relayPast (sha) {
4396
- if (this.memberAt(sha)) return true
4397
- const rs = this.relaySha()
4398
- return !!(rs && rs !== sha && await hasCommit(this.root, rs) && await isAncestor(this.root, sha, rs))
4399
- }
4400
-
4401
- /**
4402
- * A pull here brought commit `sha` on a branch that syncs live, and `sha` is GitHub's: waits up to
4403
- * RELAY_PULL_WAIT_MS for the relay to be at it or past it (asking it to look), or for `has()` (the session has
4404
- * the pulled versions), and says whether either came.
4405
- */
4406
- async relayHasPull (sha, has = () => false) {
4407
- if (!this.followsRelay || !sha || !this.branchLive()) return false
4408
- // Waited for this commit once already: a settle started again (an edit came in meanwhile) doesn't wait again,
4409
- // or a busy session would keep the folder held, its edits unshared, for as long as edits keep coming.
4410
- if (this.relayPullWaited === sha) return has() || this.relayPast(sha)
4411
- // A commit made here (git commit, not a merge): its edits are this folder's to share; the relay never brings them in.
4412
- const last = await this.gitOut(['reflog', '-1', '--format=%H%x00%gs', 'HEAD'])
4413
- if (last) { const [at, subject] = last.split('\0'); if (at === sha && /^commit( \((amend|initial)\))?:/.test(subject || '')) return false }
4414
- const up = await upstreamOf(this.root)
4415
- if (!up || !up.sha || !(up.sha === sha || await isAncestor(this.root, sha, up.sha))) return false
4416
- for (const until = Date.now() + RELAY_PULL_WAIT_MS; !this.stopped;) {
4417
- if (has() || await this.relayPast(sha)) return true
4418
- if (Date.now() > until) { this.relayPullWaited = sha; this.log(`⏱️ the relay hasn't brought in ${sha.slice(0, 7)} after ${RELAY_PULL_WAIT_MS / 1000}s; sharing what the pull brought from here`); return false }
4419
- this.askRelayFor(sha)
4420
- await new Promise((resolve) => setTimeout(resolve, 300))
4421
- }
4422
- return false
4423
- }
4424
-
4425
- /** Asks the relay to look at GitHub for this branch now, for commit `to`: again every few seconds while it is waited for (the relay takes one ask per branch that often). */
4426
- askRelayFor (to) {
4427
- const now = Date.now()
4428
- if (this.relayAsked && this.relayAsked.to === to && now - this.relayAsked.at < RELAY_ASK_EVERY_MS) return
4429
- this.relayAsked = { to, at: now }
4430
- this.askRelaySync().then((r) => { if (r && r.error) this.emit('debug', `asking the relay to bring ${to.slice(0, 7)} in: ${r.error}`) })
4431
- }
4432
-
4433
- /** Whether this folder's branch syncs live with GitHub through the relay (GitHub as a node). */
4434
- branchLive () {
4435
- const mine = (this.branchList || []).find((b) => b.key === this.branch)
4436
- return !!(mine && mine.relay && mine.relay.live)
4437
- }
4438
-
4439
- /** The commit the relay records for this folder's branch (the session's files are it plus the session's work), or null. */
4440
- relaySha () {
4441
- const mine = (this.branchList || []).find((b) => b.key === this.branch)
4442
- return mine && mine.relay && typeof mine.relay.sha === 'string' ? mine.relay.sha : null
4443
- }
4444
-
4445
- /**
4446
- * Whether another member's folder on this branch is first in line to bring commits in (the
4447
- * lowest client id): 'busy' when it says it is bringing in `to` itself, true when it is only
4448
- * first in line, false when none is.
4449
- */
4450
- otherCatcherFirst (to = null) {
4451
- if (!this.conn || !this.conn.awareness) return false
4452
- const me = this.conn.awareness.clientID
4453
- let first = false
4454
- for (const [id, st] of this.conn.awareness.getStates()) {
4455
- if (id === me || !st || !st.git) continue
4456
- // A folder whose fetch fails (no credentials, no network) never brings commits in, nor does one
4457
- // stopped by a clash or waiting on something of its own (staged changes, a claim): not waited for.
4458
- const u = st.git.upstream
4459
- const stuck = u && (u.fetchOk === false || u.conflicts || (u.waiting && u.waiting !== CATCHER_WAITING))
4460
- if (st.git.branch === this.git.branch && !st.git.held && !stuck && id < me) {
4461
- if (to && st.git.bringing === to) return 'busy'
4462
- first = true
4463
- }
4464
- }
4465
- return first
4466
- }
4467
-
4468
- setUpstream (up) {
4469
- const prev = this.upstream
4470
- // Through the relay's git (plan §9), the repository is the session's, not the relay's address.
4471
- const viaRelay = up && up.url && up.url === this.relayGitUrl()
4472
- const entry = viaRelay ? this.relayEntryFor(this.branch) : null
4473
- const next = up ? {
4474
- name: up.name, url: entry && entry.repo ? `https://${entry.repo}.git` : (up.url || null), remote: up.remote || null, sha: up.sha || null, behind: up.behind || 0, ahead: up.ahead || 0,
4475
- diverged: !!up.diverged, conflicts: up.conflicts || [], waiting: up.waiting || null, brought: up.brought || (prev && prev.brought) || null, checkedAt: Date.now(),
4476
- // The last fetch: false with a reason when it failed, so nobody reads "up to date" from stale refs.
4477
- fetchOk: this.fetchState ? this.fetchState.ok : null, fetchError: this.fetchState ? this.fetchState.error : null, fetchAt: this.fetchState ? this.fetchState.at : null,
4478
- // Who is merging a clash stays said until reviewClash looks again (no flicker in the branch list).
4479
- mergedBy: (prev && prev.mergedBy) || null, clashTask: (prev && prev.clashTask) || null, clashMine: !!(prev && prev.clashMine)
4480
- } : null
4481
- this.upstream = next
4482
- if (!next || !(next.diverged || next.conflicts.length)) this.upstreamSaid = ''
4483
- // Found for another upstream commit or head (a bring-in, a push just made): looked at again, so the relay hears it soon.
4484
- const f = this.upstreamFacts
4485
- if (next && next.sha && (!f || f.up !== next.sha || f.head !== this.reportedHead())) this.checkUpstreamSoon()
4486
- this.shareGit()
4487
- // What this folder's AI is told about a clash depends on who is merging it (reviewClash).
4488
- this.reviewClash()
4489
- this.scheduleStatusWrite()
4490
- }
4491
-
4492
- /** Tells this person's AI about the branch's catch-up once per state (at the top of its next quilt answer, and in the log). */
4493
- sayUpstream (said) {
4494
- if (!said || said === this.upstreamSaid) return
4495
- this.upstreamSaid = said
4496
- this.notice(said)
4497
- this.log(`⬇️ ${said}`)
4498
- }
4499
-
4500
2135
  // A catch-up that stopped on clashing files is handed to ONE AI as a task (clash.js). Every
4501
2136
  // folder reporting it works out the same order from presence (clashOrder); only the first
4502
2137
  // writes the task, for its own AI, under an id derived from the branch and the commit, and its
@@ -4504,250 +2139,12 @@ export class Session extends EventEmitter {
4504
2139
  // branch is told to leave those files to them; whoever's folder gets past that commit with
4505
2140
  // nothing clashing closes it. A diverged branch is the folder's own history: never handed out.
4506
2141
 
4507
- /** Looks again in `ms`; a sooner ask moves the look earlier. */
4508
- reviewClashSoon (ms = 100) {
4509
- if (this.stopped) return
4510
- const due = Date.now() + ms
4511
- if (this.clashTimer && this.clashDue <= due) return
4512
- clearTimeout(this.clashTimer)
4513
- this.clashDue = due
4514
- this.clashTimer = setTimeout(() => { this.clashTimer = null; this.clashDue = 0; this.reviewClash() }, ms)
4515
- this.clashTimer.unref()
4516
- }
4517
-
4518
- /** Looks at this branch's clash now (one look at a time; an ask meanwhile looks again after). Resolves to reviewClashNow's answer. */
4519
- reviewClash () {
4520
- if (this.clashReview) { this.clashAgain = true; return this.clashReview }
4521
- this.clashReview = this.reviewClashNow()
4522
- .catch((err) => { this.log(`could not hand the clash on ${this.git && this.git.branch} to one AI: ${err.message}`); return null })
4523
- .finally(() => {
4524
- this.clashReview = null
4525
- if (this.clashAgain) { this.clashAgain = false; this.reviewClashSoon(0) }
4526
- })
4527
- return this.clashReview
4528
- }
4529
-
4530
- /** Whether commit a is in b's history, asked of git once per pair; null when git can't say. */
4531
- async clashAncestor (a, b) {
4532
- if (a === b) return true
4533
- const key = `${a}>${b}`
4534
- if (this.clashAncestry.has(key)) return this.clashAncestry.get(key)
4535
- const r = await isAncestor(this.root, a, b)
4536
- if (r !== null) {
4537
- this.clashAncestry.set(key, r)
4538
- if (this.clashAncestry.size > 200) this.clashAncestry.delete(this.clashAncestry.keys().next().value)
4539
- }
4540
- return r
4541
- }
4542
-
4543
- /**
4544
- * The look itself: writes, updates, reopens or hands on the clash task when this folder is
4545
- * first in line, closes it once this folder is past the commit, and tells this folder's AI who
4546
- * is merging. Returns { task, label, mine } while this folder reports a file clash, else null.
4547
- */
4548
- async reviewClashNow () {
4549
- const branch = this.git && this.git.branch
4550
- if (!branch || this.stopped || !this.conn || !this.conn.awareness) return null
4551
- // The files held for our clash task: let go of once it is done or someone else's.
4552
- if (this.clashClaims) {
4553
- const t = readTasks(this.tasks).find((x) => x.id === this.clashClaims.task)
4554
- if (!t || t.column === 'done' || !this.isMine(t.assignee)) await this.releaseClashClaims(t && t.column !== 'done' ? `task ${this.clashClaims.task} went to ${t.assignee}` : `the merge (task ${this.clashClaims.task}) is done`)
4555
- }
4556
- const u = this.upstream
4557
- let info = null
4558
- if (u && u.diverged) this.sayUpstream(catchUpAdvice({ branch, upstream: u.name, ...u }))
4559
- else if (u && u.sha && u.conflicts.length) info = await this.clashHere(branch, u)
4560
- else if (u && u.sha && !u.behind) await this.clashCaughtUp(branch, u)
4561
- if (this.stopped || this.upstream !== u || !u) return info
4562
- // The branch list says who is merging it (gitSummary).
4563
- const was = JSON.stringify([u.mergedBy, u.clashTask, u.clashMine])
4564
- u.mergedBy = info ? info.label : null
4565
- u.clashTask = info ? info.task : null
4566
- u.clashMine = info ? info.mine : false
4567
- if (was !== JSON.stringify([u.mergedBy, u.clashTask, u.clashMine])) { this.shareGit(); this.scheduleStatusWrite() }
4568
- return info
4569
- }
4570
-
4571
- /** The commit this folder's files are at. */
4572
- syncedSha () { return (this.gitSeen && this.gitSeen.sha) || (this.git && this.git.sha) || null }
4573
-
4574
- /** Who this member's clash task goes to: an agent member by name, else its AI session active last, else the person. */
4575
- clashAssignee () {
4576
- if (this.kind === 'agent') return this.name
4577
- const published = this.conn.awareness.getLocalState()?.personas
4578
- if (Array.isArray(published) && published.length) {
4579
- const lead = this.leadPersona()
4580
- return lead ? lead.name : published[0].name
4581
- }
4582
- return this.name
4583
- }
4584
-
4585
2142
  /** How a member that may take the clash is named before the task exists. */
4586
2143
  candidateLabel (c) {
4587
2144
  if (c.kind === 'agent' || !c.ai) return c.name
4588
2145
  return `${c.name}'s AI`
4589
2146
  }
4590
2147
 
4591
- /** This folder reports clashing files: see reviewClashNow. */
4592
- async clashHere (branch, u) {
4593
- const now = Date.now()
4594
- const me = this.conn.awareness.clientID
4595
- const facts = clashFacts({ branch, upstream: u.name, ...u })
4596
- const advice = catchUpAdvice({ branch, upstream: u.name, ...u })
4597
- const id = clashTaskId(branch, u.sha)
4598
- let rec = readClash(this.clashes.get(branch), branch)
4599
- if (rec && rec.upstream !== u.name) rec = null
4600
- let tasks = readTasks(this.tasks)
4601
- let task = rec ? tasks.find((t) => t.id === rec.task) || null : null
4602
- // A task about an older commit, still open: brought up to date when the new commit follows it,
4603
- // closed as superseded when the upstream was rewritten without it.
4604
- let superseded = null
4605
- if (task && rec.sha !== u.sha) {
4606
- if (task.column === 'done') task = null
4607
- else {
4608
- const follows = await this.clashAncestor(rec.sha, u.sha)
4609
- if (this.stopped || this.upstream !== u) return null
4610
- if (follows === false) { superseded = { rec, task }; task = null } else if (follows === null) return this.clashTold(task, advice, facts, u)
4611
- }
4612
- }
4613
- // The record lost to a racing write: the task for this commit is the one.
4614
- if (!task) {
4615
- tasks = readTasks(this.tasks)
4616
- const same = tasks.find((t) => t.id === id)
4617
- if (same) { rec = { branch, task: id, sha: u.sha, upstream: u.name, ts: now }; task = same }
4618
- }
4619
- const states = [...this.conn.awareness.getStates()]
4620
- const order = clashOrder(clashCandidates(states, { branch, upstream: u.name }))
4621
- const first = order[0] || null
4622
- const firstIsMe = !!first && first.id === me
4623
-
4624
- if (!task) {
4625
- // Handed out for this commit before and taken off the board: not again (a newer commit may be).
4626
- if (rec && rec.sha === u.sha) { this.sayUpstream(advice); return null }
4627
- if (!order.length) { this.sayUpstream(advice); return null }
4628
- // The first in line writes it. One whose Quilt doesn't within a while is passed over, in order.
4629
- if (!this.clashWait || this.clashWait.sha !== u.sha) this.clashWait = { sha: u.sha, since: now }
4630
- const k = Math.min(order.length - 1, Math.floor((now - this.clashWait.since) / this.clashWaitMs))
4631
- const writer = order[k]
4632
- if (writer.id !== me) {
4633
- this.reviewClashSoon(this.clashWait.since + (k + 1) * this.clashWaitMs - now + 50)
4634
- const label = this.candidateLabel(writer)
4635
- this.sayUpstream(leaveItNotice({ label, upstream: u.name, task: id, facts }))
4636
- return { task: id, label, mine: false }
4637
- }
4638
- if (superseded) this.closeClash(superseded.rec, `Superseded: ${u.name} was rewritten without ${superseded.rec.sha.slice(0, 7)}; the clash with ${u.sha.slice(0, 7)} is task ${id}.`)
4639
- const assignee = this.clashAssignee()
4640
- let t
4641
- try {
4642
- t = putTask(this.doc, this.tasks, { id, title: clashTitle({ upstream: u.name, ...u }), by: this.name, assignee, forAi: false, tool: '', files: clashFiles(u.conflicts) }, CLASH)
4643
- } catch (err) {
4644
- // A full board (or a refused write): no task, so everyone keeps the advice to pull and resolve.
4645
- this.log(`could not put the clash on the board: ${err.message}`)
4646
- this.sayUpstream(advice)
4647
- return null
4648
- }
4649
- this.writeClashRecord(branch, { task: t.id, sha: u.sha, upstream: u.name })
4650
- this.clashWait = null
4651
- this.log(`🔀 the clash with ${u.name} on ${branch} is this folder's to merge (task ${t.id}, for ${assignee})`)
4652
- // Its description is posted once the write has survived (two that raced would post it twice).
4653
- this.settleClash(t.id, assignee, clashBrief({ branch, upstream: u.name, ...u }))
4654
- this.sayUpstream(this.clashSettling(facts))
4655
- return { task: t.id, label: ownerLabel(t), mine: true }
4656
- }
4657
-
4658
- if (task.column === 'done') {
4659
- // Landed (or superseded) and this folder hasn't followed yet: it will, by itself.
4660
- const landed = /^(Brought in at|Superseded)/.test(task.verified || '')
4661
- const someoneAt = this.memberAt(u.sha)
4662
- if (landed || someoneAt) { if (!someoneAt) this.sayUpstream(advice); return null }
4663
- // Moved to Done by hand while the commit still clashes and no folder has it: open again, same assignee.
4664
- this.clashDoneSeen.add(task.id)
4665
- if (firstIsMe) {
4666
- task = patchTask(this.doc, this.tasks, { id: task.id, column: 'doing' }, CLASH)
4667
- const why = `Open again: ${u.sha.slice(0, 7)} from ${u.name} still clashes with the session's work and no folder has it yet. Quilt closes this task by itself once the merge lands: no need to move it.`
4668
- // Our own AI's task: written quietly, so it is woken (and told again) once the write has settled.
4669
- if (this.isMine(task.assignee)) this.settleClash(task.id, task.assignee, why)
4670
- else this.clashComment(task.id, why)
4671
- }
4672
- return this.clashTold(task, advice, facts, u)
4673
- }
4674
-
4675
- // Newer commits that follow the task's: the same task, brought up to date.
4676
- if (rec.sha !== u.sha && firstIsMe) {
4677
- task = patchTask(this.doc, this.tasks, { id: task.id, title: clashTitle({ upstream: u.name, ...u }), files: clashFiles(u.conflicts) }, CLASH)
4678
- this.writeClashRecord(branch, { task: task.id, sha: u.sha, upstream: u.name })
4679
- this.clashComment(task.id, `${u.name} moved on to ${u.sha.slice(0, 7)}.\n\n${clashBrief({ branch, upstream: u.name, ...u })}`)
4680
- }
4681
- // Its assignee: gone from this branch for a while, or a person without an AI while someone with one reports it.
4682
- const holder = holderOf(states, task.assignee, branch)
4683
- let handOn = ''
4684
- let stalled = false
4685
- // Present but making no progress (the clashing files unchanged, the task not moved) for a while.
4686
- const progress = JSON.stringify([task.assignee, task.column, (task.files || []).map((rel) => this.sharedKey(rel) ?? null)])
4687
- if (!this.clashProgress || this.clashProgress.task !== task.id || this.clashProgress.key !== progress) this.clashProgress = { task: task.id, key: progress, since: now }
4688
- const idleFor = now - this.clashProgress.since
4689
- if (holder && idleFor < this.clashIdleMs) this.reviewClashSoon(this.clashIdleMs - idleFor + 50)
4690
- if (holder && idleFor >= this.clashIdleMs && task.column !== 'done') {
4691
- const mins = Math.max(1, Math.round(this.clashIdleMs / 60000))
4692
- const next = order.find((c) => c.id !== holder.id)
4693
- if (!next) {
4694
- // Nobody else to hand it to: everyone behind may pull and resolve again, as before; its files are let go of.
4695
- if (this.clashClaims && this.clashClaims.task === task.id) await this.releaseClashClaims(`${task.assignee} made no progress on the merge for ${mins} minutes`)
4696
- this.sayUpstream(advice)
4697
- return null
4698
- }
4699
- if (next.id === me) { stalled = true; handOn = `${task.assignee} made no progress on it for ${mins} minute${mins === 1 ? '' : 's'}` }
4700
- }
4701
- if (!holder) {
4702
- if (!this.clashAbsent || this.clashAbsent.task !== task.id || this.clashAbsent.name !== task.assignee) this.clashAbsent = { task: task.id, name: task.assignee, since: now }
4703
- const left = this.clashAbsent.since + this.clashReassignMs - now
4704
- if (left > 0) this.reviewClashSoon(left + 50)
4705
- else {
4706
- const mins = Math.max(1, Math.round(this.clashReassignMs / 60000))
4707
- handOn = `${task.assignee} has been away from \`${branch}\` for ${mins} minute${mins === 1 ? '' : 's'}`
4708
- }
4709
- } else {
4710
- this.clashAbsent = null
4711
- // Given to a person (they had no AI session then): to the first member with an AI now (them too, once they have one).
4712
- const toPerson = holder.st.kind !== 'agent' && holder.st.name === task.assignee
4713
- if (!stalled && toPerson && first && first.rank < 2) handOn = first.id === holder.id ? `${task.assignee} has an AI session now` : `${task.assignee} has no AI session, and ${first.name} has one`
4714
- }
4715
- if (handOn && (firstIsMe || stalled) && task.assignee !== this.clashAssignee()) {
4716
- const gone = task.assignee
4717
- const assignee = this.clashAssignee()
4718
- task = patchTask(this.doc, this.tasks, { id: task.id, assignee, forAi: false, tool: '' }, CLASH)
4719
- this.log(`🔀 took over the clash on ${branch} (task ${task.id}) from ${gone}`)
4720
- this.clashAbsent = null
4721
- this.settleClash(task.id, assignee, `Handed to ${assignee}: ${handOn}, and the clash on \`${branch}\` is still there.\n\n${clashBrief({ branch, upstream: u.name, ...u })}`)
4722
- }
4723
- return this.clashTold(task, advice, facts, u)
4724
- }
4725
-
4726
- /** Tells this folder's AI about an existing clash task: it is theirs (once settled), or someone else's to leave alone. */
4727
- clashTold (task, advice, facts, u) {
4728
- const mine = this.isMine(task.assignee)
4729
- const label = ownerLabel(task)
4730
- // Open again after Done: whoever has it is told again.
4731
- if (task.column !== 'done' && this.clashDoneSeen.delete(task.id)) this.forgetClashSaid()
4732
- if (mine) {
4733
- if (this.clashPending && this.clashPending.id === task.id) this.sayUpstream(this.clashSettling(facts))
4734
- else this.sayClash(task.assignee, `${advice} This merge is yours: task ${task.id} on the board. Quilt closes it by itself once the merge lands here.`, leaveItNotice({ label, upstream: u.name, task: task.id, facts }))
4735
- } else this.sayUpstream(leaveItNotice({ label, upstream: u.name, task: task.id, facts }))
4736
- return { task: task.id, label, mine }
4737
- }
4738
-
4739
- /**
4740
- * The clash task is this member's: when it is one of its AI sessions', only that session is
4741
- * told it owns the merge and the others to leave it to that session (each session's own
4742
- * notices); otherwise every session of this member hears `own`.
4743
- */
4744
- sayClash (assignee, own, others) {
4745
- const p = [...this.personas.values()].find((q) => q.name === assignee || q.aliases.includes(assignee))
4746
- if (!p) return this.sayUpstream(own)
4747
- for (const q of this.personas.values()) this.personaNotice(q, q === p ? own : others)
4748
- if (own !== this.upstreamSaid) { this.upstreamSaid = own; this.log(`⬇️ ${p.name}: ${own}`) }
4749
- }
4750
-
4751
2148
  /** A line for one AI session of this member only (takeNotices with its `via`), once per state. */
4752
2149
  personaNotice (p, text) {
4753
2150
  if (!text || p.clashSaid === text) return
@@ -4755,273 +2152,9 @@ export class Session extends EventEmitter {
4755
2152
  p.notices = [...(p.notices || []), text].slice(-NOTICE_CAP)
4756
2153
  }
4757
2154
 
4758
- /** The clash lines said so far are said again when they come up (a task opened again, handed back). */
4759
- forgetClashSaid () {
4760
- this.upstreamSaid = ''
4761
- for (const p of this.personas.values()) p.clashSaid = ''
4762
- }
4763
-
4764
- /** What this folder's AI is told while its clash task settles: the facts, not yet whose it is. */
4765
- clashSettling (facts) { return `${facts} Quilt is handing this merge to one AI; you are told if it is you.` }
4766
-
4767
- /**
4768
- * This member just wrote the clash task for its own AI. Two members that raced both did; the
4769
- * board keeps one. After a moment, the AI is woken and told only if the write survived;
4770
- * otherwise it is told quietly who has it.
4771
- */
4772
- settleClash (id, assignee, comment = '') {
4773
- clearTimeout(this.clashPending && this.clashPending.timer)
4774
- this.forgetClashSaid()
4775
- const timer = setTimeout(() => {
4776
- if (this.clashPending && this.clashPending.id === id) this.clashPending = null
4777
- if (this.stopped) return
4778
- const t = readTasks(this.tasks).find((x) => x.id === id)
4779
- if (t && t.column !== 'done' && t.assignee === assignee) {
4780
- if (comment) this.clashComment(id, comment)
4781
- this.claimClashFiles(t, assignee).catch((err) => this.log(`could not claim the clashing files for task ${id}: ${err.message}`))
4782
- for (const p of this.personas.values()) p.inbox.forget(id)
4783
- this.inboxTracker.forget(id)
4784
- this.scanInbox({ quiet: false, personas: false })
4785
- }
4786
- this.reviewClash()
4787
- }, this.clashSettleMs)
4788
- timer.unref()
4789
- this.clashPending = { id, timer }
4790
- }
4791
-
4792
- /**
4793
- * The clash task `t` survived as this member's (its AI session `assignee`'s): every clashing file
4794
- * is claimed for that session while it merges, so nobody else edits them meanwhile (taken over
4795
- * from whoever held them for the task before). A file someone else holds is asked for in its file
4796
- * queue instead, and the task says so. The relay lets go of them when the task is done.
4797
- */
4798
- async claimClashFiles (t, assignee) {
4799
- if (!this.conn || this.stopped) return
4800
- const u = this.upstream
4801
- const paths = t.files && t.files.length ? t.files : u ? u.conflicts.map((c) => c.path) : []
4802
- const entry = [...this.personas.entries()].find(([, q]) => q.name === assignee || q.aliases.includes(assignee))
4803
- const via = entry ? entry[0] : null
4804
- const upstream = (u && u.name) || 'upstream'
4805
- const claimed = []
4806
- const asked = []
4807
- for (const rel of paths) {
4808
- try {
4809
- await this.conn.claimRequest({ op: 'claim', pattern: rel, note: `Merging commits from ${upstream} (task ${t.id})`, clash: t.id, ...this.as(via) })
4810
- claimed.push(rel)
4811
- // Held for the merge now, not because an edit followed: an AI going idle doesn't let go of it.
4812
- this.autoClaims.delete(rel)
4813
- this.autoVia.delete(rel)
4814
- } catch (err) {
4815
- const c = this.claimFor(rel)
4816
- if (!c || this.ownClaim(c)) { this.log(`could not claim ${rel} for the merge: ${err.message}`); continue }
4817
- try {
4818
- await this.conn.claimRequest({ op: 'request', path: rel, title: `Merging commits from ${upstream}`, description: `Task ${t.id}: the commits clash with the session's work in this file. I merge it once you hand it over.`, task: t.id, clash: t.id, ...this.as(via) })
4819
- asked.push(`${c.by} holds ${rel}`)
4820
- } catch (e2) { this.log(`could not ask for ${rel} for the merge: ${e2.message}`) }
4821
- }
4822
- }
4823
- this.clashClaims = { task: t.id, via, paths: claimed }
4824
- if (claimed.length) this.log(`🔒 holding ${claimed.join(', ')} for the merge (task ${t.id})`)
4825
- if (asked.length) this.clashComment(t.id, `${asked.join('; ')}: asked for ${asked.length === 1 ? 'it' : 'them'} in the file queue for ${assignee}, who gets ${asked.length === 1 ? 'it' : 'them'} with their context when they hand ${asked.length === 1 ? 'it' : 'them'} over.`)
4826
- }
4827
-
4828
- /** Lets go of the files held for our clash task (handed to whoever waits, with `why`). */
4829
- async releaseClashClaims (why) {
4830
- const held = this.clashClaims
4831
- this.clashClaims = null
4832
- if (!held || !this.conn) return
4833
- for (const rel of held.paths) {
4834
- const c = this.claimFor(rel)
4835
- // Ours for this task, or ours with no task mark (a relay that predates it drops the mark).
4836
- if (!c || (c.clash && c.clash !== held.task) || !this.ownClaim(c)) continue
4837
- try {
4838
- if (c.queue && c.queue.length) await this.conn.claimRequest({ op: 'handoff', pattern: c.pattern, context: `Quilt let go of it: ${why}.`, ...this.as(held.via) })
4839
- else await this.conn.claimRequest({ op: 'release', pattern: c.pattern, ...this.as(held.via) })
4840
- } catch {}
4841
- }
4842
- }
4843
-
4844
- /** The record of this branch's clash task, and old records pruned (a finished or dismissed one after a few days). */
4845
- writeClashRecord (branch, rec) {
4846
- const now = Date.now()
4847
- const tasks = new Map(readTasks(this.tasks).map((t) => [t.id, t]))
4848
- this.doc.transact(() => {
4849
- this.clashes.set(branch, { ...rec, ts: now })
4850
- const kept = []
4851
- for (const [key, value] of [...this.clashes.entries()]) {
4852
- if (key === branch) continue
4853
- const r = readClash(value, key)
4854
- const t = r && tasks.get(r.task)
4855
- if (!r || ((!t || t.column === 'done') && now - r.ts > CLASH_KEEP_MS)) this.clashes.delete(key)
4856
- else kept.push(r)
4857
- }
4858
- kept.sort((a, b) => a.ts - b.ts)
4859
- for (const r of kept.slice(0, Math.max(0, kept.length - CLASH_RECORDS + 1))) this.clashes.delete(r.branch)
4860
- }, CLASH)
4861
- }
4862
-
4863
- /** A note on a clash task (its description, a handover), from this member when they may post. */
4864
- clashComment (id, text) {
4865
- if (!this.mayTalk()) return
4866
- try { putComment(this.doc, this.taskComments, readTasks(this.tasks), { taskId: id, by: this.name, text }, CLASH) } catch (err) { this.log(`could not note the clash on task ${id}: ${err.message}`) }
4867
- }
4868
-
4869
- /** Moves a clash task to Done with `verified` (any member may; again is a no-op). */
4870
- closeClash (rec, verified) {
4871
- const t = readTasks(this.tasks).find((x) => x.id === rec.task)
4872
- if (!t || t.column === 'done') return
4873
- patchTask(this.doc, this.tasks, { id: rec.task, column: 'done', verified }, CLASH)
4874
- this.log(`✅ clash task ${rec.task} (${rec.upstream} on ${rec.branch}) done: ${verified}`)
4875
- this.reviewClashSoon()
4876
- }
4877
-
4878
- /**
4879
- * This folder is caught up with nothing clashing. At or past the clash's commit: it landed, the
4880
- * task is done. The upstream rewritten without that commit (a force-push): superseded.
4881
- */
4882
- async clashCaughtUp (branch, u) {
4883
- const rec = readClash(this.clashes.get(branch), branch)
4884
- if (!rec || rec.upstream !== u.name) return
4885
- const task = readTasks(this.tasks).find((t) => t.id === rec.task)
4886
- if (!task || task.column === 'done') return
4887
- const head = this.syncedSha()
4888
- if (!head) return
4889
- const past = head === rec.sha || u.sha === rec.sha ? true : await this.clashAncestor(rec.sha, head)
4890
- if (this.stopped || this.upstream !== u) return
4891
- if (past) return this.closeClash(rec, `Brought in at ${head.slice(0, 7)}: ${this.name}'s folder on ${branch} is at or past ${rec.sha.slice(0, 7)} from ${rec.upstream}, with nothing clashing. Every other folder on ${branch} follows it.`)
4892
- if (await this.clashAncestor(rec.sha, u.sha) === false && !this.stopped) {
4893
- this.closeClash(rec, `Superseded: ${rec.upstream} was rewritten without ${rec.sha.slice(0, 7)} (now at ${u.sha.slice(0, 7)}), and ${this.name}'s folder on ${branch} is caught up with it with nothing clashing.`)
4894
- }
4895
- }
4896
-
4897
- /** Reads every branch and worktree of the repository again (for the room's branch list). */
4898
- refreshRepo () {
4899
- repoBranches(this.root).then((r) => {
4900
- if (!r || this.stopped) return
4901
- const real = (p) => { try { return fs.realpathSync(p) } catch { return path.resolve(p) } }
4902
- const home = real(this.root)
4903
- this.repo = {
4904
- worktrees: r.worktrees.map((w) => ({ name: real(w.path) === home ? '.' : path.basename(w.path), branch: w.branch, sha: w.sha })),
4905
- branches: r.branches.slice(0, 30)
4906
- }
4907
- this.shareGit()
4908
- }).catch(() => {})
4909
- }
4910
-
4911
- /** What this folder tells the room about git: its branch, its upstream, the repository's branches and worktrees. */
4912
- gitSummary () {
4913
- if (!this.git) return null
4914
- const u = this.upstream
4915
- return {
4916
- branch: this.git.branch,
4917
- key: this.git.key,
4918
- sha: this.gitSeen ? this.gitSeen.sha : this.git.sha,
4919
- held: this.hold ? this.hold.kind : null,
4920
- clash: 1, // this Quilt hands a clash to one AI (reviewClash): only such members are given one
4921
- // On a branch that syncs live with GitHub, this Quilt leaves GitHub's commits to the relay and follows them
4922
- // (bringIn, planSettle): the relay brings them in only while every folder there says so (relay-upstream.js catcherAble).
4923
- ...(this.followsRelay ? { followsRelay: 1 } : {}),
4924
- on: this.hold && this.hold.kind === 'switching' ? this.hold.to : this.git.key,
4925
- // The commit this folder is bringing into the session right now (bringIn): others on the branch wait for it.
4926
- ...(this.bringing ? { bringing: this.bringing } : {}),
4927
- upstream: u ? { name: u.name, url: u.url, remote: u.remote || null, sha: u.sha || null, behind: u.behind, ahead: u.ahead, diverged: u.diverged, conflicts: u.conflicts.length, waiting: u.waiting, checkedAt: u.checkedAt, mergedBy: u.mergedBy || null, clashTask: u.clashTask || null, mayWrite: u.conflicts.length ? this.mayWriteAll(u.conflicts.map((c) => c.path)) : null, ...(u.fetchOk === false ? { fetchOk: false, fetchError: u.fetchError || 'git fetch failed' } : {}), ...this.upstreamFactsFor(u) } : null,
4928
- repo: this.repo
4929
- }
4930
- }
4931
-
4932
2155
  /** Whether this member may change every one of these files (its role and folders). */
4933
2156
  mayWriteAll (paths) { return paths.every((rel) => !this.writeRefusal(rel)) }
4934
2157
 
4935
- /** The parts of everyone's presence that bear on who merges a clash (reviewClash looks again when they change). */
4936
- clashPresence () {
4937
- const out = []
4938
- for (const [id, st] of this.conn.awareness.getStates()) {
4939
- if (!st || !st.git) continue
4940
- const g = st.git
4941
- const u = g.upstream || {}
4942
- out.push([id, st.name, st.kind, (Array.isArray(st.personas) ? st.personas : []).map((p) => p && p.name), g.branch, g.sha, g.held, g.clash, u.name, u.behind, u.conflicts, u.diverged, u.mayWrite])
4943
- }
4944
- return JSON.stringify(out)
4945
- }
4946
-
4947
- /** The commit this folder says it is at (gitSummary's sha). */
4948
- reportedHead () { return this.git ? (this.gitSeen ? this.gitSeen.sha : this.git.sha) : null }
4949
-
4950
- /**
4951
- * What checkUpstream found about the upstream `u` that presence says alongside it (relay-upstream.js
4952
- * noteFolders), only while it still holds: `at`, the head it was found for (the relay takes no word
4953
- * from a folder whose head has moved since: a commit made here, or a bring-in); `past` for the
4954
- * upstream's commit and the relay's record it was found for; `headPushed` for that commit and head;
4955
- * `relayKnown` for that record. A fact from another moment would have the relay record a commit
4956
- * nobody pushed, or step back to an older one.
4957
- */
4958
- upstreamFactsFor (u) {
4959
- const f = this.upstreamFacts
4960
- const rs = this.relaySha()
4961
- const forUp = !!f && f.up === (u.sha || null)
4962
- const out = { past: forUp && f.rs === rs ? f.past || null : null }
4963
- if (f) out.at = f.head || null
4964
- if (forUp && f.head === this.reportedHead() && typeof f.headPushed === 'boolean') out.headPushed = f.headPushed
4965
- if (f && f.rs === rs && typeof f.relayKnown === 'boolean') out.relayKnown = f.relayKnown
4966
- return out
4967
- }
4968
-
4969
- shareGit () {
4970
- if (!this.conn || !this.conn.awareness) return
4971
- const g = this.gitSummary()
4972
- if (JSON.stringify(g) !== JSON.stringify(this.conn.awareness.getLocalState()?.git ?? null)) this.conn.awareness.setLocalStateField('git', g)
4973
- }
4974
-
4975
- /** git left a conflict here (`stash pop`, a merge, a rebase): held until it's resolved, said once per hold. */
4976
- noteConflict (paths) {
4977
- if (!this.hold) return
4978
- for (const rel of paths) this.heldConflicts.add(rel)
4979
- const fresh = !this.hold.conflict
4980
- this.hold.conflict = [...this.heldConflicts]
4981
- if (!fresh) return
4982
- const list = this.hold.conflict.slice(0, 3).join(', ') + (this.hold.conflict.length > 3 ? ', …' : '')
4983
- this.log(`⏸️ git left a conflict in ${list} on this computer; Quilt shares your resolution once you resolve it and git add it.`)
4984
- this.emit('hold', this.hold)
4985
- this.scheduleStatusWrite()
4986
- }
4987
-
4988
- /** Puts the room's version of each path back on disk (a discard on this machine never discards the room's work). */
4989
- writeBack (paths, say = false) {
4990
- let n = 0
4991
- for (const rel of paths) {
4992
- if (!this.syncable(rel) || this.merging.has(rel)) continue
4993
- const disk = this.readDisk(rel)
4994
- if (disk && (disk.skip || disk.tooLarge)) continue
4995
- if ((disk ? disk.key : undefined) === this.sharedKey(rel)) { if (disk) this.lastKnown.set(rel, disk.key); continue }
4996
- // What's on disk is what git put there (it has its own copy): no need to keep one under .quilt/conflicts.
4997
- if (disk) this.lastKnown.set(rel, disk.key); else this.lastKnown.delete(rel)
4998
- if (this.tryWrite(rel)) n++
4999
- }
5000
- if (!say || !n) return
5001
- const files = `${n} file${n === 1 ? '' : 's'}`
5002
- if (say === 'reset') this.log(`↩️ Quilt kept the session's work: git put ${files} back to your last commit on this computer only, and the session's version is back.`)
5003
- else this.log(`↩️ Quilt kept the session's work; your stash still has your copy. (${files})`)
5004
- }
5005
-
5006
- /** Runs mergeOne over held entries ({ rel, base }), keeping each out of normal sync while it runs. Returns the conflicts. */
5007
- /** Merges each entry into the room: how many need a person (conflicts), and how many changed the room (changed). */
5008
- async mergeHeld (entries) {
5009
- for (const e of entries) this.merging.add(e.rel)
5010
- let conflicts = 0
5011
- let changed = 0
5012
- for (const e of entries) {
5013
- if (this.stopped) break
5014
- try {
5015
- const r = await this.mergeOne(e)
5016
- if (r === 'conflict') conflicts++
5017
- else if (r) changed++
5018
- } catch (err) { this.log(`could not merge ${e.rel}: ${err.message}`) }
5019
- this.merging.delete(e.rel)
5020
- }
5021
- for (const e of entries) this.merging.delete(e.rel)
5022
- return { conflicts, changed }
5023
- }
5024
-
5025
2158
  /**
5026
2159
  * Pushes the on-disk state of a path into the shared doc. Returns true if anything changed.
5027
2160
  * `pulled`: git brought this version (a pull), so it is not an edit of ours: never claimed, and counted as the pull's.
@@ -5030,14 +2163,11 @@ export class Session extends EventEmitter {
5030
2163
  ingest (rel, { pulled = false, same = false, claim: mayClaim = true } = {}) {
5031
2164
  if (!this.syncable(rel) || this.inSquare || this.syncStopped) return false
5032
2165
  if (this.merging.has(rel)) return false // its offline merge hasn't run yet; see mergeOffline
5033
- if (this.held()) { this.heldPaths.add(rel); return false } // git is at work in this folder; see onSettled
5034
- if (this.pullWait.has(rel)) { if (!this.onDisk(rel)) return false; this.endPullWait(rel) } // made way for a pull: not a deletion
5035
2166
  // A plain `rm` of a file a fetched pull would bring (git said to remove it): making way, not a deletion.
5036
- if (!this.onDisk(rel) && this.blocksPull(rel)) { this.waitForPull([rel], false); return false }
5037
2167
  if (this.downloading.has(rel)) return false // our copy is being replaced by a download
5038
2168
  if (this.writeFailed.has(rel)) return false // the shared version never reached the disk: what's there is no edit of ours
5039
2169
  // New ignore rules: a box of ours on a file they now leave out lets go (settleBoxesOnce).
5040
- if (IGNORE_FILES.includes(path.posix.basename(rel)) || rel === '.gitmodules') { this.ig = loadIgnore(this.root); this.settleBoxesSoon() }
2170
+ if (IGNORE_FILES.includes(path.posix.basename(rel)) || rel === '.gitmodules') { this.ig = loadIgnore(this.root) }
5041
2171
  const disk = this.readDisk(rel)
5042
2172
  const boxed = this.boxedByOther(rel)
5043
2173
  if (boxed) {
@@ -5103,7 +2233,6 @@ export class Session extends EventEmitter {
5103
2233
  if (quiltOwn) { this.quiltOwnWrite = null; this.quiltOwnKey = null }
5104
2234
  // With boxes, a deletion is boxed too: it reaches nobody else's folder until it's committed.
5105
2235
  if (mayClaim && (disk || this.boxesOn()) && !pulled && !quiltOwn && this.ready && !this.seeding && changesIt && (!claim || this.autoClaims.has(rel) || this.unboxedOwn(rel, claim)) && this.aiMayBeEditing()) this.autoClaim(rel)
5106
- if (!pulled && this.ownBox(rel)) this.settleBoxesSoon() // put back to what was committed, say: the box opens
5107
2236
 
5108
2237
  if (!disk) {
5109
2238
  if (!this.files.has(rel) && !this.blobs.has(rel)) {
@@ -5427,8 +2556,6 @@ export class Session extends EventEmitter {
5427
2556
  if (!this.syncable(rel) || this.inSquare || this.syncStopped) return
5428
2557
  if (this.ghost(rel)) return
5429
2558
  if (this.merging.has(rel)) return // mergeOffline writes this path once it has merged it
5430
- if (this.held()) { this.heldPaths.add(rel); return } // written back when the hold ends (writeBack)
5431
- if (this.pullWait.has(rel) && !this.onDisk(rel)) return // out of the way for a pull: putting it back would block it
5432
2559
  let abs
5433
2560
  try { abs = resolveInside(this.root, rel) } catch (err) { this.log(err.message); return }
5434
2561
  const shared = this.sharedKey(rel)
@@ -5470,7 +2597,7 @@ export class Session extends EventEmitter {
5470
2597
  this.lastKnown.set(rel, shared)
5471
2598
  }
5472
2599
  // New ignore rules: a box of ours on a file they now leave out lets go (settleBoxesOnce).
5473
- if (IGNORE_FILES.includes(path.posix.basename(rel)) || rel === '.gitmodules') { this.ig = loadIgnore(this.root); this.settleBoxesSoon() }
2600
+ if (IGNORE_FILES.includes(path.posix.basename(rel)) || rel === '.gitmodules') { this.ig = loadIgnore(this.root) }
5474
2601
 
5475
2602
  if (this.ready && !this.arriving) { // a move writing its branch onto the folder is no partner's change
5476
2603
  this.emit('file-changed', { path: rel, by: this.lastEditorOf(rel) || 'partner' })
@@ -5623,7 +2750,6 @@ export class Session extends EventEmitter {
5623
2750
  /** Tries failed uploads, downloads and writes again. */
5624
2751
  retryFailed () {
5625
2752
  if (!this.ready || this.stopped || this.inSquare || this.syncStopped) return
5626
- if (this.hold && this.hold.kind === 'switching' && !this.following) this.recheckSoon()
5627
2753
  const failed = [...this.retry]
5628
2754
  this.retry.clear()
5629
2755
  for (const [rel, kind] of failed) {
@@ -5833,7 +2959,6 @@ export class Session extends EventEmitter {
5833
2959
  const cur = this.blobs.get(rel)
5834
2960
  if (!cur || cur.hash !== entry.hash) return // replaced meanwhile; that version is on its way
5835
2961
  if (this.stopped) return
5836
- if (this.held()) { this.heldPaths.add(rel); return } // git is at work on the folder: written back when the hold ends
5837
2962
  if (this.ghost(rel)) return // boxed by someone else meanwhile: written once it opens
5838
2963
  const abs = resolveInside(this.root, rel)
5839
2964
  let st = null
@@ -5875,26 +3000,7 @@ export class Session extends EventEmitter {
5875
3000
 
5876
3001
  // --------------------------------------------------------------- watcher --
5877
3002
 
5878
- /** Watches git in this folder (HEAD, the index, fetches) and looks for new upstream commits about once a minute. */
5879
- startGitWatch () {
5880
- if (this.gitWatcher || !this.git) return
5881
- this.gitWatcher = watchGit(this.root, (e) => {
5882
- if (this.stopped) return
5883
- if (e.type === 'head') { this.headChangedAt = Date.now(); this.queue(HEAD_CHANGED) } else if (e.type === 'busy') { this.setHold('busy'); this.settleSoon() } else if (e.type === 'fetch') { this.pullWaitOver.clear(); this.refreshPull(); this.checkUpstreamSoon() } else if (e.type === 'ref') { this.checkUpstreamSoon(); this.settleBoxesSoon() } else this.settleSoon() // idle, index (a branch moving is a commit: it may take boxed work in)
5884
- })
5885
- if (!this.hold || this.hold.kind !== 'switching') this.settleSoon() // a hold resumed at start ends once the tree has settled
5886
- this.upstreamPoll = setInterval(() => this.checkUpstreamSoon({ fetch: true }), UPSTREAM_MS)
5887
- this.upstreamPoll.unref()
5888
- this.checkUpstreamSoon({ fetch: true })
5889
- }
5890
-
5891
3003
  async startWatcher () {
5892
- if (this.git) this.ensureGitHelpers().catch(() => {})
5893
- this.syncPersonGitSoon()
5894
- this.gitIndex = indexStamp(this.root)
5895
- this.stashSeen = stashStamp(this.root)
5896
- this.refreshPull() // a fetch before this start may already have something to say
5897
- if (this.git) this.startGitWatch()
5898
3004
  this.scanDisk({ baseline: true }) // the folder was just reconciled; the first re-scan catches anything since
5899
3005
  this.watcher = watch(this.root, {
5900
3006
  ignoreInitial: true,
@@ -5953,7 +3059,7 @@ export class Session extends EventEmitter {
5953
3059
  const color = this.color || COLORS[Math.abs(hashCode(this.name)) % COLORS.length]
5954
3060
  this.conn.awareness.setLocalState({
5955
3061
  name: this.name, tool: this.tool, color, focus: '', editing: {}, agents: [], kind: this.kind,
5956
- agent: { tool: null, status: 'idle', sharing: this.agentSharing }, git: this.gitSummary(),
3062
+ agent: { tool: null, status: 'idle', sharing: this.agentSharing }, branch: this.branch,
5957
3063
  ...(this.aiOf ? { aiOf: this.aiOf } : {})
5958
3064
  })
5959
3065
  this.conn.awareness.on('change', ({ added, removed }, origin) => {
@@ -5966,11 +3072,6 @@ export class Session extends EventEmitter {
5966
3072
  this.scheduleStatusWrite()
5967
3073
  this.scheduleTypingExpiry()
5968
3074
  })
5969
- // Who reports a clash, and who is still here, decide who merges it (reviewClash).
5970
- this.conn.awareness.on('change', () => {
5971
- const seen = this.clashPresence()
5972
- if (seen !== this.clashSeen) { this.clashSeen = seen; this.reviewClashSoon() }
5973
- })
5974
3075
  }
5975
3076
 
5976
3077
  // ---------------------------------------------------------------- voice --
@@ -6153,77 +3254,6 @@ export class Session extends EventEmitter {
6153
3254
  return this.work
6154
3255
  }
6155
3256
 
6156
- /**
6157
- * Asks for a commit of `files` (or, without them, of the task's changes, or of everything this
6158
- * member changed): a person's Quilt with git on this branch commits exactly those that differ
6159
- * from HEAD and pushes (commitRequest), then tells the asker the hash. Agents' folders have no
6160
- * git, so this is how their work reaches the repository.
6161
- */
6162
- requestCommit (message, { files = null, task = null, via = null, by = null, id = null, description = '', quiet = false } = {}) {
6163
- message = String(message || '').trim().slice(0, MAX_MESSAGE)
6164
- if (this.access && this.access.state === 'approved' && this.access.role === 'viewer') throw new Error('viewers can’t ask for commits')
6165
- // In Seam nobody waits on someone with git: whoever is on a square commits it.
6166
- if (this.quilt && this.quilt.layout === 3) throw new Error('This session\'s code is committed in Seam, so there is no one to ask: commit your square yourself (the square button at the top of the app, quilt commit "<message>", or seam_commit for an AI).')
6167
- // A commit request is a message to the host: the relay undoes it from someone who may not post.
6168
- if (!this.mayTalk()) throw new Error(TALK_REFUSED)
6169
- const t = task ? this.taskList().find((x) => x.id === String(task)) : null
6170
- if (task && !t) throw new Error(`no task ${task} on the board`)
6171
- if (!message && t) message = t.title
6172
- if (!message) throw new Error('say what the commit is for')
6173
- const asker = by || this.actorName(via)
6174
- if (this.kind === 'agent' && this.canCommitHere()) throw new Error(`Get it into git yourself: commit the files you changed and push from this folder (git signs in through Quilt; by default to a branch of your own, ${agentPrefix(this.name)}<topic>, with a pull request), with nobody else needed. Asking a person is for when agents may not commit here.`)
6175
- let list = cleanFiles(cleanCommitFiles(files)) // a path outside the project is refused, not dropped
6176
- if (files && files.length && !list.length) throw new Error('none of those are files in the project (give paths relative to its top folder)')
6177
- if (!list.length) list = this.changedFilesFor({ task: t, by: t ? null : (asker === this.actorName(via) ? this.name : asker) })
6178
- if (!list.length) throw new Error(t ? `task ${t.id} has no changed files on record: give them in files` : 'Quilt has no changes of yours on record: give the files in files')
6179
- const desc = String(description || '').trim().slice(0, MAX_COMMIT_DESCRIPTION)
6180
- const r = { id: id || crypto.randomBytes(6).toString('hex'), by: asker, message, ...(desc ? { description: desc } : {}), branch: this.branch, ts: Date.now(), state: 'open', files: list, ...(t ? { task: { id: t.id, title: t.title } } : {}) }
6181
- // The session owner (whose machine usually commits) is told with a direct message that wakes their
6182
- // AI; it asks for a commit, not a reply, and waits in their inbox until the request is done.
6183
- const owner = ownerName(this.members)
6184
- const note = !quiet && owner && !this.isMine(owner) && owner !== asker
6185
- ? commitRequestMessage(r, { owner, id: crypto.randomBytes(8).toString('hex'), defaultBranch: DEFAULT_KEY, of: this.persona(via) ? this.name : null })
6186
- : null
6187
- this.doc.transact(() => {
6188
- this.commitRequests.set(r.id, r)
6189
- if (note) {
6190
- this.chat.push([note])
6191
- if (this.chat.length > 500) this.chat.delete(0, this.chat.length - 500)
6192
- }
6193
- // Keep the list short: drop old finished requests.
6194
- const done = [...this.commitRequests.values()].filter((x) => x.state === 'done').sort((a, b) => a.ts - b.ts)
6195
- for (const x of done.slice(0, Math.max(0, done.length - KEEP_DONE_COMMITS))) this.commitRequests.delete(x.id)
6196
- }, LOCAL)
6197
- this.log(`📌 ${asker === this.name ? 'you' : asker} asked for a commit: ${message} (${list.length} file${list.length === 1 ? '' : 's'})`)
6198
- this.reviewCommitsSoon()
6199
- return { ...r, notified: note ? owner : null, committer: this.committerLine() }
6200
- }
6201
-
6202
- /**
6203
- * The files a request covers when it names none: what was changed for `task` (the session's
6204
- * history, and the files on the card), or every file `by` changed, newest last. Pulls from git
6205
- * are not anyone's changes. The committer keeps only those that differ from HEAD.
6206
- */
6207
- changedFilesFor ({ task = null, by = null } = {}) {
6208
- const out = []
6209
- const add = (p) => { if (p && !out.includes(p) && isSafeRelPath(p)) out.push(p) }
6210
- for (const e of this.history.entries()) {
6211
- if (e.pulled) continue
6212
- if (task ? e.task && e.task.id === task.id : e.by === by) add(e.path)
6213
- }
6214
- if (task) for (const f of task.files || []) add(f)
6215
- return out.slice(-MAX_REQUEST_FILES)
6216
- }
6217
-
6218
- /** Who will commit, in a line for the asker: a person with git on this branch, or what is missing. */
6219
- committerLine () {
6220
- const st = this.status()
6221
- const here = [st.me, ...st.peers].filter((p) => p && p.git && p.git.key === this.branch && !p.persona && p.name !== this.name)
6222
- if (this.canCommit()) return `This folder has git on ${this.branch}: its app shows the request with a Commit button.`
6223
- if (here.length) return `${here.map((p) => p.name).join(' or ')} ${here.length === 1 ? 'has' : 'have'} git on ${this.branch} and will be asked in the app; you are told the commit when it is made.`
6224
- return `Nobody with git on ${this.branch} is here right now; the request waits in the app until someone is, and you are told the commit when it is made.`
6225
- }
6226
-
6227
3257
  // ------------------------------------------------------------- tasks --
6228
3258
 
6229
3259
  /** The shared board: To do, In progress, QA, Done. Everyone in the room sees the same list. */
@@ -6512,8 +3542,7 @@ export class Session extends EventEmitter {
6512
3542
  if (all) return { ...r, events: this.withContext(r.events, via), me: this.readerNames(via) }
6513
3543
  const msgs = this.chat.toArray().filter((m) => this.canSee(m))
6514
3544
  const me = this.readerNames(via)
6515
- // A commit request (e.commit: its id) waits until the request is done; a note that a commit was made is shown once.
6516
- const open = (e) => typeof e.commit === 'string' ? this.commitRequests.get(e.commit)?.state === 'open' : (e.kind !== 'dm' && e.kind !== 'mention') || e.queue || e.commit || e.nudge ||
3545
+ const open = (e) => (e.kind !== 'dm' && e.kind !== 'mention') || e.queue || e.commit || e.nudge ||
6517
3546
  (!this.settledIds.has(e.id) && !answered(msgs, me, e.by, e.ts))
6518
3547
  return { ...r, events: this.withContext(r.events.filter(open), via), me: this.readerNames(via) }
6519
3548
  }
@@ -6726,16 +3755,6 @@ export class Session extends EventEmitter {
6726
3755
  return this.webhookSending
6727
3756
  }
6728
3757
 
6729
- /** Marks open requests as done (by a commit made outside Quilt, or not needed): `ids`, or all. */
6730
- resolveCommitRequests ({ hash = '', ids = null } = {}) {
6731
- const open = [...this.commitRequests.values()].filter((r) => r.state === 'open' && (!ids || ids.includes(r.id)))
6732
- if (!open.length) return 0
6733
- this.doc.transact(() => {
6734
- for (const r of open) this.commitRequests.set(r.id, { ...r, state: 'done', doneBy: this.name, hash, doneAt: Date.now() })
6735
- }, LOCAL)
6736
- return open.length
6737
- }
6738
-
6739
3758
  // ------------------------------------------------------------- commits --
6740
3759
  // Work gets into git with git: an agent's folder signs in to GitHub through Quilt (gitCredential,
6741
3760
  // pushCheck) within what the owner lets agents do, with nobody else needed; agents without a
@@ -6743,304 +3762,6 @@ export class Session extends EventEmitter {
6743
3762
  // (no GitHub connection, not GitHub, the owner keeps agents from committing): a person's folder
6744
3763
  // with git commits exactly its files (commit.js), from the app's commit panel.
6745
3764
 
6746
- /**
6747
- * A task moved to Done: when its work is in a pull request not yet merged, the relay merges it
6748
- * (relay-commit.js finishTaskFor): people always, agents only when the owner lets agents push to
6749
- * any branch. Resolves { merged } or throws with what to do. Without a relay connection, nothing.
6750
- */
6751
- async finishTask (id) {
6752
- if (!this.conn || !this.conn.adminRequest) return { merged: null }
6753
- const r = await this.conn.adminRequest({ op: 'finishTask', task: String(id || '') }, { timeoutMs: PUSH_REQUEST_TIMEOUT_MS })
6754
- return r && r.finishTask ? r.finishTask : { merged: null }
6755
- }
6756
-
6757
- /**
6758
- * An agent took a task: its folder goes onto a branch of its own for it (quilt/<agent>/<task>),
6759
- * cut from where the folder is, when the folder is on a branch that isn't the agent's own and
6760
- * agents may push here. The agent then works and pushes there like anyone; the pull request opens
6761
- * by itself. Returns the branch, or null when nothing was done (people's folders, no git, already on its own branch).
6762
- */
6763
- branchForTask (task) { return this.branchMove(() => this.branchForTaskNow(task)) }
6764
-
6765
- /**
6766
- * One branch move at a time in this agent's folder: a task's new branch (branchForTask) and leaving
6767
- * a merged one (leaveMergedBranch) both check out; at once, they fought over git's index, or the
6768
- * second undid the first (an agent taking a task as its last one's branch merged got no branch).
6769
- */
6770
- branchMove (fn) {
6771
- const p = (this.branchMoves || Promise.resolve()).then(fn)
6772
- this.branchMoves = p.catch(() => {})
6773
- return p
6774
- }
6775
-
6776
- async branchForTaskNow (task) {
6777
- if (this.kind !== 'agent' || !this.git || !this.git.branch || !task || !task.title || !this.canCommitHere()) return null
6778
- const prefix = agentPrefix(this.name)
6779
- const settle = async () => { for (let i = 0; i < 20 && (this.hold || gitBusy(this.root, gitDir(this.root))); i++) await new Promise((r) => setTimeout(r, 250)) }
6780
- const run = (args) => new Promise((resolve) => execFile('git', args, { cwd: this.root, encoding: 'utf8', timeout: 20000 }, (err, _out, stderr) => resolve(err ? String(stderr || err.message) : '')))
6781
- // Right after a switch the folder settles for a moment (a hold): wait for it, briefly, rather than skip the branch.
6782
- await settle()
6783
- if (this.git.branch.startsWith(prefix)) {
6784
- // On a branch of its own already: the one it is still working on stays; a finished task's branch
6785
- // (merged, or its task Done) is left for the default branch, so the new task starts from there.
6786
- // A task is on the branch when its pull request is, or, not pushed yet, when the branch is named after it (as relay-commit.js ties them).
6787
- const here = this.git.branch
6788
- const tail = here.slice(prefix.length)
6789
- const named = (s) => tail === s || (tail.startsWith(`${s}-`) && /^\d+$/.test(tail.slice(s.length + 1)))
6790
- const onIt = (t) => (t.pr && t.pr.branch === here) || (!t.pr && named(slug(t.title)))
6791
- const busy = this.taskList().some((t) => t && !t.archived && t.column !== 'done' && onIt(t))
6792
- if (busy) return null
6793
- // Its own work not committed yet would come along onto the next task's branch: stay, and say why.
6794
- // Others' uncommitted work in the folder is the session's, not this task's: it doesn't hold the agent here.
6795
- const own = await this.myUncommitted().catch(() => null)
6796
- const dirty = own ? own.files.length > 0 : !!(await this.gitOut(['status', '--porcelain', '--untracked-files=no']))
6797
- if (dirty) { this.log(`🌿 staying on ${here}: it has changes not committed yet; commit them (or put them back) and take the task again for a branch of its own`); return null }
6798
- const main = (this.branchList.find((b) => b && b.default) || {}).key || 'main'
6799
- if (this.git.branch === main) return null
6800
- const back = await run(['checkout', '-q', main])
6801
- if (back) { this.emit('debug', `could not go back to ${main} for the task: ${back.trim()}`); return null }
6802
- for (let i = 0; i < 40 && (!this.git || this.git.branch !== main); i++) await new Promise((r) => setTimeout(r, 250))
6803
- await settle()
6804
- }
6805
- if (this.hold || gitBusy(this.root, gitDir(this.root)) || !this.git || !this.git.branch || this.git.branch.startsWith(prefix)) return null
6806
- // The new branch starts from the latest of where the folder is: fetch, and move forward when that is all it takes (never a merge here).
6807
- {
6808
- const here = this.git.branch
6809
- const remote = (await this.gitOut(['config', '--get', `branch.${here}.remote`])) || 'origin'
6810
- await new Promise((resolve) => execFile('git', ['fetch', '-q', remote, here], { cwd: this.root, encoding: 'utf8', timeout: 60000, env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } }, () => resolve()))
6811
- await run(['merge', '-q', '--ff-only', `refs/remotes/${remote}/${here}`])
6812
- await settle()
6813
- }
6814
- let name = `${prefix}${slug(task.title)}`
6815
- for (let n = 2; (await this.gitOut(['rev-parse', '--verify', '-q', `refs/heads/${name}`])) !== null && n < 50; n++) name = `${prefix}${slug(task.title)}-${n}`
6816
- const r = await run(['checkout', '-q', '-b', name])
6817
- if (r) { this.emit('debug', `could not make a branch for the task: ${r.trim()}`); return null }
6818
- this.log(`🌿 on ${name} for "${task.title}": commit and push there; the pull request opens by itself`)
6819
- return name
6820
- }
6821
-
6822
- /**
6823
- * This agent's folder is on branch `b`, which was merged (into b.merged.into): unless it has work of
6824
- * its own the merge didn't take (changes not committed, or commits the session's record of the branch
6825
- * hasn't), the folder checks out the branch it merged into and brings it up to date. Returns the branch, or null.
6826
- */
6827
- leaveMergedBranch (b) { return this.branchMove(() => this.leaveMergedBranchNow(b)) }
6828
-
6829
- async leaveMergedBranchNow (b) {
6830
- const here = b.key
6831
- const into = this.branchList.some((x) => x.key === b.merged.into) ? b.merged.into : (this.branchList.find((x) => x.default) || {}).key || 'main'
6832
- if (!into || into === here) return null
6833
- for (let i = 0; i < 20 && (this.hold || gitBusy(this.root, gitDir(this.root))); i++) await new Promise((r) => setTimeout(r, 250))
6834
- if (this.stopped || !this.git || this.git.branch !== here) return null
6835
- const own = await this.myUncommitted().catch(() => null)
6836
- const dirty = own ? own.files.length > 0 : !!(await this.gitOut(['status', '--porcelain', '--untracked-files=no']))
6837
- const head = await this.gitOut(['rev-parse', 'HEAD'])
6838
- const rs = this.relaySha()
6839
- const ahead = head && rs && head !== rs && !(await isAncestor(this.root, head, rs))
6840
- if (dirty || ahead) { this.log(`🌿 staying on ${here}, which was merged into ${into}: it has ${dirty ? 'changes not committed yet' : 'commits the merge may not have'}; commit (or put back) and push them, then git checkout ${into}`); return null }
6841
- const r = await new Promise((resolve) => execFile('git', ['checkout', '-q', into], { cwd: this.root, encoding: 'utf8', timeout: 20000 }, (err, _o, stderr) => resolve(err ? String(stderr || err.message) : '')))
6842
- if (r) { this.emit('debug', `could not go back to ${into}: ${r.trim()}`); return null }
6843
- this.log(`🌿 ${here} was merged into ${into} (pull request #${b.merged.number || '?'}): this folder is on ${into} again`)
6844
- for (let i = 0; i < 40 && (!this.git || this.git.branch !== into); i++) await new Promise((res) => setTimeout(res, 250))
6845
- this.checkUpstreamSoon({ fetch: true })
6846
- return into
6847
- }
6848
-
6849
- /** Which of this member's files (or a task's) aren't in git on GitHub yet, and whether agents may commit here themselves (relay-commit.js). People's sessions: nothing (they commit with git). */
6850
- async unpublished ({ files = null, task = null, column = '' } = {}) {
6851
- if (this.kind !== 'agent') return { able: false, unpublished: [] }
6852
- // A clash task (clash.js) is the merge itself: moving it on is how the merge lands, not a commit.
6853
- if (task && [...this.clashes.values()].some((c) => c && c.task === task)) return { able: false, unpublished: [] }
6854
- if (!this.conn || !this.conn.adminRequest) return { able: false, unpublished: [] }
6855
- const r = await this.conn.adminRequest({ op: 'unpublished', files, task, column: String(column || '') })
6856
- return r && r.unpublished ? r.unpublished : { able: false, unpublished: [] }
6857
- }
6858
-
6859
- /** Asks the relay to look again at whether a member's GitHub connection covers this branch's repository (someone just connected). */
6860
- async githubRecheck () {
6861
- if (!this.conn || !this.conn.adminRequest) throw new Error('not connected to the relay')
6862
- const r = await this.conn.adminRequest({ op: 'githubRecheck' })
6863
- return r && r.github ? r.github : null
6864
- }
6865
-
6866
- /** Owner only: what agents may do with commits ('off', 'branches', 'any'). */
6867
- async setAgentCommits (mode) {
6868
- if (!this.isOwner) throw new Error('only the session owner can decide what agents may commit')
6869
- const r = await this.conn.adminRequest({ op: 'agentCommits', agentCommits: String(mode || '') })
6870
- return { agentCommits: r.agentCommits }
6871
- }
6872
-
6873
- /** Whether this folder can make the session's commits: a git checkout on a branch (agents' copies have no git). */
6874
- canCommit () {
6875
- return !!this.git && !!this.git.branch && !!gitDir(this.root) &&
6876
- !(this.access && this.access.state === 'approved' && this.access.role === 'viewer')
6877
- }
6878
-
6879
- /**
6880
- * Commits request `id` here: its files that differ from HEAD, with its message, then pushes to
6881
- * the branch's upstream. Marks it done with the hash and tells the asker. Throws with the reason
6882
- * when it can't (another branch, behind the remote, conflict markers, git refused).
6883
- */
6884
- async commitRequest (id) {
6885
- const r = this.commitRequests.get(String(id || ''))
6886
- if (!r) throw new Error(`no commit request ${id}`)
6887
- if (r.state !== 'open') throw new Error(`that request is already ${r.hash ? `committed (${r.hash.slice(0, 7)})` : 'done'}`)
6888
- if (!this.canCommit()) throw new Error('this folder has no git (or no branch checked out), so it can’t commit: ask for the commit with quilt_request_commit, and a person with git commits it')
6889
- if (r.branch && r.branch !== this.git.branch) throw new Error(`the request is for ${r.branch} and this folder is on ${this.git.branch}`)
6890
- if (this.committing) throw new Error('a commit is already being made here')
6891
- this.committing = r.id
6892
- try {
6893
- return await this.gitTask(() => this.makeCommit(r))
6894
- } catch (err) {
6895
- const now = this.commitRequests.get(r.id)
6896
- if (now && now.state === 'open') this.doc.transact(() => this.commitRequests.set(r.id, { ...now, error: String(err.message).slice(0, 300), errorAt: Date.now() }), LOCAL)
6897
- throw err
6898
- } finally { this.committing = null }
6899
- }
6900
-
6901
- /**
6902
- * Commits everything the session changed since its last commit, from this folder, with `message`,
6903
- * and pushes: the files here that differ from HEAD and sync. `mine` (the git panel's Commit and
6904
- * push): only this person's own work (myUncommitted), boxed or not. It goes on record as a commit
6905
- * request made and done here, so nobody else is asked.
6906
- */
6907
- async commitSessionWork (message, { mine = false } = {}) {
6908
- if (!this.canCommit()) throw new Error('this folder has no git (or no branch checked out), so it can’t commit: commit with git from a folder on this branch')
6909
- let files
6910
- if (mine) {
6911
- const own = await this.myUncommitted()
6912
- files = own ? own.files.map((f) => f.path).filter((rel) => this.syncable(rel)).slice(0, MAX_REQUEST_FILES) : []
6913
- if (!files.length) throw new Error('nothing of yours to commit: your work here is all in git')
6914
- } else {
6915
- const dirty = await uncommitted(this.root)
6916
- files = dirty ? [...dirty.keys()].filter((rel) => this.syncable(rel)).slice(0, MAX_REQUEST_FILES) : []
6917
- }
6918
- if (!files.length) throw new Error('nothing to commit: this folder matches its last commit')
6919
- const r = this.requestCommit(message, { files, by: this.name, quiet: true })
6920
- return this.commitRequest(r.id)
6921
- }
6922
-
6923
- async makeCommit (r) {
6924
- let up = await upstreamOf(this.root)
6925
- if (up && up.remote && up.remote !== '.') {
6926
- const f = await fetchUpstream(this.root, up.remote, up.url)
6927
- if (f.ok) up = await upstreamOf(this.root)
6928
- const head = await headKey(this.root)
6929
- // The remote has commits this folder doesn't: catch up first (Quilt brings them in), then commit on top.
6930
- if (f.ok && up && up.sha && head && head.sha && !(await isAncestor(this.root, up.sha, head.sha))) {
6931
- const diverged = !(await isAncestor(this.root, head.sha, up.sha))
6932
- if (diverged) throw new Error(`${this.git.branch} has diverged from ${up.name}: a person needs to pull here before Quilt can commit on top`)
6933
- this.checkUpstreamSoon({ fetch: false })
6934
- throw new Error(`${up.name} has commits this folder doesn't have yet; Quilt is bringing them in: try again in a moment`)
6935
- }
6936
- }
6937
- const files = cleanFiles(r.files)
6938
- for (const rel of files) {
6939
- let text = null
6940
- try { text = fs.readFileSync(path.join(this.root, rel), 'utf8') } catch {}
6941
- if (text !== null && text.length < 2_000_000 && hasMarkers(text)) throw new Error(`${rel} has conflict markers: resolve them first`)
6942
- }
6943
- const res = await commitFiles(this.root, { files, message: commitMessage(r), upstream: up, branch: this.git.branch })
6944
- const done = { ...this.commitRequests.get(r.id), state: 'done', doneBy: this.name, doneAt: Date.now(), hash: res.hash || '', committed: res.files, pushed: res.pushed, ...(res.pushError ? { pushError: res.pushError } : {}) }
6945
- delete done.error; delete done.errorAt
6946
- this.doc.transact(() => this.commitRequests.set(r.id, done), LOCAL)
6947
- const short = res.hash ? res.hash.slice(0, 7) : ''
6948
- const what = res.nothing
6949
- ? `Nothing to commit for "${r.message}": those files are already in git as they are.`
6950
- : `Committed ${short} on ${this.git.branch}${res.pushed ? ` and pushed to ${up.remote}` : ''}: "${r.message}" (${res.files.length} file${res.files.length === 1 ? '' : 's'}).${res.pushError ? ` Not pushed yet: ${res.pushError}.` : ''}`
6951
- this.log(`${res.nothing ? 'ℹ️' : '✅'} ${what}`)
6952
- // The asker hears it like a handoff: it wakes them, and asks for no reply (duties.js).
6953
- if (!res.nothing && r.by && r.by !== this.name && this.mayTalk()) this.postCommitNote(r.by, what)
6954
- if (r.task && r.task.id && !res.nothing) {
6955
- try { putComment(this.doc, this.taskComments, readTasks(this.tasks), { taskId: r.task.id, by: this.name, text: what }, LOCAL) } catch {}
6956
- }
6957
- this.reviewCommitsSoon()
6958
- return done
6959
- }
6960
-
6961
- /** A chat note to everyone about git (a push from this folder): it wakes nobody, and asks for no reply. */
6962
- postGitNote (text) {
6963
- const msg = { id: crypto.randomBytes(8).toString('hex'), by: this.name, text, ts: Date.now(), kind: 'commit' }
6964
- this.doc.transact(() => {
6965
- this.chat.push([msg])
6966
- if (this.chat.length > 500) this.chat.delete(0, this.chat.length - 500)
6967
- }, LOCAL)
6968
- this.markRead([msg.id])
6969
- }
6970
-
6971
- /** A chat note to `to` that their commit was made: it wakes them, and asks for no reply. */
6972
- postCommitNote (to, text) {
6973
- const msg = { id: crypto.randomBytes(8).toString('hex'), by: this.name, to, text, ts: Date.now(), kind: 'commit' }
6974
- this.doc.transact(() => {
6975
- this.chat.push([msg])
6976
- if (this.chat.length > 500) this.chat.delete(0, this.chat.length - 500)
6977
- }, LOCAL)
6978
- this.markRead([msg.id])
6979
- }
6980
-
6981
- /**
6982
- * The uncommitted work in this folder, by who made it: { groups: [{ by, files }], requests:
6983
- * [{ id, editors: { path: [names] }, blocker }] } (null without git). For the app's commit panel
6984
- * and for committing by itself.
6985
- */
6986
- async uncommittedWork () {
6987
- if (!this.canCommit()) return null
6988
- const dirty = await uncommitted(this.root)
6989
- if (!dirty) return null
6990
- const rels = [...dirty.keys()].filter((rel) => this.syncable(rel))
6991
- const history = this.history.entries()
6992
- const byPath = new Map()
6993
- for (const h of history) { if (h && h.path) { if (!byPath.has(h.path)) byPath.set(h.path, []); byPath.get(h.path).push(h) } }
6994
- // Who changed a file is worked out again only when the file, its history or HEAD changed since the
6995
- // last look. Done for every file every two minutes (reviewCommits), reading each one and its copy
6996
- // at HEAD and diffing them, it held hundreds of MB at a time in a folder far from its HEAD.
6997
- const head = ((await this.gitOut(['rev-parse', '--verify', '-q', 'HEAD'])) || '').trim()
6998
- const keyOf = (rel) => {
6999
- let st = null
7000
- try { st = fs.statSync(path.join(this.root, rel)) } catch {}
7001
- const h = byPath.get(rel) || []
7002
- return `${head} ${dirty.get(rel)} ${st ? `${st.size} ${st.mtimeMs}` : '-'} ${h.length} ${h.length ? h[h.length - 1].ts : 0}`
7003
- }
7004
- const cache = (this.editorsCache ||= new Map())
7005
- const editors = new Map()
7006
- const todo = new Map() // rel -> its key
7007
- for (const rel of rels) {
7008
- const key = keyOf(rel)
7009
- const c = cache.get(rel)
7010
- if (c && c.key === key) editors.set(rel, c.names)
7011
- else todo.set(rel, key)
7012
- }
7013
- // Each changed line is put down to whoever added or removed it last; a file git or this folder
7014
- // can't give as text falls back to who edited it since it was last committed.
7015
- const before = todo.size ? await filesAt(this.root, 'HEAD', [...todo.keys()].filter((rel) => dirty.get(rel) !== 'A')) || new Map() : new Map()
7016
- const rest = []
7017
- for (const rel of todo.keys()) {
7018
- const was = dirty.get(rel) === 'A' ? '' : before.get(rel)
7019
- let now = ''
7020
- if (dirty.get(rel) !== 'D') { try { now = fs.readFileSync(path.join(this.root, rel), 'utf8') } catch { now = null } }
7021
- if (typeof was !== 'string' || typeof now !== 'string' || now.includes('\0') || was.includes('\0')) { rest.push(rel); continue }
7022
- editors.set(rel, blameChange({ before: was, after: now, entries: byPath.get(rel) || [] }))
7023
- }
7024
- if (rest.length) for (const [rel, names] of editorsSince(rest, history, await lastCommitted(this.root))) editors.set(rel, names)
7025
- for (const [rel, key] of todo) cache.set(rel, { key, names: editors.get(rel) })
7026
- for (const rel of cache.keys()) if (!dirty.has(rel)) cache.delete(rel)
7027
- const st = this.commitStatus({ includeMe: false })
7028
- const busy = st.busy.map((b) => b.name)
7029
- const claims = [...this.claims.values()]
7030
- const requests = []
7031
- for (const r of st.open) {
7032
- const mine = new Map(cleanFiles(r.files).filter((f) => editors.has(f)).map((f) => [f, editors.get(f)]))
7033
- const markers = new Set()
7034
- for (const rel of mine.keys()) {
7035
- try { const t = fs.readFileSync(path.join(this.root, rel), 'utf8'); if (t.length < 2_000_000 && hasMarkers(t)) markers.add(rel) } catch {}
7036
- }
7037
- const taskEditors = new Set(r.task ? history.filter((e) => e.task && e.task.id === r.task.id && !e.pulled).map((e) => e.by) : [])
7038
- const blocker = r.branch && r.branch !== this.git.branch ? `for ${r.branch}` : !mine.size ? 'nothing to commit' : requestWarning(r, { editors: mine, claims, markers, taskEditors, busy })
7039
- requests.push({ id: r.id, editors: Object.fromEntries(mine), blocker })
7040
- }
7041
- return { branch: this.git.branch, groups: uncommittedByPerson(editors), requests }
7042
- }
7043
-
7044
3765
  /** Whether a name the history gives is this person's: theirs, one of their AI sessions', or their cloud AI's. */
7045
3766
  ownName (name) {
7046
3767
  if (this.isMine(name)) return true
@@ -7058,79 +3779,6 @@ export class Session extends EventEmitter {
7058
3779
  return this.cloudNames
7059
3780
  }
7060
3781
 
7061
- /**
7062
- * This person's own work not committed in this folder (theirs and their AIs'; nobody else's), for
7063
- * the app's git panel: { branch, files: [{ path, boxed }] }, `boxed` when it is in their box (in
7064
- * this folder only so far), else everyone has it. Null without git.
7065
- */
7066
- async myUncommitted () {
7067
- const work = await this.uncommittedWork()
7068
- if (!work) return null
7069
- const dirty = new Set(work.groups.flatMap((g) => g.files))
7070
- const paths = new Set(work.groups.filter((g) => g.by && this.ownName(g.by)).flatMap((g) => g.files))
7071
- const boxed = new Set()
7072
- for (const c of this.claims.values()) if (c && c.box && this.ownClaim(c) && dirty.has(c.pattern)) boxed.add(c.pattern)
7073
- for (const rel of boxed) paths.add(rel)
7074
- return { branch: work.branch, files: [...paths].sort().map((p) => ({ path: p, boxed: boxed.has(p) })) }
7075
- }
7076
-
7077
- /** Looks at the open requests again soon (one came in, or one was committed). */
7078
- reviewCommitsSoon (ms = 5000) {
7079
- if (this.stopped || !this.canCommit()) return
7080
- clearTimeout(this.reviewCommitsTimer)
7081
- this.reviewCommitsTimer = setTimeout(() => this.reviewCommits().catch((err) => this.emit('debug', `commit requests: ${err.message}`)), ms)
7082
- if (this.reviewCommitsTimer.unref) this.reviewCommitsTimer.unref()
7083
- }
7084
-
7085
- /**
7086
- * In a folder that can commit: a request on this branch whose files are already in git as they
7087
- * are (its asker committed them, or someone did) is closed quietly once it is a minute old, and
7088
- * the app's count of what is not committed here is brought up to date.
7089
- */
7090
- async reviewCommits () {
7091
- if (this.stopped || !this.canCommit() || this.committing || this.hold) return
7092
- const work = await this.uncommittedWork()
7093
- if (!work) return
7094
- // For the app's commit chip: how much work here is not in git yet, and whose.
7095
- const summary = { files: new Set(work.groups.flatMap((g) => g.files)).size, people: work.groups.filter((g) => g.by).map((g) => g.by) }
7096
- if (JSON.stringify(summary) !== JSON.stringify(this.uncommittedSummary)) { this.uncommittedSummary = summary; this.emit('status-changed') }
7097
- let next = Infinity
7098
- for (const w of work.requests) {
7099
- const r = this.commitRequests.get(w.id)
7100
- if (!r || r.state !== 'open' || (r.branch && r.branch !== work.branch)) continue
7101
- const young = Date.now() - (r.ts || 0) < REQUEST_SETTLE_MS
7102
- if (young) { next = Math.min(next, r.ts + REQUEST_SETTLE_MS - Date.now()); continue }
7103
- if (w.blocker === 'nothing to commit') {
7104
- this.doc.transact(() => this.commitRequests.set(r.id, { ...r, state: 'done', doneBy: this.name, doneAt: Date.now(), hash: '', committed: [], note: 'already in git' }), LOCAL)
7105
- continue
7106
- }
7107
- }
7108
- if (next < Infinity) this.reviewCommitsSoon(Math.max(1000, next + 500))
7109
- }
7110
-
7111
- /**
7112
- * Who's still working, open commit requests, and whether it's a good moment
7113
- * to commit. `includeMe: false` leaves out our own AI (it's the one asking).
7114
- */
7115
- commitStatus ({ includeMe = true } = {}) {
7116
- const st = this.status()
7117
- const people = [...(includeMe ? [{ ...st.me, work: this.work, isMe: true }] : []), ...st.peers]
7118
- const busy = []
7119
- for (const p of people) {
7120
- const a = p.agent
7121
- if (a && a.sharing !== false && a.status === 'working') busy.push({ name: p.name, why: `${p.isMe ? 'your' : `${p.name}'s`} ${a.tool || 'AI'} is working` })
7122
- else if (p.work && p.work.state === 'working') busy.push({ name: p.name, why: `${p.isMe ? 'you are' : `${p.name} is`} working${p.work.note ? `: ${p.work.note}` : ''}` })
7123
- }
7124
- const requests = [...this.commitRequests.values()].sort((a, b) => a.ts - b.ts)
7125
- return {
7126
- open: requests.filter((r) => r.state === 'open'),
7127
- recent: requests.filter((r) => r.state === 'done').slice(-5),
7128
- busy,
7129
- ready: busy.length === 0,
7130
- canCommit: this.canCommit()
7131
- }
7132
- }
7133
-
7134
3782
  setFocus (text) {
7135
3783
  const focus = String(text || '').slice(0, 500)
7136
3784
  // The stitching clock starts with something new (or again after done) and stops with no focus.
@@ -7835,7 +4483,6 @@ export class Session extends EventEmitter {
7835
4483
 
7836
4484
  releaseQuietAutoClaims () {
7837
4485
  if (!this.ready || this.stopped) return
7838
- this.settleBoxesSoon()
7839
4486
  this.handOnForgotten().catch(() => {})
7840
4487
  const cutoff = Date.now() - this.autoClaimQuietMs
7841
4488
  this.releaseAutoClaims((rel, ts) => ts <= cutoff).then(() => {
@@ -7850,95 +4497,6 @@ export class Session extends EventEmitter {
7850
4497
  return c && c.box && this.ownClaim(c) ? c : null
7851
4498
  }
7852
4499
 
7853
- /** settleBoxes in a moment: after a commit, a file put back, or an edit to one of our boxed files. */
7854
- settleBoxesSoon (ms = SETTLE_BOXES_MS) {
7855
- if (this.stopped || !this.git || this.settleBoxesTimer || this.inSquare || this.syncStopped) return
7856
- this.settleBoxesTimer = setTimeout(() => {
7857
- this.settleBoxesTimer = null
7858
- this.settleBoxes().catch((err) => this.log(`could not look at your boxes: ${err.message}`))
7859
- }, ms)
7860
- if (this.settleBoxesTimer.unref) this.settleBoxesTimer.unref()
7861
- }
7862
-
7863
- /**
7864
- * Our boxes whose file this folder's git has exactly as the session has it (committed, or put back to what
7865
- * was committed) open, and their claims go: the work is no longer unfinished. A box with edits since stays.
7866
- * Resolves to how many opened.
7867
- */
7868
- async settleBoxes () {
7869
- if (!this.git || !this.conn || !this.conn.connected || this.stopped) return 0
7870
- if (this.settlingBoxes) { this.settleBoxesAgain = true; return 0 }
7871
- this.settlingBoxes = true
7872
- let n = 0
7873
- try {
7874
- do {
7875
- this.settleBoxesAgain = false
7876
- n += await this.settleBoxesOnce()
7877
- } while (this.settleBoxesAgain && !this.stopped)
7878
- } finally { this.settlingBoxes = false }
7879
- if (n) this.log(`📦 ${n} boxed file${n === 1 ? '' : 's'} committed: in everyone's folder now`)
7880
- return n
7881
- }
7882
-
7883
- async settleBoxesOnce () {
7884
- const ours = [...this.claims.values()].filter((c) => c.box && this.ownClaim(c))
7885
- if (!ours.length) return 0
7886
- // A file the session no longer shares (ignored since, say in .gitignore) is never committed here, so its
7887
- // box would stay forever: it lets go. Nothing goes out: the file isn't synced.
7888
- for (const c of ours.filter((x) => !this.syncable(x.pattern))) {
7889
- this.autoClaims.delete(c.pattern)
7890
- this.autoVia.delete(c.pattern)
7891
- try {
7892
- await this.conn.claimRequest({ op: 'unbox', pattern: c.pattern, release: true })
7893
- this.boxFrom.delete(c.pattern)
7894
- this.log(`📦 ${c.pattern} isn't shared any more (ignored): let go of its box`)
7895
- } catch (err) { this.log(`could not let go of the box on ${c.pattern}: ${err.message}`) }
7896
- }
7897
- const mine = ours.filter((c) => this.syncable(c.pattern))
7898
- if (!mine.length) return 0
7899
- const head = await headKey(this.root)
7900
- if (!head || !head.sha) return 0
7901
- const rels = mine.map((c) => c.pattern)
7902
- const at = await filesAt(this.root, head.sha, rels)
7903
- if (!at) return 0
7904
- // What git couldn't read (a large file, one behind a filter such as Git LFS): committed when the folder
7905
- // has the session's version and git has nothing to commit there.
7906
- const unread = rels.filter((rel) => at.get(rel) === undefined)
7907
- const dirty = unread.length ? await this.gitOut(['status', '--porcelain', '-z', '--', ...unread]) : ''
7908
- let n = 0
7909
- const asks = []
7910
- for (const c of mine) {
7911
- const rel = c.pattern
7912
- const shared = this.sharedKey(rel) ?? null
7913
- let committed = at.get(rel) // a key, null (not in the commit) or undefined (git couldn't say)
7914
- if (committed === undefined) {
7915
- if (dirty === null || dirty.split('\0').some((l) => toPosix(l.replace(/^[ MADRCTU?!]{1,2} /, '')) === rel)) continue
7916
- const d = this.readDisk(rel)
7917
- committed = d && !d.skip && !d.tooLarge ? d.key : d ? undefined : null
7918
- if (committed === undefined) continue
7919
- }
7920
- // Committed (or put back), or back to what the session had before the box: nothing unfinished of ours in it.
7921
- // Where git converts line endings (core.autocrlf, as Git for Windows sets it), the commit as checkout
7922
- // writes it (CRLF) and an AI's LF copy differ only in carriage returns, though git calls it committed:
7923
- // git itself decides (committedAs), or such a box would never open.
7924
- if (committed !== shared && !(this.boxFrom.has(rel) && this.boxFrom.get(rel) === shared) &&
7925
- !(typeof committed === 'string' && typeof shared === 'string' && !shared.startsWith('bin:') &&
7926
- committed.replace(/\r/g, '') === shared.replace(/\r/g, '') && await committedAs(this.root, head.sha, rel, shared))) continue
7927
- // Not on the list from here (in this tick, with the request sent): an edit landing next opens a new box,
7928
- // never one that goes out without a box.
7929
- const was = this.autoClaims.get(rel)
7930
- const via = this.autoVia.get(rel)
7931
- this.autoClaims.delete(rel)
7932
- this.autoVia.delete(rel)
7933
- asks.push(this.conn.claimRequest({ op: 'unbox', pattern: rel, release: true }).then((r) => { if (r.unboxed) n++; this.boxFrom.delete(rel) }, (err) => {
7934
- if (was !== undefined && !this.autoClaims.has(rel)) { this.autoClaims.set(rel, was); if (via) this.autoVia.set(rel, via) }
7935
- this.log(`could not open the box on ${rel}: ${err.message}`)
7936
- }))
7937
- }
7938
- await Promise.all(asks)
7939
- return n
7940
- }
7941
-
7942
4500
  /**
7943
4501
  * The check every agent makes before it changes files, whatever tool it runs in (the local
7944
4502
  * MCP's quilt_before_edit, or a Claude Code hook): for each path, whether it is ours to edit
@@ -8103,12 +4661,6 @@ export class Session extends EventEmitter {
8103
4661
  */
8104
4662
  forPerson (via = null) { return this.kind !== 'agent' && !this.persona(via) }
8105
4663
 
8106
- /** Whether Quilt pushes this person's local commits for them (Settings, off unless they turn it on; read fresh so a change applies at once). */
8107
- pushesLocalCommits () {
8108
- if (this.pushLocalCommits != null) return this.pushLocalCommits === true
8109
- try { return getSettings().pushLocalCommits === true } catch { return false }
8110
- }
8111
-
8112
4664
  /** Whether our AI picks up tasks by itself: "off", "mine" or "any" (Settings, read fresh so a change applies at once). */
8113
4665
  pickupMode () {
8114
4666
  if (this.aiTasks != null) return pickupMode(this.aiTasks)
@@ -8201,7 +4753,6 @@ export class Session extends EventEmitter {
8201
4753
  const released = await this.releaseAutoClaims(this.persona(via) ? (rel) => this.autoVia.get(rel) === via : () => true)
8202
4754
  if (this.work?.state === 'working') this.setWork('done')
8203
4755
  this.workFromEdits = false
8204
- await this.settleBoxes().catch(() => 0) // a box committed meanwhile opens now
8205
4756
  return { released }
8206
4757
  }
8207
4758
 
@@ -8252,7 +4803,6 @@ export class Session extends EventEmitter {
8252
4803
  */
8253
4804
  async boxes () {
8254
4805
  const text = (d) => (!d || d.skip || d.tooLarge ? null : d.binary ? { binary: true } : d.text)
8255
- let head
8256
4806
  const out = []
8257
4807
  for (const c of this.claims.values()) {
8258
4808
  if (!c || !c.box || /[*?[]/.test(c.pattern) || !isSafeRelPath(c.pattern)) continue
@@ -8262,9 +4812,8 @@ export class Session extends EventEmitter {
8262
4812
  const mine = this.ownClaim(c)
8263
4813
  let before = null
8264
4814
  if (mine) {
8265
- if (head === undefined) head = this.git ? await headKey(this.root).catch(() => null) : null
8266
- const buf = head && head.sha ? await blobAt(this.root, head.sha, rel).catch(() => null) : null
8267
- before = buf === null ? null : looksBinary(buf) ? { binary: true } : buf.toString('utf8')
4815
+ const base = this.boxBase.has(rel) ? this.boxBase.get(rel) : null
4816
+ before = base === null ? null : typeof base.text === 'string' ? base.text : { binary: true }
8268
4817
  } else if (this.boxBase.has(rel)) {
8269
4818
  const base = this.boxBase.get(rel)
8270
4819
  before = base === null ? null : typeof base.text === 'string' ? base.text : { binary: true }
@@ -8326,7 +4875,7 @@ export class Session extends EventEmitter {
8326
4875
  // A box someone else held opened (committed, unpacked, handed on or released): its file reaches this folder now.
8327
4876
  // (Also one that opened while this folder was away: its ghost is still on record.)
8328
4877
  // A box handed to us (our cloud AI stopped with it, server.js handBackCloudBoxes): our git may have it already.
8329
- if (this.ready && [...next].some(([p, c]) => c.box && c.handedFrom && this.ownClaim(c) && !(was.get(p) && this.ownClaim(was.get(p))))) this.settleBoxesSoon()
4878
+ if (this.ready && [...next].some(([p, c]) => c.box && c.handedFrom && this.ownClaim(c) && !(was.get(p) && this.ownClaim(was.get(p))))) this.scheduleStatusWrite()
8330
4879
  if (this.ready) {
8331
4880
  const opened = new Set([...was].filter(([, c]) => c.box && !this.ownClaim(c)).map(([p]) => p))
8332
4881
  for (const p of this.boxBase.keys()) opened.add(p)
@@ -8791,7 +5340,6 @@ export class Session extends EventEmitter {
8791
5340
  for (const [n, v] of Object.entries(this.typing)) if (n !== this.name && now - v.ts < v.ms) typing.set(n, { to: v.to || null })
8792
5341
  for (const [id, s] of states) {
8793
5342
  if (id === this.doc.clientID || !s || !s.name) continue
8794
- const git = cleanGit(s.git)
8795
5343
  peers.push({
8796
5344
  name: s.name,
8797
5345
  // An older app keeps its person's stale tool after their AI connects: show the AI that did.
@@ -8806,10 +5354,8 @@ export class Session extends EventEmitter {
8806
5354
  focus: s.focus || '',
8807
5355
  ...(s.focusAt > 0 ? { focusAt: Number(s.focusAt) } : {}),
8808
5356
  editing: recentEdits(s.editing, now),
8809
- git,
8810
- // The branch document their folder is on: from their git presence (mid-switch, where they're headed),
8811
- // or, for a folder without git (cleanGit(s.git) is then null), the plain `branch` field announceBranch sets.
8812
- branch: (git && (git.on || git.key)) || (typeof s.branch === 'string' ? s.branch : null)
5357
+ // The branch document their folder is on (the `branch` field of their presence).
5358
+ branch: typeof s.branch === 'string' ? s.branch : null
8813
5359
  })
8814
5360
  }
8815
5361
  // AI sessions working through someone's app (an older app still lists them in its presence)
@@ -8878,12 +5424,6 @@ export class Session extends EventEmitter {
8878
5424
  voice: { transcribe: transcribeSetting(this.doc, this.members).on },
8879
5425
  peers,
8880
5426
  claims: [...this.claims.values()].sort((a, b) => a.ts - b.ts),
8881
- commits: [...this.commitRequests.values()].sort((a, b) => a.ts - b.ts),
8882
- canCommit: this.canCommit(),
8883
- agentCommits: (this.access && this.access.agentCommits) || 'branches',
8884
- gitForEveryone: this.gitForEveryone(),
8885
- // A rule over the session (its org's, a team's, its workspace's: server.js refreshPolicy): a ceiling for the owner's own choice.
8886
- ...(this.access && this.access.agentCommitsBy ? { agentCommitsBy: this.access.agentCommitsBy } : {}),
8887
5427
  tasks: this.taskList(),
8888
5428
  // Without the texts (up to 400 KB a record): status goes out on every
8889
5429
  // change. The full records are at GET /merges and the app's merges route.
@@ -8893,9 +5433,7 @@ export class Session extends EventEmitter {
8893
5433
  changes: this.changes().people.map((p) => ({ ...p, files: p.files.slice(0, 10), pulled: p.pulled && { ...p.pulled, files: p.pulled.files.slice(0, 10) } })),
8894
5434
  chat: this.messages({ limit: 20, markRead: false }),
8895
5435
  unread: this.unreadCount(),
8896
- fileCount: this.files.size + this.blobs.size,
8897
- git: this.git ? { branch: this.git.branch, key: this.git.key, full: !!this.branchList.find((b) => b.key === this.branch)?.full, hold: this.hold ? { kind: this.hold.kind, since: this.hold.since, to: this.hold.to || null, conflict: this.hold.conflict || null, waiting: this.hold.waiting || null } : null, pull: this.pull, upstream: this.upstream, repo: this.repo, others: (this.repo ? this.repo.branches.map((b) => b.name) : []).filter((b) => !this.branchList.some((x) => x.key === b)) } : null,
8898
- branches: branchBoard([{ name: this.name, git: this.gitSummary() }, ...peers.map((p) => ({ name: p.name, git: p.git, persona: !!p.persona }))], this.branchList)
5436
+ fileCount: this.files.size + this.blobs.size
8899
5437
  }
8900
5438
  }
8901
5439
 
@@ -8908,15 +5446,9 @@ export class Session extends EventEmitter {
8908
5446
  }
8909
5447
 
8910
5448
  async stop () {
8911
- for (const w of this.pullWait.values()) clearTimeout(w.timer)
8912
5449
  this.ready = false
8913
- // Leaving: a person's git is as it was before Quilt (no helper, no hook of Quilt's).
8914
- if (this.kind !== 'agent' && !this.stopped) await this.syncPersonGit({ leaving: true }).catch(() => {})
8915
- clearTimeout(this.pushTimer)
8916
5450
  clearInterval(this.autoClaimTimer)
8917
5451
  clearInterval(this.personaTimer)
8918
- clearInterval(this.reviewCommitsTimer2)
8919
- clearTimeout(this.reviewCommitsTimer)
8920
5452
  if (this.autoClaims.size && this.conn && !this.stopped) {
8921
5453
  await Promise.race([this.releaseAutoClaims(), new Promise((r) => setTimeout(r, 2000))])
8922
5454
  }