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/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
- readRegistry()
32
+ registry()
10
33
  const [base, branch] = repository.split('@')
11
34
  if (branch) {
12
35
  git(['check-ref-format', '--branch', branch])
13
- add(base)
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
- git(['clone', `git@github.com:${repository}.git`, directory], { stdio: 'inherit' })
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 register(repository, directory, worktree) {
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
+ }