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/README.md +279 -17
- 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/man/man1/git-workspace.1 +240 -0
- package/package.json +6 -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/lib/rename-root.js
CHANGED
|
@@ -5,7 +5,7 @@ import { manifestDirectory, updateRegistry } from './registry.js'
|
|
|
5
5
|
import repositoryPath from './repository-path.js'
|
|
6
6
|
import removeEmptyParents from './remove-empty-parents.js'
|
|
7
7
|
|
|
8
|
-
export default function renameRoot(from, to) {
|
|
8
|
+
export default function renameRoot(from, to, { update = updateRegistry } = {}) {
|
|
9
9
|
const source = repositoryPath(from)
|
|
10
10
|
const destination = repositoryPath(to)
|
|
11
11
|
if (from === to) throw new Error(`${to} is already the registered name`)
|
|
@@ -13,101 +13,100 @@ export default function renameRoot(from, to) {
|
|
|
13
13
|
const moved = []
|
|
14
14
|
let originalConfig
|
|
15
15
|
try {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
const gitDirectory = path.join(canonical, '.git')
|
|
24
|
-
if (
|
|
25
|
-
fs.lstatSync(source).isSymbolicLink() ||
|
|
26
|
-
!fs.lstatSync(gitDirectory).isDirectory() ||
|
|
27
|
-
fs.realpathSync(git(source, 'rev-parse', '--show-toplevel')) !== canonical ||
|
|
28
|
-
fs.realpathSync(git(source, 'rev-parse', '--path-format=absolute', '--git-common-dir')) !==
|
|
29
|
-
gitDirectory
|
|
30
|
-
) {
|
|
31
|
-
throw new Error('Source is not a standalone root repository')
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
const urls = configValues(source, 'remote.origin.url')
|
|
35
|
-
if (urls.length !== 1 || !matches(urls[0], from, to)) {
|
|
36
|
-
throw new Error(`Source origin does not match ${from} or ${to}`)
|
|
37
|
-
}
|
|
38
|
-
const pushurls = configValues(source, 'remote.origin.pushurl')
|
|
39
|
-
const entries = git(source, 'worktree', 'list', '--porcelain', '-z')
|
|
40
|
-
.split('\0\0')
|
|
41
|
-
.filter(Boolean)
|
|
42
|
-
.map((entry) => entry.split('\0'))
|
|
43
|
-
const moves = [[source, destination]]
|
|
44
|
-
for (const entry of entries.slice(1)) {
|
|
45
|
-
const directory = entry[0].slice('worktree '.length)
|
|
46
|
-
if (!exists(directory)) {
|
|
47
|
-
throw new Error(`Missing linked worktree: ${directory}; repair or prune it first`)
|
|
16
|
+
update(
|
|
17
|
+
(worktrees) => {
|
|
18
|
+
if (!worktrees.includes(source)) throw new Error(`${from} is not registered`)
|
|
19
|
+
if (!exists(source)) throw new Error(`${from} is not checked out`)
|
|
20
|
+
const canonical = fs.realpathSync(source)
|
|
21
|
+
if (canonical === fs.realpathSync(manifestDirectory())) {
|
|
22
|
+
throw new Error('Cannot rename the active manifest repository')
|
|
48
23
|
}
|
|
24
|
+
const gitDirectory = path.join(canonical, '.git')
|
|
49
25
|
if (
|
|
50
|
-
fs.
|
|
51
|
-
|
|
26
|
+
fs.lstatSync(source).isSymbolicLink() ||
|
|
27
|
+
!fs.lstatSync(gitDirectory).isDirectory() ||
|
|
28
|
+
fs.realpathSync(git(source, 'rev-parse', '--show-toplevel')) !== canonical ||
|
|
52
29
|
fs.realpathSync(
|
|
53
|
-
git(
|
|
30
|
+
git(source, 'rev-parse', '--path-format=absolute', '--git-common-dir')
|
|
54
31
|
) !== gitDirectory
|
|
55
32
|
) {
|
|
56
|
-
throw new Error(
|
|
33
|
+
throw new Error('Source is not a standalone root repository')
|
|
57
34
|
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
.some((entry) => entry.startsWith('160000 '))
|
|
63
|
-
) {
|
|
64
|
-
throw new Error(`Linked worktree contains submodules: ${directory}`)
|
|
65
|
-
}
|
|
66
|
-
if (!directory.startsWith(source + '@')) continue
|
|
67
|
-
if (entry.some((field) => field === 'locked' || field.startsWith('locked '))) {
|
|
68
|
-
throw new Error(`Linked worktree is locked: ${directory}`)
|
|
35
|
+
|
|
36
|
+
const urls = configValues(source, 'remote.origin.url')
|
|
37
|
+
if (urls.length !== 1 || !matches(urls[0], from, to)) {
|
|
38
|
+
throw new Error(`Source origin does not match ${from} or ${to}`)
|
|
69
39
|
}
|
|
70
|
-
|
|
71
|
-
|
|
40
|
+
const pushurls = configValues(source, 'remote.origin.pushurl')
|
|
41
|
+
const entries = git(source, 'worktree', 'list', '--porcelain', '-z')
|
|
42
|
+
.split('\0\0')
|
|
43
|
+
.filter(Boolean)
|
|
44
|
+
.map((entry) => entry.split('\0'))
|
|
45
|
+
const moves = [[source, destination]]
|
|
46
|
+
for (const entry of entries.slice(1)) {
|
|
47
|
+
const directory = entry[0].slice('worktree '.length)
|
|
48
|
+
if (!exists(directory)) {
|
|
49
|
+
throw new Error(`Missing linked worktree: ${directory}; repair or prune it first`)
|
|
50
|
+
}
|
|
51
|
+
if (
|
|
52
|
+
fs.realpathSync(git(directory, 'rev-parse', '--show-toplevel')) !==
|
|
53
|
+
fs.realpathSync(directory) ||
|
|
54
|
+
fs.realpathSync(
|
|
55
|
+
git(directory, 'rev-parse', '--path-format=absolute', '--git-common-dir')
|
|
56
|
+
) !== gitDirectory
|
|
57
|
+
) {
|
|
58
|
+
throw new Error(`Linked worktree needs repair: ${directory}`)
|
|
59
|
+
}
|
|
60
|
+
// Worktree repair does not repair the separate Git links inside submodules.
|
|
61
|
+
if (
|
|
62
|
+
git(directory, 'ls-files', '--stage', '-z')
|
|
63
|
+
.split('\0')
|
|
64
|
+
.some((entry) => entry.startsWith('160000 '))
|
|
65
|
+
) {
|
|
66
|
+
throw new Error(`Linked worktree contains submodules: ${directory}`)
|
|
67
|
+
}
|
|
68
|
+
if (!directory.startsWith(source + '@')) continue
|
|
69
|
+
if (entry.some((field) => field === 'locked' || field.startsWith('locked '))) {
|
|
70
|
+
throw new Error(`Linked worktree is locked: ${directory}`)
|
|
71
|
+
}
|
|
72
|
+
if (fs.lstatSync(directory).isSymbolicLink()) {
|
|
73
|
+
throw new Error(`Linked worktree is a symbolic link: ${directory}`)
|
|
74
|
+
}
|
|
75
|
+
moves.push([directory, destination + directory.slice(source.length)])
|
|
72
76
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
)
|
|
77
|
+
const mapping = new Map(moves)
|
|
78
|
+
for (const directory of worktrees) {
|
|
79
|
+
if (directory.startsWith(source + '@') && !mapping.has(directory)) {
|
|
80
|
+
throw new Error(
|
|
81
|
+
`Registered worktree is missing or does not belong to ${from}: ${directory}`
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
if (directory === destination || directory.startsWith(destination + '@')) {
|
|
85
|
+
throw new Error(`${to} already has registered checkouts`)
|
|
86
|
+
}
|
|
81
87
|
}
|
|
82
|
-
|
|
83
|
-
throw new Error(`${
|
|
88
|
+
for (const [, target] of moves) {
|
|
89
|
+
if (exists(target)) throw new Error(`${target} already exists`)
|
|
90
|
+
// Resolve parents before any moves, including nested branch destinations.
|
|
91
|
+
fs.mkdirSync(path.dirname(target), { recursive: true })
|
|
84
92
|
}
|
|
85
|
-
}
|
|
86
|
-
for (const [, target] of moves) {
|
|
87
|
-
if (exists(target)) throw new Error(`${target} already exists`)
|
|
88
|
-
// Resolve parents before any moves, including nested branch destinations.
|
|
89
|
-
fs.mkdirSync(path.dirname(target), { recursive: true })
|
|
90
|
-
}
|
|
91
93
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
}
|
|
97
|
-
const linked = entries.slice(1).map((entry) => {
|
|
98
|
-
const directory = entry[0].slice('worktree '.length)
|
|
99
|
-
return mapping.get(directory) || directory
|
|
100
|
-
})
|
|
101
|
-
git(destination, 'worktree', 'repair', ...linked)
|
|
102
|
-
git(destination, 'config', 'remote.origin.url', replaceURL(urls[0], from, to))
|
|
103
|
-
if (pushurls.length) {
|
|
104
|
-
git(destination, 'config', '--unset-all', 'remote.origin.pushurl')
|
|
105
|
-
for (const url of pushurls) {
|
|
106
|
-
git(destination, 'config', '--add', 'remote.origin.pushurl', replaceURL(url, from, to))
|
|
94
|
+
originalConfig = fs.readFileSync(path.join(source, '.git', 'config'))
|
|
95
|
+
for (const [oldPath, newPath] of moves) {
|
|
96
|
+
fs.renameSync(oldPath, newPath)
|
|
97
|
+
moved.push([oldPath, newPath])
|
|
107
98
|
}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
99
|
+
const linked = entries.slice(1).map((entry) => {
|
|
100
|
+
const directory = entry[0].slice('worktree '.length)
|
|
101
|
+
return mapping.get(directory) || directory
|
|
102
|
+
})
|
|
103
|
+
git(destination, 'worktree', 'repair', ...linked)
|
|
104
|
+
git(destination, 'config', 'remote.origin.url', replaceURL(urls[0], from, to))
|
|
105
|
+
replacePushURLs(destination, pushurls, from, to)
|
|
106
|
+
return worktrees.map((directory) => mapping.get(directory) || directory)
|
|
107
|
+
},
|
|
108
|
+
{ rename: { from, to } }
|
|
109
|
+
)
|
|
111
110
|
} catch (err) {
|
|
112
111
|
if (moved.length) {
|
|
113
112
|
try {
|
|
@@ -126,6 +125,105 @@ export default function renameRoot(from, to) {
|
|
|
126
125
|
)
|
|
127
126
|
}
|
|
128
127
|
|
|
128
|
+
// Finish a root move interrupted before Git links or origin were repaired.
|
|
129
|
+
// Validate the on-disk Git pointers before moving or repairing any worktree.
|
|
130
|
+
export function finishRootRename(from, to, registered) {
|
|
131
|
+
const source = repositoryPath(from)
|
|
132
|
+
const destination = repositoryPath(to)
|
|
133
|
+
if (exists(source)) throw new Error('Original root still exists; cannot finish its move')
|
|
134
|
+
if (fs.lstatSync(destination).isSymbolicLink()) throw new Error('Renamed root is a symbolic link')
|
|
135
|
+
const common = path.join(destination, '.git')
|
|
136
|
+
if (
|
|
137
|
+
!fs.lstatSync(common).isDirectory() ||
|
|
138
|
+
fs.realpathSync(git(destination, 'rev-parse', '--path-format=absolute', '--git-common-dir')) !==
|
|
139
|
+
fs.realpathSync(common) ||
|
|
140
|
+
fs.realpathSync(git(destination, 'rev-parse', '--show-toplevel')) !==
|
|
141
|
+
fs.realpathSync(destination)
|
|
142
|
+
) {
|
|
143
|
+
throw new Error('Renamed destination is not a standalone root repository')
|
|
144
|
+
}
|
|
145
|
+
if (fs.realpathSync(destination) === fs.realpathSync(manifestDirectory())) {
|
|
146
|
+
throw new Error('Cannot rename the active manifest repository')
|
|
147
|
+
}
|
|
148
|
+
const urls = configValues(destination, 'remote.origin.url')
|
|
149
|
+
if (urls.length !== 1 || !matches(urls[0], from, to)) {
|
|
150
|
+
throw new Error('Renamed root has an unexpected origin')
|
|
151
|
+
}
|
|
152
|
+
const pushurls = configValues(destination, 'remote.origin.pushurl')
|
|
153
|
+
const entries = git(destination, 'worktree', 'list', '--porcelain', '-z')
|
|
154
|
+
.split('\0\0')
|
|
155
|
+
.filter(Boolean)
|
|
156
|
+
.slice(1)
|
|
157
|
+
.map((entry) => entry.split('\0'))
|
|
158
|
+
const moves = entries.map((entry) => {
|
|
159
|
+
const oldPath = entry[0].slice('worktree '.length)
|
|
160
|
+
const owned = oldPath.startsWith(source + '@') || oldPath.startsWith(destination + '@')
|
|
161
|
+
const target = oldPath.startsWith(source + '@')
|
|
162
|
+
? destination + oldPath.slice(source.length)
|
|
163
|
+
: oldPath
|
|
164
|
+
if (owned && entry.some((field) => field === 'locked' || field.startsWith('locked '))) {
|
|
165
|
+
throw new Error(`Linked worktree is locked: ${oldPath}`)
|
|
166
|
+
}
|
|
167
|
+
if (oldPath !== target && exists(oldPath) && exists(target)) {
|
|
168
|
+
throw new Error(`Both worktree paths exist: ${oldPath} and ${target}`)
|
|
169
|
+
}
|
|
170
|
+
const actual = exists(target) ? target : oldPath
|
|
171
|
+
if (!exists(actual) || fs.lstatSync(actual).isSymbolicLink()) {
|
|
172
|
+
throw new Error(`Missing or symbolic linked worktree: ${actual}`)
|
|
173
|
+
}
|
|
174
|
+
const pointerFile = path.join(actual, '.git')
|
|
175
|
+
if (!fs.lstatSync(pointerFile).isFile()) throw new Error(`Invalid Git pointer: ${actual}`)
|
|
176
|
+
const pointer = /^gitdir: (.+)\r?\n?$/.exec(fs.readFileSync(pointerFile, 'utf8'))
|
|
177
|
+
if (!pointer) throw new Error(`Invalid Git pointer: ${actual}`)
|
|
178
|
+
let metadata = path.resolve(actual, pointer[1])
|
|
179
|
+
if (metadata.startsWith(path.join(source, '.git/worktrees') + path.sep)) {
|
|
180
|
+
metadata = common + metadata.slice(path.join(source, '.git').length)
|
|
181
|
+
}
|
|
182
|
+
if (path.dirname(metadata) !== path.join(common, 'worktrees')) {
|
|
183
|
+
throw new Error(`Worktree belongs to another repository: ${actual}`)
|
|
184
|
+
}
|
|
185
|
+
const backlink = fs.readFileSync(path.join(metadata, 'gitdir'), 'utf8').trim()
|
|
186
|
+
if (![path.join(oldPath, '.git'), path.join(target, '.git')].includes(backlink)) {
|
|
187
|
+
throw new Error(`Unexpected worktree backlink: ${actual}`)
|
|
188
|
+
}
|
|
189
|
+
return [actual, target]
|
|
190
|
+
})
|
|
191
|
+
for (const directory of registered.filter((name) => name.startsWith(source + '@'))) {
|
|
192
|
+
if (!moves.some(([, target]) => target === destination + directory.slice(source.length))) {
|
|
193
|
+
throw new Error(`Missing registered worktree: ${directory}`)
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
for (const [actual, target] of moves) {
|
|
197
|
+
if (actual === target) continue
|
|
198
|
+
fs.mkdirSync(path.dirname(target), { recursive: true })
|
|
199
|
+
fs.renameSync(actual, target)
|
|
200
|
+
}
|
|
201
|
+
git(destination, 'worktree', 'repair', ...moves.map(([, target]) => target))
|
|
202
|
+
git(destination, 'config', 'remote.origin.url', replaceURL(urls[0], from, to))
|
|
203
|
+
replacePushURLs(destination, pushurls, from, to)
|
|
204
|
+
for (const directory of registered.filter((name) => name.startsWith(source + '@'))) {
|
|
205
|
+
removeEmptyParents(directory, path.dirname(source))
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Set each push URL in place, so an interruption cannot lose the remaining URLs.
|
|
210
|
+
function replacePushURLs(destination, pushurls, from, to) {
|
|
211
|
+
for (const url of pushurls) {
|
|
212
|
+
const replacement = replaceURL(url, from, to)
|
|
213
|
+
if (replacement !== url) {
|
|
214
|
+
git(
|
|
215
|
+
destination,
|
|
216
|
+
'config',
|
|
217
|
+
'--fixed-value',
|
|
218
|
+
'--replace-all',
|
|
219
|
+
'remote.origin.pushurl',
|
|
220
|
+
replacement,
|
|
221
|
+
url
|
|
222
|
+
)
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
129
227
|
function matches(url, from, to) {
|
|
130
228
|
const match = parseURL(url)
|
|
131
229
|
return match && [from.toLowerCase(), to.toLowerCase()].includes(match[2].toLowerCase())
|
package/lib/rename.js
CHANGED
|
@@ -1,18 +1,38 @@
|
|
|
1
1
|
import fs from 'bare-fs'
|
|
2
2
|
import path from 'bare-path'
|
|
3
3
|
import { spawnSync } from 'bare-subprocess'
|
|
4
|
-
import { updateRegistry } from './registry.js'
|
|
4
|
+
import { manifestDirectory, updateRegistry } from './registry.js'
|
|
5
5
|
import repositoryPath from './repository-path.js'
|
|
6
6
|
import checkedOutBranch from './checked-out-branch.js'
|
|
7
7
|
import removeEmptyParents from './remove-empty-parents.js'
|
|
8
8
|
import renameRoot from './rename-root.js'
|
|
9
|
+
import commitManifest from './commit-manifest.js'
|
|
10
|
+
import { refreshCheckpoint } from './checkpoint.js'
|
|
11
|
+
import { assertNoPendingPull } from './pull-state.js'
|
|
12
|
+
import { withPostHooks } from './hook-context.js'
|
|
9
13
|
|
|
10
|
-
export default function rename(from, to) {
|
|
14
|
+
export default function rename(from, to, options = {}) {
|
|
15
|
+
assertNoPendingPull()
|
|
16
|
+
return withPostHooks('rename', { from, to }, options, () => {
|
|
17
|
+
renameCheckout(from, to)
|
|
18
|
+
try {
|
|
19
|
+
commitManifest(manifestDirectory(), `Rename ${from} to ${to}`)
|
|
20
|
+
} catch (err) {
|
|
21
|
+
err.message = `Renamed ${from} to ${to}, but could not commit the manifest: ${err.message}`
|
|
22
|
+
err.hint =
|
|
23
|
+
'the rename is already complete; resolve the Git error and commit only workspace.json in the manifest repository'
|
|
24
|
+
throw err
|
|
25
|
+
}
|
|
26
|
+
refreshCheckpoint()
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function renameCheckout(from, to, { update = updateRegistry } = {}) {
|
|
11
31
|
const source = repositoryPath(from)
|
|
12
32
|
const destination = repositoryPath(to)
|
|
13
33
|
const [base, requested] = from.split('@')
|
|
14
34
|
const [target, branch] = to.split('@')
|
|
15
|
-
if (!requested && !branch) return renameRoot(from, to)
|
|
35
|
+
if (!requested && !branch) return renameRoot(from, to, { update })
|
|
16
36
|
if (!requested || !branch) {
|
|
17
37
|
throw new Error('Use owner/repo on both sides, or owner/repo@branch on both sides')
|
|
18
38
|
}
|
|
@@ -55,20 +75,76 @@ export default function rename(from, to) {
|
|
|
55
75
|
throw new Error(`${destination} already exists`)
|
|
56
76
|
}
|
|
57
77
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
git(['-C', root, '
|
|
67
|
-
|
|
78
|
+
update(
|
|
79
|
+
(worktrees) => {
|
|
80
|
+
const registered = worktrees.includes(source) ? source : canonical
|
|
81
|
+
if (!worktrees.includes(registered)) {
|
|
82
|
+
throw new Error(`${from} is not registered`)
|
|
83
|
+
}
|
|
84
|
+
if (worktrees.includes(destination)) throw new Error(`${to} is already registered`)
|
|
85
|
+
fs.mkdirSync(path.dirname(destination), { recursive: true })
|
|
86
|
+
git(['-C', root, 'worktree', 'move', canonical, destination])
|
|
87
|
+
if (current !== branch) {
|
|
88
|
+
git(['-C', root, 'branch', '-m', current, branch])
|
|
89
|
+
retargetUpstream(root, branch)
|
|
90
|
+
}
|
|
91
|
+
removeEmptyParents(source, path.dirname(root))
|
|
92
|
+
return worktrees.map((worktree) => (worktree === registered ? destination : worktree))
|
|
93
|
+
},
|
|
94
|
+
{ rename: { from, to } }
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function finishBranchRename(from, to) {
|
|
99
|
+
const [base, original] = from.split('@')
|
|
100
|
+
const [target, branch] = to.split('@')
|
|
101
|
+
if (!original || !branch || base !== target) throw new Error('Invalid interrupted branch rename')
|
|
102
|
+
const root = repositoryPath(base)
|
|
103
|
+
const destination = repositoryPath(to)
|
|
104
|
+
if (fs.lstatSync(destination).isSymbolicLink()) {
|
|
105
|
+
throw new Error('Renamed worktree is a symbolic link')
|
|
106
|
+
}
|
|
107
|
+
const common = (directory) =>
|
|
108
|
+
fs.realpathSync(
|
|
109
|
+
git(['-C', directory, 'rev-parse', '--path-format=absolute', '--git-common-dir'])
|
|
110
|
+
)
|
|
111
|
+
if (
|
|
112
|
+
fs.realpathSync(git(['-C', destination, 'rev-parse', '--show-toplevel'])) !==
|
|
113
|
+
fs.realpathSync(destination) ||
|
|
114
|
+
common(root) !== common(destination)
|
|
115
|
+
) {
|
|
116
|
+
throw new Error('Renamed destination is not a worktree of the requested repository')
|
|
117
|
+
}
|
|
118
|
+
const current = checkedOutBranch(destination)
|
|
119
|
+
if (current === original) {
|
|
120
|
+
if (hasRef(root, `refs/heads/${branch}`)) throw new Error(`Branch ${branch} already exists`)
|
|
121
|
+
git(['-C', root, 'branch', '-m', original, branch])
|
|
122
|
+
} else if (current !== branch) {
|
|
123
|
+
throw new Error(`Renamed worktree is on an unexpected branch: ${current || 'detached HEAD'}`)
|
|
124
|
+
}
|
|
125
|
+
if (hasRef(root, `refs/heads/${original}`)) {
|
|
126
|
+
throw new Error(
|
|
127
|
+
`Original branch ${original} still exists after the rename; inspect it before retrying`
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
// Git can rename the ref before failing to rename the config section.
|
|
131
|
+
if (!hasRef(root, `refs/heads/${original}`)) {
|
|
132
|
+
const section = spawnSync('git', [
|
|
133
|
+
'-C',
|
|
134
|
+
root,
|
|
135
|
+
'config',
|
|
136
|
+
'--get-regexp',
|
|
137
|
+
`^branch\\.${original.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\.`
|
|
138
|
+
])
|
|
139
|
+
if (section.error) throw section.error
|
|
140
|
+
if (section.status === 0) {
|
|
141
|
+
git(['-C', root, 'config', '--rename-section', `branch.${original}`, `branch.${branch}`])
|
|
142
|
+
} else if (section.status !== 1) {
|
|
143
|
+
throw new Error(section.stderr.toString().trim() || 'Could not inspect branch config')
|
|
68
144
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
145
|
+
}
|
|
146
|
+
retargetUpstream(root, branch)
|
|
147
|
+
removeEmptyParents(repositoryPath(from), path.dirname(root))
|
|
72
148
|
}
|
|
73
149
|
|
|
74
150
|
function retargetUpstream(root, branch) {
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
.TH GIT-WORKSPACE 1 "November 8, 2026" "git-workspace-bare" "Git Workspace Manual"
|
|
2
|
+
.SH NAME
|
|
3
|
+
git-workspace \- manage Git repositories and branch worktrees
|
|
4
|
+
.SH SYNOPSIS
|
|
5
|
+
.B git workspace init
|
|
6
|
+
.I owner/repo
|
|
7
|
+
.br
|
|
8
|
+
.B git workspace add
|
|
9
|
+
.RB [ --no-hooks ]
|
|
10
|
+
.I owner/repo[@branch]
|
|
11
|
+
.br
|
|
12
|
+
.B git workspace remove
|
|
13
|
+
.RB [ --no-hooks ]
|
|
14
|
+
.I owner/repo[@branch]
|
|
15
|
+
.br
|
|
16
|
+
.B git workspace rename
|
|
17
|
+
.RB [ --no-hooks ]
|
|
18
|
+
.I from to
|
|
19
|
+
.br
|
|
20
|
+
.B git workspace list
|
|
21
|
+
.br
|
|
22
|
+
.B git workspace status
|
|
23
|
+
.br
|
|
24
|
+
.B git workspace pull
|
|
25
|
+
.RB [ --dry-run ] " [" --no-hooks ]
|
|
26
|
+
.br
|
|
27
|
+
.B git workspace hook list
|
|
28
|
+
.br
|
|
29
|
+
.B git workspace completion bash
|
|
30
|
+
.br
|
|
31
|
+
.B git workspace
|
|
32
|
+
.RB [ -h " | " --version ]
|
|
33
|
+
.SH DESCRIPTION
|
|
34
|
+
.B git workspace
|
|
35
|
+
manages root repositories under
|
|
36
|
+
.I ~/workspace/owner/repo
|
|
37
|
+
and linked worktrees under
|
|
38
|
+
.IR ~/workspace/owner/repo@branch .
|
|
39
|
+
Use an at sign to separate the repository name from its branch name.
|
|
40
|
+
Branches can contain slashes, for example
|
|
41
|
+
.IR owner/repo@feature/jobs .
|
|
42
|
+
.PP
|
|
43
|
+
A Git repository containing
|
|
44
|
+
.I workspace.json
|
|
45
|
+
stores the portable workspace manifest.
|
|
46
|
+
Repositories and worktrees have stable IDs so that renames can be applied on
|
|
47
|
+
other machines without reserving old names.
|
|
48
|
+
Run
|
|
49
|
+
.B init
|
|
50
|
+
before managing checkouts.
|
|
51
|
+
.PP
|
|
52
|
+
The executable can also be invoked directly as
|
|
53
|
+
.BR git-workspace .
|
|
54
|
+
Bare and Git must be available on
|
|
55
|
+
.BR PATH .
|
|
56
|
+
.SH COMMANDS
|
|
57
|
+
.TP
|
|
58
|
+
.BI "init " owner/repo
|
|
59
|
+
Clone the manifest repository from GitHub over SSH.
|
|
60
|
+
If cloning fails and the destination is absent or empty, initialize it locally
|
|
61
|
+
and configure its origin URL instead.
|
|
62
|
+
Creating the repository on GitHub remains manual.
|
|
63
|
+
Create and commit an empty version-1 manifest when none exists, save the local
|
|
64
|
+
configuration, and record the applied workspace state.
|
|
65
|
+
An existing manifest is validated and preserved; its checkouts are not created.
|
|
66
|
+
.TP
|
|
67
|
+
.BI "add " owner/repo[@branch]
|
|
68
|
+
Clone or initialize a root repository, or create a linked branch worktree.
|
|
69
|
+
Adding a worktree also registers its root.
|
|
70
|
+
Reuse an existing local branch, track a matching origin branch, or create a new
|
|
71
|
+
branch from the root's HEAD without tracking another branch.
|
|
72
|
+
An empty root needs its first commit before a worktree can be created.
|
|
73
|
+
Existing destinations must belong to the requested repository and branch.
|
|
74
|
+
Repeating add does not fetch, switch branches, or duplicate registrations.
|
|
75
|
+
.TP
|
|
76
|
+
.BI "remove " owner/repo[@branch]
|
|
77
|
+
Remove the checkout and its manifest entry.
|
|
78
|
+
A root must have the expected origin, no local changes or untracked files, and
|
|
79
|
+
no linked worktrees.
|
|
80
|
+
The active manifest repository cannot be removed.
|
|
81
|
+
Worktree removal deletes its actual checked-out branch, even if unmerged;
|
|
82
|
+
a detached worktree has no branch to delete.
|
|
83
|
+
Dirty and locked worktrees are refused.
|
|
84
|
+
Ignored files are deleted with the checkout.
|
|
85
|
+
Remote project branches and repositories are not deleted.
|
|
86
|
+
.TP
|
|
87
|
+
.BI "rename " "from to"
|
|
88
|
+
Rename a root repository or a branch worktree and retain its stable ID.
|
|
89
|
+
A root rename moves its linked worktrees, repairs Git links, and updates
|
|
90
|
+
matching
|
|
91
|
+
origin URLs to the new owner/repository name.
|
|
92
|
+
A branch worktree rename also renames its local branch.
|
|
93
|
+
Running work and uncommitted files are preserved.
|
|
94
|
+
The repository is not renamed or transferred on GitHub; do that separately.
|
|
95
|
+
.TP
|
|
96
|
+
.B list
|
|
97
|
+
Print registered repository and worktree names, one per line.
|
|
98
|
+
.TP
|
|
99
|
+
.B status
|
|
100
|
+
Show each registered checkout as clean, modified, or missing.
|
|
101
|
+
.TP
|
|
102
|
+
.B pull
|
|
103
|
+
Fetch and fast-forward the manifest from its configured upstream, then apply
|
|
104
|
+
additions, renames, and clean removals to this machine's layout.
|
|
105
|
+
Without an upstream, use the current branch name on origin.
|
|
106
|
+
Require a clean manifest checkout and refuse divergent manifest histories.
|
|
107
|
+
Preserve dirty work in retained checkouts; refuse unsafe removals and occupied
|
|
108
|
+
destinations.
|
|
109
|
+
Existing project repositories are not fetched or pulled.
|
|
110
|
+
Interrupted operations are recorded locally and resumed by the next pull.
|
|
111
|
+
Other manifest mutations are blocked while a pull is pending.
|
|
112
|
+
Pull creates no manifest commits and pushes nothing.
|
|
113
|
+
.TP
|
|
114
|
+
.B hook list
|
|
115
|
+
List discovered post hooks with their command, name, and resolved executable
|
|
116
|
+
path.
|
|
117
|
+
This command works before workspace initialization and does not execute hooks.
|
|
118
|
+
.TP
|
|
119
|
+
.B completion bash
|
|
120
|
+
Print the Bash completion script.
|
|
121
|
+
Load it in an interactive shell with:
|
|
122
|
+
.PP
|
|
123
|
+
.RS
|
|
124
|
+
.nf
|
|
125
|
+
eval "$(git-workspace completion bash)"
|
|
126
|
+
.fi
|
|
127
|
+
.RE
|
|
128
|
+
.PP
|
|
129
|
+
Also load Git's Bash completion for completion of the
|
|
130
|
+
.B git workspace
|
|
131
|
+
form.
|
|
132
|
+
.SH OPTIONS
|
|
133
|
+
.TP
|
|
134
|
+
.B \-\-no-hooks
|
|
135
|
+
Skip post hooks for add, remove, rename, or pull.
|
|
136
|
+
During pull, also discard pending hook delivery from an interrupted operation.
|
|
137
|
+
This does not disable manifest commits or pushes.
|
|
138
|
+
.TP
|
|
139
|
+
.B \-\-dry-run
|
|
140
|
+
With pull, fetch the manifest and preview layout changes and blockers without
|
|
141
|
+
applying them, advancing the applied checkpoint, or executing hooks.
|
|
142
|
+
.TP
|
|
143
|
+
.B \-h
|
|
144
|
+
Print command-line usage.
|
|
145
|
+
Use it after a command for that command's options.
|
|
146
|
+
.TP
|
|
147
|
+
.B \-\-help
|
|
148
|
+
Git handles
|
|
149
|
+
.B git workspace --help
|
|
150
|
+
as a request for this manual page.
|
|
151
|
+
.B git-workspace --help
|
|
152
|
+
prints command-line usage instead.
|
|
153
|
+
.TP
|
|
154
|
+
.B \-\-version
|
|
155
|
+
Print the package version.
|
|
156
|
+
.SH MANIFEST COMMITS AND PUSHES
|
|
157
|
+
Init commits newly created manifests.
|
|
158
|
+
Successful add, remove, and rename operations commit the current
|
|
159
|
+
.I workspace.json
|
|
160
|
+
and attempt to push the manifest branch.
|
|
161
|
+
Pending manual edits to the manifest are included; unrelated staged files remain
|
|
162
|
+
staged.
|
|
163
|
+
No empty commit is created when the manifest is unchanged.
|
|
164
|
+
.PP
|
|
165
|
+
Push to the configured upstream, or to the same branch name on origin when no
|
|
166
|
+
upstream is configured, and establish tracking on success.
|
|
167
|
+
Only the manifest branch is pushed, including its existing local commits.
|
|
168
|
+
Project branches and tags are not published.
|
|
169
|
+
Pushes never force, fetch, or merge remote history.
|
|
170
|
+
.PP
|
|
171
|
+
A push failure warns but retains the local commit and completed checkout
|
|
172
|
+
operation, allows post hooks and local applied tracking, and does not turn a
|
|
173
|
+
successful command into an error.
|
|
174
|
+
Repeat add for a registered checkout to retry pushing without another commit,
|
|
175
|
+
or push the manifest branch manually.
|
|
176
|
+
.PP
|
|
177
|
+
A commit failure reports an error and retains the local changes.
|
|
178
|
+
Add can be repeated after fixing the Git error.
|
|
179
|
+
After remove or rename, commit the manifest manually; the filesystem operation
|
|
180
|
+
is already complete.
|
|
181
|
+
.SH HOOKS
|
|
182
|
+
Executable files on PATH named
|
|
183
|
+
.I git-workspace-hook-<command>-post-<name>
|
|
184
|
+
run after add, remove, and rename, including operations performed by pull.
|
|
185
|
+
Installation on PATH enables hooks without registration or manifest
|
|
186
|
+
configuration.
|
|
187
|
+
Hooks receive the command and post as arguments and version-1 JSON on standard
|
|
188
|
+
input, including stable IDs, before/after checkout paths, and the operation
|
|
189
|
+
result.
|
|
190
|
+
Hooks also run for attempted operations that partially fail.
|
|
191
|
+
.PP
|
|
192
|
+
Hooks run sequentially and are awaited.
|
|
193
|
+
Failures warn without changing the workspace command's exit status or rolling
|
|
194
|
+
back its work.
|
|
195
|
+
Repeated delivery is possible after an interrupted pull; hooks must be
|
|
196
|
+
idempotent.
|
|
197
|
+
Help, completion, and dry runs do not execute hooks.
|
|
198
|
+
.PP
|
|
199
|
+
The optional
|
|
200
|
+
.B git-workspace-hook-tmux
|
|
201
|
+
package creates detached sessions, preserves running panes across renames, and
|
|
202
|
+
terminates managed sessions and their programs when checkouts are removed.
|
|
203
|
+
Install it with
|
|
204
|
+
.BR "npm install -g git-workspace-hook-tmux" .
|
|
205
|
+
.SH FILES
|
|
206
|
+
.TP
|
|
207
|
+
.I ~/.config/git-workspace/config.json
|
|
208
|
+
Local configuration identifying the absolute manifest repository path.
|
|
209
|
+
.TP
|
|
210
|
+
.I <manifest-repository>/workspace.json
|
|
211
|
+
Version-1 manifest containing repository names, branch worktrees, and stable
|
|
212
|
+
IDs.
|
|
213
|
+
.TP
|
|
214
|
+
.I <manifest-repository>/.git/refs/git-workspace/applied
|
|
215
|
+
Local Git ref recording the last verified applied manifest commit.
|
|
216
|
+
.TP
|
|
217
|
+
.I <manifest-repository>/.git/workspace-pull.json
|
|
218
|
+
Local progress journal used to resume an interrupted pull.
|
|
219
|
+
.SH EXAMPLES
|
|
220
|
+
.nf
|
|
221
|
+
git workspace init me/workspace
|
|
222
|
+
git workspace add holepunchto/brittle
|
|
223
|
+
git workspace add holepunchto/brittle@feature/jobs
|
|
224
|
+
git workspace rename holepunchto/brittle@feature/jobs holepunchto/brittle@fixes
|
|
225
|
+
git workspace pull --dry-run
|
|
226
|
+
git workspace pull
|
|
227
|
+
git workspace remove holepunchto/brittle@fixes
|
|
228
|
+
git workspace hook list
|
|
229
|
+
.fi
|
|
230
|
+
.SH EXIT STATUS
|
|
231
|
+
Exit 0 on success and 1 on command failure, including blockers found by pull.
|
|
232
|
+
Push failures and post-hook failures are warnings and preserve the command's
|
|
233
|
+
original exit status.
|
|
234
|
+
.SH SEE ALSO
|
|
235
|
+
.BR git (1),
|
|
236
|
+
.BR git-worktree (1)
|
|
237
|
+
.PP
|
|
238
|
+
Project documentation:
|
|
239
|
+
.UR https://github.com/nicolasfont/git-workspace-bare
|
|
240
|
+
.UE
|