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/lib/pull.js ADDED
@@ -0,0 +1,269 @@
1
+ import fs from 'bare-fs'
2
+ import path from 'bare-path'
3
+ import { spawnSync } from 'bare-subprocess'
4
+ import { manifestDirectory, readManifest } from './registry.js'
5
+ import { readCheckpoint, readCommitRef, recordCheckpoint, snapshot } from './checkpoint.js'
6
+ import { emptyManifest, manifestNames } from './manifest.js'
7
+ import planPull, { changes } from './plan-pull.js'
8
+ import applyPull, { layoutAfter, pendingRename } from './apply-pull.js'
9
+ import { readPullState, writePullState, statePath, targetRef, lockPull } from './pull-state.js'
10
+ import repositoryPath from './repository-path.js'
11
+ import Hooks from './hooks.js'
12
+ import { validateHookProgress } from './hook-context.js'
13
+
14
+ export default function pull({ dryRun = false, noHooks = false } = {}) {
15
+ const directory = manifestDirectory()
16
+ const hooks = new Hooks({ noHooks: noHooks || dryRun })
17
+ if (
18
+ fs.lstatSync(directory).isSymbolicLink() ||
19
+ !fs.lstatSync(path.join(directory, '.git')).isDirectory()
20
+ ) {
21
+ throw new Error('Manifest must be a standalone Git repository')
22
+ }
23
+ const unlock = lockPull(directory)
24
+ try {
25
+ const manifest = path.join(directory, 'workspace.json')
26
+ const local = readManifest(manifest)
27
+ const contents = fs.readFileSync(manifest, 'utf8')
28
+ if (
29
+ fs.realpathSync(git(directory, ['rev-parse', '--show-toplevel'])) !==
30
+ fs.realpathSync(directory)
31
+ ) {
32
+ throw new Error('Manifest must be a standalone Git repository')
33
+ }
34
+ const head = git(directory, ['rev-parse', '--verify', 'HEAD^{commit}'])
35
+ if (git(directory, ['status', '--porcelain', '--untracked-files=all'])) {
36
+ throw new Error(
37
+ 'Manifest repository has uncommitted changes; commit or stash them before pulling'
38
+ )
39
+ }
40
+ if (JSON.stringify(local) !== JSON.stringify(snapshot(directory, head))) {
41
+ throw new Error('workspace.json differs from HEAD')
42
+ }
43
+ const symbolic = run(directory, ['symbolic-ref', '--quiet', '--short', 'HEAD'])
44
+ if (symbolic.status === 1) {
45
+ throw new Error('Manifest repository has a detached HEAD; check out a branch before pulling')
46
+ }
47
+ if (symbolic.status !== 0) fail(symbolic)
48
+ const branch = symbolic.stdout.toString().trim()
49
+ const progress = readPullState(directory)
50
+ if (progress) return resume(directory, progress, head, branch, dryRun, hooks)
51
+ const remote = config(directory, `branch.${branch}.remote`) || 'origin'
52
+ const merge = config(directory, `branch.${branch}.merge`) || `refs/heads/${branch}`
53
+ if (remote === '.' || !merge.startsWith('refs/heads/')) {
54
+ throw new Error('Manifest branch must track a remote branch')
55
+ }
56
+ git(directory, ['check-ref-format', merge])
57
+ git(directory, ['remote', 'get-url', remote])
58
+ const applied = readCheckpoint(directory)
59
+ if (applied && !ancestor(directory, applied, head)) {
60
+ throw new Error('Manifest HEAD does not descend from the locally applied commit')
61
+ }
62
+
63
+ git(directory, [
64
+ 'fetch',
65
+ '--no-tags',
66
+ '--no-recurse-submodules',
67
+ '--no-auto-maintenance',
68
+ '--',
69
+ remote,
70
+ merge
71
+ ])
72
+ const fetched = git(directory, ['rev-parse', '--verify', 'FETCH_HEAD^{commit}'])
73
+ let target = fetched
74
+ if (ancestor(directory, fetched, head)) target = head
75
+ else if (!ancestor(directory, head, fetched)) {
76
+ throw new Error(
77
+ 'Local and remote manifest histories have diverged; resolve them before pulling'
78
+ )
79
+ }
80
+ const previous = applied ? snapshot(directory, applied) : emptyManifest()
81
+ const desired = snapshot(directory, target)
82
+ const plan = planPull(previous, desired, directory)
83
+ if (
84
+ git(directory, ['rev-parse', '--verify', 'HEAD^{commit}']) !== head ||
85
+ readCheckpoint(directory) !== applied ||
86
+ fs.readFileSync(manifest, 'utf8') !== contents ||
87
+ git(directory, ['status', '--porcelain', '--untracked-files=all'])
88
+ ) {
89
+ throw new Error('Local manifest state changed during planning; retry')
90
+ }
91
+ const result = {
92
+ ...plan,
93
+ head,
94
+ fetched,
95
+ target,
96
+ applied,
97
+ remote,
98
+ branch: merge.slice('refs/heads/'.length),
99
+ dryRun
100
+ }
101
+ if (dryRun || plan.blockers.length) return result
102
+ const state = {
103
+ version: 1,
104
+ head,
105
+ target,
106
+ applied,
107
+ remote,
108
+ merge,
109
+ localBranch: branch,
110
+ completed: 0,
111
+ pending: false
112
+ }
113
+ const pinned = readCommitRef(directory, targetRef)
114
+ git(directory, [
115
+ 'update-ref',
116
+ '--no-deref',
117
+ targetRef,
118
+ target,
119
+ pinned || '0'.repeat(target.length)
120
+ ])
121
+ writePullState(directory, state)
122
+ execute(directory, state, previous, desired, hooks)
123
+ return result
124
+ } finally {
125
+ unlock()
126
+ }
127
+ }
128
+
129
+ function resume(directory, state, head, branch, dryRun, hooks) {
130
+ const previous = state.applied ? snapshot(directory, state.applied) : emptyManifest()
131
+ const desired = snapshot(directory, state.target)
132
+ const original = changes(previous, desired)
133
+ validateHookProgress(state, original.actions)
134
+ const applied = readCheckpoint(directory)
135
+ const finished = state.completed === original.actions.length && !state.pending
136
+ if (
137
+ original.blockers.length ||
138
+ state.completed > original.actions.length ||
139
+ (state.pending && state.completed === original.actions.length) ||
140
+ branch !== state.localBranch ||
141
+ ![state.head, state.target].includes(head) ||
142
+ ((state.completed > 0 || state.pending) && head !== state.target) ||
143
+ !ancestor(directory, state.head, state.target) ||
144
+ (state.applied && !ancestor(directory, state.applied, state.head)) ||
145
+ (applied !== state.applied && !(finished && applied === state.target)) ||
146
+ readCommitRef(directory, targetRef) !== state.target
147
+ ) {
148
+ throw new Error(
149
+ 'Manifest or local pull progress changed unexpectedly; inspect .git/workspace-pull.json'
150
+ )
151
+ }
152
+ const layout = layoutAfter(previous, desired, original.actions.slice(0, state.completed))
153
+ const interrupted = state.pending ? original.actions[state.completed] : null
154
+ let plan
155
+ if (interrupted && pendingRename(interrupted)) {
156
+ plan = { actions: original.actions.slice(state.completed), blockers: [] }
157
+ if (dryRun) {
158
+ plan.blockers.push(
159
+ 'Interrupted rename needs repair before remaining changes can be checked; rerun git workspace pull'
160
+ )
161
+ }
162
+ } else plan = planPull(layout, desired, directory)
163
+ const result = {
164
+ ...plan,
165
+ head,
166
+ fetched: state.target,
167
+ target: state.target,
168
+ applied: state.applied,
169
+ remote: state.remote,
170
+ branch: state.merge.slice('refs/heads/'.length),
171
+ dryRun,
172
+ resumed: true,
173
+ completed: state.completed
174
+ }
175
+ if (dryRun) return result
176
+ // execute repeats preflight after recovering the pending operation.
177
+ execute(directory, state, previous, desired, hooks, true)
178
+ return result
179
+ }
180
+
181
+ function execute(directory, state, previous, desired, hooks, resumed = false) {
182
+ if (git(directory, ['rev-parse', '--verify', 'HEAD^{commit}']) !== state.target) {
183
+ git(directory, [
184
+ '-c',
185
+ 'submodule.recurse=false',
186
+ 'merge',
187
+ '--ff-only',
188
+ '--no-edit',
189
+ '--no-stat',
190
+ state.target
191
+ ])
192
+ }
193
+ applyPull(directory, state, previous, desired, hooks, resumed)
194
+ if (
195
+ git(directory, ['rev-parse', '--verify', 'HEAD^{commit}']) !== state.target ||
196
+ ![state.applied, state.target].includes(readCheckpoint(directory)) ||
197
+ git(directory, ['status', '--porcelain', '--untracked-files=all'])
198
+ ) {
199
+ throw new Error(
200
+ 'Manifest state changed during pull; resolve the changes and rerun git workspace pull'
201
+ )
202
+ }
203
+ recordCheckpoint(directory, {
204
+ replacedPaths: new Set(manifestNames(desired).map(repositoryPath))
205
+ })
206
+ // Keep the target pinned until progress is removed, including a crash after verification.
207
+ fs.unlinkSync(statePath(directory))
208
+ git(directory, ['update-ref', '--no-deref', '-d', targetRef, state.target])
209
+ }
210
+
211
+ export function formatPlan(plan) {
212
+ const lines = [`Manifest: ${plan.remote}/${plan.branch}`]
213
+ if (plan.resumed) lines.push(`Resuming saved pull (${plan.completed} changes already completed)`)
214
+ const preview = plan.dryRun || plan.blockers.length
215
+ if (plan.target !== plan.head) {
216
+ lines.push(preview ? 'Would fast-forward the manifest' : 'Fast-forwarded the manifest')
217
+ } else if (plan.fetched !== plan.head) {
218
+ lines.push('Manifest is ahead of the remote; pushing remains manual')
219
+ } else {
220
+ lines.push('Manifest is up to date')
221
+ }
222
+ if (!plan.applied) {
223
+ lines.push(
224
+ preview
225
+ ? 'First setup: existing matching checkouts would be adopted'
226
+ : 'First setup: existing matching checkouts adopted'
227
+ )
228
+ }
229
+ for (const action of plan.actions) {
230
+ if (action.type === 'rename') lines.push(`rename ${action.from} -> ${action.to}`)
231
+ else lines.push(`${action.present ? 'adopt' : action.type} ${action.from || action.to}`)
232
+ }
233
+ if (!plan.actions.length) lines.push('No layout changes planned')
234
+ for (const blocker of plan.blockers) lines.push(`blocked: ${blocker}`)
235
+ if (plan.dryRun) lines.push('Dry run: no manifest checkout or workspace changes applied')
236
+ else if (plan.blockers.length) lines.push('No manifest checkout or workspace changes applied')
237
+ else lines.push('Workspace applied; managed repositories were not fetched or pulled')
238
+ return lines.join('\n')
239
+ }
240
+
241
+ function config(directory, key) {
242
+ const result = run(directory, ['config', '--get', key])
243
+ if (result.status === 1) return null
244
+ if (result.status !== 0) fail(result)
245
+ return result.stdout.toString().trim()
246
+ }
247
+
248
+ function ancestor(directory, from, to) {
249
+ const result = run(directory, ['merge-base', '--is-ancestor', from, to])
250
+ if (result.status === 1) return false
251
+ if (result.status !== 0) fail(result)
252
+ return true
253
+ }
254
+
255
+ function run(directory, args) {
256
+ const result = spawnSync('git', ['--no-optional-locks', '-C', directory, ...args])
257
+ if (result.error) throw result.error
258
+ return result
259
+ }
260
+
261
+ function fail(result) {
262
+ throw new Error(result.stderr.toString().trim() || 'Git command failed')
263
+ }
264
+
265
+ function git(directory, args) {
266
+ const result = run(directory, args)
267
+ if (result.status !== 0) fail(result)
268
+ return result.stdout.toString().replace(/\r?\n$/, '')
269
+ }
@@ -0,0 +1,53 @@
1
+ import { spawnSync } from 'bare-subprocess'
2
+ import networkEnvironment from './git-network-environment.js'
3
+
4
+ export default function pushManifest(directory) {
5
+ try {
6
+ const symbolic = spawnSync('git', [
7
+ '-C',
8
+ directory,
9
+ 'symbolic-ref',
10
+ '--quiet',
11
+ '--short',
12
+ 'HEAD'
13
+ ])
14
+ if (!symbolic.error && !symbolic.signal && symbolic.status === 1) {
15
+ throw new Error('Manifest repository has a detached HEAD; check out a branch before pushing')
16
+ }
17
+ const branch = output(symbolic)
18
+ const remote = config(directory, `branch.${branch}.remote`) || 'origin'
19
+ const merge = config(directory, `branch.${branch}.merge`) || `refs/heads/${branch}`
20
+ if (remote === '.' || !merge.startsWith('refs/heads/')) {
21
+ throw new Error('Manifest branch must track a remote branch')
22
+ }
23
+ git(directory, ['push', '--set-upstream', '--no-follow-tags', '--', remote, `HEAD:${merge}`], {
24
+ env: networkEnvironment(directory)
25
+ })
26
+ } catch (err) {
27
+ console.error(`warning: could not push workspace manifest: ${err.message}`)
28
+ console.error(
29
+ 'hint: changes are saved locally; repeat add to retry, or push the manifest branch manually'
30
+ )
31
+ }
32
+ }
33
+
34
+ function config(directory, key) {
35
+ const result = spawnSync('git', ['-C', directory, 'config', '--get', key])
36
+ if (result.error) throw result.error
37
+ if (!result.signal && result.status === 1) return null
38
+ return output(result)
39
+ }
40
+
41
+ function git(directory, args, options) {
42
+ return output(spawnSync('git', ['-C', directory, ...args], options))
43
+ }
44
+
45
+ function output(result) {
46
+ if (result.error) throw result.error
47
+ if (result.signal || result.status !== 0) {
48
+ throw new Error(
49
+ result.stderr?.toString().trim() || `Git command failed (${result.signal || result.status})`
50
+ )
51
+ }
52
+ return result.stdout.toString().trim()
53
+ }
package/lib/registry.js CHANGED
@@ -3,6 +3,7 @@ import os from 'bare-os'
3
3
  import path from 'bare-path'
4
4
  import repositoryPath from './repository-path.js'
5
5
  import repositoryName from './repository-name.js'
6
+ import { manifestNames, updateManifest, validateManifest } from './manifest.js'
6
7
 
7
8
  export function configPath() {
8
9
  return path.join(os.homedir(), '.config', 'git-workspace', 'config.json')
@@ -33,10 +34,14 @@ export function registryPath() {
33
34
  }
34
35
 
35
36
  export function readRegistry(registry = registryPath()) {
36
- let worktrees
37
+ return manifestNames(readManifest(registry)).map(repositoryPath)
38
+ }
39
+
40
+ export function readManifest(registry = registryPath()) {
41
+ let manifest
37
42
  try {
38
43
  if (!fs.lstatSync(registry).isFile()) throw new Error('Manifest must be a regular file')
39
- worktrees = JSON.parse(fs.readFileSync(registry, 'utf8'))
44
+ manifest = JSON.parse(fs.readFileSync(registry, 'utf8'))
40
45
  } catch (err) {
41
46
  if (err.code === 'ENOENT') {
42
47
  throw new Error('Workspace is not initialized; run git workspace init owner/repo')
@@ -44,24 +49,30 @@ export function readRegistry(registry = registryPath()) {
44
49
  throw err
45
50
  }
46
51
 
47
- if (!Array.isArray(worktrees) || !worktrees.every((entry) => typeof entry === 'string')) {
48
- throw new Error('Invalid worktree registry')
49
- }
50
-
51
- return worktrees.map(repositoryPath)
52
+ validateManifest(manifest)
53
+ return manifest
52
54
  }
53
55
 
54
- export function updateRegistry(update, registry = registryPath()) {
55
- const lock = path.join(manifestDirectory(), '.git', 'workspace.lock')
56
- if (!fs.existsSync(path.join(manifestDirectory(), '.git'))) {
56
+ export function updateRegistry(
57
+ update,
58
+ {
59
+ directory = manifestDirectory(),
60
+ registry = path.join(directory, 'workspace.json'),
61
+ rename
62
+ } = {}
63
+ ) {
64
+ const lock = path.join(directory, '.git', 'workspace.lock')
65
+ if (!fs.existsSync(path.join(directory, '.git'))) {
57
66
  throw new Error('Workspace is not initialized; run git workspace init owner/repo')
58
67
  }
59
68
  fs.mkdirSync(lock)
60
69
  const pending = path.join(lock, 'worktrees.json')
61
70
  try {
62
- const worktrees = update(readRegistry(registry))
71
+ const previous = readManifest(registry)
72
+ const worktrees = update(manifestNames(previous).map(repositoryPath))
63
73
  const entries = worktrees.map(repositoryName)
64
- fs.writeFileSync(pending, JSON.stringify(entries, null, 2) + '\n')
74
+ const manifest = updateManifest(previous, entries, rename)
75
+ fs.writeFileSync(pending, JSON.stringify(manifest, null, 2) + '\n')
65
76
  fs.renameSync(pending, registry)
66
77
  } finally {
67
78
  if (fs.existsSync(pending)) fs.unlinkSync(pending)
package/lib/remove.js CHANGED
@@ -5,8 +5,28 @@ import { manifestDirectory, updateRegistry } from './registry.js'
5
5
  import repositoryPath from './repository-path.js'
6
6
  import checkedOutBranch from './checked-out-branch.js'
7
7
  import removeEmptyParents from './remove-empty-parents.js'
8
+ import commitManifest from './commit-manifest.js'
9
+ import { refreshCheckpoint } from './checkpoint.js'
10
+ import { assertNoPendingPull } from './pull-state.js'
11
+ import { withPostHooks } from './hook-context.js'
8
12
 
9
- export default function remove(repository) {
13
+ export default function remove(repository, options = {}) {
14
+ assertNoPendingPull()
15
+ return withPostHooks('remove', { repository }, options, () => {
16
+ removeCheckout(repository)
17
+ try {
18
+ commitManifest(manifestDirectory(), `Remove ${repository}`)
19
+ } catch (err) {
20
+ err.message = `Removed ${repository}, but could not commit the manifest: ${err.message}`
21
+ err.hint =
22
+ 'the checkout is already removed; resolve the Git error and commit only workspace.json in the manifest repository'
23
+ throw err
24
+ }
25
+ refreshCheckpoint()
26
+ })
27
+ }
28
+
29
+ export function removeCheckout(repository, { update = updateRegistry } = {}) {
10
30
  const absolute = repositoryPath(repository)
11
31
  let canonical = absolute
12
32
  let exists = true
@@ -17,9 +37,14 @@ export default function remove(repository) {
17
37
  exists = false
18
38
  }
19
39
 
20
- updateRegistry((worktrees) => {
40
+ update((worktrees) => {
21
41
  const target = worktrees.includes(absolute) ? absolute : canonical
22
42
  const [base, requested] = repository.split('@')
43
+ if (!requested && worktrees.some((worktree) => worktree.startsWith(absolute + '@'))) {
44
+ const err = new Error(`cannot remove ${repository}: linked worktrees exist`)
45
+ err.hint = 'remove the linked worktrees first'
46
+ throw err
47
+ }
23
48
  let branch = requested
24
49
  if (requested && exists) {
25
50
  const root = repositoryPath(base)