git-workspace-bare 0.1.0 → 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 +246 -24
- 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
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import fs from 'bare-fs'
|
|
2
|
+
import { spawnSync } from 'bare-subprocess'
|
|
3
|
+
import networkEnvironment from './git-network-environment.js'
|
|
4
|
+
|
|
5
|
+
export default function cloneRepository(repository, directory, { initialize = false } = {}) {
|
|
6
|
+
const remote = `git@github.com:${repository}.git`
|
|
7
|
+
const clone = spawnSync('git', ['clone', remote, directory], {
|
|
8
|
+
stdio: 'inherit',
|
|
9
|
+
...(initialize ? { env: networkEnvironment() } : {})
|
|
10
|
+
})
|
|
11
|
+
if (clone.error) throw clone.error
|
|
12
|
+
// An interrupted command is not a request to create a different repository.
|
|
13
|
+
if (clone.signal) throw new Error(`Git clone was interrupted by signal ${clone.signal}`)
|
|
14
|
+
if (clone.status === 0) return
|
|
15
|
+
if (!initialize) throw new Error(`Could not clone ${repository}`)
|
|
16
|
+
if (!emptyDestination(directory)) {
|
|
17
|
+
const err = new Error(
|
|
18
|
+
`Could not clone ${repository}; destination is not an empty directory: ${directory}`
|
|
19
|
+
)
|
|
20
|
+
err.hint = 'the destination was preserved; inspect the failed clone before retrying'
|
|
21
|
+
throw err
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
git(['init', directory])
|
|
25
|
+
git(['-C', directory, 'remote', 'add', 'origin', remote])
|
|
26
|
+
console.error(
|
|
27
|
+
`warning: could not clone ${repository}; initialized an empty local repository with origin configured`
|
|
28
|
+
)
|
|
29
|
+
console.error(
|
|
30
|
+
'hint: create the GitHub repository or synchronize with its existing history when ready; no project commits were pushed'
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function emptyDestination(directory) {
|
|
35
|
+
let entry
|
|
36
|
+
try {
|
|
37
|
+
entry = fs.lstatSync(directory)
|
|
38
|
+
} catch (err) {
|
|
39
|
+
if (err.code === 'ENOENT') return true
|
|
40
|
+
throw err
|
|
41
|
+
}
|
|
42
|
+
return entry.isDirectory() && !entry.isSymbolicLink() && fs.readdirSync(directory).length === 0
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function git(args) {
|
|
46
|
+
const result = spawnSync('git', args)
|
|
47
|
+
if (result.error) throw result.error
|
|
48
|
+
if (result.signal || result.status !== 0) {
|
|
49
|
+
throw new Error(result.stderr?.toString().trim() || 'Could not initialize local repository')
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { spawnSync } from 'bare-subprocess'
|
|
2
|
+
import pushManifest from './push-manifest.js'
|
|
3
|
+
|
|
4
|
+
export default function commitManifest(directory, message) {
|
|
5
|
+
git(directory, ['add', '--', 'workspace.json'])
|
|
6
|
+
const diff = spawnSync('git', [
|
|
7
|
+
'-C',
|
|
8
|
+
directory,
|
|
9
|
+
'diff',
|
|
10
|
+
'--cached',
|
|
11
|
+
'--quiet',
|
|
12
|
+
'--',
|
|
13
|
+
'workspace.json'
|
|
14
|
+
])
|
|
15
|
+
if (diff.error) throw diff.error
|
|
16
|
+
if (diff.status !== 0 && diff.status !== 1) {
|
|
17
|
+
throw new Error(diff.stderr?.toString().trim() || 'Could not inspect manifest changes')
|
|
18
|
+
}
|
|
19
|
+
if (diff.status === 1) {
|
|
20
|
+
git(directory, ['commit', '--only', '-m', message, '--', 'workspace.json'])
|
|
21
|
+
}
|
|
22
|
+
pushManifest(directory)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function git(directory, args) {
|
|
26
|
+
const result = spawnSync('git', ['-C', directory, ...args])
|
|
27
|
+
if (result.error) throw result.error
|
|
28
|
+
if (result.status !== 0) throw new Error(result.stderr?.toString().trim() || 'Git command failed')
|
|
29
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { spawnSync } from 'bare-subprocess'
|
|
2
|
+
import { readManifest } from './registry.js'
|
|
3
|
+
import { manifestNames } from './manifest.js'
|
|
4
|
+
import repositoryPath from './repository-path.js'
|
|
5
|
+
|
|
6
|
+
// The last word is the partial word at the cursor, including an empty word.
|
|
7
|
+
// Never parse this request as a command: completing a mutation must only read.
|
|
8
|
+
export default function completion(words, definition) {
|
|
9
|
+
const current = words.at(-1) || ''
|
|
10
|
+
const previous = words.slice(0, -1)
|
|
11
|
+
let command = definition
|
|
12
|
+
let name = null
|
|
13
|
+
const args = []
|
|
14
|
+
let positional = false
|
|
15
|
+
|
|
16
|
+
for (const word of previous) {
|
|
17
|
+
if (!positional && word === '--') {
|
|
18
|
+
positional = true
|
|
19
|
+
} else if (!positional && word.startsWith('-')) {
|
|
20
|
+
// Current flags are booleans; help/version terminate the invocation.
|
|
21
|
+
if (['-h', '--help', '--version'].includes(word)) return []
|
|
22
|
+
const flags = Object.keys(command.flags).flatMap((flag) => flag.split('|'))
|
|
23
|
+
if (!flags.includes(word)) return []
|
|
24
|
+
} else if (name === null) {
|
|
25
|
+
if (!Object.hasOwn(definition.commands, word)) return []
|
|
26
|
+
name = word
|
|
27
|
+
command = definition.commands[name]
|
|
28
|
+
} else {
|
|
29
|
+
args.push(word)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let candidates = []
|
|
34
|
+
if (!positional && current.startsWith('-')) {
|
|
35
|
+
candidates = Object.keys(command.flags).flatMap((flag) => flag.split('|'))
|
|
36
|
+
} else if (name === null) {
|
|
37
|
+
candidates = Object.keys(definition.commands)
|
|
38
|
+
} else if (name === 'hook' && args.length === 0) {
|
|
39
|
+
candidates = Object.keys(command.commands)
|
|
40
|
+
} else if (name === 'completion' && args.length === 0) {
|
|
41
|
+
candidates = ['bash']
|
|
42
|
+
} else if (name === 'rename' && args.length === 1) {
|
|
43
|
+
// The target is a new name. Offer the required prefix, not existing targets.
|
|
44
|
+
if (args[0].includes('@')) candidates = [args[0].split('@')[0] + '@']
|
|
45
|
+
} else if (args.length === 0 && ['add', 'remove', 'rename'].includes(name)) {
|
|
46
|
+
try {
|
|
47
|
+
const manifest = readManifest()
|
|
48
|
+
if (name !== 'add') {
|
|
49
|
+
candidates = manifestNames(manifest)
|
|
50
|
+
} else if (current.includes('@')) {
|
|
51
|
+
const root = current.split('@')[0]
|
|
52
|
+
if (manifest.repositories.some(({ name }) => name === root)) {
|
|
53
|
+
candidates = branches(root).map((branch) => `${root}@${branch}`)
|
|
54
|
+
}
|
|
55
|
+
} else {
|
|
56
|
+
candidates = manifest.repositories.map(({ name }) => name)
|
|
57
|
+
}
|
|
58
|
+
} catch {
|
|
59
|
+
// Tab must stay quiet for an uninitialized, missing, or invalid workspace.
|
|
60
|
+
return []
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return [...new Set(candidates)].filter((candidate) => candidate.startsWith(current)).sort()
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function branches(repository) {
|
|
68
|
+
const result = spawnSync('git', [
|
|
69
|
+
'--no-optional-locks',
|
|
70
|
+
'-C',
|
|
71
|
+
repositoryPath(repository),
|
|
72
|
+
'for-each-ref',
|
|
73
|
+
'--format=%(refname) %(symref)',
|
|
74
|
+
'refs/heads/',
|
|
75
|
+
'refs/remotes/origin/'
|
|
76
|
+
])
|
|
77
|
+
if (result.error || result.status !== 0) return []
|
|
78
|
+
return result.stdout
|
|
79
|
+
.toString()
|
|
80
|
+
.split('\n')
|
|
81
|
+
.flatMap((line) => {
|
|
82
|
+
const [ref, symbolic] = line.trim().split(' ')
|
|
83
|
+
if (!ref || symbolic) return []
|
|
84
|
+
const branch = ref.replace(/^refs\/(?:heads|remotes\/origin)\//, '')
|
|
85
|
+
try {
|
|
86
|
+
// Only suggest branch names that the workspace CLI accepts.
|
|
87
|
+
repositoryPath(`${repository}@${branch}`)
|
|
88
|
+
return [branch]
|
|
89
|
+
} catch {
|
|
90
|
+
return []
|
|
91
|
+
}
|
|
92
|
+
})
|
|
93
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import env from 'bare-env'
|
|
2
|
+
import { spawnSync } from 'bare-subprocess'
|
|
3
|
+
|
|
4
|
+
export default function networkEnvironment(directory) {
|
|
5
|
+
const environment = { ...env, GIT_TERMINAL_PROMPT: '0' }
|
|
6
|
+
// Preserve custom SSH programs. Supply bounded, noninteractive connection
|
|
7
|
+
// defaults only for ordinary OpenSSH, including offline manifest pushes.
|
|
8
|
+
if (
|
|
9
|
+
env.GIT_SSH_COMMAND !== undefined ||
|
|
10
|
+
env.GIT_SSH !== undefined ||
|
|
11
|
+
config(directory, 'core.sshCommand') !== null
|
|
12
|
+
) {
|
|
13
|
+
return environment
|
|
14
|
+
}
|
|
15
|
+
const variant = env.GIT_SSH_VARIANT || config(directory, 'ssh.variant')
|
|
16
|
+
if (variant && !['ssh', 'auto'].includes(variant)) return environment
|
|
17
|
+
environment.GIT_SSH_COMMAND = 'ssh -o ConnectTimeout=5 -o ConnectionAttempts=1 -o BatchMode=yes'
|
|
18
|
+
return environment
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function config(directory, key) {
|
|
22
|
+
const args = directory ? ['-C', directory] : []
|
|
23
|
+
const result = spawnSync('git', [...args, 'config', '--get', key])
|
|
24
|
+
if (result.error) throw result.error
|
|
25
|
+
if (result.signal) throw new Error(`Git config was interrupted by signal ${result.signal}`)
|
|
26
|
+
if (result.status === 1) return null
|
|
27
|
+
if (result.status !== 0) {
|
|
28
|
+
throw new Error(result.stderr?.toString().trim() || 'Could not read Git config')
|
|
29
|
+
}
|
|
30
|
+
return result.stdout.toString().replace(/\r?\n$/, '')
|
|
31
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import fs from 'bare-fs'
|
|
2
|
+
import os from 'bare-os'
|
|
3
|
+
import path from 'bare-path'
|
|
4
|
+
import { randomUUID } from 'bare-crypto'
|
|
5
|
+
import { spawnSync } from 'bare-subprocess'
|
|
6
|
+
import { readManifest, registryPath } from './registry.js'
|
|
7
|
+
import repositoryPath from './repository-path.js'
|
|
8
|
+
import Hooks from './hooks.js'
|
|
9
|
+
|
|
10
|
+
// Hook metadata is observational: failure to collect it must not change the command outcome.
|
|
11
|
+
export function observe(fn) {
|
|
12
|
+
try {
|
|
13
|
+
return fn()
|
|
14
|
+
} catch (err) {
|
|
15
|
+
console.error(`warning: could not prepare post hook context: ${err.message}`)
|
|
16
|
+
return null
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function withPostHooks(command, input, options, fn) {
|
|
21
|
+
for (const name of Object.values(input)) repositoryPath(name)
|
|
22
|
+
const hooks = new Hooks(options)
|
|
23
|
+
const operation = hooks.has(command)
|
|
24
|
+
? observe(() => beginOperation(command, input, readManifest()))
|
|
25
|
+
: null
|
|
26
|
+
let error = null
|
|
27
|
+
try {
|
|
28
|
+
return fn()
|
|
29
|
+
} catch (err) {
|
|
30
|
+
error = err
|
|
31
|
+
throw err
|
|
32
|
+
} finally {
|
|
33
|
+
if (operation) hooks.run(observe(() => finishOperation(operation, readManifest(), error)))
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function beginOperation(
|
|
38
|
+
command,
|
|
39
|
+
input,
|
|
40
|
+
manifest,
|
|
41
|
+
{ source = 'cli', known = manifest } = {}
|
|
42
|
+
) {
|
|
43
|
+
const names = [input.repository || input.from]
|
|
44
|
+
if (command === 'add' && input.repository.includes('@')) {
|
|
45
|
+
names.unshift(input.repository.split('@')[0])
|
|
46
|
+
}
|
|
47
|
+
if (command === 'rename' && !input.from.includes('@')) {
|
|
48
|
+
const root = manifest.repositories.find((repo) => repo.name === input.from)
|
|
49
|
+
for (const entry of root?.worktrees || []) names.push(`${input.from}@${entry.branch}`)
|
|
50
|
+
// Root renames also move unregistered linked worktrees in the workspace layout.
|
|
51
|
+
const prefix = repositoryPath(input.from) + '@'
|
|
52
|
+
const linked = git(repositoryPath(input.from), ['worktree', 'list', '--porcelain', '-z'])
|
|
53
|
+
for (const field of (linked || '').split('\0')) {
|
|
54
|
+
if (field.startsWith('worktree ' + prefix)) {
|
|
55
|
+
names.push(input.from + field.slice('worktree '.length + prefix.length - 1))
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const targets = [...new Set(names)].map((from) => {
|
|
60
|
+
const to = command === 'rename' ? input.to + from.slice(input.from.length) : from
|
|
61
|
+
return {
|
|
62
|
+
from,
|
|
63
|
+
to,
|
|
64
|
+
destinationExisted: exists(checkoutPath(to)),
|
|
65
|
+
before: checkout(from, manifest, identity(from, known))
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
return {
|
|
69
|
+
context: {
|
|
70
|
+
version: 1,
|
|
71
|
+
command,
|
|
72
|
+
phase: 'post',
|
|
73
|
+
operation: { id: randomUUID(), source, resumed: false },
|
|
74
|
+
workspace: { root: path.join(os.homedir(), 'workspace'), manifest: registryPath() },
|
|
75
|
+
input
|
|
76
|
+
},
|
|
77
|
+
targets
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function finishOperation(operation, manifest, error = null) {
|
|
82
|
+
const { context, targets } = operation
|
|
83
|
+
const checkouts = targets.map(({ from, to, destinationExisted, before }) => {
|
|
84
|
+
// On a failed rename, an occupied destination is not the checkout we moved.
|
|
85
|
+
const moved =
|
|
86
|
+
from !== to && !destinationExisted && !exists(checkoutPath(from)) && exists(checkoutPath(to))
|
|
87
|
+
const name = moved ? to : from
|
|
88
|
+
const after = checkout(name, manifest, before)
|
|
89
|
+
return { before, after }
|
|
90
|
+
})
|
|
91
|
+
const changed = checkouts.some(
|
|
92
|
+
({ before, after }) => JSON.stringify(before) !== JSON.stringify(after)
|
|
93
|
+
)
|
|
94
|
+
return {
|
|
95
|
+
...context,
|
|
96
|
+
result: {
|
|
97
|
+
status: error ? (changed ? 'partial' : 'error') : 'ok',
|
|
98
|
+
data: { changed, checkouts },
|
|
99
|
+
error: error ? { message: error.message, code: error.code || null } : null
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function validateHookProgress(state, actions) {
|
|
105
|
+
const validContext = (context, action) => {
|
|
106
|
+
const input =
|
|
107
|
+
action?.type === 'rename'
|
|
108
|
+
? { from: action.from, to: action.to }
|
|
109
|
+
: { repository: action?.from || action?.to }
|
|
110
|
+
return (
|
|
111
|
+
action &&
|
|
112
|
+
context?.version === 1 &&
|
|
113
|
+
context.phase === 'post' &&
|
|
114
|
+
context.command === action.type &&
|
|
115
|
+
JSON.stringify(context.input) === JSON.stringify(input) &&
|
|
116
|
+
typeof context.operation?.id === 'string' &&
|
|
117
|
+
/^[a-z0-9-]+$/i.test(context.operation.id) &&
|
|
118
|
+
context.operation.source === 'pull' &&
|
|
119
|
+
typeof context.operation.resumed === 'boolean' &&
|
|
120
|
+
context.workspace?.root === path.join(os.homedir(), 'workspace') &&
|
|
121
|
+
context.workspace.manifest === registryPath()
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
const validCheckout = (entry) => {
|
|
125
|
+
if (!entry || typeof entry.name !== 'string') return false
|
|
126
|
+
return (
|
|
127
|
+
entry.path === checkoutPath(entry.name) &&
|
|
128
|
+
['repository', 'worktree'].includes(entry.kind) &&
|
|
129
|
+
(entry.id === null || typeof entry.id === 'string') &&
|
|
130
|
+
typeof entry.exists === 'boolean' &&
|
|
131
|
+
typeof entry.registered === 'boolean' &&
|
|
132
|
+
typeof entry.repository?.name === 'string' &&
|
|
133
|
+
entry.repository.path === repositoryPath(entry.repository.name) &&
|
|
134
|
+
(entry.repository.id === null || typeof entry.repository.id === 'string')
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
const active = state.hookOperation
|
|
138
|
+
const post = state.postHook
|
|
139
|
+
let valid = true
|
|
140
|
+
try {
|
|
141
|
+
if (active) {
|
|
142
|
+
const action = actions[state.completed]
|
|
143
|
+
valid =
|
|
144
|
+
state.pending &&
|
|
145
|
+
validContext(active.context, action) &&
|
|
146
|
+
Array.isArray(active.targets) &&
|
|
147
|
+
active.targets.length > 0 &&
|
|
148
|
+
active.targets.every((target) => {
|
|
149
|
+
const from = action.from || action.to
|
|
150
|
+
const expected =
|
|
151
|
+
action.type === 'rename' ? action.to + target.from.slice(from.length) : target.from
|
|
152
|
+
return (
|
|
153
|
+
typeof target.from === 'string' &&
|
|
154
|
+
typeof target.to === 'string' &&
|
|
155
|
+
(target.from === from ||
|
|
156
|
+
(!from.includes('@') && target.from.startsWith(from + '@')) ||
|
|
157
|
+
(action.type === 'add' && target.from === from.split('@')[0])) &&
|
|
158
|
+
target.to === expected &&
|
|
159
|
+
typeof target.destinationExisted === 'boolean' &&
|
|
160
|
+
validCheckout(target.before) &&
|
|
161
|
+
target.before.name === target.from
|
|
162
|
+
)
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
if (post) {
|
|
166
|
+
valid =
|
|
167
|
+
valid &&
|
|
168
|
+
validContext(post, actions[state.pending ? state.completed : state.completed - 1]) &&
|
|
169
|
+
(state.pending
|
|
170
|
+
? ['partial', 'error'].includes(post.result?.status)
|
|
171
|
+
: post.result?.status === 'ok') &&
|
|
172
|
+
(!active || active.context.operation.id === post.operation.id) &&
|
|
173
|
+
typeof post.result?.data?.changed === 'boolean' &&
|
|
174
|
+
Array.isArray(post.result.data.checkouts) &&
|
|
175
|
+
post.result.data.checkouts.length > 0 &&
|
|
176
|
+
post.result.data.checkouts.every(
|
|
177
|
+
({ before, after }) => validCheckout(before) && validCheckout(after)
|
|
178
|
+
)
|
|
179
|
+
}
|
|
180
|
+
} catch {
|
|
181
|
+
valid = false
|
|
182
|
+
}
|
|
183
|
+
if (!valid) throw new Error('Invalid hook progress; inspect .git/workspace-pull.json')
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function identity(name, manifest) {
|
|
187
|
+
const [base] = name.split('@')
|
|
188
|
+
const branch = name.includes('@') ? name.slice(base.length + 1) : null
|
|
189
|
+
const root = manifest.repositories.find((repo) => repo.name === base)
|
|
190
|
+
const entry = branch !== null ? root?.worktrees.find((entry) => entry.branch === branch) : root
|
|
191
|
+
return { id: entry?.id || null, repository: { id: root?.id || null } }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function checkout(name, manifest, known) {
|
|
195
|
+
const [base] = name.split('@')
|
|
196
|
+
const requested = name.includes('@') ? name.slice(base.length + 1) : null
|
|
197
|
+
const directory = checkoutPath(name)
|
|
198
|
+
const root = repositoryPath(base)
|
|
199
|
+
const entry = identity(name, manifest)
|
|
200
|
+
const present = exists(directory)
|
|
201
|
+
return {
|
|
202
|
+
id: entry.id || known.id,
|
|
203
|
+
kind: requested !== null ? 'worktree' : 'repository',
|
|
204
|
+
name,
|
|
205
|
+
path: directory,
|
|
206
|
+
repository: { id: entry.repository.id || known.repository.id, name: base, path: root },
|
|
207
|
+
exists: present,
|
|
208
|
+
registered: entry.id !== null,
|
|
209
|
+
branch: present ? git(directory, ['symbolic-ref', '--quiet', '--short', 'HEAD']) : null,
|
|
210
|
+
// Retain the requested local ref even when the worktree directory has disappeared.
|
|
211
|
+
ref: requested ? git(root, ['rev-parse', '--verify', `refs/heads/${requested}`]) : null,
|
|
212
|
+
origin: requested !== null ? null : git(root, ['config', '--get', 'remote.origin.url'])
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function checkoutPath(name) {
|
|
217
|
+
// A root rename can also move unregistered worktrees made with ordinary Git.
|
|
218
|
+
// Their suffixes may use characters the CLI does not accept as input names.
|
|
219
|
+
const [base] = name.split('@')
|
|
220
|
+
const directory = repositoryPath(base) + name.slice(base.length)
|
|
221
|
+
if (path.normalize(directory) !== directory) throw new Error('Invalid checkout path')
|
|
222
|
+
return directory
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function exists(directory) {
|
|
226
|
+
try {
|
|
227
|
+
fs.lstatSync(directory)
|
|
228
|
+
return true
|
|
229
|
+
} catch (err) {
|
|
230
|
+
if (err.code !== 'ENOENT' && err.code !== 'ENOTDIR') throw err
|
|
231
|
+
return false
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function git(directory, args) {
|
|
236
|
+
const result = spawnSync('git', ['--no-optional-locks', '-C', directory, ...args])
|
|
237
|
+
if (result.error) throw result.error
|
|
238
|
+
return result.status === 0 ? result.stdout.toString().replace(/\r?\n$/, '') : null
|
|
239
|
+
}
|
package/lib/hooks.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import fs from 'bare-fs'
|
|
2
|
+
import path from 'bare-path'
|
|
3
|
+
import os from 'bare-os'
|
|
4
|
+
import env from 'bare-env'
|
|
5
|
+
import { spawnSync } from 'bare-subprocess'
|
|
6
|
+
|
|
7
|
+
export function discoverHooks() {
|
|
8
|
+
const found = new Map()
|
|
9
|
+
for (const directory of (env.PATH || '').split(path.delimiter)) {
|
|
10
|
+
let names
|
|
11
|
+
try {
|
|
12
|
+
names = fs.readdirSync(directory || '.')
|
|
13
|
+
} catch {
|
|
14
|
+
continue
|
|
15
|
+
}
|
|
16
|
+
for (const executable of names) {
|
|
17
|
+
const match = /^git-workspace-hook-(add|remove|rename)-post-([a-z0-9][a-z0-9_-]*)$/.exec(
|
|
18
|
+
executable
|
|
19
|
+
)
|
|
20
|
+
if (!match || found.has(executable)) continue
|
|
21
|
+
const file = path.resolve(directory || '.', executable)
|
|
22
|
+
try {
|
|
23
|
+
if (!fs.statSync(file).isFile()) continue
|
|
24
|
+
fs.accessSync(file, fs.constants.X_OK)
|
|
25
|
+
} catch {
|
|
26
|
+
continue
|
|
27
|
+
}
|
|
28
|
+
found.set(executable, { command: match[1], name: match[2], executable, path: file })
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return [...found.values()].sort((a, b) => (a.executable < b.executable ? -1 : 1))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export default class Hooks {
|
|
35
|
+
constructor({ noHooks = false } = {}) {
|
|
36
|
+
this.entries = noHooks || env.GIT_WORKSPACE_HOOK ? [] : discoverHooks()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
has(command) {
|
|
40
|
+
return this.entries.some((hook) => hook.command === command)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
run(context) {
|
|
44
|
+
if (!context) return
|
|
45
|
+
for (const hook of this.entries.filter((hook) => hook.command === context.command)) {
|
|
46
|
+
let input
|
|
47
|
+
try {
|
|
48
|
+
input = openInput(context)
|
|
49
|
+
const result = spawnSync(hook.path, [context.command, 'post'], {
|
|
50
|
+
cwd: context.workspace.root,
|
|
51
|
+
env: { ...env, GIT_WORKSPACE_HOOK: '1' },
|
|
52
|
+
stdio: [input === 0 ? 'inherit' : input, 2, 2]
|
|
53
|
+
})
|
|
54
|
+
if (result.error) throw result.error
|
|
55
|
+
if (result.signal || result.status !== 0) {
|
|
56
|
+
throw new Error(
|
|
57
|
+
result.signal ? `signal ${result.signal}` : `exit status ${result.status}`
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
} catch (err) {
|
|
61
|
+
console.error(`warning: hook ${hook.executable} failed: ${err.message}`)
|
|
62
|
+
} finally {
|
|
63
|
+
if (input !== undefined) fs.closeSync(input)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function openInput(context) {
|
|
70
|
+
// bare-subprocess 6.x does not write/close spawnSync's input pipe. An unlinked
|
|
71
|
+
// read-only file descriptor provides the same stdin protocol, including EOF,
|
|
72
|
+
// without pipe limits or temporary files left behind if a hook kills the CLI.
|
|
73
|
+
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'git-workspace-hook-'))
|
|
74
|
+
const file = path.join(directory, 'input.json')
|
|
75
|
+
let input
|
|
76
|
+
try {
|
|
77
|
+
fs.writeFileSync(file, JSON.stringify(context) + '\n', { mode: 0o600 })
|
|
78
|
+
input = fs.openSync(file, 'r')
|
|
79
|
+
fs.unlinkSync(file)
|
|
80
|
+
fs.rmdirSync(directory)
|
|
81
|
+
return input
|
|
82
|
+
} catch (err) {
|
|
83
|
+
if (input !== undefined) fs.closeSync(input)
|
|
84
|
+
fs.rmSync(directory, { recursive: true, force: true })
|
|
85
|
+
throw err
|
|
86
|
+
}
|
|
87
|
+
}
|
package/lib/init.js
CHANGED
|
@@ -2,10 +2,15 @@ import fs from 'bare-fs'
|
|
|
2
2
|
import path from 'bare-path'
|
|
3
3
|
import { spawnSync } from 'bare-subprocess'
|
|
4
4
|
import repositoryPath from './repository-path.js'
|
|
5
|
-
import { configPath, manifestDirectory,
|
|
5
|
+
import { configPath, manifestDirectory, readManifest } from './registry.js'
|
|
6
|
+
import commitManifest from './commit-manifest.js'
|
|
7
|
+
import { emptyManifest } from './manifest.js'
|
|
8
|
+
import { refreshCheckpoint } from './checkpoint.js'
|
|
9
|
+
import { assertNoPendingPull } from './pull-state.js'
|
|
6
10
|
|
|
7
11
|
export default function init(repository) {
|
|
8
12
|
const directory = repositoryPath(repository)
|
|
13
|
+
assertNoPendingPull(directory)
|
|
9
14
|
if (repository.includes('@')) throw new Error('Expected a manifest repository in owner/repo form')
|
|
10
15
|
|
|
11
16
|
if (fs.existsSync(configPath()) && manifestDirectory() !== directory) {
|
|
@@ -60,10 +65,17 @@ export default function init(repository) {
|
|
|
60
65
|
hasManifest = false
|
|
61
66
|
}
|
|
62
67
|
if (!hasManifest) {
|
|
63
|
-
fs.writeFileSync(manifest, '
|
|
64
|
-
|
|
65
|
-
|
|
68
|
+
fs.writeFileSync(manifest, JSON.stringify(emptyManifest(), null, 2) + '\n')
|
|
69
|
+
try {
|
|
70
|
+
commitManifest(directory, 'Initialize workspace manifest')
|
|
71
|
+
} catch (err) {
|
|
72
|
+
err.message = `Created ${manifest}, but could not commit it: ${err.message}`
|
|
73
|
+
err.hint =
|
|
74
|
+
'resolve the Git error, commit only workspace.json in the manifest repository, then rerun init'
|
|
75
|
+
throw err
|
|
76
|
+
}
|
|
66
77
|
}
|
|
78
|
+
if (hasManifest) readManifest(manifest)
|
|
67
79
|
fs.mkdirSync(path.dirname(configPath()), { recursive: true })
|
|
68
80
|
const lock = configPath() + '.lock'
|
|
69
81
|
fs.mkdirSync(lock)
|
|
@@ -75,6 +87,7 @@ export default function init(repository) {
|
|
|
75
87
|
if (fs.existsSync(pending)) fs.unlinkSync(pending)
|
|
76
88
|
fs.rmdirSync(lock)
|
|
77
89
|
}
|
|
90
|
+
refreshCheckpoint(directory)
|
|
78
91
|
}
|
|
79
92
|
|
|
80
93
|
function git(args, options) {
|
package/lib/manifest.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { randomUUID } from 'bare-crypto'
|
|
2
|
+
import repositoryPath from './repository-path.js'
|
|
3
|
+
|
|
4
|
+
export function emptyManifest() {
|
|
5
|
+
return { version: 1, repositories: [] }
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function manifestNames(manifest) {
|
|
9
|
+
validateManifest(manifest)
|
|
10
|
+
return manifest.repositories.flatMap(({ name, worktrees }) => [
|
|
11
|
+
name,
|
|
12
|
+
...worktrees.map(({ branch }) => `${name}@${branch}`)
|
|
13
|
+
])
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function validateManifest(manifest) {
|
|
17
|
+
if (!manifest || manifest.version !== 1 || !Array.isArray(manifest.repositories)) {
|
|
18
|
+
throw new Error('Invalid workspace manifest: expected version 1 and a repositories array')
|
|
19
|
+
}
|
|
20
|
+
const ids = new Set()
|
|
21
|
+
const names = []
|
|
22
|
+
const checkId = (entry) => {
|
|
23
|
+
if (!entry || typeof entry.id !== 'string' || !/^[a-z0-9_-]+$/i.test(entry.id)) {
|
|
24
|
+
throw new Error('Invalid workspace manifest: every repository and worktree needs an ID')
|
|
25
|
+
}
|
|
26
|
+
if (ids.has(entry.id)) throw new Error(`Duplicate manifest ID: ${entry.id}`)
|
|
27
|
+
ids.add(entry.id)
|
|
28
|
+
}
|
|
29
|
+
for (const repository of manifest.repositories) {
|
|
30
|
+
checkId(repository)
|
|
31
|
+
if (
|
|
32
|
+
typeof repository.name !== 'string' ||
|
|
33
|
+
repository.name.includes('@') ||
|
|
34
|
+
!Array.isArray(repository.worktrees)
|
|
35
|
+
) {
|
|
36
|
+
throw new Error('Invalid manifest repository: expected owner/repo and a worktrees array')
|
|
37
|
+
}
|
|
38
|
+
names.push(repository.name)
|
|
39
|
+
for (const worktree of repository.worktrees) {
|
|
40
|
+
checkId(worktree)
|
|
41
|
+
if (typeof worktree.branch !== 'string' || !worktree.branch) {
|
|
42
|
+
throw new Error('Invalid manifest worktree: expected a branch')
|
|
43
|
+
}
|
|
44
|
+
names.push(`${repository.name}@${worktree.branch}`)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
validateNames(names)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function validateNames(names) {
|
|
51
|
+
const unique = new Set()
|
|
52
|
+
for (const name of names) {
|
|
53
|
+
if (typeof name !== 'string') {
|
|
54
|
+
throw new Error('Invalid manifest entry: expected a repository name')
|
|
55
|
+
}
|
|
56
|
+
repositoryPath(name)
|
|
57
|
+
if (unique.has(name)) throw new Error(`Duplicate manifest name: ${name}`)
|
|
58
|
+
unique.add(name)
|
|
59
|
+
}
|
|
60
|
+
for (const name of names) {
|
|
61
|
+
const [root, branch] = name.split('@')
|
|
62
|
+
if (branch && !unique.has(root)) {
|
|
63
|
+
throw new Error(`Manifest worktree ${name} needs a root entry for ${root}`)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function updateManifest(previous, names, rename) {
|
|
69
|
+
validateManifest(previous)
|
|
70
|
+
validateNames(names)
|
|
71
|
+
const renamed = (name) => {
|
|
72
|
+
if (!rename) return name
|
|
73
|
+
if (name === rename.from) return rename.to
|
|
74
|
+
if (!rename.from.includes('@') && name.startsWith(rename.from + '@')) {
|
|
75
|
+
return rename.to + name.slice(rename.from.length)
|
|
76
|
+
}
|
|
77
|
+
return name
|
|
78
|
+
}
|
|
79
|
+
const repositories = new Map()
|
|
80
|
+
const worktrees = new Map()
|
|
81
|
+
for (const repository of previous.repositories) {
|
|
82
|
+
repositories.set(renamed(repository.name), repository)
|
|
83
|
+
for (const worktree of repository.worktrees) {
|
|
84
|
+
worktrees.set(renamed(`${repository.name}@${worktree.branch}`), worktree)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const next = emptyManifest()
|
|
88
|
+
const roots = new Map()
|
|
89
|
+
for (const name of names.filter((name) => !name.includes('@'))) {
|
|
90
|
+
const existing = repositories.get(name)
|
|
91
|
+
const entry = { id: existing?.id || randomUUID(), name, worktrees: [] }
|
|
92
|
+
next.repositories.push(entry)
|
|
93
|
+
roots.set(name, entry)
|
|
94
|
+
}
|
|
95
|
+
for (const name of names.filter((name) => name.includes('@'))) {
|
|
96
|
+
const [root, branch] = name.split('@')
|
|
97
|
+
const existing = worktrees.get(name)
|
|
98
|
+
roots.get(root).worktrees.push({ id: existing?.id || randomUUID(), branch })
|
|
99
|
+
}
|
|
100
|
+
validateManifest(next)
|
|
101
|
+
return next
|
|
102
|
+
}
|