git-workspace-bare 0.1.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/.lunterc.json +5 -0
- package/.prettierrc +1 -0
- package/LICENSE +201 -0
- package/README.md +252 -0
- package/bin/git-workspace.js +96 -0
- package/lib/add.js +105 -0
- package/lib/checked-out-branch.js +12 -0
- package/lib/init.js +85 -0
- package/lib/list.js +6 -0
- package/lib/map-concurrent.js +22 -0
- package/lib/registry.js +70 -0
- package/lib/remove-empty-parents.js +14 -0
- package/lib/remove.js +126 -0
- package/lib/rename-root.js +173 -0
- package/lib/rename.js +97 -0
- package/lib/report-error.js +9 -0
- package/lib/repository-name.js +14 -0
- package/lib/repository-path.js +18 -0
- package/lib/status.js +52 -0
- package/package.json +30 -0
- package/test/fixtures/cli.js +5 -0
- package/test/helpers.js +83 -0
- package/test/lifecycle.js +479 -0
package/lib/status.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import fs from 'bare-fs'
|
|
2
|
+
import { spawn } from 'bare-subprocess'
|
|
3
|
+
import list from './list.js'
|
|
4
|
+
import repositoryPath from './repository-path.js'
|
|
5
|
+
import mapConcurrent from './map-concurrent.js'
|
|
6
|
+
|
|
7
|
+
export default async function status() {
|
|
8
|
+
return mapConcurrent(list(), async (repository) => {
|
|
9
|
+
const directory = repositoryPath(repository)
|
|
10
|
+
let canonical
|
|
11
|
+
try {
|
|
12
|
+
canonical = fs.realpathSync(directory)
|
|
13
|
+
} catch (err) {
|
|
14
|
+
if (err.code === 'ENOENT' || err.code === 'ENOTDIR') return { repository, state: 'missing' }
|
|
15
|
+
throw err
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (fs.realpathSync(await git(directory, ['rev-parse', '--show-toplevel'])) !== canonical) {
|
|
19
|
+
throw new Error(`${repository}: not a Git worktree root`)
|
|
20
|
+
}
|
|
21
|
+
const changes = await git(directory, [
|
|
22
|
+
'status',
|
|
23
|
+
'--porcelain',
|
|
24
|
+
'--untracked-files=all',
|
|
25
|
+
'--ignore-submodules=none'
|
|
26
|
+
])
|
|
27
|
+
return { repository, state: changes ? 'modified' : 'clean' }
|
|
28
|
+
})
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function git(directory, args) {
|
|
32
|
+
return new Promise((resolve, reject) => {
|
|
33
|
+
const child = spawn('git', ['--no-optional-locks', '-C', directory, ...args], {
|
|
34
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
35
|
+
})
|
|
36
|
+
let stdout = ''
|
|
37
|
+
let stderr = ''
|
|
38
|
+
child.stdout.setEncoding('utf8')
|
|
39
|
+
child.stderr.setEncoding('utf8')
|
|
40
|
+
child.stdout.on('data', (data) => {
|
|
41
|
+
stdout += data
|
|
42
|
+
})
|
|
43
|
+
child.stderr.on('data', (data) => {
|
|
44
|
+
stderr += data
|
|
45
|
+
})
|
|
46
|
+
child.on('error', reject)
|
|
47
|
+
child.on('close', (code) => {
|
|
48
|
+
if (code !== 0) reject(new Error(stderr.trim() || 'Git command failed'))
|
|
49
|
+
else resolve(stdout.replace(/\r?\n$/, ''))
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "git-workspace-bare",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Manage Git worktrees as development workspaces",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"git-workspace": "./bin/git-workspace.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"test": "bare test/lifecycle.js",
|
|
11
|
+
"format": "prettier . --write",
|
|
12
|
+
"lint": "lunte"
|
|
13
|
+
},
|
|
14
|
+
"license": "Apache-2.0",
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"bare-fs": "^4.8.1",
|
|
17
|
+
"bare-os": "^3.9.3",
|
|
18
|
+
"bare-path": "^3.1.2",
|
|
19
|
+
"bare-subprocess": "^6.1.0",
|
|
20
|
+
"bare-tty": "^5.2.1",
|
|
21
|
+
"paparam": "^1.13.0"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"bare-process": "^4.5.1",
|
|
25
|
+
"brittle": "^4.2.0",
|
|
26
|
+
"lunte": "^1.8.4",
|
|
27
|
+
"prettier": "^3.9.8",
|
|
28
|
+
"prettier-config-holepunch": "^2.0.0"
|
|
29
|
+
}
|
|
30
|
+
}
|
package/test/helpers.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import fs from 'bare-fs'
|
|
2
|
+
import os from 'bare-os'
|
|
3
|
+
import path from 'bare-path'
|
|
4
|
+
import process from 'bare-process'
|
|
5
|
+
import { spawnSync } from 'bare-subprocess'
|
|
6
|
+
|
|
7
|
+
export default function setup(t, { initialize = true } = {}) {
|
|
8
|
+
const temporary = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'git-workspace-test-')))
|
|
9
|
+
t.teardown(() => fs.rmSync(temporary, { recursive: true }), { force: true })
|
|
10
|
+
const home = path.join(temporary, 'home')
|
|
11
|
+
const upstream = path.join(temporary, 'upstream')
|
|
12
|
+
const config = path.join(temporary, 'gitconfig')
|
|
13
|
+
fs.mkdirSync(home)
|
|
14
|
+
fs.mkdirSync(path.join(upstream, 'owner'), { recursive: true })
|
|
15
|
+
fs.mkdirSync(path.join(temporary, 'hooks'))
|
|
16
|
+
fs.writeFileSync(config, '')
|
|
17
|
+
|
|
18
|
+
const env = { ...process.env }
|
|
19
|
+
for (const key of Object.keys(env)) {
|
|
20
|
+
if (key.startsWith('GIT_')) delete env[key]
|
|
21
|
+
}
|
|
22
|
+
Object.assign(env, {
|
|
23
|
+
GIT_CONFIG_NOSYSTEM: '1',
|
|
24
|
+
GIT_CONFIG_GLOBAL: config,
|
|
25
|
+
GIT_ALLOW_PROTOCOL: 'file',
|
|
26
|
+
GIT_TERMINAL_PROMPT: '0'
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
function run(file, args) {
|
|
30
|
+
const result = spawnSync(file, args, { cwd: temporary, env })
|
|
31
|
+
if (result.error) throw result.error
|
|
32
|
+
return {
|
|
33
|
+
status: result.status,
|
|
34
|
+
stdout: result.stdout.toString(),
|
|
35
|
+
stderr: result.stderr.toString()
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function git(...args) {
|
|
40
|
+
const result = run('git', args)
|
|
41
|
+
if (result.status !== 0) throw new Error(result.stderr)
|
|
42
|
+
return result.stdout.trim()
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
git('config', '--file', config, 'user.name', 'Workspace Test')
|
|
46
|
+
git('config', '--file', config, 'user.email', 'test@example.com')
|
|
47
|
+
git('config', '--file', config, 'commit.gpgsign', 'false')
|
|
48
|
+
git('config', '--file', config, 'init.defaultBranch', 'main')
|
|
49
|
+
git('config', '--file', config, 'core.hooksPath', path.join(temporary, 'hooks'))
|
|
50
|
+
git('config', '--file', config, `url.file://${upstream}/.insteadOf`, 'git@github.com:')
|
|
51
|
+
const source = path.join(temporary, 'source')
|
|
52
|
+
git('init', source)
|
|
53
|
+
fs.writeFileSync(path.join(source, 'tracked.txt'), 'original\n')
|
|
54
|
+
git('-C', source, 'add', 'tracked.txt')
|
|
55
|
+
git('-C', source, 'commit', '-m', 'Fixture')
|
|
56
|
+
git('clone', '--bare', source, path.join(upstream, 'owner/repo.git'))
|
|
57
|
+
|
|
58
|
+
const runner = fs.realpathSync(new URL('./fixtures/cli.js', import.meta.url))
|
|
59
|
+
function cli(...args) {
|
|
60
|
+
return cliAt(home, ...args)
|
|
61
|
+
}
|
|
62
|
+
function cliAt(directory, ...args) {
|
|
63
|
+
return run(os.execPath(), [runner, directory, ...args])
|
|
64
|
+
}
|
|
65
|
+
function ok(...args) {
|
|
66
|
+
const result = cli(...args)
|
|
67
|
+
if (result.status !== 0) throw new Error(result.stderr)
|
|
68
|
+
return result.stdout
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (initialize) ok('init', 'owner/workspace')
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
cli,
|
|
75
|
+
cliAt,
|
|
76
|
+
ok,
|
|
77
|
+
git,
|
|
78
|
+
home,
|
|
79
|
+
upstream,
|
|
80
|
+
root: path.join(home, 'workspace/owner/repo'),
|
|
81
|
+
registry: path.join(home, 'workspace/owner/workspace/workspace.json')
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,479 @@
|
|
|
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
|
+
test('clone, list, and remove a root repository', (t) => {
|
|
7
|
+
const { ok, root, registry } = setup(t)
|
|
8
|
+
t.is(ok('list'), '', 'empty manifest is empty')
|
|
9
|
+
ok('add', 'owner/repo')
|
|
10
|
+
t.ok(fs.existsSync(path.join(root, '.git')), 'repository cloned')
|
|
11
|
+
t.is(ok('list'), 'owner/repo\n', 'prints repository name outside the checkout')
|
|
12
|
+
ok('add', 'owner/repo')
|
|
13
|
+
t.alike(JSON.parse(fs.readFileSync(registry, 'utf8')), ['owner/repo'], 'repeat add is idempotent')
|
|
14
|
+
ok('remove', 'owner/repo')
|
|
15
|
+
t.absent(fs.existsSync(root), 'checkout deleted')
|
|
16
|
+
t.is(ok('list'), '', 'registry empty after removal')
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
test('nested branches and empty parent cleanup', (t) => {
|
|
20
|
+
const { ok, git, root } = setup(t)
|
|
21
|
+
ok('add', 'owner/repo@feature/jobs')
|
|
22
|
+
ok('add', 'owner/repo@feature/fixes/deep')
|
|
23
|
+
t.is(git('-C', root + '@feature/jobs', 'symbolic-ref', '--short', 'HEAD'), 'feature/jobs')
|
|
24
|
+
t.is(ok('list'), 'owner/repo\nowner/repo@feature/fixes/deep\nowner/repo@feature/jobs\n')
|
|
25
|
+
ok('remove', 'owner/repo@feature/fixes/deep')
|
|
26
|
+
t.absent(fs.existsSync(root + '@feature/fixes'), 'empty intermediate directory removed')
|
|
27
|
+
t.ok(fs.existsSync(root + '@feature/jobs'), 'sibling worktree preserved')
|
|
28
|
+
ok('remove', 'owner/repo@feature/jobs')
|
|
29
|
+
t.absent(fs.existsSync(root + '@feature'), 'empty branch parent removed')
|
|
30
|
+
t.ok(fs.existsSync(path.dirname(root)), 'owner directory preserved')
|
|
31
|
+
t.is(git('-C', root, 'branch', '--list', 'feature/*'), '', 'local branches deleted')
|
|
32
|
+
ok('add', 'owner/repo@keep/child')
|
|
33
|
+
fs.writeFileSync(root + '@keep/note', 'keep')
|
|
34
|
+
ok('remove', 'owner/repo@keep/child')
|
|
35
|
+
t.is(fs.readFileSync(root + '@keep/note', 'utf8'), 'keep', 'nonempty parent preserved')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('root removal refuses linked worktrees', (t) => {
|
|
39
|
+
const { cli, ok, root, registry } = setup(t)
|
|
40
|
+
ok('add', 'owner/repo@jobs')
|
|
41
|
+
const before = fs.readFileSync(registry, 'utf8')
|
|
42
|
+
const result = cli('remove', 'owner/repo')
|
|
43
|
+
t.is(result.status, 1)
|
|
44
|
+
t.is(result.stdout, '')
|
|
45
|
+
t.is(
|
|
46
|
+
result.stderr,
|
|
47
|
+
'error: cannot remove owner/repo: linked worktrees exist\nhint: remove the linked worktrees first\n'
|
|
48
|
+
)
|
|
49
|
+
t.ok(fs.existsSync(root), 'root preserved')
|
|
50
|
+
t.ok(fs.existsSync(root + '@jobs'), 'linked worktree preserved')
|
|
51
|
+
t.is(fs.readFileSync(registry, 'utf8'), before, 'registry unchanged')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('dirty worktrees and roots survive failed removal', (t) => {
|
|
55
|
+
const { cli, ok, git, root, registry } = setup(t)
|
|
56
|
+
ok('add', 'owner/repo@jobs')
|
|
57
|
+
const worktree = root + '@jobs'
|
|
58
|
+
const tracked = path.join(worktree, 'tracked.txt')
|
|
59
|
+
const before = fs.readFileSync(registry, 'utf8')
|
|
60
|
+
fs.writeFileSync(tracked, 'changed\n')
|
|
61
|
+
t.is(cli('remove', 'owner/repo@jobs').status, 1, 'modified tracked file blocks removal')
|
|
62
|
+
t.is(fs.readFileSync(tracked, 'utf8'), 'changed\n')
|
|
63
|
+
fs.writeFileSync(tracked, 'original\n')
|
|
64
|
+
fs.writeFileSync(path.join(worktree, 'untracked'), 'keep')
|
|
65
|
+
t.is(cli('remove', 'owner/repo@jobs').status, 1, 'untracked file blocks removal')
|
|
66
|
+
t.is(fs.readFileSync(registry, 'utf8'), before)
|
|
67
|
+
t.is(git('-C', worktree, 'symbolic-ref', '--short', 'HEAD'), 'jobs', 'branch preserved')
|
|
68
|
+
fs.unlinkSync(path.join(worktree, 'untracked'))
|
|
69
|
+
git('-C', worktree, 'commit', '--allow-empty', '-m', 'Unmerged commit')
|
|
70
|
+
ok('remove', 'owner/repo@jobs')
|
|
71
|
+
t.is(git('-C', root, 'branch', '--list', 'jobs'), '', 'clean unmerged branch is force-deleted')
|
|
72
|
+
fs.writeFileSync(path.join(root, 'tracked.txt'), 'root changes\n')
|
|
73
|
+
t.is(cli('remove', 'owner/repo').status, 1, 'dirty root is protected')
|
|
74
|
+
t.is(fs.readFileSync(path.join(root, 'tracked.txt'), 'utf8'), 'root changes\n')
|
|
75
|
+
t.is(ok('list'), 'owner/repo\n')
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('worktrees are removed along with whichever branch they hold', (t) => {
|
|
79
|
+
const { ok, git, root } = setup(t)
|
|
80
|
+
ok('add', 'owner/repo@jobs')
|
|
81
|
+
const worktree = root + '@jobs'
|
|
82
|
+
git('-C', worktree, 'switch', '-c', 'renamed')
|
|
83
|
+
git('-C', root, 'branch', '-D', 'jobs')
|
|
84
|
+
ok('remove', 'owner/repo@jobs')
|
|
85
|
+
t.absent(fs.existsSync(worktree), 'worktree removed despite the branch mismatch')
|
|
86
|
+
t.is(git('-C', root, 'branch', '--list', 'renamed'), '', 'checked-out branch deleted')
|
|
87
|
+
t.is(ok('list'), 'owner/repo\n', 'entry unregistered')
|
|
88
|
+
|
|
89
|
+
ok('add', 'owner/repo@detached')
|
|
90
|
+
const second = root + '@detached'
|
|
91
|
+
git('-C', second, 'checkout', '--detach')
|
|
92
|
+
ok('remove', 'owner/repo@detached')
|
|
93
|
+
t.absent(fs.existsSync(second), 'detached worktree removed')
|
|
94
|
+
t.is(
|
|
95
|
+
git('-C', root, 'branch', '--list', 'detached').trim(),
|
|
96
|
+
'detached',
|
|
97
|
+
'detached HEAD leaves the branch alone'
|
|
98
|
+
)
|
|
99
|
+
t.is(ok('list'), 'owner/repo\n')
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test('root rename moves dirty checkouts, repairs links, and updates origin locally', (t) => {
|
|
103
|
+
const { ok, git, home, root, upstream } = setup(t)
|
|
104
|
+
ok('add', 'owner/repo@feature/jobs')
|
|
105
|
+
const external = path.join(home, 'external')
|
|
106
|
+
git('-C', root, 'worktree', 'add', '-b', 'external', external)
|
|
107
|
+
git('-C', root, 'config', '--add', 'remote.origin.pushurl', 'https://github.com/owner/repo.git')
|
|
108
|
+
git('-C', root, 'config', '--add', 'remote.origin.pushurl', 'git@github.com:other/mirror.git')
|
|
109
|
+
fs.writeFileSync(path.join(root, 'untracked.txt'), 'keep root work')
|
|
110
|
+
fs.writeFileSync(path.join(root + '@feature/jobs', 'tracked.txt'), 'keep branch work')
|
|
111
|
+
const head = git('-C', root, 'rev-parse', 'HEAD')
|
|
112
|
+
ok('rename', 'owner/repo', 'owner/renamed')
|
|
113
|
+
const renamed = path.join(path.dirname(root), 'renamed')
|
|
114
|
+
t.absent(fs.existsSync(root), 'root moved')
|
|
115
|
+
t.absent(fs.existsSync(root + '@feature'), 'empty branch parent removed')
|
|
116
|
+
t.is(ok('list'), 'owner/renamed\nowner/renamed@feature/jobs\n')
|
|
117
|
+
t.is(fs.readFileSync(path.join(renamed, 'untracked.txt'), 'utf8'), 'keep root work')
|
|
118
|
+
t.is(
|
|
119
|
+
fs.readFileSync(path.join(renamed + '@feature/jobs', 'tracked.txt'), 'utf8'),
|
|
120
|
+
'keep branch work'
|
|
121
|
+
)
|
|
122
|
+
t.is(git('-C', renamed + '@feature/jobs', 'symbolic-ref', '--short', 'HEAD'), 'feature/jobs')
|
|
123
|
+
t.is(git('-C', renamed, 'rev-parse', 'HEAD'), head, 'no commits')
|
|
124
|
+
t.is(
|
|
125
|
+
git('-C', external, 'rev-parse', '--path-format=absolute', '--git-common-dir'),
|
|
126
|
+
path.join(renamed, '.git')
|
|
127
|
+
)
|
|
128
|
+
t.is(
|
|
129
|
+
git('-C', renamed, 'config', '--get', 'remote.origin.url'),
|
|
130
|
+
'git@github.com:owner/renamed.git'
|
|
131
|
+
)
|
|
132
|
+
t.is(
|
|
133
|
+
git('-C', renamed, 'config', '--get-all', 'remote.origin.pushurl'),
|
|
134
|
+
'https://github.com/owner/renamed.git\ngit@github.com:other/mirror.git'
|
|
135
|
+
)
|
|
136
|
+
t.ok(fs.existsSync(path.join(upstream, 'owner/repo.git')), 'remote untouched')
|
|
137
|
+
t.absent(fs.existsSync(path.join(upstream, 'owner/renamed.git')), 'no remote created')
|
|
138
|
+
ok('add', 'owner/renamed')
|
|
139
|
+
git('-C', renamed + '@feature/jobs', 'restore', 'tracked.txt')
|
|
140
|
+
ok('remove', 'owner/renamed@feature/jobs')
|
|
141
|
+
t.absent(fs.existsSync(renamed + '@feature/jobs'), 'worktree remains usable')
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
test('root rename moves checkouts to a different owner locally', (t) => {
|
|
145
|
+
const { ok, git, home, root, upstream } = setup(t)
|
|
146
|
+
ok('add', 'owner/repo@feature/jobs')
|
|
147
|
+
const renamed = path.join(home, 'workspace/other/new')
|
|
148
|
+
ok('rename', 'owner/repo', 'other/new')
|
|
149
|
+
t.absent(fs.existsSync(root), 'old root moved')
|
|
150
|
+
t.absent(fs.existsSync(root + '@feature'), 'old branch parents cleaned up')
|
|
151
|
+
t.is(ok('list'), 'other/new\nother/new@feature/jobs\n')
|
|
152
|
+
t.is(git('-C', renamed, 'config', '--get', 'remote.origin.url'), 'git@github.com:other/new.git')
|
|
153
|
+
t.is(
|
|
154
|
+
git('-C', renamed + '@feature/jobs', 'rev-parse', '--path-format=absolute', '--git-common-dir'),
|
|
155
|
+
path.join(renamed, '.git')
|
|
156
|
+
)
|
|
157
|
+
t.is(git('-C', renamed + '@feature/jobs', 'symbolic-ref', '--short', 'HEAD'), 'feature/jobs')
|
|
158
|
+
t.ok(fs.existsSync(path.join(upstream, 'owner/repo.git')), 'remote unchanged')
|
|
159
|
+
ok('add', 'other/new')
|
|
160
|
+
ok('remove', 'other/new@feature/jobs')
|
|
161
|
+
t.is(ok('list'), 'other/new\n', 'worktree removal still works')
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
test('root rename accepts an origin already updated manually and preserves URL style', (t) => {
|
|
165
|
+
const { ok, git, root } = setup(t)
|
|
166
|
+
ok('add', 'owner/repo')
|
|
167
|
+
git('-C', root, 'config', 'remote.origin.url', 'ssh://git@github.com/owner/new.git')
|
|
168
|
+
ok('rename', 'owner/repo', 'owner/new')
|
|
169
|
+
const renamed = path.join(path.dirname(root), 'new')
|
|
170
|
+
t.is(
|
|
171
|
+
git('-C', renamed, 'config', '--get', 'remote.origin.url'),
|
|
172
|
+
'ssh://git@github.com/owner/new.git'
|
|
173
|
+
)
|
|
174
|
+
ok('rename', 'owner/new', 'owner/repo')
|
|
175
|
+
t.is(
|
|
176
|
+
git('-C', root, 'config', '--get', 'remote.origin.url'),
|
|
177
|
+
'ssh://git@github.com/owner/repo.git'
|
|
178
|
+
)
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
test('root rename refuses unsafe destinations and linked worktrees before moving', (t) => {
|
|
182
|
+
const { cli, ok, git, root, registry } = setup(t)
|
|
183
|
+
ok('add', 'owner/repo@jobs')
|
|
184
|
+
const before = fs.readFileSync(registry, 'utf8')
|
|
185
|
+
const renamed = path.join(path.dirname(root), 'new')
|
|
186
|
+
fs.symlinkSync(path.join(root, 'absent'), renamed)
|
|
187
|
+
t.is(cli('rename', 'owner/repo', 'owner/new').status, 1, 'dangling destination symlink')
|
|
188
|
+
fs.unlinkSync(renamed)
|
|
189
|
+
fs.mkdirSync(renamed + '@jobs')
|
|
190
|
+
t.is(cli('rename', 'owner/repo', 'owner/new').status, 1, 'branch destination conflict')
|
|
191
|
+
fs.rmdirSync(renamed + '@jobs')
|
|
192
|
+
git('-C', root, 'worktree', 'lock', root + '@jobs')
|
|
193
|
+
t.is(cli('rename', 'owner/repo', 'owner/new').status, 1, 'locked worktree')
|
|
194
|
+
git('-C', root, 'worktree', 'unlock', root + '@jobs')
|
|
195
|
+
const head = git('-C', root, 'rev-parse', 'HEAD')
|
|
196
|
+
git('-C', root + '@jobs', 'update-index', '--add', '--cacheinfo', `160000,${head},module`)
|
|
197
|
+
t.is(cli('rename', 'owner/repo', 'owner/new').status, 1, 'linked submodule')
|
|
198
|
+
git('-C', root + '@jobs', 'reset', 'HEAD')
|
|
199
|
+
fs.renameSync(root + '@jobs', root + '@missing')
|
|
200
|
+
t.is(cli('rename', 'owner/repo', 'owner/new').status, 1, 'missing linked worktree')
|
|
201
|
+
fs.renameSync(root + '@missing', root + '@jobs')
|
|
202
|
+
git('-C', root, 'config', 'remote.origin.url', 'git@github.com:other/repo.git')
|
|
203
|
+
t.is(cli('rename', 'owner/repo', 'owner/new').status, 1, 'wrong origin')
|
|
204
|
+
t.is(fs.readFileSync(registry, 'utf8'), before, 'manifest unchanged')
|
|
205
|
+
t.ok(fs.existsSync(root), 'root unchanged')
|
|
206
|
+
t.ok(fs.existsSync(root + '@jobs'), 'worktree unchanged')
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
test('root rename restores local paths and Git links on failure', (t) => {
|
|
210
|
+
const { cli, ok, git, root, registry } = setup(t)
|
|
211
|
+
ok('add', 'owner/repo@jobs')
|
|
212
|
+
const before = fs.readFileSync(registry, 'utf8')
|
|
213
|
+
fs.writeFileSync(path.join(root, '.git/config.lock'), '')
|
|
214
|
+
t.is(cli('rename', 'owner/repo', 'owner/new').status, 1, 'config write fails')
|
|
215
|
+
t.is(fs.readFileSync(registry, 'utf8'), before)
|
|
216
|
+
t.is(
|
|
217
|
+
git('-C', root + '@jobs', 'rev-parse', '--path-format=absolute', '--git-common-dir'),
|
|
218
|
+
path.join(root, '.git')
|
|
219
|
+
)
|
|
220
|
+
t.is(git('-C', root, 'config', '--get', 'remote.origin.url'), 'git@github.com:owner/repo.git')
|
|
221
|
+
fs.unlinkSync(path.join(root, '.git/config.lock'))
|
|
222
|
+
ok('rename', 'owner/repo', 'owner/new')
|
|
223
|
+
t.is(ok('list'), 'owner/new\nowner/new@jobs\n', 'retry works')
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
test('root rename refuses the active manifest repository', (t) => {
|
|
227
|
+
const { cli, ok, registry } = setup(t)
|
|
228
|
+
ok('add', 'owner/workspace')
|
|
229
|
+
const result = cli('rename', 'owner/workspace', 'owner/new')
|
|
230
|
+
t.is(result.status, 1)
|
|
231
|
+
t.ok(result.stderr.includes('Cannot rename the active manifest repository'))
|
|
232
|
+
t.ok(fs.existsSync(registry))
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
test('rename moves a worktree and its branch together', (t) => {
|
|
236
|
+
const { ok, git, root } = setup(t)
|
|
237
|
+
ok('add', 'owner/repo@jobs')
|
|
238
|
+
git('-C', root, 'branch', '--set-upstream-to=origin/main', 'jobs')
|
|
239
|
+
fs.writeFileSync(path.join(root + '@jobs', 'tracked.txt'), 'in progress\n')
|
|
240
|
+
ok('rename', 'owner/repo@jobs', 'owner/repo@fixes')
|
|
241
|
+
t.absent(fs.existsSync(root + '@jobs'), 'old directory gone')
|
|
242
|
+
t.is(
|
|
243
|
+
fs.readFileSync(path.join(root + '@fixes', 'tracked.txt'), 'utf8'),
|
|
244
|
+
'in progress\n',
|
|
245
|
+
'uncommitted work carried along'
|
|
246
|
+
)
|
|
247
|
+
t.is(git('-C', root + '@fixes', 'symbolic-ref', '--short', 'HEAD'), 'fixes', 'branch renamed')
|
|
248
|
+
t.is(git('-C', root, 'branch', '--list', 'jobs'), '', 'old branch gone')
|
|
249
|
+
t.is(ok('list'), 'owner/repo\nowner/repo@fixes\n', 'manifest updated')
|
|
250
|
+
t.is(
|
|
251
|
+
git('-C', root, 'config', '--get', 'branch.fixes.merge'),
|
|
252
|
+
'refs/heads/fixes',
|
|
253
|
+
'upstream retargeted to the new name'
|
|
254
|
+
)
|
|
255
|
+
t.is(git('-C', root, 'config', '--get', 'branch.fixes.remote'), 'origin', 'remote preserved')
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
test('rename realigns a worktree switched to another branch', (t) => {
|
|
259
|
+
const { ok, git, root } = setup(t)
|
|
260
|
+
ok('add', 'owner/repo@jobs')
|
|
261
|
+
git('-C', root + '@jobs', 'switch', '-c', 'wandered')
|
|
262
|
+
ok('rename', 'owner/repo@jobs', 'owner/repo@wandered')
|
|
263
|
+
t.absent(fs.existsSync(root + '@jobs'), 'stale directory gone')
|
|
264
|
+
t.is(git('-C', root + '@wandered', 'symbolic-ref', '--short', 'HEAD'), 'wandered')
|
|
265
|
+
t.is(ok('list'), 'owner/repo\nowner/repo@wandered\n', 'name matches the branch again')
|
|
266
|
+
t.is(
|
|
267
|
+
git('-C', root, 'branch', '--list', 'jobs').trim(),
|
|
268
|
+
'jobs',
|
|
269
|
+
'branch left behind by the\n switch is untouched'
|
|
270
|
+
)
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
test('rename handles nested names and refuses unsafe targets', (t) => {
|
|
274
|
+
const { cli, ok, git, root } = setup(t)
|
|
275
|
+
ok('add', 'owner/repo@feature/jobs')
|
|
276
|
+
ok('add', 'owner/repo@taken')
|
|
277
|
+
ok('rename', 'owner/repo@feature/jobs', 'owner/repo@plain')
|
|
278
|
+
t.absent(fs.existsSync(root + '@feature'), 'empty parent cleaned up')
|
|
279
|
+
t.is(git('-C', root + '@plain', 'symbolic-ref', '--short', 'HEAD'), 'plain')
|
|
280
|
+
ok('rename', 'owner/repo@plain', 'owner/repo@deep/nest')
|
|
281
|
+
t.is(git('-C', root + '@deep/nest', 'symbolic-ref', '--short', 'HEAD'), 'deep/nest')
|
|
282
|
+
|
|
283
|
+
const before = ok('list')
|
|
284
|
+
t.is(cli('rename', 'owner/repo@deep/nest', 'owner/repo@taken').status, 1, 'existing branch')
|
|
285
|
+
t.is(cli('rename', 'owner/repo@deep/nest', 'other/repo@x').status, 1, 'cross repository')
|
|
286
|
+
t.is(cli('rename', 'owner/repo@deep/nest', 'owner/repo').status, 1, 'root target')
|
|
287
|
+
t.is(cli('rename', 'owner/repo', 'owner/repo@x').status, 1, 'root source')
|
|
288
|
+
t.is(cli('rename', 'owner/repo@missing', 'owner/repo@x').status, 1, 'absent worktree')
|
|
289
|
+
git('-C', root + '@deep/nest', 'checkout', '--detach')
|
|
290
|
+
t.is(cli('rename', 'owner/repo@deep/nest', 'owner/repo@x').status, 1, 'detached HEAD')
|
|
291
|
+
t.is(ok('list'), before, 'manifest unchanged by refusals')
|
|
292
|
+
t.ok(fs.existsSync(root + '@deep/nest'), 'worktree preserved by refusals')
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
test('status reports sorted clean, modified, and missing checkouts without writes', (t) => {
|
|
296
|
+
const { ok, git, root, registry } = setup(t)
|
|
297
|
+
t.is(ok('status'), '', 'empty manifest is silent')
|
|
298
|
+
ok('add', 'owner/repo@z/missing')
|
|
299
|
+
ok('add', 'owner/repo@a/changed')
|
|
300
|
+
fs.writeFileSync(path.join(root, '.git/info/exclude'), 'ignored\n')
|
|
301
|
+
fs.writeFileSync(path.join(root, 'ignored'), 'ignored data')
|
|
302
|
+
const changed = root + '@a/changed'
|
|
303
|
+
const tracked = path.join(changed, 'tracked.txt')
|
|
304
|
+
fs.writeFileSync(tracked, 'changed\n')
|
|
305
|
+
fs.renameSync(root + '@z/missing', root + '@moved')
|
|
306
|
+
const before = fs.readFileSync(registry, 'utf8')
|
|
307
|
+
const index = fs.readFileSync(path.join(root, '.git/index'))
|
|
308
|
+
t.is(
|
|
309
|
+
ok('status'),
|
|
310
|
+
'owner/repo clean\nowner/repo@a/changed modified\nowner/repo@z/missing missing\n'
|
|
311
|
+
)
|
|
312
|
+
t.alike(fs.readFileSync(path.join(root, '.git/index')), index, 'root index unchanged')
|
|
313
|
+
git('-C', changed, 'add', 'tracked.txt')
|
|
314
|
+
t.ok(ok('status').includes('owner/repo@a/changed modified'), 'staged change detected')
|
|
315
|
+
git('-C', changed, 'commit', '-m', 'Change')
|
|
316
|
+
fs.writeFileSync(path.join(changed, 'untracked'), 'keep')
|
|
317
|
+
t.ok(ok('status').includes('owner/repo@a/changed modified'), 'untracked file detected')
|
|
318
|
+
t.is(fs.readFileSync(registry, 'utf8'), before, 'registry unchanged')
|
|
319
|
+
t.is(fs.readFileSync(tracked, 'utf8'), 'changed\n', 'working files unchanged')
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
test('status reports invalid existing checkouts as errors', (t) => {
|
|
323
|
+
const { cli, ok, root, registry } = setup(t)
|
|
324
|
+
ok('add', 'owner/repo')
|
|
325
|
+
fs.renameSync(path.join(root, '.git'), path.join(root, 'saved-git'))
|
|
326
|
+
const before = fs.readFileSync(registry, 'utf8')
|
|
327
|
+
const result = cli('status')
|
|
328
|
+
t.is(result.status, 1)
|
|
329
|
+
t.is(result.stdout, '', 'no misleading status output')
|
|
330
|
+
t.ok(result.stderr.startsWith('error: '))
|
|
331
|
+
t.is(fs.readFileSync(registry, 'utf8'), before)
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
test('bounded workers overlap, preserve order, and drain on failure', async (t) => {
|
|
335
|
+
const { default: mapConcurrent } = await import('../lib/map-concurrent.js')
|
|
336
|
+
let active = 0
|
|
337
|
+
let peak = 0
|
|
338
|
+
const release = []
|
|
339
|
+
let secondBatch
|
|
340
|
+
const started = new Promise((resolve) => {
|
|
341
|
+
secondBatch = resolve
|
|
342
|
+
})
|
|
343
|
+
const result = mapConcurrent([0, 1, 2, 3, 4, 5, 6, 7], async (item) => {
|
|
344
|
+
active++
|
|
345
|
+
peak = Math.max(peak, active)
|
|
346
|
+
await new Promise((resolve) => {
|
|
347
|
+
release[item] = resolve
|
|
348
|
+
if (item === 7) secondBatch()
|
|
349
|
+
})
|
|
350
|
+
active--
|
|
351
|
+
return item
|
|
352
|
+
})
|
|
353
|
+
t.is(active, 4, 'starts four concurrent checks')
|
|
354
|
+
for (const resolve of release.slice().reverse()) resolve()
|
|
355
|
+
await started
|
|
356
|
+
t.is(active, 4, 'refills workers without exceeding limit')
|
|
357
|
+
for (const resolve of release.slice(4).reverse()) resolve()
|
|
358
|
+
t.alike(await result, [0, 1, 2, 3, 4, 5, 6, 7], 'completion order does not affect output')
|
|
359
|
+
t.is(peak, 4)
|
|
360
|
+
|
|
361
|
+
let finish
|
|
362
|
+
let calls = 0
|
|
363
|
+
let drained = false
|
|
364
|
+
const failure = new Error('failed check')
|
|
365
|
+
const pending = mapConcurrent(
|
|
366
|
+
[0, 1, 2],
|
|
367
|
+
async (item) => {
|
|
368
|
+
calls++
|
|
369
|
+
if (item === 0) throw failure
|
|
370
|
+
await new Promise((resolve) => {
|
|
371
|
+
finish = resolve
|
|
372
|
+
})
|
|
373
|
+
drained = true
|
|
374
|
+
},
|
|
375
|
+
2
|
|
376
|
+
)
|
|
377
|
+
await Promise.resolve()
|
|
378
|
+
t.is(calls, 2, 'does not start queued checks after failure')
|
|
379
|
+
finish()
|
|
380
|
+
await t.exception(pending, /failed check/)
|
|
381
|
+
t.ok(drained, 'active work finishes before rejection')
|
|
382
|
+
})
|
|
383
|
+
|
|
384
|
+
test('commands require init and ignore old registry files', (t) => {
|
|
385
|
+
const { cli, home, root } = setup(t, { initialize: false })
|
|
386
|
+
fs.mkdirSync(path.join(home, '.git-workspace'))
|
|
387
|
+
fs.writeFileSync(path.join(home, '.git-workspace/worktrees.json'), JSON.stringify([root]))
|
|
388
|
+
for (const args of [['list'], ['status'], ['add', 'owner/repo'], ['remove', 'owner/repo']]) {
|
|
389
|
+
const result = cli(...args)
|
|
390
|
+
t.is(result.status, 1)
|
|
391
|
+
t.ok(result.stderr.includes('git workspace init owner/repo'))
|
|
392
|
+
}
|
|
393
|
+
t.absent(fs.existsSync(root), 'add does not clone before initialization')
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
test('init creates a local manifest without importing old registries or committing', (t) => {
|
|
397
|
+
const { cli, ok, git, home, upstream, registry } = setup(t, { initialize: false })
|
|
398
|
+
fs.mkdirSync(path.join(home, '.git-workspace'))
|
|
399
|
+
fs.writeFileSync(path.join(home, '.git-workspace/worktrees.json'), '["/old/workspace/repo"]')
|
|
400
|
+
const result = cli('init', 'owner/workspace')
|
|
401
|
+
t.is(result.status, 0)
|
|
402
|
+
t.ok(result.stderr.includes('initialized locally'))
|
|
403
|
+
const directory = path.dirname(registry)
|
|
404
|
+
t.is(
|
|
405
|
+
git('-C', directory, 'config', '--get', 'remote.origin.url'),
|
|
406
|
+
'git@github.com:owner/workspace.git'
|
|
407
|
+
)
|
|
408
|
+
t.alike(JSON.parse(fs.readFileSync(registry, 'utf8')), [], 'new manifest starts empty')
|
|
409
|
+
t.is(git('-C', directory, 'status', '--porcelain'), '?? workspace.json')
|
|
410
|
+
t.alike(
|
|
411
|
+
JSON.parse(fs.readFileSync(path.join(home, '.config/git-workspace/config.json'), 'utf8')),
|
|
412
|
+
{ manifestRepository: directory },
|
|
413
|
+
'config points to repo in workspace'
|
|
414
|
+
)
|
|
415
|
+
t.absent(fs.existsSync(path.join(upstream, 'owner/workspace.git')), 'no remote created')
|
|
416
|
+
ok('add', 'owner/repo@feature/jobs')
|
|
417
|
+
t.alike(JSON.parse(fs.readFileSync(registry, 'utf8')), ['owner/repo', 'owner/repo@feature/jobs'])
|
|
418
|
+
ok('remove', 'owner/repo@feature/jobs')
|
|
419
|
+
ok('init', 'owner/workspace')
|
|
420
|
+
t.alike(
|
|
421
|
+
JSON.parse(fs.readFileSync(registry, 'utf8')),
|
|
422
|
+
['owner/repo'],
|
|
423
|
+
'repeat init preserves edits'
|
|
424
|
+
)
|
|
425
|
+
t.is(cli('init', 'owner/other').status, 1, 'different remote refused')
|
|
426
|
+
})
|
|
427
|
+
|
|
428
|
+
test('existing manifests are portable and not applied during init', (t) => {
|
|
429
|
+
const { cliAt, ok, git, home, upstream, registry } = setup(t, { initialize: false })
|
|
430
|
+
const source = path.join(home, 'manifest-source')
|
|
431
|
+
git('init', source)
|
|
432
|
+
const entries = ['owner/repo', 'owner/repo@feature/jobs']
|
|
433
|
+
fs.writeFileSync(path.join(source, 'workspace.json'), JSON.stringify(entries))
|
|
434
|
+
git('-C', source, 'add', 'workspace.json')
|
|
435
|
+
git('-C', source, 'commit', '-m', 'Manifest fixture')
|
|
436
|
+
git('clone', '--bare', source, path.join(upstream, 'owner/workspace.git'))
|
|
437
|
+
ok('init', 'owner/workspace')
|
|
438
|
+
t.is(ok('list'), entries.join('\n') + '\n')
|
|
439
|
+
t.absent(fs.existsSync(path.join(home, 'workspace/owner/repo')), 'no managed repos cloned')
|
|
440
|
+
t.is(git('-C', path.dirname(registry), 'status', '--porcelain'), '', 'clone unchanged')
|
|
441
|
+
const secondHome = path.join(home, 'second-machine')
|
|
442
|
+
fs.mkdirSync(secondHome)
|
|
443
|
+
t.is(cliAt(secondHome, 'init', 'owner/workspace').status, 0)
|
|
444
|
+
t.is(cliAt(secondHome, 'list').stdout, entries.join('\n') + '\n')
|
|
445
|
+
t.ok(
|
|
446
|
+
cliAt(secondHome, 'status')
|
|
447
|
+
.stdout.split('\n')
|
|
448
|
+
.filter(Boolean)
|
|
449
|
+
.every((line) => line.endsWith('missing'))
|
|
450
|
+
)
|
|
451
|
+
})
|
|
452
|
+
|
|
453
|
+
test('invalid manifests fail without being overwritten', (t) => {
|
|
454
|
+
const { cli, git, home, upstream, registry } = setup(t, { initialize: false })
|
|
455
|
+
t.is(cli('init', 'owner/workspace@branch').status, 1)
|
|
456
|
+
const source = path.join(home, 'bad-manifest')
|
|
457
|
+
git('init', source)
|
|
458
|
+
const invalid = '["/another/machine/path"]'
|
|
459
|
+
fs.writeFileSync(path.join(source, 'workspace.json'), invalid)
|
|
460
|
+
git('-C', source, 'add', 'workspace.json')
|
|
461
|
+
git('-C', source, 'commit', '-m', 'Invalid manifest')
|
|
462
|
+
git('clone', '--bare', source, path.join(upstream, 'owner/workspace.git'))
|
|
463
|
+
t.is(cli('init', 'owner/workspace').status, 1)
|
|
464
|
+
t.is(cli('list').status, 1)
|
|
465
|
+
t.is(fs.readFileSync(registry, 'utf8'), invalid)
|
|
466
|
+
})
|
|
467
|
+
|
|
468
|
+
test('active manifest repository cannot be removed', (t) => {
|
|
469
|
+
const { cli, ok, git, registry } = setup(t)
|
|
470
|
+
ok('add', 'owner/workspace')
|
|
471
|
+
const directory = path.dirname(registry)
|
|
472
|
+
git('-C', directory, 'add', 'workspace.json')
|
|
473
|
+
git('-C', directory, 'commit', '-m', 'Manifest')
|
|
474
|
+
const before = fs.readFileSync(registry, 'utf8')
|
|
475
|
+
const result = cli('remove', 'owner/workspace')
|
|
476
|
+
t.is(result.status, 1)
|
|
477
|
+
t.ok(result.stderr.includes('Cannot remove the active manifest repository'))
|
|
478
|
+
t.is(fs.readFileSync(registry, 'utf8'), before)
|
|
479
|
+
})
|