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.
- package/RELEASES.md +10 -0
- package/bin/quilt.js +7 -143
- package/package.json +1 -1
- package/src/account.js +0 -29
- package/src/api/memory-store.js +0 -29
- package/src/api/routes/teams.js +0 -23
- package/src/api/server.js +5 -19
- package/src/api/supabase-store.js +0 -52
- package/src/branch-view.js +6 -131
- package/src/control.js +2 -33
- package/src/duties.js +1 -1
- package/src/github-commit.js +3 -315
- package/src/mcp.js +8 -157
- package/src/relay-ask.js +3 -66
- package/src/relay-cluster.js +3 -5
- package/src/relay-mcp.js +6 -418
- package/src/relay-prune.js +6 -145
- package/src/relay-turn-sync.js +1 -1
- package/src/server.js +33 -1331
- package/src/session-follow.js +18 -5
- package/src/session.js +58 -3526
- package/src/status.js +2 -58
- package/src/turn-sync.js +15 -2
- package/src/ui/changes.js +5 -14
- package/src/ui/chat.js +0 -1
- package/src/ui/common.js +0 -2
- package/src/ui/git-rule.js +2 -30
- package/src/ui/home.js +3 -76
- package/src/ui/session.js +34 -528
- package/src/ui/tree.js +2 -8
- package/src/ui/workspaces.js +2 -122
- package/src/ui-server.js +1 -38
- package/src/api/routes/github.js +0 -380
- package/src/branches.js +0 -194
- package/src/clash.js +0 -128
- package/src/commit-request.js +0 -59
- package/src/commit.js +0 -213
- package/src/git-hook.js +0 -48
- package/src/gitignore.js +0 -49
- package/src/gitstate.js +0 -1069
- package/src/relay-ci.js +0 -111
- package/src/relay-clash.js +0 -214
- package/src/relay-commit.js +0 -923
- package/src/relay-filekey.js +0 -46
- package/src/relay-git.js +0 -274
- package/src/relay-githttp.js +0 -166
- package/src/relay-github.js +0 -109
- package/src/relay-outbound.js +0 -1295
- package/src/relay-repo-moves.js +0 -43
- package/src/relay-sync.js +0 -795
- package/src/relay-upstream.js +0 -889
- package/src/sync-words.js +0 -42
- package/src/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
|
|
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 {
|
|
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(
|
|
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
|
-
//
|
|
676
|
-
//
|
|
677
|
-
|
|
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 =
|
|
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
|
-
//
|
|
688
|
-
|
|
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, ...
|
|
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
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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.
|
|
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
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
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) {
|
|
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
|
-
|
|
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)
|
|
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)
|
|
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 },
|
|
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
|
-
|
|
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
|
-
|
|
8266
|
-
|
|
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.
|
|
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
|
-
|
|
8810
|
-
|
|
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
|
}
|