git-workspace-bare 0.1.1 → 0.3.1

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/package.json CHANGED
@@ -1,11 +1,14 @@
1
1
  {
2
2
  "name": "git-workspace-bare",
3
- "version": "0.1.1",
3
+ "version": "0.3.1",
4
4
  "description": "Manage Git worktrees as development workspaces",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "git-workspace": "./bin/git-workspace.js"
8
8
  },
9
+ "man": [
10
+ "./man/man1/git-workspace.1"
11
+ ],
9
12
  "scripts": {
10
13
  "test": "bare test/lifecycle.js",
11
14
  "format": "prettier . --write",
@@ -13,6 +16,8 @@
13
16
  },
14
17
  "license": "Apache-2.0",
15
18
  "dependencies": {
19
+ "bare-crypto": "^1.15.3",
20
+ "bare-env": "^3.0.1",
16
21
  "bare-fs": "^4.8.1",
17
22
  "bare-os": "^3.9.3",
18
23
  "bare-path": "^3.1.2",
@@ -0,0 +1,240 @@
1
+ import test from 'brittle'
2
+ import fs from 'bare-fs'
3
+ import path from 'bare-path'
4
+ import setup from './helpers.js'
5
+
6
+ const quote = (value) => "'" + value.replaceAll("'", "'\\''") + "'"
7
+
8
+ function gitShim(context, body) {
9
+ const executable = path.join(context.bin, 'git')
10
+ const realGit = fs.realpathSync(executable)
11
+ fs.unlinkSync(executable)
12
+ fs.writeFileSync(executable, `#!/bin/sh\n${body}\nexec ${quote(realGit)} "$@"\n`, { mode: 0o755 })
13
+ }
14
+
15
+ test('add initializes an unavailable root, commits its registration and runs hooks', (t) => {
16
+ const context = setup(t)
17
+ const { cli, ok, git, registry, home, upstream, bin, run } = context
18
+ const root = path.join(home, 'workspace/owner/new')
19
+ const directory = path.dirname(registry)
20
+ const log = path.join(home, 'hook.json')
21
+ fs.writeFileSync(
22
+ path.join(bin, 'git-workspace-hook-add-post-capture'),
23
+ `#!/bin/sh\ncat > ${quote(log)}\n`,
24
+ { mode: 0o755 }
25
+ )
26
+ const result = cli('add', 'owner/new')
27
+ t.is(result.status, 0, result.stderr)
28
+ t.ok(result.stderr.includes('initialized an empty local repository with origin configured'))
29
+ t.absent(
30
+ result.stderr.includes('local workspace tracking unchanged'),
31
+ 'unborn roots can be checkpointed'
32
+ )
33
+ t.is(git('-C', root, 'remote', 'get-url', 'origin'), `file://${upstream}/owner/new.git`)
34
+ t.is(git('-C', root, 'config', '--get', 'remote.origin.url'), 'git@github.com:owner/new.git')
35
+ t.is(git('-C', root, 'symbolic-ref', '--short', 'HEAD'), 'main', 'respects init.defaultBranch')
36
+ t.unlike(
37
+ run('git', ['-C', root, 'rev-parse', '--verify', 'HEAD']).status,
38
+ 0,
39
+ 'no synthetic first commit'
40
+ )
41
+ t.is(git('-C', root, 'status', '--porcelain'), '')
42
+ t.is(ok('status'), 'owner/new clean\n')
43
+ t.absent(fs.existsSync(path.join(upstream, 'owner/new.git')), 'no remote repository created')
44
+ t.is(git('-C', directory, 'log', '-1', '--format=%s'), 'Add owner/new')
45
+ const head = git('-C', directory, 'rev-parse', 'HEAD')
46
+ t.is(git('-C', directory, 'rev-parse', 'refs/git-workspace/applied'), head)
47
+ const payload = JSON.parse(fs.readFileSync(log, 'utf8'))
48
+ const entry = JSON.parse(fs.readFileSync(registry, 'utf8')).repositories[0]
49
+ t.is(payload.result.status, 'ok')
50
+ t.is(payload.result.data.checkouts[0].before.exists, false)
51
+ t.is(payload.result.data.checkouts[0].after.id, entry.id)
52
+ t.is(payload.result.data.checkouts[0].after.registered, true)
53
+ t.is(payload.result.data.checkouts[0].after.branch, 'main')
54
+ t.is(ok('list'), 'owner/new\n')
55
+ gitShim(context, 'if [ "$1" = clone ]; then echo unexpected-clone >&2; exit 99; fi')
56
+ const repeated = cli('add', 'owner/new')
57
+ t.is(repeated.status, 0)
58
+ t.absent(repeated.stderr.includes('could not clone'), 'repeat add never retries cloning')
59
+ t.ok(
60
+ repeated.stderr.includes('could not push workspace manifest'),
61
+ 'manifest push is retried independently'
62
+ )
63
+ t.is(git('-C', directory, 'rev-parse', 'HEAD'), head, 'repeat add creates no manifest commit')
64
+ t.is(JSON.parse(fs.readFileSync(log, 'utf8')).result.data.changed, false)
65
+ ok('rename', 'owner/new', 'owner/renamed')
66
+ ok('remove', 'owner/renamed')
67
+ t.is(ok('list'), '', 'empty local roots also support rename and remove')
68
+ })
69
+
70
+ for (const diagnostic of [
71
+ 'Network is unreachable',
72
+ 'Permission denied (publickey).',
73
+ 'Could not resolve hostname github.com'
74
+ ]) {
75
+ test(`add works after SSH reports ${diagnostic}`, (t) => {
76
+ const context = setup(t)
77
+ const { cli, git, env, upstream, bin, root, home } = context
78
+ // Exercise Git's real SSH transport with a private fake executable, without networking.
79
+ git('config', '--file', env.GIT_CONFIG_GLOBAL, '--unset', `url.file://${upstream}/.insteadOf`)
80
+ env.GIT_ALLOW_PROTOCOL = 'ssh'
81
+ const log = path.join(home, 'ssh-args')
82
+ fs.writeFileSync(
83
+ path.join(bin, 'ssh'),
84
+ `#!/bin/sh\nprintf '%s\\n' "$@" > ${quote(log)}\necho ${quote(diagnostic)} >&2\nexit 255\n`,
85
+ { mode: 0o755 }
86
+ )
87
+ const result = cli('add', 'owner/repo')
88
+ t.is(result.status, 0, result.stderr)
89
+ t.ok(result.stderr.includes(diagnostic), 'original failure is visible')
90
+ t.ok(result.stderr.includes('initialized an empty local repository'))
91
+ const args = fs.readFileSync(log, 'utf8')
92
+ t.ok(args.includes('ConnectTimeout=5'))
93
+ t.ok(args.includes('ConnectionAttempts=1'))
94
+ t.ok(args.includes('BatchMode=yes'))
95
+ t.is(git('-C', root, 'status', '--porcelain'), '')
96
+ t.absent(fs.existsSync(path.join(root, 'tracked.txt')), 'no history was downloaded')
97
+ })
98
+ }
99
+
100
+ for (const setting of ['GIT_SSH', 'GIT_SSH_COMMAND', 'core.sshCommand']) {
101
+ test(`add honors custom SSH transport from ${setting}`, (t) => {
102
+ const { cli, git, env, upstream, home } = setup(t)
103
+ git('config', '--file', env.GIT_CONFIG_GLOBAL, '--unset', `url.file://${upstream}/.insteadOf`)
104
+ env.GIT_ALLOW_PROTOCOL = 'ssh'
105
+ const executable = path.join(home, 'custom ssh')
106
+ const log = path.join(home, 'custom-args')
107
+ fs.writeFileSync(
108
+ executable,
109
+ `#!/bin/sh\nprintf '%s\\n' "$@" >> ${quote(log)}\necho offline >&2\nexit 255\n`,
110
+ { mode: 0o755 }
111
+ )
112
+ if (setting === 'core.sshCommand') {
113
+ git('config', '--file', env.GIT_CONFIG_GLOBAL, setting, quote(executable) + ' --custom')
114
+ } else env[setting] = setting === 'GIT_SSH' ? executable : quote(executable) + ' --custom'
115
+ const result = cli('add', 'owner/repo')
116
+ t.is(result.status, 0, result.stderr)
117
+ t.ok(result.stderr.includes('offline'))
118
+ const args = fs.readFileSync(log, 'utf8')
119
+ t.absent(args.includes('ConnectTimeout='), 'custom transports retain their options')
120
+ if (setting !== 'GIT_SSH') t.ok(args.includes('--custom'))
121
+ })
122
+ }
123
+
124
+ test('existing checkouts and local branch worktrees do not fetch or clone project history', (t) => {
125
+ const context = setup(t)
126
+ const { ok, cli, git, root } = context
127
+ ok('add', 'owner/repo')
128
+ const head = git('-C', root, 'rev-parse', 'HEAD')
129
+ fs.writeFileSync(path.join(root, 'tracked.txt'), 'local edits\n')
130
+ gitShim(
131
+ context,
132
+ 'case "$1" in clone|fetch|ls-remote) echo unexpected-network >&2; exit 99;; esac'
133
+ )
134
+ const repeated = cli('add', 'owner/repo')
135
+ t.is(repeated.status, 0)
136
+ t.absent(repeated.stderr.includes('could not clone'))
137
+ const added = cli('add', 'owner/repo@offline')
138
+ t.is(added.status, 0, added.stderr)
139
+ t.absent(added.stderr.includes('unexpected-network'))
140
+ t.is(git('-C', root + '@offline', 'rev-parse', 'HEAD'), head)
141
+ t.is(fs.readFileSync(path.join(root, 'tracked.txt'), 'utf8'), 'local edits\n')
142
+ })
143
+
144
+ for (const before of [false, true]) {
145
+ test(`add can initialize an empty destination ${before ? 'present before cloning' : 'left by a failed clone'}`, (t) => {
146
+ const context = setup(t)
147
+ const { cli, root, git } = context
148
+ if (before) fs.mkdirSync(root, { recursive: true })
149
+ gitShim(context, 'if [ "$1" = clone ]; then mkdir -p "$3"; echo offline >&2; exit 128; fi')
150
+ const result = cli('add', 'owner/repo')
151
+ t.is(result.status, 0, result.stderr)
152
+ t.ok(result.stderr.includes('initialized an empty local repository'))
153
+ t.is(git('-C', root, 'config', '--get', 'remote.origin.url'), 'git@github.com:owner/repo.git')
154
+ })
155
+ }
156
+
157
+ for (const target of ['partial', 'symlink', 'file']) {
158
+ test(`add preserves a ${target} destination left by a failed clone`, (t) => {
159
+ const context = setup(t)
160
+ const { cli, root, registry, home, git } = context
161
+ const external = path.join(home, 'external')
162
+ fs.mkdirSync(external)
163
+ const setupDestination =
164
+ target === 'partial'
165
+ ? 'mkdir "$3"; echo keep > "$3/partial"'
166
+ : target === 'symlink'
167
+ ? `ln -s ${quote(external)} "$3"`
168
+ : 'echo keep > "$3"'
169
+ gitShim(
170
+ context,
171
+ `if [ "$1" = clone ]; then ${setupDestination}; echo clone-failed >&2; exit 128; fi`
172
+ )
173
+ const contents = fs.readFileSync(registry, 'utf8')
174
+ const head = git('-C', path.dirname(registry), 'rev-parse', 'HEAD')
175
+ const result = cli('add', 'owner/repo')
176
+ t.is(result.status, 1)
177
+ t.ok(result.stderr.includes('destination was preserved'))
178
+ t.is(fs.readFileSync(registry, 'utf8'), contents)
179
+ t.is(git('-C', path.dirname(registry), 'rev-parse', 'HEAD'), head)
180
+ if (target === 'partial') t.is(fs.readFileSync(path.join(root, 'partial'), 'utf8'), 'keep\n')
181
+ if (target === 'symlink') t.ok(fs.lstatSync(root).isSymbolicLink())
182
+ if (target === 'file') t.is(fs.readFileSync(root, 'utf8'), 'keep\n')
183
+ t.alike(fs.readdirSync(external), [], 'nothing initialized through the symlink')
184
+ })
185
+ }
186
+
187
+ test('Git invocation, interruption, and local initialization failures remain errors', (t) => {
188
+ for (const failure of ['spawn', 'signal', 'init']) {
189
+ const context = setup(t)
190
+ const { cli, root, registry, bin } = context
191
+ const contents = fs.readFileSync(registry, 'utf8')
192
+ if (failure === 'spawn') fs.unlinkSync(path.join(bin, 'git'))
193
+ else if (failure === 'signal') gitShim(context, 'if [ "$1" = clone ]; then kill -TERM "$$"; fi')
194
+ else {
195
+ gitShim(
196
+ context,
197
+ 'if [ "$1" = clone ]; then exit 128; fi\nif [ "$1" = init ]; then echo "cannot write repository: permission denied" >&2; exit 128; fi'
198
+ )
199
+ }
200
+ const result = cli('add', 'owner/repo')
201
+ t.is(result.status, 1, failure)
202
+ t.absent(fs.existsSync(path.join(root, '.git')))
203
+ t.is(fs.readFileSync(registry, 'utf8'), contents)
204
+ t.absent(result.stderr.includes('initialized an empty local repository'))
205
+ }
206
+ })
207
+
208
+ test('branch creation on a new empty root explains the first-commit requirement and can be retried', (t) => {
209
+ const { cli, ok, git, home, registry } = setup(t)
210
+ const root = path.join(home, 'workspace/owner/new')
211
+ const result = cli('add', 'owner/new@jobs')
212
+ t.is(result.status, 1)
213
+ t.ok(result.stderr.includes('has no commits; create its first commit'))
214
+ t.ok(fs.existsSync(path.join(root, '.git')))
215
+ const entries = JSON.parse(fs.readFileSync(registry, 'utf8')).repositories
216
+ t.is(entries[0].name, 'owner/new')
217
+ t.alike(entries[0].worktrees, [])
218
+ git('-C', root, 'commit', '--allow-empty', '-m', 'First commit')
219
+ ok('add', 'owner/new@jobs')
220
+ t.is(git('-C', root + '@jobs', 'symbolic-ref', '--short', 'HEAD'), 'jobs')
221
+ t.is(git('-C', path.dirname(registry), 'status', '--porcelain'), '')
222
+ })
223
+
224
+ test('offline registration can retry a failed manifest commit without replacing local work', (t) => {
225
+ const { cli, ok, git, home, registry } = setup(t)
226
+ const directory = path.dirname(registry)
227
+ const lock = path.join(directory, '.git/index.lock')
228
+ fs.writeFileSync(lock, '')
229
+ const result = cli('add', 'owner/new')
230
+ t.is(result.status, 1)
231
+ t.ok(result.stderr.includes('could not commit the manifest'))
232
+ const root = path.join(home, 'workspace/owner/new')
233
+ fs.writeFileSync(path.join(root, 'work.txt'), 'offline work\n')
234
+ const entry = JSON.parse(fs.readFileSync(registry, 'utf8')).repositories[0]
235
+ fs.unlinkSync(lock)
236
+ ok('add', 'owner/new')
237
+ t.is(fs.readFileSync(path.join(root, 'work.txt'), 'utf8'), 'offline work\n')
238
+ t.is(JSON.parse(fs.readFileSync(registry, 'utf8')).repositories[0].id, entry.id)
239
+ t.is(git('-C', directory, 'log', '-1', '--format=%s'), 'Add owner/new')
240
+ })
@@ -0,0 +1,243 @@
1
+ import test from 'brittle'
2
+ import fs from 'bare-fs'
3
+ import path from 'bare-path'
4
+ import setup from './helpers.js'
5
+
6
+ const ref = 'refs/git-workspace/applied'
7
+
8
+ function manifest(registry) {
9
+ return JSON.parse(fs.readFileSync(registry, 'utf8'))
10
+ }
11
+
12
+ function save(git, registry, value) {
13
+ fs.writeFileSync(registry, JSON.stringify(value, null, 2) + '\n')
14
+ git('-C', path.dirname(registry), 'add', 'workspace.json')
15
+ git(
16
+ '-C',
17
+ path.dirname(registry),
18
+ 'commit',
19
+ '--only',
20
+ '-m',
21
+ 'Manifest update',
22
+ '--',
23
+ 'workspace.json'
24
+ )
25
+ }
26
+
27
+ test('init and successful mutations advance the local checkpoint without extra commits', (t) => {
28
+ const { ok, git, registry, checked } = setup(t)
29
+ const directory = path.dirname(registry)
30
+ const head = () => git('-C', directory, 'rev-parse', 'HEAD')
31
+ t.is(checked('--show').trim(), head(), 'empty init applied')
32
+ for (const args of [
33
+ ['add', 'owner/repo@jobs'],
34
+ ['rename', 'owner/repo@jobs', 'owner/repo@fixes'],
35
+ ['rename', 'owner/repo', 'other/new'],
36
+ ['remove', 'other/new@fixes'],
37
+ ['remove', 'other/new']
38
+ ]) {
39
+ ok(...args)
40
+ t.is(checked('--show').trim(), head(), args[0] + ' checkpointed')
41
+ }
42
+ t.is(git('-C', directory, 'rev-list', '--count', 'HEAD'), '6')
43
+ const before = head()
44
+ t.is(checked().trim(), before)
45
+ t.is(head(), before, 'verification does not commit')
46
+ t.is(git('-C', directory, 'status', '--porcelain'), '', 'no machine data in working tree')
47
+ })
48
+
49
+ test('existing checkouts are verified on bootstrap, and dirty work is allowed', (t) => {
50
+ const { ok, git, registry, root, checked } = setup(t)
51
+ const directory = path.dirname(registry)
52
+ ok('add', 'owner/repo@jobs')
53
+ git('-C', directory, 'update-ref', '-d', ref)
54
+ t.is(checked('--show'), 'No applied checkpoint\n')
55
+ fs.writeFileSync(path.join(root, 'tracked.txt'), 'root edits\n')
56
+ fs.writeFileSync(path.join(root + '@jobs', 'tracked.txt'), 'worktree edits\n')
57
+ const index = fs.readFileSync(path.join(root, '.git/index'))
58
+ const head = git('-C', directory, 'rev-parse', 'HEAD')
59
+ ok('init', 'owner/workspace')
60
+ t.is(checked('--show').trim(), head)
61
+ t.is(fs.readFileSync(path.join(root, 'tracked.txt'), 'utf8'), 'root edits\n')
62
+ t.is(fs.readFileSync(path.join(root + '@jobs', 'tracked.txt'), 'utf8'), 'worktree edits\n')
63
+ t.alike(
64
+ fs.readFileSync(path.join(root, '.git/index')),
65
+ index,
66
+ 'verification does not refresh index'
67
+ )
68
+ })
69
+
70
+ test('cloning a manifest on another machine does not copy or prematurely create a checkpoint', (t) => {
71
+ const { ok, cliAt, git, registry, home, upstream, checked, checkAt } = setup(t)
72
+ ok('add', 'owner/repo@jobs')
73
+ git('clone', '--bare', path.dirname(registry), path.join(upstream, 'owner/workspace.git'))
74
+ const second = path.join(home, 'second')
75
+ fs.mkdirSync(second)
76
+ const initialized = cliAt(second, 'init', 'owner/workspace')
77
+ t.is(initialized.status, 0)
78
+ t.ok(initialized.stderr.includes('Missing checkout'))
79
+ t.is(checkAt(second, '--show').stdout, 'No applied checkpoint\n')
80
+ t.is(cliAt(second, 'add', 'owner/repo').status, 0)
81
+ t.is(
82
+ checkAt(second, '--show').stdout,
83
+ 'No applied checkpoint\n',
84
+ 'missing worktree prevents partial checkpoint'
85
+ )
86
+ t.is(cliAt(second, 'add', 'owner/repo@jobs').status, 0)
87
+ t.is(checkAt(second, '--show').stdout, checked('--show'))
88
+ })
89
+
90
+ test('a failed branch operation does not advance and retry checkpoints the whole manifest', (t) => {
91
+ const { cli, ok, registry, root, check, checked } = setup(t)
92
+ const before = checked('--show')
93
+ fs.mkdirSync(root + '@jobs', { recursive: true })
94
+ t.is(cli('add', 'owner/repo@jobs').status, 1)
95
+ t.is(checked('--show'), before)
96
+ t.is(check().status, 1, 'uncommitted partial registration not checkpointed')
97
+ fs.rmdirSync(root + '@jobs')
98
+ ok('add', 'owner/repo@jobs')
99
+ t.not(checked('--show'), before)
100
+ t.is(manifest(registry).repositories[0].worktrees.length, 1)
101
+ })
102
+
103
+ for (const args of [
104
+ ['add', 'owner/repo@next'],
105
+ ['remove', 'owner/repo@jobs'],
106
+ ['rename', 'owner/repo', 'owner/new']
107
+ ]) {
108
+ test(`failed ${args[0]} commit preserves checkpoint until recovery`, (t) => {
109
+ const { cli, ok, git, registry, check, checked } = setup(t)
110
+ const directory = path.dirname(registry)
111
+ ok('add', 'owner/repo@jobs')
112
+ const before = checked('--show')
113
+ const hooks = path.join(directory, '.git/hooks')
114
+ git('-C', directory, 'config', 'core.hooksPath', hooks)
115
+ const hook = path.join(hooks, 'pre-commit')
116
+ fs.writeFileSync(hook, '#!/bin/sh\nexit 1\n')
117
+ fs.chmodSync(hook, 0o755)
118
+ t.is(cli(...args).status, 1)
119
+ t.is(checked('--show'), before)
120
+ t.is(check().status, 1)
121
+ fs.unlinkSync(hook)
122
+ git('-C', directory, 'commit', '--only', '-m', 'Recover operation', '--', 'workspace.json')
123
+ t.is(checked('--show'), before, 'manual commit alone does not advance')
124
+ t.is(checked().trim(), git('-C', directory, 'rev-parse', 'HEAD'))
125
+ })
126
+ }
127
+
128
+ test('unresolved checkouts hold back successful unrelated commands and read-only commands', (t) => {
129
+ const { cli, ok, git, registry, root, check, checked } = setup(t)
130
+ const directory = path.dirname(registry)
131
+ ok('add', 'owner/repo@jobs')
132
+ const before = checked('--show')
133
+ fs.renameSync(root + '@jobs', root + '@temporarily-moved')
134
+ const result = cli('add', 'owner/repo')
135
+ t.is(result.status, 0)
136
+ t.ok(result.stderr.includes('local workspace tracking unchanged'))
137
+ t.is(check().status, 1)
138
+ ok('list')
139
+ ok('status')
140
+ t.is(checked('--show'), before)
141
+ fs.renameSync(root + '@temporarily-moved', root + '@jobs')
142
+ git('-C', directory, 'commit', '--allow-empty', '-m', 'Other manifest repo work')
143
+ ok('list')
144
+ ok('status')
145
+ t.is(checked('--show'), before, 'read-only commands do not advance to new HEAD')
146
+ t.is(checked().trim(), git('-C', directory, 'rev-parse', 'HEAD'))
147
+ })
148
+
149
+ test('bootstrap rejects wrong origins, wrong branches, detached HEAD, and foreign checkouts', (t) => {
150
+ const { ok, git, registry, root, upstream, check, checked } = setup(t)
151
+ const directory = path.dirname(registry)
152
+ ok('add', 'owner/repo@jobs')
153
+ git('-C', directory, 'update-ref', '-d', ref)
154
+ git('-C', root, 'remote', 'set-url', 'origin', 'git@github.com:other/repo.git')
155
+ t.is(check().status, 1)
156
+ git('-C', root, 'remote', 'set-url', 'origin', 'git@github.com:owner/repo.git')
157
+ git('-C', root + '@jobs', 'switch', '-c', 'different')
158
+ t.is(check().status, 1)
159
+ git('-C', root + '@jobs', 'checkout', '--detach')
160
+ t.is(check().status, 1)
161
+ git('-C', root + '@jobs', 'switch', 'jobs')
162
+ fs.renameSync(root + '@jobs', root + '@saved')
163
+ git('clone', path.join(upstream, 'owner/repo.git'), root + '@jobs')
164
+ git('-C', root + '@jobs', 'switch', '-c', 'jobs')
165
+ t.is(check().status, 1, 'same branch in a separate clone is not a linked worktree')
166
+ fs.renameSync(root + '@jobs', root + '@foreign')
167
+ fs.symlinkSync(root + '@saved', root + '@jobs')
168
+ t.is(check().status, 1, 'symlink refused')
169
+ fs.unlinkSync(root + '@jobs')
170
+ fs.renameSync(root + '@saved', root + '@jobs')
171
+ t.is(checked('--show'), 'No applied checkpoint\n')
172
+ t.is(checked().trim(), git('-C', directory, 'rev-parse', 'HEAD'))
173
+ })
174
+
175
+ test('checkpoint requires a committed manifest but preserves unrelated staged edits', (t) => {
176
+ const { git, registry, check, checked } = setup(t)
177
+ const directory = path.dirname(registry)
178
+ const before = checked('--show')
179
+ const contents = fs.readFileSync(registry, 'utf8')
180
+ fs.writeFileSync(registry, contents + '\n')
181
+ t.is(check().status, 1)
182
+ git('-C', directory, 'add', 'workspace.json')
183
+ fs.writeFileSync(registry, contents)
184
+ t.is(check().status, 1, 'staged changes block even when working copy matches HEAD')
185
+ t.is(checked('--show'), before)
186
+ git('-C', directory, 'restore', '--staged', 'workspace.json')
187
+ fs.writeFileSync(path.join(directory, 'other.txt'), 'keep staged\n')
188
+ git('-C', directory, 'add', 'other.txt')
189
+ const staged = git('-C', directory, 'diff', '--cached')
190
+ t.is(checked(), before)
191
+ t.is(git('-C', directory, 'diff', '--cached'), staged)
192
+ })
193
+
194
+ test('manual removals and reused IDs cannot hide old checkouts from the checkpoint', (t) => {
195
+ const { ok, git, registry, root, check, checked } = setup(t)
196
+ ok('add', 'owner/repo')
197
+ const before = checked('--show')
198
+ const changed = manifest(registry)
199
+ changed.repositories[0].id = 'replacement-id'
200
+ save(git, registry, changed)
201
+ t.is(check().status, 1, 'same path with a different ID is ambiguous')
202
+ t.is(checked('--show'), before)
203
+ save(git, registry, { version: 1, repositories: [] })
204
+ t.is(check().status, 1, 'removed entry still on disk')
205
+ t.ok(fs.existsSync(root), 'verification never deletes a checkout')
206
+ ok('remove', 'owner/repo')
207
+ t.not(checked('--show'), before, 'actual removal resolves mismatch')
208
+ })
209
+
210
+ test('stale linked worktree records block checkpointing a removal', (t) => {
211
+ const { ok, git, registry, root, check, checked } = setup(t)
212
+ ok('add', 'owner/repo@jobs')
213
+ const before = checked('--show')
214
+ fs.renameSync(root + '@jobs', root + '@elsewhere')
215
+ const changed = manifest(registry)
216
+ changed.repositories[0].worktrees = []
217
+ save(git, registry, changed)
218
+ const result = check()
219
+ t.is(result.status, 1)
220
+ t.ok(result.stderr.includes('still has a Git record'))
221
+ t.is(checked('--show'), before)
222
+ })
223
+
224
+ test('checkpoint does not follow rewritten history or symbolic checkpoint refs', (t) => {
225
+ const { ok, git, registry, check, checked } = setup(t)
226
+ const directory = path.dirname(registry)
227
+ ok('add', 'owner/repo')
228
+ const before = checked('--show')
229
+ git('-C', directory, 'checkout', '-b', 'rewritten', 'HEAD~1')
230
+ git('-C', directory, 'commit', '--allow-empty', '-m', 'Different history')
231
+ t.is(check().status, 1)
232
+ t.is(checked('--show'), before)
233
+ const head = git('-C', directory, 'rev-parse', 'HEAD')
234
+ git('-C', directory, 'symbolic-ref', ref, 'refs/heads/rewritten')
235
+ t.is(check().status, 1)
236
+ t.is(git('-C', directory, 'rev-parse', 'HEAD'), head, 'symbolic ref cannot move a branch')
237
+ })
238
+
239
+ test('checkpoint is internal and absent from CLI help', (t) => {
240
+ const { cli, ok } = setup(t)
241
+ t.absent(ok('-h').includes('checkpoint'))
242
+ t.not(cli('checkpoint').status, 0)
243
+ })