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/docs/hooks.md
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# Writing command hooks
|
|
2
|
+
|
|
3
|
+
Hooks are independent executables. `git-workspace` supplies command results; packages such as
|
|
4
|
+
a tmux integration or a notifier own their behavior, dependencies, and configuration.
|
|
5
|
+
|
|
6
|
+
## Discovery and installation
|
|
7
|
+
|
|
8
|
+
Put executable files or executable symlinks on `PATH` using these names:
|
|
9
|
+
|
|
10
|
+
```text
|
|
11
|
+
git-workspace-hook-add-post-<name>
|
|
12
|
+
git-workspace-hook-remove-post-<name>
|
|
13
|
+
git-workspace-hook-rename-post-<name>
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
The hook name starts with a lowercase letter or digit and may contain lowercase letters,
|
|
17
|
+
digits, `_`, and `-`. A hook is enabled whenever it is discoverable. Discovery happens once
|
|
18
|
+
per command invocation. For duplicate executable names, the first executable on `PATH`
|
|
19
|
+
wins; a nonexecutable file does not hide a later executable. Distinct hooks for a command
|
|
20
|
+
run sequentially in alphabetical order of their executable names.
|
|
21
|
+
|
|
22
|
+
`git workspace hook list` lists command/phase, name, and resolved executable path in
|
|
23
|
+
tab-separated columns. It needs no initialized workspace and never executes the hooks.
|
|
24
|
+
|
|
25
|
+
Install and remove hooks using their package manager, or manage executables yourself. No
|
|
26
|
+
cloning, registration, or installer is built into `git-workspace`. For example, a reusable
|
|
27
|
+
npm package can expose one implementation under several names:
|
|
28
|
+
|
|
29
|
+
```json
|
|
30
|
+
{
|
|
31
|
+
"name": "git-workspace-hook-example",
|
|
32
|
+
"version": "1.0.0",
|
|
33
|
+
"type": "module",
|
|
34
|
+
"bin": {
|
|
35
|
+
"git-workspace-hook-add-post-example": "./hook.js",
|
|
36
|
+
"git-workspace-hook-remove-post-example": "./hook.js",
|
|
37
|
+
"git-workspace-hook-rename-post-example": "./hook.js"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
After publishing that package, `npm install -g git-workspace-hook-example` installs its bin
|
|
43
|
+
links. The npm global bin directory must be on `PATH`. This is an example package name,
|
|
44
|
+
not a bundled integration. The executable needs a shebang and executable permissions.
|
|
45
|
+
It can use Bare, Node.js, Python, a shell, or another runtime supplied by the hook author.
|
|
46
|
+
|
|
47
|
+
An example Node.js `hook.js` that reports results:
|
|
48
|
+
|
|
49
|
+
```js
|
|
50
|
+
#!/usr/bin/env node
|
|
51
|
+
import fs from 'node:fs'
|
|
52
|
+
|
|
53
|
+
const context = JSON.parse(fs.readFileSync(0, 'utf8'))
|
|
54
|
+
if (context.version !== 1) throw new Error('Unsupported hook protocol')
|
|
55
|
+
|
|
56
|
+
for (const { before, after } of context.result.data.checkouts) {
|
|
57
|
+
console.error(`${context.command}: ${before.name} -> ${after.name} (${context.result.status})`)
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Execution
|
|
62
|
+
|
|
63
|
+
The executable receives two arguments: the command (`add`, `remove`, or `rename`) and
|
|
64
|
+
`post`. Its stdin contains one UTF-8 JSON document, followed by a newline and EOF. The
|
|
65
|
+
working directory is the workspace root, normally `~/workspace`, so a removed checkout
|
|
66
|
+
is never used as the working directory. The environment is inherited. Both hook stdout
|
|
67
|
+
and stderr are forwarded to the CLI's stderr.
|
|
68
|
+
|
|
69
|
+
Direct command hooks run after the checkout operations, the manifest commit and push
|
|
70
|
+
attempt, and the local applied checkpoint attempt. A failed manifest push warns and keeps
|
|
71
|
+
the successful local command result; it does not prevent hooks from running. A failed checkpoint only warns, as it does without hooks.
|
|
72
|
+
Once an operation has been attempted, its hook also runs if the command fails, reporting
|
|
73
|
+
the failure and any partial effects. Invalid command syntax, unresolved workspace configuration,
|
|
74
|
+
help, completion, and dry runs do not trigger hooks.
|
|
75
|
+
|
|
76
|
+
One logical command means one call to each matching hook. Adding `owner/repo@jobs` may
|
|
77
|
+
create its root as well; both are included in one add payload. A root rename includes its
|
|
78
|
+
worktrees. A successful repeated add still runs its hooks, allowing an integration to restore
|
|
79
|
+
a missing session even when no checkout changed. `init`, `list`, `status`, `completion`,
|
|
80
|
+
and `hook list` do not have hooks.
|
|
81
|
+
|
|
82
|
+
Nonzero exit, a signal, or failure to launch a hook prints a warning. Remaining hooks still
|
|
83
|
+
run; the original command's exit status is preserved. Hooks cannot veto or roll back an
|
|
84
|
+
operation. Hook output is not interpreted as instructions. Execution waits for each hook;
|
|
85
|
+
hooks should finish promptly or arrange their own background work.
|
|
86
|
+
|
|
87
|
+
`--no-hooks` on `add`, `remove`, `rename`, or `pull` skips hooks for that invocation.
|
|
88
|
+
Hook subprocesses receive `GIT_WORKSPACE_HOOK=1`; nested workspace commands inherit it and
|
|
89
|
+
skip hook dispatch to prevent accidental recursion. Workspace mutations are still blocked
|
|
90
|
+
while a pull is pending, including from its hooks.
|
|
91
|
+
|
|
92
|
+
## JSON protocol, version 1
|
|
93
|
+
|
|
94
|
+
The common envelope is:
|
|
95
|
+
|
|
96
|
+
```json
|
|
97
|
+
{
|
|
98
|
+
"version": 1,
|
|
99
|
+
"command": "rename",
|
|
100
|
+
"phase": "post",
|
|
101
|
+
"operation": {
|
|
102
|
+
"id": "42f9daef-e15f-4286-a7c9-0c6e93b75943",
|
|
103
|
+
"source": "cli",
|
|
104
|
+
"resumed": false
|
|
105
|
+
},
|
|
106
|
+
"workspace": {
|
|
107
|
+
"root": "/home/me/workspace",
|
|
108
|
+
"manifest": "/home/me/workspace/me/workspace/workspace.json"
|
|
109
|
+
},
|
|
110
|
+
"input": {
|
|
111
|
+
"from": "owner/repo@jobs",
|
|
112
|
+
"to": "owner/repo@fixes"
|
|
113
|
+
},
|
|
114
|
+
"result": {
|
|
115
|
+
"status": "ok",
|
|
116
|
+
"data": {
|
|
117
|
+
"changed": true,
|
|
118
|
+
"checkouts": []
|
|
119
|
+
},
|
|
120
|
+
"error": null
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
The empty `checkouts` above abbreviates the payload: it contains a `{ before, after }`
|
|
126
|
+
pair for each checkout involved. Each snapshot has this shape:
|
|
127
|
+
|
|
128
|
+
```json
|
|
129
|
+
{
|
|
130
|
+
"id": "e8375481-bac0-4e98-b035-599cbdf7ad4d",
|
|
131
|
+
"kind": "worktree",
|
|
132
|
+
"name": "owner/repo@fixes",
|
|
133
|
+
"path": "/home/me/workspace/owner/repo@fixes",
|
|
134
|
+
"repository": {
|
|
135
|
+
"id": "34a017c5-a0c4-43bb-a3e4-abeb5301c56a",
|
|
136
|
+
"name": "owner/repo",
|
|
137
|
+
"path": "/home/me/workspace/owner/repo"
|
|
138
|
+
},
|
|
139
|
+
"exists": true,
|
|
140
|
+
"registered": true,
|
|
141
|
+
"branch": "fixes",
|
|
142
|
+
"ref": "0123456789012345678901234567890123456789",
|
|
143
|
+
"origin": null
|
|
144
|
+
}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
- `operation.id` identifies this logical operation. Direct invocations get fresh IDs.
|
|
148
|
+
Pull retries retain an operation's ID. `source` is `cli` or `pull`; `resumed` identifies
|
|
149
|
+
delivery during a resumed pull.
|
|
150
|
+
- `input` is `{ "repository": "owner/repo[@branch]" }` for add/remove, or `{ "from": "...",
|
|
151
|
+
"to": "..." }` for rename. These are parsed names, not the original shell command line.
|
|
152
|
+
- `status` is `ok` on success, `partial` on failure with an observed checkout/registration
|
|
153
|
+
change, or `error` on failure without one. `error` is either `null` or `{ "message": "...",
|
|
154
|
+
"code": null }`; `code` is the error's code string when available.
|
|
155
|
+
- `changed` compares the checkout snapshots. It is false for an unchanged repeated add.
|
|
156
|
+
It does not describe unrelated staged files, manifest commit metadata, or a history of
|
|
157
|
+
transient changes that were rolled back.
|
|
158
|
+
- Snapshot `id` is the stable manifest ID when known, otherwise `null`. Removed entries
|
|
159
|
+
retain their ID in the after snapshot. New direct additions acquire IDs when registered;
|
|
160
|
+
pull additions already have IDs in the desired manifest. Never invent IDs from paths.
|
|
161
|
+
- `kind` is `repository` or `worktree`. `repository` identifies the root and points to the
|
|
162
|
+
checkout itself for root entries. All paths are absolute.
|
|
163
|
+
- `exists` describes the path on disk. A missing checkout still has a snapshot with its
|
|
164
|
+
name, path, and any known ID. `registered` describes the operation's manifest layout;
|
|
165
|
+
during pull this is the intermediate layout as operations are applied, since the manifest
|
|
166
|
+
file already contains the full desired layout.
|
|
167
|
+
- `branch` is the currently checked-out branch, or `null` for a missing, detached, or
|
|
168
|
+
unreadable checkout. It may differ from the branch in `name` after a manual Git switch.
|
|
169
|
+
`ref` is the commit of the local branch named by `@branch`, or `null` if unavailable;
|
|
170
|
+
it is also `null` for a root. This distinguishes a removed worktree from a branch ref
|
|
171
|
+
whose deletion failed. `origin` is the root's origin URL, otherwise `null`.
|
|
172
|
+
|
|
173
|
+
For renames, `after` follows a checkout that moved even if a later branch or manifest update
|
|
174
|
+
failed. An occupied destination that caused a rename to be refused is not reported as the
|
|
175
|
+
renamed checkout. Root renames include unregistered linked worktrees under the root's
|
|
176
|
+
`@branch` naming layout, with null IDs. External linked worktrees whose paths stay unchanged
|
|
177
|
+
are not included. Discovered unregistered worktrees can have suffix characters accepted by
|
|
178
|
+
ordinary Git but unsupported as CLI input. Add payloads include the root first, then the requested
|
|
179
|
+
worktree.
|
|
180
|
+
|
|
181
|
+
For example, if removal deletes a worktree but cannot delete its branch, the result is
|
|
182
|
+
`partial`: `after.exists` is false, `after.registered` is true, and `after.ref` still identifies
|
|
183
|
+
the branch. If checkout creation succeeds but the manifest commit fails, the hook sees the
|
|
184
|
+
created checkout and a `partial` result with the commit error. Consumers should examine the
|
|
185
|
+
outcome and snapshots instead of assuming every post hook means success.
|
|
186
|
+
|
|
187
|
+
Treat context as data, not shell source. Ignore unfamiliar JSON fields for forward
|
|
188
|
+
compatibility. Command-specific data lives under `result.data`; a future command such as
|
|
189
|
+
fetch can define its own result shape without adding notification logic to the core.
|
|
190
|
+
This release implements neither fetch nor prune.
|
|
191
|
+
|
|
192
|
+
## Pull and recovery
|
|
193
|
+
|
|
194
|
+
Pull invokes add/remove/rename hooks for its individual operations, with `source: "pull"`.
|
|
195
|
+
It records the filesystem operation as completed and saves its post-hook context together
|
|
196
|
+
before running the hooks. It does not run a second set of top-level pull hooks. Pull's final
|
|
197
|
+
applied checkpoint is recorded after all operations and hook delivery attempts.
|
|
198
|
+
|
|
199
|
+
After a crash, the next pull resumes the saved target and delivers any saved post context
|
|
200
|
+
without repeating completed filesystem operations. If the process died during hook delivery,
|
|
201
|
+
the matching hooks can all run again, including ones that already succeeded. Use stable
|
|
202
|
+
checkout IDs and idempotent operations, such as ensuring a session exists or removing it only
|
|
203
|
+
if present. `operation.id` can help detect duplicate delivery. The same operation can first
|
|
204
|
+
report a partial failure and later succeed, so do not discard a later success solely because
|
|
205
|
+
its ID was seen before.
|
|
206
|
+
|
|
207
|
+
Each retry resolves the currently installed hooks on `PATH`; executable paths are not saved
|
|
208
|
+
in the progress journal. Hook failures count as attempted delivery and do not prevent pull
|
|
209
|
+
from completing. A dry run never delivers or clears pending hooks. Resuming with `--no-hooks`
|
|
210
|
+
discards pending delivery and skips hooks for the remaining operations; it does not defer them
|
|
211
|
+
for a later invocation. It still finishes and verifies the workspace normally.
|
|
212
|
+
|
|
213
|
+
Direct commands have no durable hook journal. A process crash can prevent their hooks from
|
|
214
|
+
running, so the API provides no exactly-once delivery guarantee.
|
package/lib/add.js
CHANGED
|
@@ -1,16 +1,39 @@
|
|
|
1
1
|
import fs from 'bare-fs'
|
|
2
2
|
import path from 'bare-path'
|
|
3
3
|
import { spawnSync } from 'bare-subprocess'
|
|
4
|
-
import { readRegistry, updateRegistry } from './registry.js'
|
|
4
|
+
import { manifestDirectory, readRegistry, updateRegistry } from './registry.js'
|
|
5
5
|
import repositoryPath from './repository-path.js'
|
|
6
|
+
import commitManifest from './commit-manifest.js'
|
|
7
|
+
import { refreshCheckpoint } from './checkpoint.js'
|
|
8
|
+
import { assertNoPendingPull } from './pull-state.js'
|
|
9
|
+
import { withPostHooks } from './hook-context.js'
|
|
10
|
+
import cloneRepository, { emptyDestination } from './clone-repository.js'
|
|
6
11
|
|
|
7
|
-
export default function add(repository) {
|
|
12
|
+
export default function add(repository, options = {}) {
|
|
13
|
+
assertNoPendingPull()
|
|
14
|
+
return withPostHooks('add', { repository }, options, () => {
|
|
15
|
+
addCheckout(repository, { initialize: true })
|
|
16
|
+
try {
|
|
17
|
+
commitManifest(manifestDirectory(), `Add ${repository}`)
|
|
18
|
+
} catch (err) {
|
|
19
|
+
err.message = `Registered ${repository}, but could not commit the manifest: ${err.message}`
|
|
20
|
+
err.hint = `resolve the Git error, then rerun git workspace add ${repository}`
|
|
21
|
+
throw err
|
|
22
|
+
}
|
|
23
|
+
refreshCheckpoint()
|
|
24
|
+
})
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function addCheckout(
|
|
28
|
+
repository,
|
|
29
|
+
{ registry = readRegistry, register = registerCheckout, initialize = false } = {}
|
|
30
|
+
) {
|
|
8
31
|
const directory = repositoryPath(repository)
|
|
9
|
-
|
|
32
|
+
registry()
|
|
10
33
|
const [base, branch] = repository.split('@')
|
|
11
34
|
if (branch) {
|
|
12
35
|
git(['check-ref-format', '--branch', branch])
|
|
13
|
-
|
|
36
|
+
addCheckout(base, { registry, register, initialize })
|
|
14
37
|
const root = repositoryPath(base)
|
|
15
38
|
if (!exists(directory)) {
|
|
16
39
|
const args = ['-C', root, 'worktree', 'add']
|
|
@@ -21,6 +44,13 @@ export default function add(repository) {
|
|
|
21
44
|
stdio: 'inherit'
|
|
22
45
|
})
|
|
23
46
|
} else {
|
|
47
|
+
if (!hasCommit(root)) {
|
|
48
|
+
const err = new Error(
|
|
49
|
+
`${base} has no commits; create its first commit before adding a branch worktree`
|
|
50
|
+
)
|
|
51
|
+
err.hint = `the root is retained locally; commit in ${root}, then rerun git workspace add ${repository}`
|
|
52
|
+
throw err
|
|
53
|
+
}
|
|
24
54
|
git([...args, '--no-track', '-b', branch, directory, 'HEAD'], { stdio: 'inherit' })
|
|
25
55
|
}
|
|
26
56
|
}
|
|
@@ -41,9 +71,9 @@ export default function add(repository) {
|
|
|
41
71
|
return
|
|
42
72
|
}
|
|
43
73
|
|
|
44
|
-
if (!exists(directory)) {
|
|
74
|
+
if (!exists(directory) || (initialize && emptyDestination(directory))) {
|
|
45
75
|
fs.mkdirSync(path.dirname(directory), { recursive: true })
|
|
46
|
-
|
|
76
|
+
cloneRepository(repository, directory, { initialize })
|
|
47
77
|
}
|
|
48
78
|
|
|
49
79
|
const worktree = fs.realpathSync(directory)
|
|
@@ -62,6 +92,24 @@ export default function add(repository) {
|
|
|
62
92
|
register(repository, directory, worktree)
|
|
63
93
|
}
|
|
64
94
|
|
|
95
|
+
function hasCommit(directory) {
|
|
96
|
+
const result = spawnSync('git', [
|
|
97
|
+
'-C',
|
|
98
|
+
directory,
|
|
99
|
+
'rev-parse',
|
|
100
|
+
'--verify',
|
|
101
|
+
'--quiet',
|
|
102
|
+
'HEAD^{commit}'
|
|
103
|
+
])
|
|
104
|
+
if (result.error) throw result.error
|
|
105
|
+
if (result.signal) throw new Error(`Git was interrupted by signal ${result.signal}`)
|
|
106
|
+
if (result.status === 1) return false
|
|
107
|
+
if (result.status !== 0) {
|
|
108
|
+
throw new Error(result.stderr?.toString().trim() || 'Could not inspect Git HEAD')
|
|
109
|
+
}
|
|
110
|
+
return true
|
|
111
|
+
}
|
|
112
|
+
|
|
65
113
|
function exists(directory) {
|
|
66
114
|
try {
|
|
67
115
|
fs.lstatSync(directory)
|
|
@@ -82,7 +130,7 @@ function hasRef(directory, ref) {
|
|
|
82
130
|
return true
|
|
83
131
|
}
|
|
84
132
|
|
|
85
|
-
function
|
|
133
|
+
function registerCheckout(repository, directory, worktree) {
|
|
86
134
|
try {
|
|
87
135
|
updateRegistry((worktrees) => {
|
|
88
136
|
if (!worktrees.includes(worktree)) worktrees.push(worktree)
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import fs from 'bare-fs'
|
|
2
|
+
import { manifestNames } from './manifest.js'
|
|
3
|
+
import repositoryPath from './repository-path.js'
|
|
4
|
+
import { addCheckout } from './add.js'
|
|
5
|
+
import { removeCheckout } from './remove.js'
|
|
6
|
+
import { renameCheckout, finishBranchRename } from './rename.js'
|
|
7
|
+
import { finishRootRename } from './rename-root.js'
|
|
8
|
+
import planPull, { changes } from './plan-pull.js'
|
|
9
|
+
import { writePullState } from './pull-state.js'
|
|
10
|
+
import { verifyCheckouts } from './checkpoint.js'
|
|
11
|
+
import { beginOperation, finishOperation, observe } from './hook-context.js'
|
|
12
|
+
|
|
13
|
+
export function layoutAfter(previous, desired, actions) {
|
|
14
|
+
const layout = JSON.parse(JSON.stringify(previous))
|
|
15
|
+
for (const action of actions) {
|
|
16
|
+
const parent = layout.repositories.find((repo) => repo.id === (action.root || action.id))
|
|
17
|
+
if (action.kind === 'repository') {
|
|
18
|
+
if (action.type === 'remove') {
|
|
19
|
+
layout.repositories = layout.repositories.filter((repo) => repo.id !== action.id)
|
|
20
|
+
} else if (action.type === 'rename') parent.name = action.to
|
|
21
|
+
else {
|
|
22
|
+
const entry = desired.repositories.find((repo) => repo.id === action.id)
|
|
23
|
+
layout.repositories.push({ ...entry, worktrees: [] })
|
|
24
|
+
}
|
|
25
|
+
} else {
|
|
26
|
+
if (action.type === 'remove') {
|
|
27
|
+
parent.worktrees = parent.worktrees.filter((entry) => entry.id !== action.id)
|
|
28
|
+
} else if (action.type === 'rename') {
|
|
29
|
+
parent.worktrees.find((entry) => entry.id === action.id).branch = action.to.split('@')[1]
|
|
30
|
+
} else {
|
|
31
|
+
const entry = desired.repositories
|
|
32
|
+
.find((repo) => repo.id === action.root)
|
|
33
|
+
.worktrees.find((entry) => entry.id === action.id)
|
|
34
|
+
parent.worktrees.push({ ...entry })
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return layout
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function pendingRename(action) {
|
|
42
|
+
if (action.type !== 'rename') return false
|
|
43
|
+
const from = exists(repositoryPath(action.from))
|
|
44
|
+
const to = exists(repositoryPath(action.to))
|
|
45
|
+
if (from && !to) return false
|
|
46
|
+
if (!from && to) return true
|
|
47
|
+
throw new Error(`Interrupted rename has ambiguous paths: ${action.from} and ${action.to}`)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export default function applyPull(directory, state, previous, desired, hooks, resumed = false) {
|
|
51
|
+
const { actions, blockers } = changes(previous, desired)
|
|
52
|
+
if (blockers.length) throw new Error(blockers.join('\n'))
|
|
53
|
+
let layout = layoutAfter(previous, desired, actions.slice(0, state.completed))
|
|
54
|
+
const paths = () => manifestNames(layout).map(repositoryPath)
|
|
55
|
+
const update = (fn) => fn(paths())
|
|
56
|
+
const apply = (action) => {
|
|
57
|
+
if (action.type === 'add') addCheckout(action.to, { registry: paths, register: () => {} })
|
|
58
|
+
else if (action.type === 'remove') {
|
|
59
|
+
const root = action.from.split('@')[0]
|
|
60
|
+
// A root already absent has no remaining branch refs to delete.
|
|
61
|
+
if (action.kind !== 'worktree' || exists(repositoryPath(root))) {
|
|
62
|
+
removeCheckout(action.from, { update })
|
|
63
|
+
}
|
|
64
|
+
} else renameCheckout(action.from, action.to, { update })
|
|
65
|
+
}
|
|
66
|
+
const deliver = () => {
|
|
67
|
+
if (!state.postHook) return
|
|
68
|
+
if (resumed) state.postHook.operation.resumed = true
|
|
69
|
+
hooks.run(state.postHook)
|
|
70
|
+
delete state.postHook
|
|
71
|
+
writePullState(directory, state)
|
|
72
|
+
}
|
|
73
|
+
const perform = (action, repair = false) => {
|
|
74
|
+
if (!state.hookOperation && hooks.has(action.type)) {
|
|
75
|
+
const input =
|
|
76
|
+
action.type === 'rename'
|
|
77
|
+
? { from: action.from, to: action.to }
|
|
78
|
+
: { repository: action.from || action.to }
|
|
79
|
+
state.hookOperation = observe(() =>
|
|
80
|
+
beginOperation(action.type, input, layout, { source: 'pull', known: desired })
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
if (resumed && state.hookOperation) state.hookOperation.context.operation.resumed = true
|
|
84
|
+
state.pending = true
|
|
85
|
+
writePullState(directory, state)
|
|
86
|
+
try {
|
|
87
|
+
if (!repair) apply(action)
|
|
88
|
+
else if (action.kind === 'repository') finishRootRename(action.from, action.to, paths())
|
|
89
|
+
else {
|
|
90
|
+
const root = layout.repositories.find((repo) => repo.id === action.root)
|
|
91
|
+
verifyCheckouts({ repositories: [{ ...root, worktrees: [] }] })
|
|
92
|
+
finishBranchRename(action.from, action.to)
|
|
93
|
+
}
|
|
94
|
+
} catch (err) {
|
|
95
|
+
if (state.hookOperation) {
|
|
96
|
+
state.postHook = observe(() => finishOperation(state.hookOperation, layout, err))
|
|
97
|
+
writePullState(directory, state)
|
|
98
|
+
deliver()
|
|
99
|
+
}
|
|
100
|
+
throw err
|
|
101
|
+
}
|
|
102
|
+
layout = layoutAfter(previous, desired, actions.slice(0, state.completed + 1))
|
|
103
|
+
if (state.hookOperation) {
|
|
104
|
+
state.postHook = observe(() => finishOperation(state.hookOperation, layout))
|
|
105
|
+
}
|
|
106
|
+
delete state.hookOperation
|
|
107
|
+
state.completed++
|
|
108
|
+
state.pending = false
|
|
109
|
+
// Persist the completed filesystem step AND its pending post hook together.
|
|
110
|
+
// Recovery may redeliver a hook, but must never replay a completed removal.
|
|
111
|
+
writePullState(directory, state)
|
|
112
|
+
deliver()
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
deliver()
|
|
116
|
+
// Only the action marked pending can have been partially performed.
|
|
117
|
+
const interrupted = state.pending ? actions[state.completed] : null
|
|
118
|
+
if (interrupted && pendingRename(interrupted)) perform(interrupted, true)
|
|
119
|
+
const plan = planPull(layout, desired, directory)
|
|
120
|
+
if (plan.blockers.length) throw new Error(plan.blockers.join('\n'))
|
|
121
|
+
while (state.completed < actions.length) {
|
|
122
|
+
perform(actions[state.completed])
|
|
123
|
+
}
|
|
124
|
+
} catch (err) {
|
|
125
|
+
err.message = `Workspace pull stopped after ${state.completed} of ${actions.length} changes: ${err.message}`
|
|
126
|
+
err.hint =
|
|
127
|
+
'completed changes are retained; resolve the error and rerun git workspace pull to resume'
|
|
128
|
+
throw err
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function exists(directory) {
|
|
133
|
+
try {
|
|
134
|
+
fs.lstatSync(directory)
|
|
135
|
+
return true
|
|
136
|
+
} catch (err) {
|
|
137
|
+
if (err.code !== 'ENOENT' && err.code !== 'ENOTDIR') throw err
|
|
138
|
+
return false
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
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 { validateManifest } from './manifest.js'
|
|
6
|
+
import repositoryPath from './repository-path.js'
|
|
7
|
+
import checkedOutBranch from './checked-out-branch.js'
|
|
8
|
+
import { assertNoPendingPull } from './pull-state.js'
|
|
9
|
+
|
|
10
|
+
const ref = 'refs/git-workspace/applied'
|
|
11
|
+
|
|
12
|
+
export function readCheckpoint(directory = manifestDirectory()) {
|
|
13
|
+
return readCommitRef(directory, ref)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function readCommitRef(directory, ref) {
|
|
17
|
+
const symbolic = run(directory, ['symbolic-ref', '--quiet', ref])
|
|
18
|
+
if (symbolic.status === 0) throw new Error('Applied checkpoint must not be a symbolic ref')
|
|
19
|
+
if (symbolic.status !== 1) fail(symbolic)
|
|
20
|
+
const result = run(directory, ['rev-parse', '--verify', '--quiet', ref])
|
|
21
|
+
if (result.status === 1) {
|
|
22
|
+
if (result.stderr?.toString().trim()) fail(result)
|
|
23
|
+
return null
|
|
24
|
+
}
|
|
25
|
+
if (result.status !== 0) fail(result)
|
|
26
|
+
const commit = result.stdout.toString().trim()
|
|
27
|
+
if (git(directory, ['cat-file', '-t', commit]) !== 'commit') {
|
|
28
|
+
throw new Error('Applied checkpoint does not point to a commit')
|
|
29
|
+
}
|
|
30
|
+
return commit
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Ordinary commands succeed even if some other checkout prevents full verification.
|
|
34
|
+
// A checkpoint failure must never be mistaken for a failed commit or undo a rename.
|
|
35
|
+
export function refreshCheckpoint(directory = manifestDirectory()) {
|
|
36
|
+
try {
|
|
37
|
+
return checkpoint(directory)
|
|
38
|
+
} catch (err) {
|
|
39
|
+
console.error(`warning: local workspace tracking unchanged: ${err.message}`)
|
|
40
|
+
return null
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export default function checkpoint(directory = manifestDirectory()) {
|
|
45
|
+
assertNoPendingPull(directory)
|
|
46
|
+
const lock = path.join(directory, '.git', 'workspace.lock')
|
|
47
|
+
fs.mkdirSync(lock)
|
|
48
|
+
try {
|
|
49
|
+
return recordCheckpoint(directory)
|
|
50
|
+
} finally {
|
|
51
|
+
fs.rmdirSync(lock)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Called by pull while it already holds the workspace lock. Confirmed replacements
|
|
56
|
+
// come from completed local operations, not from guessing at occupied paths.
|
|
57
|
+
export function recordCheckpoint(directory, { replacedPaths = new Set() } = {}) {
|
|
58
|
+
const previous = readCheckpoint(directory)
|
|
59
|
+
const head = git(directory, ['rev-parse', '--verify', 'HEAD^{commit}'])
|
|
60
|
+
const current = snapshot(directory, head)
|
|
61
|
+
cleanManifest(directory)
|
|
62
|
+
// Validate the actual manifest too, including refusing symlinks.
|
|
63
|
+
readManifest(path.join(directory, 'workspace.json'))
|
|
64
|
+
let retired = new Set()
|
|
65
|
+
if (previous) {
|
|
66
|
+
const ancestry = run(directory, ['merge-base', '--is-ancestor', previous, head])
|
|
67
|
+
if (ancestry.status === 1) {
|
|
68
|
+
throw new Error('HEAD does not descend from the applied checkpoint')
|
|
69
|
+
}
|
|
70
|
+
if (ancestry.status !== 0) fail(ancestry)
|
|
71
|
+
retired = verifyRetiredEntries(snapshot(directory, previous), current, replacedPaths)
|
|
72
|
+
}
|
|
73
|
+
verifyCheckouts(current, retired)
|
|
74
|
+
// Do not label another command's concurrent commit as applied.
|
|
75
|
+
if (git(directory, ['rev-parse', '--verify', 'HEAD^{commit}']) !== head) {
|
|
76
|
+
throw new Error('Manifest HEAD changed during verification; retry')
|
|
77
|
+
}
|
|
78
|
+
cleanManifest(directory)
|
|
79
|
+
if (head !== previous) {
|
|
80
|
+
git(directory, ['update-ref', '--no-deref', ref, head, previous || '0'.repeat(head.length)])
|
|
81
|
+
}
|
|
82
|
+
return head
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function cleanManifest(directory) {
|
|
86
|
+
if (git(directory, ['status', '--porcelain', '--untracked-files=all', '--', 'workspace.json'])) {
|
|
87
|
+
throw new Error(
|
|
88
|
+
'workspace.json has uncommitted changes; commit it before local workspace tracking can advance'
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function snapshot(directory, commit) {
|
|
94
|
+
const manifest = JSON.parse(git(directory, ['show', `${commit}:workspace.json`]))
|
|
95
|
+
validateManifest(manifest)
|
|
96
|
+
return manifest
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function entries(manifest) {
|
|
100
|
+
return manifest.repositories.flatMap(({ id, name, worktrees }) => [
|
|
101
|
+
{ id, name },
|
|
102
|
+
...worktrees.map((worktree) => ({ id: worktree.id, name: `${name}@${worktree.branch}` }))
|
|
103
|
+
])
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function verifyRetiredEntries(previous, current, replacedPaths) {
|
|
107
|
+
const retired = new Set()
|
|
108
|
+
const next = new Map(entries(current).map((entry) => [entry.id, entry.name]))
|
|
109
|
+
for (const entry of entries(previous)) {
|
|
110
|
+
if (next.get(entry.id) === entry.name) continue
|
|
111
|
+
if (replacedPaths.has(repositoryPath(entry.name))) continue
|
|
112
|
+
retired.add(repositoryPath(entry.name))
|
|
113
|
+
if (exists(repositoryPath(entry.name))) {
|
|
114
|
+
throw new Error(
|
|
115
|
+
`Previous checkout ${entry.name} still exists or its name has been reused; cannot verify its removal or rename`
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return retired
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function verifyCheckouts(manifest, retired = new Set(), renames = new Map()) {
|
|
123
|
+
for (const repository of manifest.repositories) {
|
|
124
|
+
const root = repositoryPath(repository.name)
|
|
125
|
+
verifyDirectory(root, repository.name)
|
|
126
|
+
const canonical = fs.realpathSync(root)
|
|
127
|
+
const gitDirectory = path.join(canonical, '.git')
|
|
128
|
+
if (!fs.lstatSync(gitDirectory).isDirectory() || common(root) !== gitDirectory) {
|
|
129
|
+
throw new Error(`${repository.name} is not a standalone root repository`)
|
|
130
|
+
}
|
|
131
|
+
const origin = git(root, ['config', '--get', 'remote.origin.url'])
|
|
132
|
+
const match =
|
|
133
|
+
/^(?:git@github\.com:|ssh:\/\/git@github\.com\/|https:\/\/github\.com\/)([^/]+\/[^/]+?)\/?$/i.exec(
|
|
134
|
+
origin
|
|
135
|
+
)
|
|
136
|
+
const expected = [repository.name, renames.get(repository.id)?.to].filter(Boolean)
|
|
137
|
+
if (
|
|
138
|
+
!match ||
|
|
139
|
+
!expected.some((name) => match[1].replace(/\.git$/i, '').toLowerCase() === name.toLowerCase())
|
|
140
|
+
) {
|
|
141
|
+
throw new Error(`${repository.name} has an unexpected origin`)
|
|
142
|
+
}
|
|
143
|
+
const listed = git(root, ['worktree', 'list', '--porcelain', '-z'])
|
|
144
|
+
.split('\0')
|
|
145
|
+
.filter((field) => field.startsWith('worktree '))
|
|
146
|
+
.map((field) => field.slice('worktree '.length))
|
|
147
|
+
for (const directory of listed) {
|
|
148
|
+
if (retired.has(directory)) {
|
|
149
|
+
throw new Error(`Previous worktree still has a Git record: ${directory}`)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
for (const worktree of repository.worktrees) {
|
|
153
|
+
const name = `${repository.name}@${worktree.branch}`
|
|
154
|
+
const directory = repositoryPath(name)
|
|
155
|
+
verifyDirectory(directory, name)
|
|
156
|
+
if (common(directory) !== gitDirectory || !listed.includes(fs.realpathSync(directory))) {
|
|
157
|
+
throw new Error(`${name} is not a linked worktree of ${repository.name}`)
|
|
158
|
+
}
|
|
159
|
+
if (checkedOutBranch(directory) !== worktree.branch) {
|
|
160
|
+
throw new Error(`${name} is not on branch ${worktree.branch}`)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function verifyDirectory(directory, name) {
|
|
167
|
+
if (!exists(directory)) throw new Error(`Missing checkout: ${name}`)
|
|
168
|
+
if (fs.lstatSync(directory).isSymbolicLink()) {
|
|
169
|
+
throw new Error(`Checkout is a symbolic link: ${name}`)
|
|
170
|
+
}
|
|
171
|
+
if (
|
|
172
|
+
fs.realpathSync(git(directory, ['rev-parse', '--show-toplevel'])) !== fs.realpathSync(directory)
|
|
173
|
+
) {
|
|
174
|
+
throw new Error(`${name} is not a Git worktree root`)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function common(directory) {
|
|
179
|
+
return fs.realpathSync(
|
|
180
|
+
git(directory, ['rev-parse', '--path-format=absolute', '--git-common-dir'])
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function exists(directory) {
|
|
185
|
+
try {
|
|
186
|
+
fs.lstatSync(directory)
|
|
187
|
+
return true
|
|
188
|
+
} catch (err) {
|
|
189
|
+
if (err.code !== 'ENOENT' && err.code !== 'ENOTDIR') throw err
|
|
190
|
+
return false
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function run(directory, args) {
|
|
195
|
+
const result = spawnSync('git', ['--no-optional-locks', '-C', directory, ...args])
|
|
196
|
+
if (result.error) throw result.error
|
|
197
|
+
return result
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function fail(result) {
|
|
201
|
+
throw new Error(result.stderr?.toString().trim() || 'Git command failed')
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function git(directory, args) {
|
|
205
|
+
const result = run(directory, args)
|
|
206
|
+
if (result.status !== 0) fail(result)
|
|
207
|
+
return result.stdout.toString().replace(/\r?\n$/, '')
|
|
208
|
+
}
|