git-workspace-bare 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +238 -16
- package/bin/git-workspace.js +63 -9
- package/completions/git-workspace.bash +26 -0
- package/docs/hooks.md +214 -0
- package/lib/add.js +55 -7
- package/lib/apply-pull.js +140 -0
- package/lib/checkpoint.js +208 -0
- package/lib/clone-repository.js +51 -0
- package/lib/commit-manifest.js +29 -0
- package/lib/completion.js +93 -0
- package/lib/git-network-environment.js +31 -0
- package/lib/hook-context.js +239 -0
- package/lib/hooks.js +87 -0
- package/lib/init.js +17 -4
- package/lib/manifest.js +102 -0
- package/lib/plan-pull.js +332 -0
- package/lib/pull-state.js +113 -0
- package/lib/pull.js +269 -0
- package/lib/push-manifest.js +53 -0
- package/lib/registry.js +23 -12
- package/lib/remove.js +27 -2
- package/lib/rename-root.js +182 -84
- package/lib/rename.js +92 -16
- package/package.json +3 -1
- package/test/add-offline.js +240 -0
- package/test/checkpoint.js +243 -0
- package/test/completion.js +180 -0
- package/test/fixtures/checkpoint.js +14 -0
- package/test/fixtures/completion.bash +28 -0
- package/test/helpers.js +60 -2
- package/test/hooks.js +451 -0
- package/test/lifecycle.js +393 -13
- package/test/manifest-push.js +135 -0
- package/test/manifest.js +170 -0
- package/test/pull.js +696 -0
package/lib/plan-pull.js
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import fs from 'bare-fs'
|
|
2
|
+
import path from 'bare-path'
|
|
3
|
+
import { spawnSync } from 'bare-subprocess'
|
|
4
|
+
import { validateManifest } from './manifest.js'
|
|
5
|
+
import { verifyCheckouts } from './checkpoint.js'
|
|
6
|
+
import repositoryPath from './repository-path.js'
|
|
7
|
+
|
|
8
|
+
// Plan dependencies before consulting the filesystem: remove children first,
|
|
9
|
+
// rename roots before their branches, and add parents before children.
|
|
10
|
+
export function changes(previous, desired) {
|
|
11
|
+
validateManifest(previous)
|
|
12
|
+
validateManifest(desired)
|
|
13
|
+
const before = entries(previous)
|
|
14
|
+
const after = entries(desired)
|
|
15
|
+
const actions = []
|
|
16
|
+
const blockers = []
|
|
17
|
+
for (const entry of before.values()) {
|
|
18
|
+
const next = after.get(entry.id)
|
|
19
|
+
if (next && (next.kind !== entry.kind || next.root !== entry.root)) {
|
|
20
|
+
blockers.push(`Identity ${entry.id} changes entry type or parent repository`)
|
|
21
|
+
continue
|
|
22
|
+
}
|
|
23
|
+
if (!next) actions.push({ type: 'remove', ...entry, from: entry.name })
|
|
24
|
+
else if (entry.kind === 'repository' && entry.name !== next.name) {
|
|
25
|
+
actions.push({ type: 'rename', ...entry, from: entry.name, to: next.name })
|
|
26
|
+
} else if (entry.kind === 'worktree' && entry.branch !== next.branch) {
|
|
27
|
+
const root = after.get(entry.root)
|
|
28
|
+
actions.push({
|
|
29
|
+
type: 'rename',
|
|
30
|
+
...entry,
|
|
31
|
+
source: entry.name,
|
|
32
|
+
from: `${root.name}@${entry.branch}`,
|
|
33
|
+
to: next.name
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
for (const entry of after.values()) {
|
|
38
|
+
if (!before.has(entry.id)) actions.push({ type: 'add', ...entry, to: entry.name })
|
|
39
|
+
}
|
|
40
|
+
const order = (action) => {
|
|
41
|
+
const child = action.kind === 'worktree'
|
|
42
|
+
if (action.type === 'remove') return child ? 0 : 1
|
|
43
|
+
if (action.type === 'rename') return child ? 3 : 2
|
|
44
|
+
return child ? 5 : 4
|
|
45
|
+
}
|
|
46
|
+
actions.sort((a, b) => order(a) - order(b) || a.name.localeCompare(b.name))
|
|
47
|
+
return { actions, blockers }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export default function planPull(previous, desired, manifestDirectory) {
|
|
51
|
+
const plan = changes(previous, desired)
|
|
52
|
+
if (plan.blockers.length) return plan
|
|
53
|
+
const before = entries(previous)
|
|
54
|
+
const removals = new Set(plan.actions.filter((a) => a.type === 'remove').map((a) => a.name))
|
|
55
|
+
const removedPaths = new Set([...removals].map(repositoryPath))
|
|
56
|
+
const roots = new Map()
|
|
57
|
+
const rootRenames = new Map(
|
|
58
|
+
plan.actions.filter((a) => a.type === 'rename' && a.kind === 'repository').map((a) => [a.id, a])
|
|
59
|
+
)
|
|
60
|
+
const attempt = (label, fn) => {
|
|
61
|
+
try {
|
|
62
|
+
fn()
|
|
63
|
+
} catch (err) {
|
|
64
|
+
plan.blockers.push(`${label}: ${err.message}`)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const verify = (repository, worktrees = [], retired = new Set()) =>
|
|
68
|
+
verifyCheckouts({ repositories: [{ ...repository, worktrees }] }, retired, rootRenames)
|
|
69
|
+
|
|
70
|
+
for (const repository of desired.repositories) {
|
|
71
|
+
for (const worktree of repository.worktrees) {
|
|
72
|
+
attempt(`${repository.name}@${worktree.branch}`, () => {
|
|
73
|
+
git(manifestDirectory, ['check-ref-format', '--branch', worktree.branch])
|
|
74
|
+
if (repository.worktrees.some((other) => worktree.branch.startsWith(other.branch + '/'))) {
|
|
75
|
+
throw new Error('Branch name conflicts with another desired branch prefix')
|
|
76
|
+
}
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
for (const repository of previous.repositories) {
|
|
82
|
+
const directory = repositoryPath(repository.name)
|
|
83
|
+
if (!exists(directory) && removals.has(repository.name)) {
|
|
84
|
+
roots.set(repository.id, { name: repository.name, directory, listed: [] })
|
|
85
|
+
continue
|
|
86
|
+
}
|
|
87
|
+
attempt(repository.name, () => {
|
|
88
|
+
verify(repository)
|
|
89
|
+
const listed = worktrees(directory)
|
|
90
|
+
roots.set(repository.id, { name: repository.name, directory, listed })
|
|
91
|
+
})
|
|
92
|
+
for (const worktree of repository.worktrees) {
|
|
93
|
+
const name = `${repository.name}@${worktree.branch}`
|
|
94
|
+
if (!exists(repositoryPath(name)) && removals.has(name)) continue
|
|
95
|
+
attempt(name, () => verify(repository, [worktree]))
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Existing untracked destinations may be adopted only on initial/add setup.
|
|
100
|
+
// A reused path with an old ID is removed and recreated, never silently adopted.
|
|
101
|
+
for (const repository of desired.repositories) {
|
|
102
|
+
if (before.has(repository.id)) continue
|
|
103
|
+
const directory = repositoryPath(repository.name)
|
|
104
|
+
if (!exists(directory) || removals.has(repository.name)) continue
|
|
105
|
+
attempt(repository.name, () => {
|
|
106
|
+
verify(repository)
|
|
107
|
+
roots.set(repository.id, { name: repository.name, directory, listed: worktrees(directory) })
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
for (const action of plan.actions) {
|
|
112
|
+
attempt(`${action.type} ${action.from || action.to}`, () => {
|
|
113
|
+
const root = roots.get(action.kind === 'repository' ? action.id : action.root)
|
|
114
|
+
const physicalSource = action.source || action.from
|
|
115
|
+
if (action.type === 'remove') {
|
|
116
|
+
const directory = repositoryPath(action.from)
|
|
117
|
+
if (exists(directory)) {
|
|
118
|
+
protectedManifest(directory, manifestDirectory)
|
|
119
|
+
if (fs.lstatSync(directory).isSymbolicLink()) {
|
|
120
|
+
throw new Error('Checkout is a symbolic link')
|
|
121
|
+
}
|
|
122
|
+
if (
|
|
123
|
+
git(directory, [
|
|
124
|
+
'status',
|
|
125
|
+
'--porcelain',
|
|
126
|
+
'--untracked-files=all',
|
|
127
|
+
'--ignore-submodules=none'
|
|
128
|
+
])
|
|
129
|
+
) {
|
|
130
|
+
throw new Error('Local changes or untracked files would be removed')
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (root) {
|
|
134
|
+
if (action.kind === 'repository') {
|
|
135
|
+
for (const entry of root.listed.slice(1)) {
|
|
136
|
+
const name = [...before.values()].find(
|
|
137
|
+
(item) => repositoryPath(item.name) === entry.directory
|
|
138
|
+
)?.name
|
|
139
|
+
if (!name || !removals.has(name)) {
|
|
140
|
+
throw new Error(`Linked worktree would remain: ${entry.directory}`)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
} else {
|
|
144
|
+
const entry = root.listed.find((entry) => entry.directory === directory)
|
|
145
|
+
if (entry?.locked) throw new Error('Worktree is locked')
|
|
146
|
+
if (entry && !exists(directory)) {
|
|
147
|
+
throw new Error('Missing worktree still has a Git record; repair or prune it first')
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (action.type === 'rename') {
|
|
155
|
+
const source = repositoryPath(physicalSource)
|
|
156
|
+
if (!exists(source)) throw new Error(`Missing checkout: ${physicalSource}`)
|
|
157
|
+
protectedManifest(source, manifestDirectory)
|
|
158
|
+
if (action.kind === 'repository' && root) {
|
|
159
|
+
for (const entry of root.listed.slice(1)) {
|
|
160
|
+
if (removedPaths.has(entry.directory)) continue
|
|
161
|
+
if (!exists(entry.directory)) {
|
|
162
|
+
throw new Error(`Missing linked worktree: ${entry.directory}`)
|
|
163
|
+
}
|
|
164
|
+
if (
|
|
165
|
+
fs.realpathSync(
|
|
166
|
+
git(entry.directory, ['rev-parse', '--path-format=absolute', '--git-common-dir'])
|
|
167
|
+
) !== path.join(fs.realpathSync(root.directory), '.git')
|
|
168
|
+
) {
|
|
169
|
+
throw new Error(`Linked worktree needs repair: ${entry.directory}`)
|
|
170
|
+
}
|
|
171
|
+
if (entry.directory.startsWith(root.directory + '@')) {
|
|
172
|
+
if (entry.locked) throw new Error(`Worktree is locked: ${entry.directory}`)
|
|
173
|
+
if (fs.lstatSync(entry.directory).isSymbolicLink()) {
|
|
174
|
+
throw new Error(`Worktree is a symbolic link: ${entry.directory}`)
|
|
175
|
+
}
|
|
176
|
+
vacant(repositoryPath(action.to) + entry.directory.slice(root.directory.length))
|
|
177
|
+
}
|
|
178
|
+
if (
|
|
179
|
+
git(entry.directory, ['ls-files', '--stage', '-z'])
|
|
180
|
+
.split('\0')
|
|
181
|
+
.some((item) => item.startsWith('160000 '))
|
|
182
|
+
) {
|
|
183
|
+
throw new Error(`Linked worktree contains submodules: ${entry.directory}`)
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
} else if (root) {
|
|
187
|
+
const entry = root.listed.find((entry) => entry.directory === source)
|
|
188
|
+
if (entry?.locked) throw new Error('Worktree is locked')
|
|
189
|
+
const branch = action.to.split('@')[1]
|
|
190
|
+
branchTarget(root, branch, action.branch)
|
|
191
|
+
outsideWorktrees(repositoryPath(`${root.name}@${branch}`))
|
|
192
|
+
}
|
|
193
|
+
vacant(repositoryPath(action.to))
|
|
194
|
+
return
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const destination = repositoryPath(action.to)
|
|
198
|
+
// A root rename changes the base path before a branch can be added.
|
|
199
|
+
const physical =
|
|
200
|
+
action.kind === 'worktree' && rootRenames.has(action.root)
|
|
201
|
+
? repositoryPath(`${rootRenames.get(action.root).from}@${action.branch}`)
|
|
202
|
+
: destination
|
|
203
|
+
checkParents(destination)
|
|
204
|
+
if (exists(destination) && destination !== physical) vacant(destination)
|
|
205
|
+
if (exists(physical) && !removedPaths.has(physical)) {
|
|
206
|
+
const repository = desired.repositories.find(
|
|
207
|
+
(repo) => repo.id === (action.root || action.id)
|
|
208
|
+
)
|
|
209
|
+
const localRepository = root ? { ...repository, name: root.name } : repository
|
|
210
|
+
verify(localRepository, action.kind === 'worktree' ? [{ branch: action.branch }] : [])
|
|
211
|
+
action.present = true
|
|
212
|
+
} else if (action.kind === 'worktree' && root) {
|
|
213
|
+
branchTarget(root, action.branch)
|
|
214
|
+
const checkedOut = root.listed.find((entry) => entry.branch === action.branch)
|
|
215
|
+
if (checkedOut) {
|
|
216
|
+
const name = `${root.name}@${action.branch}`
|
|
217
|
+
if (!removals.has(name) || checkedOut.directory !== repositoryPath(name)) {
|
|
218
|
+
throw new Error(
|
|
219
|
+
`Branch ${action.branch} is already checked out at ${checkedOut.directory}`
|
|
220
|
+
)
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
outsideWorktrees(physical)
|
|
225
|
+
})
|
|
226
|
+
}
|
|
227
|
+
return { actions: plan.actions, blockers: [...new Set(plan.blockers)] }
|
|
228
|
+
|
|
229
|
+
function vacant(directory) {
|
|
230
|
+
if (exists(directory)) {
|
|
231
|
+
if (!removedPaths.has(directory)) throw new Error(`Destination already exists: ${directory}`)
|
|
232
|
+
}
|
|
233
|
+
checkParents(directory)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function checkParents(directory) {
|
|
237
|
+
// Catch files and dangling symlinks in parent components without creating them.
|
|
238
|
+
let parent = path.dirname(directory)
|
|
239
|
+
while (!exists(parent)) parent = path.dirname(parent)
|
|
240
|
+
if (!fs.statSync(parent).isDirectory()) {
|
|
241
|
+
throw new Error(`Destination parent is not a directory: ${parent}`)
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function outsideWorktrees(directory) {
|
|
246
|
+
// An absent nested destination can still sit inside another worktree.
|
|
247
|
+
for (const entry of roots.values()) {
|
|
248
|
+
for (const worktree of entry.listed.slice(1)) {
|
|
249
|
+
if (
|
|
250
|
+
directory.startsWith(worktree.directory + path.sep) &&
|
|
251
|
+
!removedPaths.has(worktree.directory)
|
|
252
|
+
) {
|
|
253
|
+
throw new Error(`Destination is inside another worktree: ${worktree.directory}`)
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function branchTarget(root, target, source = null) {
|
|
260
|
+
const refs = git(root.directory, [
|
|
261
|
+
'for-each-ref',
|
|
262
|
+
'--format=%(refname:strip=2)',
|
|
263
|
+
'refs/heads/'
|
|
264
|
+
]).split('\n')
|
|
265
|
+
for (const branch of refs) {
|
|
266
|
+
if (!branch || branch === source || removals.has(`${root.name}@${branch}`)) continue
|
|
267
|
+
if (source && branch === target) throw new Error(`Branch ${target} already exists`)
|
|
268
|
+
if (target.startsWith(branch + '/') || branch.startsWith(target + '/')) {
|
|
269
|
+
throw new Error(`Branch ${target} conflicts with existing branch ${branch}`)
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function entries(manifest) {
|
|
276
|
+
return new Map(
|
|
277
|
+
manifest.repositories.flatMap((repository) => [
|
|
278
|
+
[repository.id, { id: repository.id, kind: 'repository', root: null, name: repository.name }],
|
|
279
|
+
...repository.worktrees.map((worktree) => [
|
|
280
|
+
worktree.id,
|
|
281
|
+
{
|
|
282
|
+
id: worktree.id,
|
|
283
|
+
kind: 'worktree',
|
|
284
|
+
root: repository.id,
|
|
285
|
+
branch: worktree.branch,
|
|
286
|
+
name: `${repository.name}@${worktree.branch}`
|
|
287
|
+
}
|
|
288
|
+
])
|
|
289
|
+
])
|
|
290
|
+
)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function protectedManifest(directory, manifestDirectory) {
|
|
294
|
+
if (fs.realpathSync(directory) === fs.realpathSync(manifestDirectory)) {
|
|
295
|
+
throw new Error('Cannot remove or rename the active manifest repository')
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function worktrees(directory) {
|
|
300
|
+
return git(directory, ['worktree', 'list', '--porcelain', '-z'])
|
|
301
|
+
.split('\0\0')
|
|
302
|
+
.filter(Boolean)
|
|
303
|
+
.map((record) => {
|
|
304
|
+
const fields = record.split('\0')
|
|
305
|
+
return {
|
|
306
|
+
directory: fields[0].slice('worktree '.length),
|
|
307
|
+
branch: fields
|
|
308
|
+
.find((field) => field.startsWith('branch refs/heads/'))
|
|
309
|
+
?.slice('branch refs/heads/'.length),
|
|
310
|
+
locked: fields.some((field) => field === 'locked' || field.startsWith('locked '))
|
|
311
|
+
}
|
|
312
|
+
})
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function exists(directory) {
|
|
316
|
+
try {
|
|
317
|
+
fs.lstatSync(directory)
|
|
318
|
+
return true
|
|
319
|
+
} catch (err) {
|
|
320
|
+
if (err.code !== 'ENOENT' && err.code !== 'ENOTDIR') throw err
|
|
321
|
+
return false
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function git(directory, args) {
|
|
326
|
+
const result = spawnSync('git', ['--no-optional-locks', '-C', directory, ...args])
|
|
327
|
+
if (result.error) throw result.error
|
|
328
|
+
if (result.status !== 0) {
|
|
329
|
+
throw new Error(result.stderr.toString().trim() || 'Git inspection failed')
|
|
330
|
+
}
|
|
331
|
+
return result.stdout.toString().replace(/\r?\n$/, '')
|
|
332
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import fs from 'bare-fs'
|
|
2
|
+
import path from 'bare-path'
|
|
3
|
+
import os from 'bare-os'
|
|
4
|
+
import { manifestDirectory } from './registry.js'
|
|
5
|
+
|
|
6
|
+
export const targetRef = 'refs/git-workspace/pull-target'
|
|
7
|
+
|
|
8
|
+
export function lockPull(directory) {
|
|
9
|
+
const lock = path.join(directory, '.git/workspace.lock')
|
|
10
|
+
const owner = path.join(lock, 'pull-owner')
|
|
11
|
+
try {
|
|
12
|
+
fs.mkdirSync(lock)
|
|
13
|
+
} catch (err) {
|
|
14
|
+
if (err.code !== 'EEXIST') throw err
|
|
15
|
+
// Only reclaim a dead pull's lock; ordinary command locks are left alone.
|
|
16
|
+
if (!fs.lstatSync(lock).isDirectory() || fs.lstatSync(lock).isSymbolicLink()) throw err
|
|
17
|
+
let pid
|
|
18
|
+
try {
|
|
19
|
+
if (!fs.lstatSync(owner).isFile()) throw err
|
|
20
|
+
pid = Number(fs.readFileSync(owner, 'utf8'))
|
|
21
|
+
} catch {
|
|
22
|
+
throw err
|
|
23
|
+
}
|
|
24
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) throw err
|
|
25
|
+
try {
|
|
26
|
+
os.kill(pid, 0)
|
|
27
|
+
throw err
|
|
28
|
+
} catch (dead) {
|
|
29
|
+
if (dead.code !== 'ESRCH') throw dead
|
|
30
|
+
}
|
|
31
|
+
// Serialize stale-lock reclaimers and recheck ownership: another retry may
|
|
32
|
+
// have replaced the lock since we first read its PID.
|
|
33
|
+
const claiming = path.join(lock, 'pull-recovery')
|
|
34
|
+
fs.mkdirSync(claiming)
|
|
35
|
+
const retired = lock + `.retired-${os.pid()}`
|
|
36
|
+
try {
|
|
37
|
+
if (Number(fs.readFileSync(owner, 'utf8')) !== pid) throw err
|
|
38
|
+
try {
|
|
39
|
+
os.kill(pid, 0)
|
|
40
|
+
throw err
|
|
41
|
+
} catch (dead) {
|
|
42
|
+
if (dead.code !== 'ESRCH') throw dead
|
|
43
|
+
}
|
|
44
|
+
fs.renameSync(lock, retired)
|
|
45
|
+
} catch (failed) {
|
|
46
|
+
fs.rmdirSync(claiming)
|
|
47
|
+
throw failed
|
|
48
|
+
}
|
|
49
|
+
fs.rmSync(retired, { recursive: true })
|
|
50
|
+
fs.mkdirSync(lock)
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
fs.writeFileSync(owner, String(os.pid()), { flag: 'wx' })
|
|
54
|
+
} catch (err) {
|
|
55
|
+
fs.rmdirSync(lock)
|
|
56
|
+
throw err
|
|
57
|
+
}
|
|
58
|
+
return () => {
|
|
59
|
+
fs.unlinkSync(owner)
|
|
60
|
+
fs.rmdirSync(lock)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function statePath(directory) {
|
|
65
|
+
return path.join(directory, '.git/workspace-pull.json')
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function assertNoPendingPull(directory = manifestDirectory()) {
|
|
69
|
+
try {
|
|
70
|
+
fs.lstatSync(statePath(directory))
|
|
71
|
+
} catch (err) {
|
|
72
|
+
if (err.code === 'ENOENT') return
|
|
73
|
+
throw err
|
|
74
|
+
}
|
|
75
|
+
throw new Error(
|
|
76
|
+
'An interrupted workspace pull is pending; rerun git workspace pull before making workspace changes'
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function readPullState(directory) {
|
|
81
|
+
const file = statePath(directory)
|
|
82
|
+
try {
|
|
83
|
+
if (!fs.lstatSync(file).isFile()) throw new Error('Pull progress must be a regular file')
|
|
84
|
+
} catch (err) {
|
|
85
|
+
if (err.code === 'ENOENT') return null
|
|
86
|
+
throw err
|
|
87
|
+
}
|
|
88
|
+
const state = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
89
|
+
const hash = (value) => typeof value === 'string' && /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(value)
|
|
90
|
+
if (
|
|
91
|
+
!state ||
|
|
92
|
+
state.version !== 1 ||
|
|
93
|
+
!hash(state.head) ||
|
|
94
|
+
!hash(state.target) ||
|
|
95
|
+
(state.applied !== null && !hash(state.applied)) ||
|
|
96
|
+
!Number.isSafeInteger(state.completed) ||
|
|
97
|
+
state.completed < 0 ||
|
|
98
|
+
typeof state.pending !== 'boolean' ||
|
|
99
|
+
typeof state.localBranch !== 'string' ||
|
|
100
|
+
typeof state.remote !== 'string' ||
|
|
101
|
+
typeof state.merge !== 'string'
|
|
102
|
+
) {
|
|
103
|
+
throw new Error('Invalid local pull progress; inspect .git/workspace-pull.json')
|
|
104
|
+
}
|
|
105
|
+
return state
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function writePullState(directory, state) {
|
|
109
|
+
const file = statePath(directory)
|
|
110
|
+
const temporary = file + '.tmp'
|
|
111
|
+
fs.writeFileSync(temporary, JSON.stringify(state, null, 2) + '\n', { mode: 0o600 })
|
|
112
|
+
fs.renameSync(temporary, file)
|
|
113
|
+
}
|