heyquilt 0.0.1 → 0.3.90
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/LICENSE +85 -0
- package/README.md +630 -2
- package/RELEASES.md +733 -0
- package/assets/logo.svg +1 -0
- package/bin/quilt.js +816 -0
- package/desktop/main.js +315 -0
- package/package.json +163 -7
- package/src/account.js +563 -0
- package/src/admit-policy.js +32 -0
- package/src/agent-join.js +370 -0
- package/src/agent-task-workflow.js +176 -0
- package/src/agents/actions.js +85 -0
- package/src/agents/claude-code.js +180 -0
- package/src/agents/common.js +21 -0
- package/src/agents/cursor-transcripts.js +170 -0
- package/src/agents/cursor.js +404 -0
- package/src/agents/index.js +69 -0
- package/src/ai-vm-signin.js +98 -0
- package/src/ai-vm.js +380 -0
- package/src/api/access.js +75 -0
- package/src/api/activity.js +181 -0
- package/src/api/agent-auth.js +130 -0
- package/src/api/agent-placement.js +157 -0
- package/src/api/audit.js +83 -0
- package/src/api/auth.js +17 -0
- package/src/api/domains.js +30 -0
- package/src/api/file-paths.js +40 -0
- package/src/api/file-store.js +99 -0
- package/src/api/fly-machines.js +76 -0
- package/src/api/github-app.js +172 -0
- package/src/api/http.js +34 -0
- package/src/api/invite-email.js +53 -0
- package/src/api/issues.js +110 -0
- package/src/api/join-text.js +78 -0
- package/src/api/mailer.js +47 -0
- package/src/api/memory-store.js +1083 -0
- package/src/api/org-access.js +54 -0
- package/src/api/permissions.js +71 -0
- package/src/api/relay-cluster.js +251 -0
- package/src/api/relay-machines.js +60 -0
- package/src/api/routes/access-types.js +54 -0
- package/src/api/routes/agent-invites.js +115 -0
- package/src/api/routes/agents.js +157 -0
- package/src/api/routes/ai-vm.js +369 -0
- package/src/api/routes/github.js +380 -0
- package/src/api/routes/grants.js +70 -0
- package/src/api/routes/invites.js +188 -0
- package/src/api/routes/issues.js +47 -0
- package/src/api/routes/join.js +167 -0
- package/src/api/routes/members.js +110 -0
- package/src/api/routes/orgs.js +177 -0
- package/src/api/routes/relay-cluster.js +90 -0
- package/src/api/routes/relay.js +76 -0
- package/src/api/routes/session-invites.js +119 -0
- package/src/api/routes/sessions.js +92 -0
- package/src/api/routes/teams.js +132 -0
- package/src/api/routes/workspace-agents.js +332 -0
- package/src/api/routes/workspace-files.js +277 -0
- package/src/api/routes/workspace-invites.js +196 -0
- package/src/api/routes/workspaces.js +225 -0
- package/src/api/server.js +623 -0
- package/src/api/slugs.js +20 -0
- package/src/api/supabase-store.js +765 -0
- package/src/api/team-access.js +30 -0
- package/src/api/tokens.js +31 -0
- package/src/api/workspace-access.js +62 -0
- package/src/api/workspace-reach.js +60 -0
- package/src/autowork-tools.js +166 -0
- package/src/autowork.js +387 -0
- package/src/blobstore.js +99 -0
- package/src/boxes.js +37 -0
- package/src/branchdocs.js +235 -0
- package/src/branches.js +194 -0
- package/src/catchup.js +111 -0
- package/src/chat-archive.js +85 -0
- package/src/chat-links.js +923 -0
- package/src/clash.js +140 -0
- package/src/cloud-ai.js +50 -0
- package/src/commit-request.js +59 -0
- package/src/commit.js +213 -0
- package/src/connection.js +569 -0
- package/src/control.js +245 -0
- package/src/conversation.js +125 -0
- package/src/doctor.js +156 -0
- package/src/duties.js +303 -0
- package/src/editors.js +228 -0
- package/src/fsutil.js +87 -0
- package/src/git-hook.js +48 -0
- package/src/git.js +147 -0
- package/src/github-commit.js +320 -0
- package/src/gitignore.js +49 -0
- package/src/gitstate.js +900 -0
- package/src/history.js +307 -0
- package/src/hooks.js +371 -0
- package/src/hosted-agents.js +24 -0
- package/src/hosted-client.js +112 -0
- package/src/identity.js +99 -0
- package/src/inbox.js +237 -0
- package/src/integrations.js +221 -0
- package/src/key-cli.js +115 -0
- package/src/largefiles.js +91 -0
- package/src/legacy.js +25 -0
- package/src/mcp-proxy.js +40 -0
- package/src/mcp-room.js +32 -0
- package/src/mcp.js +1261 -0
- package/src/merge3.js +158 -0
- package/src/merges.js +148 -0
- package/src/pass-source.js +176 -0
- package/src/passes.js +64 -0
- package/src/pathrules.js +127 -0
- package/src/persona.js +101 -0
- package/src/presence.js +360 -0
- package/src/private-file.js +41 -0
- package/src/procs.js +70 -0
- package/src/project-rules.js +178 -0
- package/src/protocol.js +92 -0
- package/src/quiet-warnings.js +9 -0
- package/src/relay-ai-vm.js +82 -0
- package/src/relay-ask.js +141 -0
- package/src/relay-ci.js +111 -0
- package/src/relay-clash.js +597 -0
- package/src/relay-cluster.js +454 -0
- package/src/relay-commit.js +887 -0
- package/src/relay-filekey.js +46 -0
- package/src/relay-git.js +274 -0
- package/src/relay-githttp.js +137 -0
- package/src/relay-github.js +109 -0
- package/src/relay-lease.js +137 -0
- package/src/relay-lfs.js +37 -0
- package/src/relay-mcp.js +1679 -0
- package/src/relay-metrics.js +75 -0
- package/src/relay-outbound.js +1144 -0
- package/src/relay-sync.js +719 -0
- package/src/relay-turn-sync.js +307 -0
- package/src/relay-upstream.js +934 -0
- package/src/relay-webhooks.js +111 -0
- package/src/releases.js +131 -0
- package/src/report.js +129 -0
- package/src/runner.js +306 -0
- package/src/same-edit.js +18 -0
- package/src/server.js +4187 -0
- package/src/session-access.js +170 -0
- package/src/session-name.js +15 -0
- package/src/session.js +7361 -0
- package/src/settings.js +46 -0
- package/src/setup.js +184 -0
- package/src/shell-path.js +46 -0
- package/src/status.js +199 -0
- package/src/summarize.js +109 -0
- package/src/sync-words.js +42 -0
- package/src/task-comments.js +92 -0
- package/src/tasks.js +670 -0
- package/src/textdiff.js +20 -0
- package/src/turn-sync-plan.js +142 -0
- package/src/turn-sync.js +191 -0
- package/src/ui/access-form.js +37 -0
- package/src/ui/agent-env-key.js +76 -0
- package/src/ui/agent-guide.js +127 -0
- package/src/ui/agent-kinds.js +175 -0
- package/src/ui/agent-place.js +265 -0
- package/src/ui/app.css +2309 -0
- package/src/ui/app.js +542 -0
- package/src/ui/board.js +465 -0
- package/src/ui/boxes.js +76 -0
- package/src/ui/branches.js +111 -0
- package/src/ui/catchup.js +77 -0
- package/src/ui/changes.js +159 -0
- package/src/ui/chat.js +266 -0
- package/src/ui/cloud-ai-model.js +107 -0
- package/src/ui/cloud-ai.js +221 -0
- package/src/ui/common.js +431 -0
- package/src/ui/csv.js +21 -0
- package/src/ui/diffview.js +74 -0
- package/src/ui/feed-convs.js +39 -0
- package/src/ui/feed.js +174 -0
- package/src/ui/files.js +579 -0
- package/src/ui/fileview.js +123 -0
- package/src/ui/git-rule.js +60 -0
- package/src/ui/home.js +1323 -0
- package/src/ui/index.html +16 -0
- package/src/ui/invite.js +97 -0
- package/src/ui/invites.js +79 -0
- package/src/ui/loom-model.js +516 -0
- package/src/ui/loom.css +349 -0
- package/src/ui/loom.js +753 -0
- package/src/ui/mark.js +104 -0
- package/src/ui/merges.js +158 -0
- package/src/ui/rail-model.js +78 -0
- package/src/ui/rail.js +173 -0
- package/src/ui/releases.js +182 -0
- package/src/ui/schedule.js +156 -0
- package/src/ui/session.js +3394 -0
- package/src/ui/signin.js +75 -0
- package/src/ui/sounds.js +110 -0
- package/src/ui/tool-logo.js +86 -0
- package/src/ui/tree.js +276 -0
- package/src/ui/workspaces.js +542 -0
- package/src/ui-server.js +1272 -0
- package/src/update-check.js +67 -0
- package/src/upstream.js +155 -0
- package/src/webhooks.js +235 -0
- package/src/workspace-cli.js +62 -0
- package/src/workspace-tools.js +340 -0
- package/index.js +0 -2
package/src/gitstate.js
ADDED
|
@@ -0,0 +1,900 @@
|
|
|
1
|
+
// What git is doing to a synced folder. Quilt reads git: it asks git whether a burst of file changes was an edit, a discard (stash,
|
|
2
|
+
// reset, restore), new commits (pull, merge, rebase) or a branch switch, and
|
|
3
|
+
// what a file looked like at a commit, so the shared work can be kept apart
|
|
4
|
+
// from what git did. It writes to git in two ways only, both at the end of
|
|
5
|
+
// this file: a background fetch (remote-tracking refs, never files or
|
|
6
|
+
// branches), and moving the folder's own branch forward to commits it is
|
|
7
|
+
// behind (fast-forward only, never merging history; see bringIn in session.js). Every git call is asynchronous (the app's window and its
|
|
8
|
+
// relay connection never wait on git); the file reads below are synchronous.
|
|
9
|
+
import fs from 'node:fs'
|
|
10
|
+
import os from 'node:os'
|
|
11
|
+
import path from 'node:path'
|
|
12
|
+
import { execFile } from 'node:child_process'
|
|
13
|
+
import { watch } from 'chokidar'
|
|
14
|
+
import { looksBinary, sha1, MAX_STORED_BINARY_BYTES } from './fsutil.js'
|
|
15
|
+
|
|
16
|
+
export const GIT_TIMEOUT_MS = 5000
|
|
17
|
+
export const SETTLE_MS = 2000
|
|
18
|
+
export const BURST_PATHS = 20
|
|
19
|
+
// An index.lock this old, with no other operation under way, may be one a crashed git left behind.
|
|
20
|
+
export const STALE_LOCK_MS = 60 * 1000
|
|
21
|
+
|
|
22
|
+
const MARKERS = [
|
|
23
|
+
['index.lock', 'index-lock'], ['MERGE_HEAD', 'merge'], ['rebase-merge', 'rebase'], ['rebase-apply', 'rebase'],
|
|
24
|
+
['CHERRY_PICK_HEAD', 'cherry-pick'], ['REVERT_HEAD', 'revert'], ['BISECT_LOG', 'bisect']
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
// The index locks this process holds (lockIndex): Quilt's own, not another git at work.
|
|
28
|
+
const ownLocks = new Set()
|
|
29
|
+
|
|
30
|
+
// Output read in one go: a listing of a big repository, or a batch of files (see filesAt).
|
|
31
|
+
const MAX_OUTPUT = 256 * 1024 * 1024
|
|
32
|
+
// More paths than this are not passed on the command line: the whole tree is asked, and filtered.
|
|
33
|
+
const MAX_PATHSPECS = 200
|
|
34
|
+
|
|
35
|
+
/** Runs git: its output (a string, or a Buffer with `buffer`), or null when it fails. */
|
|
36
|
+
async function run (root, args, opts) {
|
|
37
|
+
return (await call(root, args, opts)).out
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const gitBinary = () => process.env.QUILT_GIT || 'git'
|
|
41
|
+
|
|
42
|
+
// Whether the last git call in a folder was stopped at GIT_TIMEOUT_MS (per folder: several
|
|
43
|
+
// sessions can be asking git at once).
|
|
44
|
+
const timedOut = new Map()
|
|
45
|
+
let lastTimedOut = false
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Runs git: { out } or, when it fails, { out: null, missing, timedOut }
|
|
49
|
+
* (missing: the git binary could not be started at all; timedOut: it ran
|
|
50
|
+
* past GIT_TIMEOUT_MS and was stopped). Never rejects.
|
|
51
|
+
*/
|
|
52
|
+
function call (root, args, { buffer = false, input, timeout = GIT_TIMEOUT_MS, env = {} } = {}) {
|
|
53
|
+
// QUILT_GIT lets tests point at a git binary that doesn't exist, to exercise
|
|
54
|
+
// the "git is unreachable" path without touching the real PATH.
|
|
55
|
+
const bin = gitBinary()
|
|
56
|
+
return new Promise((resolve) => {
|
|
57
|
+
const done = (out, missing, late, stderr = '') => {
|
|
58
|
+
timedOut.set(root, late); lastTimedOut = late
|
|
59
|
+
resolve({ out, missing, timedOut: late, stderr })
|
|
60
|
+
}
|
|
61
|
+
let child
|
|
62
|
+
try {
|
|
63
|
+
child = execFile(bin, args, {
|
|
64
|
+
cwd: root,
|
|
65
|
+
encoding: buffer ? 'buffer' : 'utf8',
|
|
66
|
+
timeout,
|
|
67
|
+
maxBuffer: MAX_OUTPUT,
|
|
68
|
+
env: { ...process.env, GIT_OPTIONAL_LOCKS: '0', ...env }
|
|
69
|
+
}, (err, stdout, stderr) => {
|
|
70
|
+
if (!err) { runs.add(bin); return done(stdout, false, false) }
|
|
71
|
+
const missing = err.code === 'ENOENT' || err.code === 'EACCES'
|
|
72
|
+
if (!missing) runs.add(bin) // it started, then failed or ran too long: git is there
|
|
73
|
+
done(null, missing, !missing && (err.killed === true || err.signal === 'SIGTERM'), String(stderr || ''))
|
|
74
|
+
})
|
|
75
|
+
} catch (err) { // a cwd that is gone, say
|
|
76
|
+
return done(null, err.code === 'ENOENT' || err.code === 'EACCES', false)
|
|
77
|
+
}
|
|
78
|
+
child.stdin.on('error', () => {}) // git may exit before reading all of it
|
|
79
|
+
child.stdin.end(input)
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const runs = new Set() // git binaries seen to run
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Whether the last git call (in `root`, or anywhere) ran past GIT_TIMEOUT_MS:
|
|
87
|
+
* asking again would stall the folder as long again.
|
|
88
|
+
*/
|
|
89
|
+
export function lastCallTimedOut (root) { return root === undefined ? lastTimedOut : !!timedOut.get(root) }
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* `ask()` (a read of git's that resolves to null when git fails), asked once
|
|
93
|
+
* more when it failed, unless that call timed out or git can't be run at all.
|
|
94
|
+
*/
|
|
95
|
+
export async function askTwice (root, ask) {
|
|
96
|
+
const r = await ask()
|
|
97
|
+
return r !== null || lastCallTimedOut(root) || !(await gitRuns(root)) ? r : ask()
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Whether git itself can be run here (`git --version`), as against one call
|
|
102
|
+
* of it failing (a timeout, a filter, a big read). Remembered once it has run.
|
|
103
|
+
*/
|
|
104
|
+
export async function gitRuns (root) {
|
|
105
|
+
const bin = gitBinary()
|
|
106
|
+
if (runs.has(bin)) return true
|
|
107
|
+
if (await run(root, ['--version']) === null) return false
|
|
108
|
+
runs.add(bin)
|
|
109
|
+
return true
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The branch .git/HEAD names (even one with no commits yet), or null (detached, or not a repo). No git call. */
|
|
113
|
+
export function headRef (root) {
|
|
114
|
+
const dir = gitDir(root)
|
|
115
|
+
if (!dir) return null
|
|
116
|
+
try {
|
|
117
|
+
const m = /^ref:\s*refs\/heads\/(.+)$/.exec(fs.readFileSync(path.join(dir, 'HEAD'), 'utf8').trim())
|
|
118
|
+
return m ? m[1] : null
|
|
119
|
+
} catch { return null }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** A file's bytes as Quilt keys them (lastKnown, sharedKey): its text, or "bin:<sha1>". */
|
|
123
|
+
function keyOf (buf) {
|
|
124
|
+
return looksBinary(buf) ? `bin:${sha1(buf)}` : buf.toString('utf8')
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The folder's .git directory (a worktree's .git file points at it), or null. */
|
|
128
|
+
export function gitDir (root) {
|
|
129
|
+
const dot = path.join(root, '.git')
|
|
130
|
+
let st
|
|
131
|
+
try { st = fs.statSync(dot) } catch { return null }
|
|
132
|
+
if (st.isDirectory()) return dot
|
|
133
|
+
try {
|
|
134
|
+
const m = /^gitdir:\s*(.+)$/m.exec(fs.readFileSync(dot, 'utf8'))
|
|
135
|
+
if (!m) return null
|
|
136
|
+
const dir = path.resolve(root, m[1].trim())
|
|
137
|
+
return fs.existsSync(dir) ? dir : null
|
|
138
|
+
} catch { return null }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Where HEAD points: a branch, or a detached commit. Null when the folder is not a
|
|
143
|
+
* repo, when git is unreachable (sha lookup fails), or on an unborn branch (a fresh
|
|
144
|
+
* `git init` with no commits yet) — in every case there's nothing to compare against.
|
|
145
|
+
*/
|
|
146
|
+
export async function headKey (root) {
|
|
147
|
+
const dir = gitDir(root)
|
|
148
|
+
if (!dir) return null
|
|
149
|
+
let head
|
|
150
|
+
try { head = fs.readFileSync(path.join(dir, 'HEAD'), 'utf8').trim() } catch { return null }
|
|
151
|
+
const sha = ((await run(root, ['rev-parse', '--verify', '-q', 'HEAD'])) || '').trim() || null
|
|
152
|
+
if (!sha) return null
|
|
153
|
+
const ref = /^ref:\s*refs\/heads\/(.+)$/.exec(head)
|
|
154
|
+
if (ref) return { key: ref[1], branch: ref[1], sha }
|
|
155
|
+
return { key: `@${sha.slice(0, 12)}`, branch: null, sha }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The git operation in progress in this folder, or null. `dir` lets a caller
|
|
160
|
+
* that already has it skip re-resolving it. `leftover`: an index.lock known to
|
|
161
|
+
* be left behind (its lockStamp, from leftoverLock), not counted; nor is one Quilt holds (lockIndex).
|
|
162
|
+
*/
|
|
163
|
+
export function busy (root, dir = gitDir(root), leftover = null) {
|
|
164
|
+
if (!dir) return null
|
|
165
|
+
for (const [file, kind] of MARKERS) {
|
|
166
|
+
if (!fs.existsSync(path.join(dir, file))) continue
|
|
167
|
+
if (kind === 'index-lock' && ((leftover && lockStamp(root, dir) === leftover) || ownLocks.has(path.join(dir, file)))) continue
|
|
168
|
+
return kind
|
|
169
|
+
}
|
|
170
|
+
return null
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Which index.lock is there (its mtime and inode), or null. A new lock is another stamp. */
|
|
174
|
+
export function lockStamp (root, dir = gitDir(root)) {
|
|
175
|
+
if (!dir) return null
|
|
176
|
+
try { const st = fs.statSync(path.join(dir, 'index.lock')); return `${st.mtimeMs}:${st.ino}` } catch { return null }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The index.lock's stamp when it looks left behind by a git that crashed: the
|
|
181
|
+
* only marker of an operation, older than STALE_LOCK_MS, and `git status`
|
|
182
|
+
* runs. Null otherwise. The file is never touched: deleting it is yours to do.
|
|
183
|
+
*/
|
|
184
|
+
export async function leftoverLock (root, now = Date.now()) {
|
|
185
|
+
const dir = gitDir(root)
|
|
186
|
+
if (busy(root, dir) !== 'index-lock') return null
|
|
187
|
+
const stamp = lockStamp(root, dir)
|
|
188
|
+
if (!stamp || busy(root, dir, stamp)) return null // another operation under way too
|
|
189
|
+
if (now - Number(stamp.split(':')[0]) < STALE_LOCK_MS) return null
|
|
190
|
+
if (await run(root, ['status', '--porcelain', '--untracked-files=no']) === null) return null
|
|
191
|
+
return lockStamp(root, dir) === stamp ? stamp : null
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** A cheap signal that git wrote the index (stash, reset, checkout, add...). Editors never do. */
|
|
195
|
+
export function indexStamp (root) {
|
|
196
|
+
const dir = gitDir(root)
|
|
197
|
+
if (!dir) return null
|
|
198
|
+
try { const st = fs.statSync(path.join(dir, 'index')); return `${st.mtimeMs}:${st.size}` } catch { return null }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* What a burst of changes to `changed` was, given the head seen `before` it.
|
|
203
|
+
* busy: a merge/rebase/... is mid-way (`leftover`: an index.lock to pay no
|
|
204
|
+
* attention to, see leftoverLock). switch: HEAD names another branch (or
|
|
205
|
+
* commit). advance: HEAD moved on the same branch (pull, merge, rebase done,
|
|
206
|
+
* commit). discard: HEAD unchanged and every changed path is clean now (stash,
|
|
207
|
+
* reset, restore). Otherwise edit, with `putBack`: the changed paths git may
|
|
208
|
+
* have put back all the same (clean, and tracked or gone), so a discard that
|
|
209
|
+
* shares its burst with an unrelated save (an untracked file, an autosave) can
|
|
210
|
+
* still be told apart from an edit.
|
|
211
|
+
* An untracked file that is gone counts as put back only when git wrote the
|
|
212
|
+
* index in the same burst (`indexWrote`: `stash -u` puts it away and resets
|
|
213
|
+
* the index); otherwise it was deleted (`git clean`, `rm`), and that is an edit.
|
|
214
|
+
*/
|
|
215
|
+
export async function classify (root, { changed = [], before = null, leftover = null, indexWrote = false } = {}) {
|
|
216
|
+
const head = await headKey(root)
|
|
217
|
+
if (!head) return { kind: 'edit', head: null, prevHead: before, putBack: [] }
|
|
218
|
+
if (busy(root, undefined, leftover)) return { kind: 'busy', head, prevHead: before }
|
|
219
|
+
if (before && head.key !== before.key) return { kind: 'switch', head, prevHead: before }
|
|
220
|
+
if (before && head.sha !== before.sha) return { kind: 'advance', head, prevHead: before }
|
|
221
|
+
if (!changed.length) return { kind: 'edit', head, prevHead: before, putBack: [] }
|
|
222
|
+
const tree = await treeState(root, changed)
|
|
223
|
+
// git could not say (a timeout in a big repo): held as busy, and asked again when it settles.
|
|
224
|
+
if (!tree) return { kind: 'busy', head, prevHead: before }
|
|
225
|
+
// git left a conflict for the person (a `stash pop` that clashed leaves no MERGE_HEAD): mid-operation too.
|
|
226
|
+
const conflict = changed.filter((rel) => tree.unmerged.has(rel))
|
|
227
|
+
if (conflict.length) return { kind: 'busy', head, prevHead: before, conflict }
|
|
228
|
+
// Clean paths print nothing, and so does a path git has never heard of (outside the repo, or nonexistent).
|
|
229
|
+
const deleted = (rel) => !indexWrote && !tree.tracked.has(rel) && !exists(root, rel)
|
|
230
|
+
const clean = changed.filter((rel) => !tree.dirty.has(rel) && !deleted(rel))
|
|
231
|
+
if (clean.length === changed.length) return { kind: 'discard', head, prevHead: before }
|
|
232
|
+
// As the settle tells them (planSettle): an untracked file git calls clean is still an edit.
|
|
233
|
+
const putBack = clean.filter((rel) => tree.tracked.has(rel) || !exists(root, rel))
|
|
234
|
+
return { kind: 'edit', head, prevHead: before, putBack }
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function exists (root, rel) {
|
|
238
|
+
try { fs.lstatSync(path.join(root, ...rel.split('/'))); return true } catch { return false }
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* The file at a commit as Quilt keys it (its text, or "bin:<sha1>" for a
|
|
243
|
+
* binary), as checkout would write it (--filters), or null when it did not
|
|
244
|
+
* exist there (or git failed).
|
|
245
|
+
*/
|
|
246
|
+
export async function fileAt (root, sha, rel) {
|
|
247
|
+
const out = await filesAt(root, sha, [rel])
|
|
248
|
+
return out ? out.get(rel) ?? null : null
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Several files at one commit, in a few git calls whatever their number:
|
|
253
|
+
* rel -> key (as fileAt), null (not there), or undefined (not read: bigger
|
|
254
|
+
* than Quilt shares, behind a filter such as Git LFS, or a git call failed).
|
|
255
|
+
* `out.failed` counts the files a failed git call left unread. Null only
|
|
256
|
+
* when git itself can't be run.
|
|
257
|
+
*/
|
|
258
|
+
export async function filesAt (root, sha, rels) {
|
|
259
|
+
const out = new Map()
|
|
260
|
+
out.failed = 0
|
|
261
|
+
if (!rels.length) return out
|
|
262
|
+
const unread = (list) => { for (const rel of list) out.set(rel, undefined); out.failed += list.length }
|
|
263
|
+
// Sizes first, so a huge file can't overflow the read of the contents.
|
|
264
|
+
const check = await call(root, ['cat-file', '--batch-check=%(objectname) %(objecttype) %(objectsize)'], { input: rels.map((r) => `${sha}:${r}\n`).join('') })
|
|
265
|
+
if (check.missing) return null
|
|
266
|
+
const lines = check.out === null ? [] : check.out.split('\n').slice(0, rels.length)
|
|
267
|
+
if (lines.length !== rels.length) { unread(rels); return out }
|
|
268
|
+
// A path with a filter (Git LFS, say) is never read: its smudged size is unknown (the blob is
|
|
269
|
+
// a pointer), and running the filter can be slow or fail. The attributes are asked in one call.
|
|
270
|
+
const filtered = await filterOf(root, rels)
|
|
271
|
+
if (filtered === null) { unread(rels); return out }
|
|
272
|
+
const fetch = [] // [rel, blob id, size]
|
|
273
|
+
rels.forEach((rel, i) => {
|
|
274
|
+
const [id, type, size] = lines[i].split(' ')
|
|
275
|
+
if (type !== 'blob' || !/^\d+$/.test(size)) out.set(rel, null) // "<name> missing", or a folder there
|
|
276
|
+
else if (Number(size) > MAX_STORED_BINARY_BYTES || filtered.has(rel)) out.set(rel, undefined)
|
|
277
|
+
else fetch.push([rel, id, Number(size)])
|
|
278
|
+
})
|
|
279
|
+
// With --filters each line is "<blob> <path>": the path picks the line-ending conversion, as
|
|
280
|
+
// checkout would. That can change a size, so each file's own size is read from the batch's headers.
|
|
281
|
+
const read = async (chunk) => {
|
|
282
|
+
const buf = await call(root, ['cat-file', '--batch', '--filters'], { buffer: true, input: chunk.map(([rel, id]) => `${id} ${rel}\n`).join('') })
|
|
283
|
+
if (buf.missing) return false
|
|
284
|
+
if (buf.out === null) { unread(chunk.map(([rel]) => rel)); return true }
|
|
285
|
+
let at = 0
|
|
286
|
+
for (let i = 0; i < chunk.length; i++) {
|
|
287
|
+
const rel = chunk[i][0]
|
|
288
|
+
const nl = buf.out.indexOf(10, at)
|
|
289
|
+
const header = nl < 0 ? '' : buf.out.subarray(at, nl).toString('utf8')
|
|
290
|
+
if (/ missing$/.test(header)) { out.set(rel, null); at = nl + 1; continue }
|
|
291
|
+
const size = Number(header.split(' ')[2])
|
|
292
|
+
if (nl < 0 || !Number.isInteger(size)) { unread(chunk.slice(i).map(([r]) => r)); return true }
|
|
293
|
+
const key = keyOf(buf.out.subarray(nl + 1, nl + 1 + size))
|
|
294
|
+
out.set(rel, key.startsWith(LFS_POINTER) ? undefined : key) // an LFS pointer is not the file
|
|
295
|
+
at = nl + 1 + size + 1
|
|
296
|
+
}
|
|
297
|
+
return true
|
|
298
|
+
}
|
|
299
|
+
let chunk = []; let bytes = 0
|
|
300
|
+
for (const f of fetch) {
|
|
301
|
+
if (chunk.length && bytes + f[2] > MAX_OUTPUT / 4) {
|
|
302
|
+
if (!(await read(chunk))) return null
|
|
303
|
+
chunk = []; bytes = 0
|
|
304
|
+
}
|
|
305
|
+
chunk.push(f); bytes += f[2]
|
|
306
|
+
}
|
|
307
|
+
if (chunk.length && !(await read(chunk))) return null
|
|
308
|
+
return out
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const LFS_POINTER = 'version https://git-lfs'
|
|
312
|
+
|
|
313
|
+
/** The paths with a `filter` attribute set (in .gitattributes), or null when git failed. */
|
|
314
|
+
async function filterOf (root, rels) {
|
|
315
|
+
const out = await run(root, ['check-attr', '-z', '--stdin', 'filter'], { input: rels.join('\0') + '\0' })
|
|
316
|
+
if (out === null) return null
|
|
317
|
+
const f = out.split('\0')
|
|
318
|
+
const filtered = new Set()
|
|
319
|
+
for (let i = 0; i + 2 < f.length; i += 3) if (f[i + 2] !== 'unspecified' && f[i + 2] !== 'unset') filtered.add(f[i])
|
|
320
|
+
return filtered
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** The commit a branch points at (wherever HEAD is), or null. */
|
|
324
|
+
export async function branchTip (root, branch) {
|
|
325
|
+
if (!branch || !gitDir(root)) return null
|
|
326
|
+
return ((await run(root, ['rev-parse', '--verify', '-q', `refs/heads/${branch}^{commit}`])) || '').trim() || null
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Paths that differ between two commits, each with git's status letter (A added, M modified, D deleted...); null when git fails. */
|
|
330
|
+
/** How many commits `to` has that `from` hasn't (what a pull brought in), or null when git can't say. */
|
|
331
|
+
export async function commitsBetween (root, from, to) {
|
|
332
|
+
const out = await run(root, ['rev-list', '--count', `${from}..${to}`])
|
|
333
|
+
const n = out === null ? NaN : Number(String(out).trim())
|
|
334
|
+
return Number.isFinite(n) ? n : null
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export async function changesBetween (root, shaA, shaB) {
|
|
338
|
+
const out = await run(root, ['diff', '--no-renames', '--no-ext-diff', '--name-status', '-z', shaA, shaB])
|
|
339
|
+
if (out === null) return null
|
|
340
|
+
const f = out.split('\0')
|
|
341
|
+
const changes = new Map()
|
|
342
|
+
for (let i = 0; i + 1 < f.length; i += 2) if (f[i + 1]) changes.set(f[i + 1], f[i][0])
|
|
343
|
+
return changes
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** Paths that differ between two commits. */
|
|
347
|
+
export async function changedBetween (root, shaA, shaB) {
|
|
348
|
+
const changes = await changesBetween(root, shaA, shaB)
|
|
349
|
+
return changes ? [...changes.keys()] : []
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* What git says about `paths` now, in two git calls whatever their number:
|
|
354
|
+
* `dirty` (changed, staged, untracked or conflicted), `unmerged` (a conflict
|
|
355
|
+
* git left for the person to resolve) and `tracked` (in the index). Null when git fails.
|
|
356
|
+
*/
|
|
357
|
+
export async function treeState (root, paths) {
|
|
358
|
+
const spec = paths.length <= MAX_PATHSPECS ? ['--', ...paths] : []
|
|
359
|
+
const status = await run(root, ['--literal-pathspecs', 'status', '--porcelain=v2', '-z', '--untracked-files=all', ...spec])
|
|
360
|
+
if (status === null) return null // not asking for the second when the first failed (or timed out)
|
|
361
|
+
const listed = await run(root, ['--literal-pathspecs', 'ls-files', '-z', ...spec])
|
|
362
|
+
if (listed === null) return null
|
|
363
|
+
const dirty = new Set()
|
|
364
|
+
const unmerged = new Set()
|
|
365
|
+
const f = status.split('\0')
|
|
366
|
+
for (let i = 0; i < f.length; i++) {
|
|
367
|
+
const e = f[i]
|
|
368
|
+
if (e[0] === '1') dirty.add(e.split(' ').slice(8).join(' '))
|
|
369
|
+
else if (e[0] === '2') dirty.add(e.split(' ').slice(9).join(' ')).add(f[++i]) // renamed: both paths
|
|
370
|
+
else if (e[0] === 'u') { const rel = e.split(' ').slice(10).join(' '); dirty.add(rel); unmerged.add(rel) }
|
|
371
|
+
else if (e[0] === '?' || e[0] === '!') dirty.add(e.slice(2))
|
|
372
|
+
}
|
|
373
|
+
return { dirty, unmerged, tracked: new Set(listed.split('\0').filter(Boolean)) }
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Paths with a conflict git left in the index for the person to resolve
|
|
378
|
+
* (stages 1-3), whatever made it: a merge, a rebase, a `stash pop`, `checkout -m`.
|
|
379
|
+
* Null when git fails.
|
|
380
|
+
*/
|
|
381
|
+
export async function unmergedPaths (root) {
|
|
382
|
+
const out = await run(root, ['ls-files', '-u', '-z'])
|
|
383
|
+
if (out === null) return null
|
|
384
|
+
const paths = new Set()
|
|
385
|
+
for (const e of out.split('\0')) { const tab = e.indexOf('\t'); if (tab > 0) paths.add(e.slice(tab + 1)) }
|
|
386
|
+
return paths
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** A cheap mark that changes whenever something is stashed or a stash is dropped (the stash reflog). */
|
|
390
|
+
export function stashStamp (root) {
|
|
391
|
+
let dir = gitDir(root)
|
|
392
|
+
if (!dir) return null
|
|
393
|
+
try { dir = path.resolve(dir, fs.readFileSync(path.join(dir, 'commondir'), 'utf8').trim()) } catch {} // a worktree keeps its stash with the main repo
|
|
394
|
+
try { const st = fs.statSync(path.join(dir, 'logs', 'refs', 'stash')); return `${st.mtimeMs}:${st.size}` } catch { return null }
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* What the last fetch brought that HEAD doesn't have yet: files the upstream
|
|
399
|
+
* branch adds (since HEAD and it parted), as Map path -> the file there as Quilt
|
|
400
|
+
* keys it (text, or "bin:<sha1>"; undefined when it can't be read). Limited
|
|
401
|
+
* to `paths` when given. Empty with no upstream or nothing new; null when git fails.
|
|
402
|
+
*/
|
|
403
|
+
export async function upstreamAdds (root, paths = null) {
|
|
404
|
+
if (paths && !paths.length) return new Map()
|
|
405
|
+
const up = await run(root, ['rev-parse', '-q', '--verify', '@{upstream}^{commit}'])
|
|
406
|
+
if (up === null) return new Map() // no upstream (or none fetched): nothing is coming
|
|
407
|
+
const sha = up.trim()
|
|
408
|
+
const spec = paths && paths.length <= MAX_PATHSPECS ? ['--', ...paths] : []
|
|
409
|
+
const out = await run(root, ['--literal-pathspecs', 'diff', '--name-only', '-z', '--no-renames', '--diff-filter=A', `HEAD...${sha}`, ...spec])
|
|
410
|
+
if (out === null) return null
|
|
411
|
+
let added = out.split('\0').filter(Boolean)
|
|
412
|
+
if (paths && !spec.length) { const want = new Set(paths); added = added.filter((rel) => want.has(rel)) }
|
|
413
|
+
if (!added.length) return new Map()
|
|
414
|
+
const at = await filesAt(root, sha, added)
|
|
415
|
+
if (!at) return null
|
|
416
|
+
return new Map(added.map((rel) => [rel, at.get(rel)]))
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Before a pull: the upstream branch, how many commits behind HEAD is, and the
|
|
421
|
+
* files those commits add that sit in this folder untracked (the session put
|
|
422
|
+
* them here). git refuses to pull over those. Null when git fails or there is
|
|
423
|
+
* no upstream.
|
|
424
|
+
*/
|
|
425
|
+
export async function pullState (root) {
|
|
426
|
+
const name = await run(root, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'])
|
|
427
|
+
if (name === null) return null
|
|
428
|
+
const count = await run(root, ['rev-list', '--count', 'HEAD..@{upstream}'])
|
|
429
|
+
if (count === null) return null
|
|
430
|
+
const behind = Number(count.trim()) || 0
|
|
431
|
+
if (!behind) return { upstream: name.trim(), behind, adds: new Map() }
|
|
432
|
+
const adds = await upstreamAdds(root)
|
|
433
|
+
if (!adds) return null
|
|
434
|
+
const paths = [...adds.keys()]
|
|
435
|
+
const listed = paths.length ? await run(root, ['--literal-pathspecs', 'ls-files', '-z', ...(paths.length <= MAX_PATHSPECS ? ['--', ...paths] : [])]) : ''
|
|
436
|
+
if (listed === null) return null
|
|
437
|
+
const tracked = new Set(listed.split('\0').filter(Boolean))
|
|
438
|
+
return { upstream: name.trim(), behind, adds: new Map([...adds].filter(([rel]) => !tracked.has(rel))) }
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Watches HEAD, the index, a fetch (FETCH_HEAD) and the in-progress markers; events: head, index, fetch, busy, idle.
|
|
443
|
+
* Also `ref`: a branch moved without HEAD's file changing (a commit, or another worktree of the
|
|
444
|
+
* same repository moving this branch: `git update-ref`, `git push . x:main`, `git fetch . x:main`).
|
|
445
|
+
*/
|
|
446
|
+
export function watchGit (root, onEvent) {
|
|
447
|
+
const dir = gitDir(root)
|
|
448
|
+
if (!dir) return { close: async () => {} }
|
|
449
|
+
const names = new Set(['HEAD', 'index', 'FETCH_HEAD', ...MARKERS.map(([f]) => f)])
|
|
450
|
+
let wasBusy = !!busy(root, dir)
|
|
451
|
+
const watcher = watch(dir, { ignoreInitial: true, depth: 0, followSymlinks: false })
|
|
452
|
+
// Branches live in the shared directory a worktree's .git points into (refs/heads, packed-refs).
|
|
453
|
+
const common = commonDir(root) || dir
|
|
454
|
+
const refs = watch([path.join(common, 'refs', 'heads'), path.join(common, 'packed-refs')], { ignoreInitial: true, followSymlinks: false })
|
|
455
|
+
const onRef = (p) => { if (!p.endsWith('.lock')) onEvent({ type: 'ref' }) }
|
|
456
|
+
refs.on('add', onRef).on('change', onRef).on('unlink', onRef).on('error', () => {})
|
|
457
|
+
const onAny = (p) => {
|
|
458
|
+
const name = path.basename(p)
|
|
459
|
+
if (!names.has(name)) return
|
|
460
|
+
if (name === 'HEAD') onEvent({ type: 'head' })
|
|
461
|
+
else if (name === 'index') onEvent({ type: 'index' })
|
|
462
|
+
else if (name === 'FETCH_HEAD') onEvent({ type: 'fetch' })
|
|
463
|
+
else {
|
|
464
|
+
const now = !!busy(root, dir)
|
|
465
|
+
if (now !== wasBusy) { wasBusy = now; onEvent({ type: now ? 'busy' : 'idle' }) }
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
watcher.on('add', onAny).on('change', onAny).on('unlink', onAny).on('addDir', onAny).on('unlinkDir', onAny)
|
|
469
|
+
return { close: () => Promise.all([watcher.close(), refs.close()]) }
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** The repository's shared git directory (a worktree's .git/worktrees/<name> points at it), or null. */
|
|
473
|
+
export function commonDir (root) {
|
|
474
|
+
const dir = gitDir(root)
|
|
475
|
+
if (!dir) return null
|
|
476
|
+
try { return path.resolve(dir, fs.readFileSync(path.join(dir, 'commondir'), 'utf8').trim()) } catch { return dir }
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// ------------------------------------------------------------ upstream --
|
|
480
|
+
// What the folder's branch has to catch up with. A fetch can wait on the network: it gets longer.
|
|
481
|
+
export const FETCH_TIMEOUT_MS = 60 * 1000
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* The branch's upstream: { name ("origin/main"), remote ("origin"), url, sha }, or null
|
|
485
|
+
* (no upstream set, or git failed). The address is read from the folder's own git config:
|
|
486
|
+
* nobody sets it in Quilt.
|
|
487
|
+
*/
|
|
488
|
+
export async function upstreamOf (root) {
|
|
489
|
+
const name = ((await run(root, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'])) || '').trim()
|
|
490
|
+
if (!name) return null
|
|
491
|
+
const sha = ((await run(root, ['rev-parse', '-q', '--verify', '@{upstream}^{commit}'])) || '').trim() || null
|
|
492
|
+
const branch = headRef(root)
|
|
493
|
+
const remote = branch ? ((await run(root, ['config', '--get', `branch.${branch}.remote`])) || '').trim() : ''
|
|
494
|
+
// The repository as configured, not as an insteadOf rule rewrites it for transport (a mirror, an SSH rule): that is which repository it is.
|
|
495
|
+
const url = remote && remote !== '.' ? (((await run(root, ['config', '--get', `remote.${remote}.url`])) || '').trim() || ((await run(root, ['remote', 'get-url', remote])) || '').trim()) : ''
|
|
496
|
+
return { name, remote: remote || null, url: url || null, sha }
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Fetches the branch's upstream remote in the background: only remote-tracking refs move, never
|
|
501
|
+
* a file, the index or a branch. No prompt ever waits for a password (GIT_TERMINAL_PROMPT=0, no
|
|
502
|
+
* askpass): a remote that needs one the folder doesn't have just isn't fetched. Returns
|
|
503
|
+
* { ok, error }: `error` says in plain English why it failed (fetchProblem), null when it worked.
|
|
504
|
+
*/
|
|
505
|
+
export async function fetchUpstream (root, remote, url = null, { token = null } = {}) {
|
|
506
|
+
if (!remote || remote === '.') return { ok: false, error: null, skipped: true }
|
|
507
|
+
const r = await call(root, ['-c', 'core.askPass=', 'fetch', '--quiet', '--no-tags', '--no-write-fetch-head', '--prune', remote], {
|
|
508
|
+
timeout: FETCH_TIMEOUT_MS,
|
|
509
|
+
env: { GIT_TERMINAL_PROMPT: '0', GIT_ASKPASS: '', SSH_ASKPASS: '', GCM_INTERACTIVE: 'never', GIT_SSH_COMMAND: process.env.GIT_SSH_COMMAND || 'ssh -o BatchMode=yes', ...gitAuthEnv(token) }
|
|
510
|
+
})
|
|
511
|
+
if (r.out !== null) return { ok: true, error: null }
|
|
512
|
+
return { ok: false, error: fetchProblem({ stderr: r.stderr, url, remote, timedOut: r.timedOut, missing: r.missing }) }
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Sign-in for one git command over https with a GitHub token (Quilt's app, read only: see
|
|
517
|
+
* relay-github.js), as environment rather than arguments, so it never shows in a process list.
|
|
518
|
+
*/
|
|
519
|
+
export function gitAuthEnv (token, { user = 'x-access-token' } = {}) {
|
|
520
|
+
if (!token) return {}
|
|
521
|
+
return { GIT_CONFIG_COUNT: '1', GIT_CONFIG_KEY_0: 'http.extraheader', GIT_CONFIG_VALUE_0: `Authorization: Basic ${Buffer.from(`${user}:${token}`).toString('base64')}` }
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Makes a folder that holds a branch's files (an agent's copy, synced by Quilt) a git checkout of
|
|
526
|
+
* that branch at commit `sha`, with every file left as it is: init, fetch `ref` from `url` (with
|
|
527
|
+
* `token` when the repository is private), the branch at `sha`, the index at `sha`, upstream set.
|
|
528
|
+
* The session's uncommitted work then shows as the working tree's changes. Resolves { ok } or
|
|
529
|
+
* { ok: false, error }; a failure leaves no .git behind.
|
|
530
|
+
*/
|
|
531
|
+
export async function backWithGit (root, { url, ref, branch = ref, sha, token = null, user = 'x-access-token', helpers = null }) {
|
|
532
|
+
if (fs.existsSync(path.join(root, '.git'))) return { ok: true, had: true }
|
|
533
|
+
const env = { GIT_TERMINAL_PROMPT: '0', GIT_ASKPASS: '', SSH_ASKPASS: '', GCM_INTERACTIVE: 'never', ...gitAuthEnv(token, { user }) }
|
|
534
|
+
const undo = () => { try { fs.rmSync(path.join(root, '.git'), { recursive: true, force: true }) } catch {} }
|
|
535
|
+
const step = async (args, what, opts = {}) => {
|
|
536
|
+
const r = await call(root, args, { timeout: FETCH_TIMEOUT_MS * 10, env, ...opts })
|
|
537
|
+
if (r.out === null) { undo(); throw new Error(`${what}: ${r.missing ? "git can't be run here" : r.timedOut ? 'took too long' : (r.stderr || 'failed').split('\n').find(Boolean)}`) }
|
|
538
|
+
return r.out
|
|
539
|
+
}
|
|
540
|
+
try {
|
|
541
|
+
await step(['init', '-q', '-b', branch], 'git init')
|
|
542
|
+
await step(['remote', 'add', 'origin', url], 'adding the remote')
|
|
543
|
+
await step(['config', 'remote.origin.fetch', `+refs/heads/*:refs/remotes/origin/*`], 'remote config')
|
|
544
|
+
await step(['-c', 'core.askPass=', 'fetch', '--quiet', '--no-tags', 'origin', ref], `fetching ${ref}`)
|
|
545
|
+
// The commit the session's files are at may be behind the branch's tip (or on it): make sure it's here.
|
|
546
|
+
if ((await call(root, ['cat-file', '-e', `${sha}^{commit}`], { env })).out === null) await step(['-c', 'core.askPass=', 'fetch', '--quiet', '--no-tags', 'origin', sha], `fetching ${sha.slice(0, 7)}`)
|
|
547
|
+
await step(['update-ref', `refs/heads/${branch}`, sha], 'setting the branch')
|
|
548
|
+
await step(['symbolic-ref', 'HEAD', `refs/heads/${branch}`], 'checking out the branch')
|
|
549
|
+
await step(['read-tree', '--reset', sha], 'setting the index')
|
|
550
|
+
await step(['branch', `--set-upstream-to=origin/${ref}`, branch], 'tracking the upstream')
|
|
551
|
+
const exclude = path.join(root, '.git', 'info', 'exclude')
|
|
552
|
+
fs.mkdirSync(path.dirname(exclude), { recursive: true })
|
|
553
|
+
fs.appendFileSync(exclude, '.quilt/\n.agent-scratch/\n')
|
|
554
|
+
if (helpers) { try { await installGitHelpers(root, { ...helpers, host: remoteHost(url), origin: urlOrigin(url) }) } catch {} }
|
|
555
|
+
return { ok: true }
|
|
556
|
+
} catch (err) { undo(); return { ok: false, error: err.message } }
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** A string as one word for sh (the credential helper and the hook run through it). */
|
|
560
|
+
export const shellQuote = (s) => `'${String(s).replace(/'/g, "'\\''")}'`
|
|
561
|
+
|
|
562
|
+
const HOOK_MARK = '# Quilt pre-push hook'
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* Lets an agent's folder use git like anyone: git asks Quilt for credentials for `host`
|
|
566
|
+
* (`quilt git-credential`, which asks the running session, which asks the relay for a short-lived
|
|
567
|
+
* token for this repository, write access within the owner's policy), a pre-push hook
|
|
568
|
+
* (`quilt git-pre-push`) holds pushes to that policy and tells the session what was pushed, and
|
|
569
|
+
* commits are credited to the agent (`identity`: { name, email }, unless the folder has its own).
|
|
570
|
+
* `node` and `script` run the CLI. Idempotent; a hook that is someone else's is left alone, and a
|
|
571
|
+
* folder with core.hooksPath gets no hook. Returns { helper, hook } (what is in place).
|
|
572
|
+
*/
|
|
573
|
+
export async function installGitHelpers (root, { node, script, host = 'github.com', origin = '', identity = null }) {
|
|
574
|
+
const dir = gitDir(root)
|
|
575
|
+
if (!dir || !node || !script) return { helper: false, hook: false }
|
|
576
|
+
const cmd = (sub) => `!${shellQuote(node)} ${shellQuote(script)} ${sub} --dir ${shellQuote(root)}`
|
|
577
|
+
let helper = false
|
|
578
|
+
if (host) {
|
|
579
|
+
// `origin`: the remote's scheme, host and port (the relay's git, http:// in tests); else https://<host>.
|
|
580
|
+
const key = `credential.${origin || `https://${host}`}.helper`
|
|
581
|
+
const have = String((await call(root, ['config', '--local', '--get-all', key])).out || '').split('\n')
|
|
582
|
+
const want = cmd('git-credential')
|
|
583
|
+
// Exactly: an empty helper first (whatever is configured globally for this host, a keychain with a
|
|
584
|
+
// person's login, is not asked in an agent's folder), then Quilt's. Anything else (an older path to node or Quilt) is replaced.
|
|
585
|
+
if (have.length !== 2 || have[0] !== '' || have[1] !== want) {
|
|
586
|
+
await call(root, ['config', '--local', '--replace-all', key, ''])
|
|
587
|
+
await call(root, ['config', '--local', '--add', key, want])
|
|
588
|
+
}
|
|
589
|
+
helper = true
|
|
590
|
+
}
|
|
591
|
+
if (identity && identity.name && identity.email) {
|
|
592
|
+
if (!(await call(root, ['config', '--local', 'user.name'])).out) await call(root, ['config', '--local', 'user.name', identity.name])
|
|
593
|
+
if (!(await call(root, ['config', '--local', 'user.email'])).out) await call(root, ['config', '--local', 'user.email', identity.email])
|
|
594
|
+
}
|
|
595
|
+
const hook = await writeQuiltHook(root, { node, script })
|
|
596
|
+
return { helper, hook }
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Quilt's pre-push hook in the folder's own hooks (a hook that is someone else's is left alone, and a
|
|
601
|
+
* folder with core.hooksPath gets none). Returns whether it is in place.
|
|
602
|
+
*/
|
|
603
|
+
async function writeQuiltHook (root, { node, script }) {
|
|
604
|
+
const dir = gitDir(root)
|
|
605
|
+
if (!dir) return false
|
|
606
|
+
const hooksPath = (await call(root, ['config', 'core.hooksPath'])).out
|
|
607
|
+
if (hooksPath) return false
|
|
608
|
+
const file = path.join(dir, 'hooks', 'pre-push')
|
|
609
|
+
let now = ''
|
|
610
|
+
try { now = fs.readFileSync(file, 'utf8') } catch {}
|
|
611
|
+
if (now && !now.includes(HOOK_MARK)) return false
|
|
612
|
+
const want = `#!/bin/sh\n${HOOK_MARK}: pushes from this folder follow the session's git setting (git for everyone; for agents, quilt_agent_commits), and the session is told what was pushed.\nexec ${shellQuote(node)} ${shellQuote(script)} git-pre-push --dir ${shellQuote(root)} "$@"\n`
|
|
613
|
+
if (now !== want) {
|
|
614
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
615
|
+
fs.writeFileSync(file, want, { mode: 0o755 })
|
|
616
|
+
try { fs.chmodSync(file, 0o755) } catch {}
|
|
617
|
+
}
|
|
618
|
+
return true
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/** The credential sections in the folder's own config whose helper is Quilt's for this folder: [credential.<url>.helper keys]. */
|
|
622
|
+
async function quiltHelperKeys (root) {
|
|
623
|
+
const out = (await call(root, ['config', '--local', '--get-regexp', '^credential\\..*\\.helper$'])).out || ''
|
|
624
|
+
const keys = new Set()
|
|
625
|
+
for (const line of out.split('\n')) {
|
|
626
|
+
const i = line.indexOf(' ')
|
|
627
|
+
if (i < 0) continue
|
|
628
|
+
if (/ git-credential --dir /.test(line.slice(i + 1))) keys.add(line.slice(0, i))
|
|
629
|
+
}
|
|
630
|
+
return [...keys]
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Git for everyone, in a person's own folder (docs/superpowers/specs/2026-10-17-git-for-everyone.md):
|
|
635
|
+
* Quilt's credential helper for the session's repository only (`url`, as git asks for it: the remote's
|
|
636
|
+
* URL with insteadOf applied), in the folder's own config: an empty helper first, so the person's other
|
|
637
|
+
* helpers (a keychain) are set aside for that URL, then `quilt git-credential --fallback`, which asks
|
|
638
|
+
* them anyway when no Quilt session answers. `helper: false` takes Quilt's helper away (any URL);
|
|
639
|
+
* `hook` puts the pre-push hook in, or takes it away. Global config and keychains are never touched.
|
|
640
|
+
* Returns { helper, hook } (what is in place now).
|
|
641
|
+
*/
|
|
642
|
+
export async function setPersonGit (root, { node, script, url = '', helper = false, hook = false }) {
|
|
643
|
+
if (!gitDir(root) || !node || !script) return { helper: false, hook: false }
|
|
644
|
+
const key = helper && url ? `credential.${url}.helper` : null
|
|
645
|
+
// Quilt's helper under any other URL (the remote moved, or git turned off) goes.
|
|
646
|
+
for (const k of await quiltHelperKeys(root)) if (k !== key) await call(root, ['config', '--local', '--unset-all', k])
|
|
647
|
+
let helperOn = false
|
|
648
|
+
if (key) {
|
|
649
|
+
const want = `!${shellQuote(node)} ${shellQuote(script)} git-credential --dir ${shellQuote(root)} --fallback`
|
|
650
|
+
const have = String((await call(root, ['config', '--local', '--get-all', key])).out || '').split('\n')
|
|
651
|
+
if (have.length !== 2 || have[0] !== '' || have[1] !== want) {
|
|
652
|
+
await call(root, ['config', '--local', '--replace-all', key, ''])
|
|
653
|
+
await call(root, ['config', '--local', '--add', key, want])
|
|
654
|
+
}
|
|
655
|
+
helperOn = true
|
|
656
|
+
}
|
|
657
|
+
let hookOn = false
|
|
658
|
+
if (hook) hookOn = await writeQuiltHook(root, { node, script })
|
|
659
|
+
else removeQuiltHook(root)
|
|
660
|
+
return { helper: helperOn, hook: hookOn }
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/** Takes Quilt's pre-push hook out of the folder (only Quilt's: a hook of the person's own stays). */
|
|
664
|
+
export function removeQuiltHook (root) {
|
|
665
|
+
const dir = gitDir(root)
|
|
666
|
+
if (!dir) return
|
|
667
|
+
const file = path.join(dir, 'hooks', 'pre-push')
|
|
668
|
+
try { if (fs.readFileSync(file, 'utf8').includes(HOOK_MARK)) fs.rmSync(file, { force: true }) } catch {}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/** scheme://host[:port] of an http(s) remote URL, or '' (a path, ssh). */
|
|
672
|
+
export function urlOrigin (url) {
|
|
673
|
+
try { const u = new URL(String(url || '')); return /^https?:$/.test(u.protocol) ? u.origin : '' } catch { return '' }
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/** The host a remote URL points at ("github.com"), or null for a local path. */
|
|
677
|
+
export function remoteHost (url) {
|
|
678
|
+
if (typeof url !== 'string' || !url) return null
|
|
679
|
+
const scp = /^[A-Za-z0-9._-]+@([A-Za-z0-9.-]+):(?!\/)/.exec(url)
|
|
680
|
+
if (scp) return scp[1].toLowerCase()
|
|
681
|
+
try { const u = new URL(url); return u.protocol === 'file:' ? null : (u.hostname || null) } catch { return null }
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* Why `git fetch` failed, in plain English, from what git printed: "git can't sign in to
|
|
686
|
+
* github.com from this folder", "can't reach github.com (no network?)", and so on. Never the
|
|
687
|
+
* URL itself (it may carry credentials).
|
|
688
|
+
*/
|
|
689
|
+
export function fetchProblem ({ stderr = '', url = null, remote = 'origin', timedOut = false, missing = false } = {}) {
|
|
690
|
+
const host = remoteHost(url) || remote || 'the remote'
|
|
691
|
+
const e = String(stderr || '').toLowerCase()
|
|
692
|
+
if (missing) return "git can't be run here"
|
|
693
|
+
if (timedOut) return `fetching from ${host} took over a minute and was stopped`
|
|
694
|
+
if (/does not appear to be a git repository|not a git repository|no such remote/.test(e)) return `${remote} isn't a repository git can fetch from`
|
|
695
|
+
if (/could not read (username|password)|terminal prompts disabled|authentication failed|permission denied \(publickey|host key verification failed|invalid username or password|access denied|returned error: 40[13]|support for password authentication was removed|repository not found|could not read from remote repository/.test(e)) {
|
|
696
|
+
return `git can't sign in to ${host} from this folder`
|
|
697
|
+
}
|
|
698
|
+
if (/could not resolve host|failed to connect|connection (timed out|refused)|network is unreachable|operation timed out|couldn't connect|unable to access|no route to host|temporary failure in name resolution/.test(e)) {
|
|
699
|
+
return `can't reach ${host} (no network?)`
|
|
700
|
+
}
|
|
701
|
+
const line = String(stderr || '').split('\n').map((l) => l.replace(/^(fatal|error):\s*/i, '').trim()).find(Boolean)
|
|
702
|
+
return line ? `git fetch failed: ${line.replace(/[a-z]+:\/\/[^\s'"]*@/gi, '').slice(0, 120)}` : 'git fetch failed'
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/** Whether the repository has commit `sha` (fetched or made here). */
|
|
706
|
+
export async function hasCommit (root, sha) {
|
|
707
|
+
if (!sha) return false
|
|
708
|
+
return (await call(root, ['cat-file', '-e', `${sha}^{commit}`])).out !== null
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/** Whether commit a is an ancestor of (or the same as) b; null when git can't say. */
|
|
712
|
+
export async function isAncestor (root, a, b) {
|
|
713
|
+
const r = await call(root, ['merge-base', '--is-ancestor', a, b])
|
|
714
|
+
if (r.out !== null) return true
|
|
715
|
+
if (r.missing || r.timedOut) return null
|
|
716
|
+
// Exit 1 is "no"; anything else (an unknown commit) is "can't say".
|
|
717
|
+
const known = await run(root, ['cat-file', '-e', `${a}^{commit}`]) !== null && await run(root, ['cat-file', '-e', `${b}^{commit}`]) !== null
|
|
718
|
+
return known ? false : null
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/** Whether the index has changes staged against `sha`; null when git can't say. */
|
|
722
|
+
export async function stagedAgainst (root, sha) {
|
|
723
|
+
const r = await call(root, ['diff-index', '--cached', '--quiet', '--ignore-submodules', sha, '--'])
|
|
724
|
+
if (r.out !== null) return false
|
|
725
|
+
return r.missing || r.timedOut ? null : true
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/**
|
|
729
|
+
* Moves the folder's branch from `from` to `to` (`branch` null: it is there already) and sets the
|
|
730
|
+
* index to `to`, leaving every file as it is; `during()` (writing `to`'s files: see catchUp) runs first.
|
|
731
|
+
* git's index lock is held throughout, so a person's `git commit` meanwhile either went first (the
|
|
732
|
+
* branch is no longer at `from`: not moved) or is refused by git and asked again. The index is set
|
|
733
|
+
* before the branch moves: git reads HEAD, then the index, and only then takes the lock, so a commit
|
|
734
|
+
* that read the new HEAD reads the new index too, and one that read the old HEAD fails git's own check
|
|
735
|
+
* that HEAD hasn't moved. Never a commit on `to` made from the old index or missing `to`'s files,
|
|
736
|
+
* which would undo `to`'s changes on GitHub. Returns whether it moved; `fastForward.indexLate` is set
|
|
737
|
+
* when the index could not follow yet.
|
|
738
|
+
*/
|
|
739
|
+
export async function fastForward (root, branch, from, to, during = null) {
|
|
740
|
+
const lock = await lockIndex(root)
|
|
741
|
+
if (!lock) return false
|
|
742
|
+
try {
|
|
743
|
+
if (branch && ((await run(root, ['rev-parse', '-q', '--verify', `refs/heads/${branch}`])) || '').trim() !== from) return false
|
|
744
|
+
if (during) await during()
|
|
745
|
+
const r = await moveUnderLock(root, lock, branch, from, to, 'quilt: catch up with upstream')
|
|
746
|
+
fastForward.indexLate = r.indexLate
|
|
747
|
+
return r.moved
|
|
748
|
+
} finally { lock.release() }
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/**
|
|
752
|
+
* Under the index lock: the index becomes `to`'s, then `branch` moves from `from` to `to` (see
|
|
753
|
+
* fastForward for why in that order). When the branch moved meanwhile, the index goes back as it was.
|
|
754
|
+
* When git can't set the index, the branch moves all the same and { indexLate } says so (fixIndex sets it).
|
|
755
|
+
*/
|
|
756
|
+
async function moveUnderLock (root, lock, branch, from, to, reason) {
|
|
757
|
+
const kept = branch ? lock.keep() : false
|
|
758
|
+
const indexSet = await lock.setIndex(to)
|
|
759
|
+
if (branch && (await call(root, ['update-ref', '-m', reason, `refs/heads/${branch}`, to, from])).out === null) {
|
|
760
|
+
if (indexSet && kept) lock.putBack()
|
|
761
|
+
return { moved: false, indexLate: false }
|
|
762
|
+
}
|
|
763
|
+
return { moved: true, indexLate: !indexSet }
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
/**
|
|
767
|
+
* Takes git's index lock (index.lock) as git itself does, asking again for up to two seconds while
|
|
768
|
+
* another git holds it; null when it can't. While it is held, `setIndex(sha)` makes the index
|
|
769
|
+
* `sha`'s tree (keeping what git knows of files that didn't change) and `release()` lets git in again.
|
|
770
|
+
*/
|
|
771
|
+
export async function lockIndex (root) {
|
|
772
|
+
const dir = gitDir(root)
|
|
773
|
+
if (!dir) return null
|
|
774
|
+
const index = path.join(dir, 'index')
|
|
775
|
+
const lock = `${index}.lock`
|
|
776
|
+
const prev = `${index}.quilt-prev-${process.pid}`
|
|
777
|
+
for (let i = 0; i < 20; i++) {
|
|
778
|
+
try {
|
|
779
|
+
fs.closeSync(fs.openSync(lock, 'wx'))
|
|
780
|
+
ownLocks.add(lock)
|
|
781
|
+
return {
|
|
782
|
+
async setIndex (sha) {
|
|
783
|
+
const next = `${index}.quilt-${process.pid}`
|
|
784
|
+
try { fs.copyFileSync(index, next) } catch {}
|
|
785
|
+
const r = await call(root, ['read-tree', '--reset', sha], { env: { GIT_INDEX_FILE: next } })
|
|
786
|
+
try {
|
|
787
|
+
if (r.out === null) return false
|
|
788
|
+
fs.renameSync(next, index)
|
|
789
|
+
return true
|
|
790
|
+
} catch { return false } finally { fs.rmSync(next, { force: true }); fs.rmSync(`${next}.lock`, { force: true }) }
|
|
791
|
+
},
|
|
792
|
+
// A copy of the index as it is, put back by putBack() (the branch it was for didn't move after all).
|
|
793
|
+
keep () { try { fs.copyFileSync(index, prev); return true } catch { return false } },
|
|
794
|
+
putBack () { try { fs.renameSync(prev, index) } catch {} },
|
|
795
|
+
release () { fs.rmSync(prev, { force: true }); fs.rmSync(lock, { force: true }); ownLocks.delete(lock) }
|
|
796
|
+
}
|
|
797
|
+
} catch (err) { if (err.code !== 'EEXIST') return null }
|
|
798
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
799
|
+
}
|
|
800
|
+
return null
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* Every branch and worktree of the repository, as seen from this folder:
|
|
805
|
+
* { worktrees: [{ path, branch, sha }], branches: [{ name, sha, upstream, ahead, behind, author, ts, subject }] }.
|
|
806
|
+
* Null when git fails. Worktrees share the repository, so one folder sees them all.
|
|
807
|
+
*/
|
|
808
|
+
export async function repoBranches (root) {
|
|
809
|
+
const wt = await run(root, ['worktree', 'list', '--porcelain'])
|
|
810
|
+
if (wt === null) return null
|
|
811
|
+
const worktrees = []
|
|
812
|
+
let cur = null
|
|
813
|
+
for (const line of wt.split('\n')) {
|
|
814
|
+
if (line.startsWith('worktree ')) { cur = { path: line.slice(9), branch: null, sha: null }; worktrees.push(cur) } else if (cur && line.startsWith('HEAD ')) { cur.sha = line.slice(5) } else if (cur && line.startsWith('branch ')) { cur.branch = line.slice(7).replace(/^refs\/heads\//, '') }
|
|
815
|
+
}
|
|
816
|
+
const fmt = '%(refname:short)%00%(objectname)%00%(upstream:short)%00%(upstream:track,nobracket)%00%(authorname)%00%(committerdate:unix)%00%(contents:subject)'
|
|
817
|
+
const refs = await run(root, ['for-each-ref', '--sort=-committerdate', '--count=200', `--format=${fmt}`, 'refs/heads'])
|
|
818
|
+
if (refs === null) return null
|
|
819
|
+
const branches = []
|
|
820
|
+
for (const line of refs.split('\n')) {
|
|
821
|
+
if (!line) continue
|
|
822
|
+
const [name, sha, upstream, track, author, ts, subject] = line.split('\0')
|
|
823
|
+
const n = (word) => { const m = new RegExp(`${word} (\\d+)`).exec(track || ''); return m ? Number(m[1]) : 0 }
|
|
824
|
+
branches.push({ name, sha, upstream: upstream || null, ahead: n('ahead'), behind: n('behind'), gone: /gone/.test(track || ''), author, ts: Number(ts) * 1000 || 0, subject })
|
|
825
|
+
}
|
|
826
|
+
return { worktrees, branches }
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/** A file's bytes at a commit as checkout would write them (--filters), or null. */
|
|
830
|
+
export async function blobAt (root, sha, rel) {
|
|
831
|
+
const out = await run(root, ['cat-file', '--filters', `${sha}:${rel}`], { buffer: true })
|
|
832
|
+
return out === null ? null : out
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
/** Whether the commit has any file under `dir` ('' is the top), or null when git fails. */
|
|
836
|
+
export async function hasFilesUnder (root, sha, dir) {
|
|
837
|
+
const out = await run(root, ['--literal-pathspecs', 'ls-tree', '-r', '--name-only', sha, '--', dir || '.'])
|
|
838
|
+
return out === null ? null : out.trim().length > 0
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/**
|
|
842
|
+
* Sets the index to `sha` (what HEAD now points at), leaving every file as it is. Another git
|
|
843
|
+
* (an AI's `git status`) may hold the index lock a moment: asked again for up to two seconds.
|
|
844
|
+
*/
|
|
845
|
+
export async function resetIndex (root, sha) {
|
|
846
|
+
for (let i = 0; i < 20; i++) {
|
|
847
|
+
const r = await call(root, ['read-tree', '--reset', sha])
|
|
848
|
+
if (r.out !== null) return true
|
|
849
|
+
if (r.missing || r.timedOut) return false
|
|
850
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
851
|
+
}
|
|
852
|
+
return false
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
/**
|
|
856
|
+
* Pushes `sha` to `remote`'s `branch` with the folder's own git and credentials: forward only (never
|
|
857
|
+
* forced), with no prompt waiting for a password. { ok, error }.
|
|
858
|
+
*/
|
|
859
|
+
export async function pushLocal (root, remote, sha, branch) {
|
|
860
|
+
const r = await call(root, ['-c', 'core.askPass=', 'push', '--quiet', '--no-verify', remote, `${sha}:refs/heads/${branch}`], {
|
|
861
|
+
timeout: FETCH_TIMEOUT_MS,
|
|
862
|
+
env: { GIT_TERMINAL_PROMPT: '0', GIT_ASKPASS: '', SSH_ASKPASS: '', GCM_INTERACTIVE: 'never', GIT_SSH_COMMAND: process.env.GIT_SSH_COMMAND || 'ssh -o BatchMode=yes' }
|
|
863
|
+
})
|
|
864
|
+
return r.out !== null ? { ok: true, error: null } : { ok: false, error: (r.stderr || '').trim().split('\n').filter(Boolean).pop() || 'git push failed' }
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/** The commits on `branch` that `upstream` hasn't, as a git bundle (a Buffer), or null. */
|
|
868
|
+
export async function bundleOf (root, branch, upstream) {
|
|
869
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'quilt-bundle-'))
|
|
870
|
+
try {
|
|
871
|
+
const file = path.join(dir, 'local.bundle')
|
|
872
|
+
const r = await call(root, ['bundle', 'create', '--quiet', file, `refs/heads/${branch}`, ...(upstream ? [`^${upstream}`] : [])], { timeout: FETCH_TIMEOUT_MS })
|
|
873
|
+
if (r.out === null) return null
|
|
874
|
+
return fs.readFileSync(file)
|
|
875
|
+
} catch { return null } finally { fs.rmSync(dir, { recursive: true, force: true }) }
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
/**
|
|
879
|
+
* The folder's branch moves to `to` (GitHub's head, which now carries this folder's local commits,
|
|
880
|
+
* published or replayed by the relay) from `from`, its local commits kept under
|
|
881
|
+
* refs/quilt/original/<branch>/<time> first. Only the branch and the index move, never a file; and
|
|
882
|
+
* only when it is safe: HEAD still on the branch at `from`, nothing staged, no git operation under
|
|
883
|
+
* way. Returns { moved, why }.
|
|
884
|
+
*/
|
|
885
|
+
export async function adoptPublished (root, branch, from, to) {
|
|
886
|
+
if (busy(root)) return { moved: false, why: 'a git operation is under way here' }
|
|
887
|
+
const head = ((await run(root, ['rev-parse', '-q', '--verify', 'HEAD'])) || '').trim()
|
|
888
|
+
if (head !== from || headRef(root) !== branch) return { moved: false, why: 'the branch moved meanwhile' }
|
|
889
|
+
const staged = await stagedAgainst(root, from)
|
|
890
|
+
if (staged !== false) return { moved: false, why: 'changes are staged for a commit here' }
|
|
891
|
+
const keep = await call(root, ['update-ref', '-m', 'quilt: local commits, published', `refs/quilt/original/${branch}/${Date.now()}`, from])
|
|
892
|
+
if (keep.out === null) return { moved: false, why: 'git could not keep the local commits' }
|
|
893
|
+
// Under the index lock, the index first, as fastForward: no commit made on `to` from the old index.
|
|
894
|
+
const lock = await lockIndex(root)
|
|
895
|
+
if (!lock) return { moved: false, why: 'another git holds the index' }
|
|
896
|
+
try {
|
|
897
|
+
const r = await moveUnderLock(root, lock, branch, from, to, 'quilt: follow the published commits')
|
|
898
|
+
return r.moved ? { moved: true, why: null } : { moved: false, why: 'the branch moved meanwhile' }
|
|
899
|
+
} finally { lock.release() }
|
|
900
|
+
}
|