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/init.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import fs from 'bare-fs'
|
|
2
|
+
import path from 'bare-path'
|
|
3
|
+
import { spawnSync } from 'bare-subprocess'
|
|
4
|
+
import repositoryPath from './repository-path.js'
|
|
5
|
+
import { configPath, manifestDirectory, readRegistry } from './registry.js'
|
|
6
|
+
|
|
7
|
+
export default function init(repository) {
|
|
8
|
+
const directory = repositoryPath(repository)
|
|
9
|
+
if (repository.includes('@')) throw new Error('Expected a manifest repository in owner/repo form')
|
|
10
|
+
|
|
11
|
+
if (fs.existsSync(configPath()) && manifestDirectory() !== directory) {
|
|
12
|
+
throw new Error('Workspace is already initialized with a different manifest repository')
|
|
13
|
+
}
|
|
14
|
+
let exists = true
|
|
15
|
+
try {
|
|
16
|
+
fs.lstatSync(directory)
|
|
17
|
+
} catch (err) {
|
|
18
|
+
if (err.code !== 'ENOENT') throw err
|
|
19
|
+
exists = false
|
|
20
|
+
}
|
|
21
|
+
if (!exists) {
|
|
22
|
+
fs.mkdirSync(path.dirname(directory), { recursive: true })
|
|
23
|
+
const remote = `git@github.com:${repository}.git`
|
|
24
|
+
const clone = spawnSync('git', ['clone', remote, directory])
|
|
25
|
+
if (clone.error) throw clone.error
|
|
26
|
+
if (clone.status !== 0) {
|
|
27
|
+
if (fs.existsSync(directory) && fs.readdirSync(directory).length > 0) {
|
|
28
|
+
throw new Error(clone.stderr.toString().trim() || 'Clone failed; destination is not empty')
|
|
29
|
+
}
|
|
30
|
+
console.error(`warning: could not clone ${repository}: ${clone.stderr.toString().trim()}`)
|
|
31
|
+
git(['init', directory])
|
|
32
|
+
git(['-C', directory, 'remote', 'add', 'origin', remote])
|
|
33
|
+
console.error('hint: initialized locally; create the remote repository and push when ready')
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (
|
|
38
|
+
fs.lstatSync(directory).isSymbolicLink() ||
|
|
39
|
+
!fs.lstatSync(path.join(directory, '.git')).isDirectory() ||
|
|
40
|
+
fs.realpathSync(git(['-C', directory, 'rev-parse', '--show-toplevel'])) !==
|
|
41
|
+
fs.realpathSync(directory)
|
|
42
|
+
) {
|
|
43
|
+
throw new Error('Manifest destination must be a standalone Git repository')
|
|
44
|
+
}
|
|
45
|
+
const origin = git(['-C', directory, 'config', '--get', 'remote.origin.url'])
|
|
46
|
+
const match =
|
|
47
|
+
/^(?:git@github\.com:|ssh:\/\/git@github\.com\/|https:\/\/github\.com\/)([^/]+\/[^/]+?)\/?$/i.exec(
|
|
48
|
+
origin
|
|
49
|
+
)
|
|
50
|
+
if (!match || match[1].replace(/\.git$/i, '').toLowerCase() !== repository.toLowerCase()) {
|
|
51
|
+
throw new Error('Manifest repository is already initialized with a different origin')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const manifest = path.join(directory, 'workspace.json')
|
|
55
|
+
let hasManifest = true
|
|
56
|
+
try {
|
|
57
|
+
fs.lstatSync(manifest)
|
|
58
|
+
} catch (err) {
|
|
59
|
+
if (err.code !== 'ENOENT') throw err
|
|
60
|
+
hasManifest = false
|
|
61
|
+
}
|
|
62
|
+
if (!hasManifest) {
|
|
63
|
+
fs.writeFileSync(manifest, '[]\n')
|
|
64
|
+
} else {
|
|
65
|
+
readRegistry(manifest)
|
|
66
|
+
}
|
|
67
|
+
fs.mkdirSync(path.dirname(configPath()), { recursive: true })
|
|
68
|
+
const lock = configPath() + '.lock'
|
|
69
|
+
fs.mkdirSync(lock)
|
|
70
|
+
const pending = path.join(lock, 'config.json')
|
|
71
|
+
try {
|
|
72
|
+
fs.writeFileSync(pending, JSON.stringify({ manifestRepository: directory }, null, 2) + '\n')
|
|
73
|
+
fs.renameSync(pending, configPath())
|
|
74
|
+
} finally {
|
|
75
|
+
if (fs.existsSync(pending)) fs.unlinkSync(pending)
|
|
76
|
+
fs.rmdirSync(lock)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function git(args, options) {
|
|
81
|
+
const result = spawnSync('git', args, options)
|
|
82
|
+
if (result.error) throw result.error
|
|
83
|
+
if (result.status !== 0) throw new Error(result.stderr?.toString().trim() || 'Git command failed')
|
|
84
|
+
return result.stdout?.toString().replace(/\r?\n$/, '')
|
|
85
|
+
}
|
package/lib/list.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export default async function mapConcurrent(items, map, limit = 4) {
|
|
2
|
+
const results = new Array(items.length)
|
|
3
|
+
let next = 0
|
|
4
|
+
let failed = false
|
|
5
|
+
let error
|
|
6
|
+
|
|
7
|
+
async function worker() {
|
|
8
|
+
while (!failed && next < items.length) {
|
|
9
|
+
const index = next++
|
|
10
|
+
try {
|
|
11
|
+
results[index] = await map(items[index])
|
|
12
|
+
} catch (err) {
|
|
13
|
+
failed = true
|
|
14
|
+
error ??= err
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker))
|
|
20
|
+
if (failed) throw error
|
|
21
|
+
return results
|
|
22
|
+
}
|
package/lib/registry.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import fs from 'bare-fs'
|
|
2
|
+
import os from 'bare-os'
|
|
3
|
+
import path from 'bare-path'
|
|
4
|
+
import repositoryPath from './repository-path.js'
|
|
5
|
+
import repositoryName from './repository-name.js'
|
|
6
|
+
|
|
7
|
+
export function configPath() {
|
|
8
|
+
return path.join(os.homedir(), '.config', 'git-workspace', 'config.json')
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function manifestDirectory() {
|
|
12
|
+
let config
|
|
13
|
+
try {
|
|
14
|
+
config = JSON.parse(fs.readFileSync(configPath(), 'utf8'))
|
|
15
|
+
} catch (err) {
|
|
16
|
+
if (err.code === 'ENOENT') {
|
|
17
|
+
throw new Error('Workspace is not initialized; run git workspace init owner/repo')
|
|
18
|
+
}
|
|
19
|
+
throw err
|
|
20
|
+
}
|
|
21
|
+
if (
|
|
22
|
+
!config ||
|
|
23
|
+
typeof config.manifestRepository !== 'string' ||
|
|
24
|
+
!path.isAbsolute(config.manifestRepository)
|
|
25
|
+
) {
|
|
26
|
+
throw new Error('Invalid workspace config: manifestRepository must be an absolute path')
|
|
27
|
+
}
|
|
28
|
+
return config.manifestRepository
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function registryPath() {
|
|
32
|
+
return path.join(manifestDirectory(), 'workspace.json')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function readRegistry(registry = registryPath()) {
|
|
36
|
+
let worktrees
|
|
37
|
+
try {
|
|
38
|
+
if (!fs.lstatSync(registry).isFile()) throw new Error('Manifest must be a regular file')
|
|
39
|
+
worktrees = JSON.parse(fs.readFileSync(registry, 'utf8'))
|
|
40
|
+
} catch (err) {
|
|
41
|
+
if (err.code === 'ENOENT') {
|
|
42
|
+
throw new Error('Workspace is not initialized; run git workspace init owner/repo')
|
|
43
|
+
}
|
|
44
|
+
throw err
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (!Array.isArray(worktrees) || !worktrees.every((entry) => typeof entry === 'string')) {
|
|
48
|
+
throw new Error('Invalid worktree registry')
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return worktrees.map(repositoryPath)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function updateRegistry(update, registry = registryPath()) {
|
|
55
|
+
const lock = path.join(manifestDirectory(), '.git', 'workspace.lock')
|
|
56
|
+
if (!fs.existsSync(path.join(manifestDirectory(), '.git'))) {
|
|
57
|
+
throw new Error('Workspace is not initialized; run git workspace init owner/repo')
|
|
58
|
+
}
|
|
59
|
+
fs.mkdirSync(lock)
|
|
60
|
+
const pending = path.join(lock, 'worktrees.json')
|
|
61
|
+
try {
|
|
62
|
+
const worktrees = update(readRegistry(registry))
|
|
63
|
+
const entries = worktrees.map(repositoryName)
|
|
64
|
+
fs.writeFileSync(pending, JSON.stringify(entries, null, 2) + '\n')
|
|
65
|
+
fs.renameSync(pending, registry)
|
|
66
|
+
} finally {
|
|
67
|
+
if (fs.existsSync(pending)) fs.unlinkSync(pending)
|
|
68
|
+
fs.rmdirSync(lock)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import fs from 'bare-fs'
|
|
2
|
+
import path from 'bare-path'
|
|
3
|
+
|
|
4
|
+
export default function removeEmptyParents(directory, owner) {
|
|
5
|
+
for (let parent = path.dirname(directory); parent !== owner; parent = path.dirname(parent)) {
|
|
6
|
+
try {
|
|
7
|
+
fs.rmdirSync(parent)
|
|
8
|
+
} catch (err) {
|
|
9
|
+
if (err.code === 'ENOENT') continue
|
|
10
|
+
if (err.code === 'ENOTEMPTY' || err.code === 'EEXIST' || err.code === 'ENOTDIR') break
|
|
11
|
+
throw err
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
}
|
package/lib/remove.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import fs from 'bare-fs'
|
|
2
|
+
import path from 'bare-path'
|
|
3
|
+
import { spawnSync } from 'bare-subprocess'
|
|
4
|
+
import { manifestDirectory, updateRegistry } from './registry.js'
|
|
5
|
+
import repositoryPath from './repository-path.js'
|
|
6
|
+
import checkedOutBranch from './checked-out-branch.js'
|
|
7
|
+
import removeEmptyParents from './remove-empty-parents.js'
|
|
8
|
+
|
|
9
|
+
export default function remove(repository) {
|
|
10
|
+
const absolute = repositoryPath(repository)
|
|
11
|
+
let canonical = absolute
|
|
12
|
+
let exists = true
|
|
13
|
+
try {
|
|
14
|
+
canonical = fs.realpathSync(absolute)
|
|
15
|
+
} catch (err) {
|
|
16
|
+
if (err.code !== 'ENOENT' && err.code !== 'ENOTDIR') throw err
|
|
17
|
+
exists = false
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
updateRegistry((worktrees) => {
|
|
21
|
+
const target = worktrees.includes(absolute) ? absolute : canonical
|
|
22
|
+
const [base, requested] = repository.split('@')
|
|
23
|
+
let branch = requested
|
|
24
|
+
if (requested && exists) {
|
|
25
|
+
const root = repositoryPath(base)
|
|
26
|
+
const common = (directory) =>
|
|
27
|
+
fs.realpathSync(
|
|
28
|
+
git(['-C', directory, 'rev-parse', '--path-format=absolute', '--git-common-dir'])
|
|
29
|
+
)
|
|
30
|
+
if (
|
|
31
|
+
fs.realpathSync(git(['-C', absolute, 'rev-parse', '--show-toplevel'])) !== canonical ||
|
|
32
|
+
common(root) !== common(absolute)
|
|
33
|
+
) {
|
|
34
|
+
throw new Error('Destination is not a worktree of the requested repository')
|
|
35
|
+
}
|
|
36
|
+
branch = checkedOutBranch(absolute)
|
|
37
|
+
git(['-C', root, 'worktree', 'remove', canonical])
|
|
38
|
+
} else if (!requested && exists) {
|
|
39
|
+
removeRoot(base, absolute, canonical)
|
|
40
|
+
} else if (!worktrees.includes(target)) {
|
|
41
|
+
throw new Error('Repository is not registered')
|
|
42
|
+
}
|
|
43
|
+
if (requested) {
|
|
44
|
+
const root = repositoryPath(base)
|
|
45
|
+
if (branch) {
|
|
46
|
+
const ref = spawnSync('git', [
|
|
47
|
+
'-C',
|
|
48
|
+
root,
|
|
49
|
+
'show-ref',
|
|
50
|
+
'--verify',
|
|
51
|
+
'--quiet',
|
|
52
|
+
`refs/heads/${branch}`
|
|
53
|
+
])
|
|
54
|
+
if (ref.error) throw ref.error
|
|
55
|
+
if (ref.status === 0) {
|
|
56
|
+
try {
|
|
57
|
+
git(['-C', root, 'branch', '-D', '--', branch])
|
|
58
|
+
} catch (err) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`Worktree directory is gone, but branch ${branch} could not be deleted. Registry entry retained; resolve the branch issue and retry.\n${err.message}`
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
} else if (ref.status !== 1) {
|
|
64
|
+
throw new Error(ref.stderr?.toString().trim() || 'Could not inspect Git branch')
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
removeEmptyParents(absolute, path.dirname(root))
|
|
68
|
+
}
|
|
69
|
+
return worktrees.filter((worktree) => worktree !== target)
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function removeRoot(repository, directory, canonical) {
|
|
74
|
+
if (canonical === fs.realpathSync(manifestDirectory())) {
|
|
75
|
+
throw new Error('Cannot remove the active manifest repository')
|
|
76
|
+
}
|
|
77
|
+
if (fs.lstatSync(directory).isSymbolicLink()) {
|
|
78
|
+
throw new Error('Refusing to delete a root repository through a symbolic link')
|
|
79
|
+
}
|
|
80
|
+
const gitDirectory = path.join(canonical, '.git')
|
|
81
|
+
if (
|
|
82
|
+
!fs.lstatSync(gitDirectory).isDirectory() ||
|
|
83
|
+
fs.realpathSync(git(['-C', directory, 'rev-parse', '--show-toplevel'])) !== canonical ||
|
|
84
|
+
fs.realpathSync(
|
|
85
|
+
git(['-C', directory, 'rev-parse', '--path-format=absolute', '--git-common-dir'])
|
|
86
|
+
) !== gitDirectory
|
|
87
|
+
) {
|
|
88
|
+
throw new Error('Destination is not a standalone root repository')
|
|
89
|
+
}
|
|
90
|
+
const origin = git(['-C', directory, 'config', '--get', 'remote.origin.url'])
|
|
91
|
+
const match =
|
|
92
|
+
/^(?:git@github\.com:|ssh:\/\/git@github\.com\/|https:\/\/github\.com\/)([^/]+\/[^/]+?)\/?$/i.exec(
|
|
93
|
+
origin
|
|
94
|
+
)
|
|
95
|
+
if (!match || match[1].replace(/\.git$/i, '').toLowerCase() !== repository.toLowerCase()) {
|
|
96
|
+
throw new Error(`Destination origin does not match ${repository}`)
|
|
97
|
+
}
|
|
98
|
+
const worktrees = git(['-C', directory, 'worktree', 'list', '--porcelain', '-z'])
|
|
99
|
+
.split('\0')
|
|
100
|
+
.filter((field) => field.startsWith('worktree '))
|
|
101
|
+
if (worktrees.length !== 1) {
|
|
102
|
+
const err = new Error(`cannot remove ${repository}: linked worktrees exist`)
|
|
103
|
+
err.hint = 'remove the linked worktrees first'
|
|
104
|
+
throw err
|
|
105
|
+
}
|
|
106
|
+
if (
|
|
107
|
+
git([
|
|
108
|
+
'-C',
|
|
109
|
+
directory,
|
|
110
|
+
'status',
|
|
111
|
+
'--porcelain',
|
|
112
|
+
'--untracked-files=all',
|
|
113
|
+
'--ignore-submodules=none'
|
|
114
|
+
])
|
|
115
|
+
) {
|
|
116
|
+
throw new Error('Root repository has local changes or untracked files')
|
|
117
|
+
}
|
|
118
|
+
fs.rmSync(directory, { recursive: true })
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function git(args) {
|
|
122
|
+
const result = spawnSync('git', args)
|
|
123
|
+
if (result.error) throw result.error
|
|
124
|
+
if (result.status !== 0) throw new Error(result.stderr?.toString().trim() || 'Git command failed')
|
|
125
|
+
return result.stdout?.toString().replace(/\r?\n$/, '')
|
|
126
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import fs from 'bare-fs'
|
|
2
|
+
import path from 'bare-path'
|
|
3
|
+
import { spawnSync } from 'bare-subprocess'
|
|
4
|
+
import { manifestDirectory, updateRegistry } from './registry.js'
|
|
5
|
+
import repositoryPath from './repository-path.js'
|
|
6
|
+
import removeEmptyParents from './remove-empty-parents.js'
|
|
7
|
+
|
|
8
|
+
export default function renameRoot(from, to) {
|
|
9
|
+
const source = repositoryPath(from)
|
|
10
|
+
const destination = repositoryPath(to)
|
|
11
|
+
if (from === to) throw new Error(`${to} is already the registered name`)
|
|
12
|
+
|
|
13
|
+
const moved = []
|
|
14
|
+
let originalConfig
|
|
15
|
+
try {
|
|
16
|
+
updateRegistry((worktrees) => {
|
|
17
|
+
if (!worktrees.includes(source)) throw new Error(`${from} is not registered`)
|
|
18
|
+
if (!exists(source)) throw new Error(`${from} is not checked out`)
|
|
19
|
+
const canonical = fs.realpathSync(source)
|
|
20
|
+
if (canonical === fs.realpathSync(manifestDirectory())) {
|
|
21
|
+
throw new Error('Cannot rename the active manifest repository')
|
|
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`)
|
|
48
|
+
}
|
|
49
|
+
if (
|
|
50
|
+
fs.realpathSync(git(directory, 'rev-parse', '--show-toplevel')) !==
|
|
51
|
+
fs.realpathSync(directory) ||
|
|
52
|
+
fs.realpathSync(
|
|
53
|
+
git(directory, 'rev-parse', '--path-format=absolute', '--git-common-dir')
|
|
54
|
+
) !== gitDirectory
|
|
55
|
+
) {
|
|
56
|
+
throw new Error(`Linked worktree needs repair: ${directory}`)
|
|
57
|
+
}
|
|
58
|
+
// Worktree repair does not repair the separate Git links inside submodules.
|
|
59
|
+
if (
|
|
60
|
+
git(directory, 'ls-files', '--stage', '-z')
|
|
61
|
+
.split('\0')
|
|
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}`)
|
|
69
|
+
}
|
|
70
|
+
if (fs.lstatSync(directory).isSymbolicLink()) {
|
|
71
|
+
throw new Error(`Linked worktree is a symbolic link: ${directory}`)
|
|
72
|
+
}
|
|
73
|
+
moves.push([directory, destination + directory.slice(source.length)])
|
|
74
|
+
}
|
|
75
|
+
const mapping = new Map(moves)
|
|
76
|
+
for (const directory of worktrees) {
|
|
77
|
+
if (directory.startsWith(source + '@') && !mapping.has(directory)) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
`Registered worktree is missing or does not belong to ${from}: ${directory}`
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
if (directory === destination || directory.startsWith(destination + '@')) {
|
|
83
|
+
throw new Error(`${to} already has registered checkouts`)
|
|
84
|
+
}
|
|
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
|
+
|
|
92
|
+
originalConfig = fs.readFileSync(path.join(source, '.git', 'config'))
|
|
93
|
+
for (const [oldPath, newPath] of moves) {
|
|
94
|
+
fs.renameSync(oldPath, newPath)
|
|
95
|
+
moved.push([oldPath, newPath])
|
|
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))
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return worktrees.map((directory) => mapping.get(directory) || directory)
|
|
110
|
+
})
|
|
111
|
+
} catch (err) {
|
|
112
|
+
if (moved.length) {
|
|
113
|
+
try {
|
|
114
|
+
for (const [oldPath, newPath] of moved.slice().reverse()) fs.renameSync(newPath, oldPath)
|
|
115
|
+
fs.writeFileSync(path.join(source, '.git', 'config'), originalConfig)
|
|
116
|
+
git(source, 'worktree', 'repair', ...moved.slice(1).map(([oldPath]) => oldPath))
|
|
117
|
+
} catch (rollback) {
|
|
118
|
+
err.message += `\nCould not fully restore local paths; inspect ${source} and ${destination}: ${rollback.message}`
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
throw err
|
|
122
|
+
}
|
|
123
|
+
for (const [oldPath] of moved.slice(1)) removeEmptyParents(oldPath, path.dirname(source))
|
|
124
|
+
console.error(
|
|
125
|
+
`hint: origin now points to ${to}; rename or transfer the repository on GitHub separately if needed`
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function matches(url, from, to) {
|
|
130
|
+
const match = parseURL(url)
|
|
131
|
+
return match && [from.toLowerCase(), to.toLowerCase()].includes(match[2].toLowerCase())
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function parseURL(url) {
|
|
135
|
+
return /^(git@github\.com:|ssh:\/\/git@github\.com\/|https:\/\/github\.com\/)([^/]+\/[^/]+?)(\.git)?(\/?)$/i.exec(
|
|
136
|
+
url
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function replaceURL(url, from, to) {
|
|
141
|
+
const match = parseURL(url)
|
|
142
|
+
if (!match || match[2].toLowerCase() !== from.toLowerCase()) return url
|
|
143
|
+
return match[1] + to + (match[3] || '') + match[4]
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function configValues(directory, key) {
|
|
147
|
+
const result = spawnSync('git', ['-C', directory, 'config', '--null', '--get-all', key])
|
|
148
|
+
if (result.error) throw result.error
|
|
149
|
+
if (result.status === 1) return []
|
|
150
|
+
if (result.status !== 0) {
|
|
151
|
+
throw new Error(result.stderr.toString().trim() || 'Could not inspect Git config')
|
|
152
|
+
}
|
|
153
|
+
return result.stdout.toString().split('\0').slice(0, -1)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function exists(directory) {
|
|
157
|
+
try {
|
|
158
|
+
fs.lstatSync(directory)
|
|
159
|
+
return true
|
|
160
|
+
} catch (err) {
|
|
161
|
+
if (err.code !== 'ENOENT') throw err
|
|
162
|
+
return false
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function git(directory, ...args) {
|
|
167
|
+
const result = spawnSync('git', ['-C', directory, ...args])
|
|
168
|
+
if (result.error) throw result.error
|
|
169
|
+
if (result.status !== 0) {
|
|
170
|
+
throw new Error(result.stderr?.toString().trim() || 'Git command failed')
|
|
171
|
+
}
|
|
172
|
+
return result.stdout?.toString().replace(/\r?\n$/, '')
|
|
173
|
+
}
|
package/lib/rename.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import fs from 'bare-fs'
|
|
2
|
+
import path from 'bare-path'
|
|
3
|
+
import { spawnSync } from 'bare-subprocess'
|
|
4
|
+
import { updateRegistry } from './registry.js'
|
|
5
|
+
import repositoryPath from './repository-path.js'
|
|
6
|
+
import checkedOutBranch from './checked-out-branch.js'
|
|
7
|
+
import removeEmptyParents from './remove-empty-parents.js'
|
|
8
|
+
import renameRoot from './rename-root.js'
|
|
9
|
+
|
|
10
|
+
export default function rename(from, to) {
|
|
11
|
+
const source = repositoryPath(from)
|
|
12
|
+
const destination = repositoryPath(to)
|
|
13
|
+
const [base, requested] = from.split('@')
|
|
14
|
+
const [target, branch] = to.split('@')
|
|
15
|
+
if (!requested && !branch) return renameRoot(from, to)
|
|
16
|
+
if (!requested || !branch) {
|
|
17
|
+
throw new Error('Use owner/repo on both sides, or owner/repo@branch on both sides')
|
|
18
|
+
}
|
|
19
|
+
if (base !== target) {
|
|
20
|
+
throw new Error(`Cannot rename a worktree of ${base} into ${target}`)
|
|
21
|
+
}
|
|
22
|
+
if (source === destination) {
|
|
23
|
+
throw new Error(`${to} is already the registered name`)
|
|
24
|
+
}
|
|
25
|
+
git(['check-ref-format', '--branch', branch])
|
|
26
|
+
|
|
27
|
+
let canonical
|
|
28
|
+
try {
|
|
29
|
+
canonical = fs.realpathSync(source)
|
|
30
|
+
} catch (err) {
|
|
31
|
+
if (err.code !== 'ENOENT' && err.code !== 'ENOTDIR') throw err
|
|
32
|
+
throw new Error(`${from} is not checked out`)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const root = repositoryPath(base)
|
|
36
|
+
const common = (directory) =>
|
|
37
|
+
fs.realpathSync(
|
|
38
|
+
git(['-C', directory, 'rev-parse', '--path-format=absolute', '--git-common-dir'])
|
|
39
|
+
)
|
|
40
|
+
if (
|
|
41
|
+
fs.realpathSync(git(['-C', source, 'rev-parse', '--show-toplevel'])) !== canonical ||
|
|
42
|
+
common(root) !== common(source)
|
|
43
|
+
) {
|
|
44
|
+
throw new Error('Destination is not a worktree of the requested repository')
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const current = checkedOutBranch(source)
|
|
48
|
+
if (!current) {
|
|
49
|
+
throw new Error(`${from} has a detached HEAD; check out a branch before renaming`)
|
|
50
|
+
}
|
|
51
|
+
if (current !== branch && hasRef(root, `refs/heads/${branch}`)) {
|
|
52
|
+
throw new Error(`Branch ${branch} already exists`)
|
|
53
|
+
}
|
|
54
|
+
if (fs.existsSync(destination)) {
|
|
55
|
+
throw new Error(`${destination} already exists`)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
updateRegistry((worktrees) => {
|
|
59
|
+
const registered = worktrees.includes(source) ? source : canonical
|
|
60
|
+
if (!worktrees.includes(registered)) {
|
|
61
|
+
throw new Error(`${from} is not registered`)
|
|
62
|
+
}
|
|
63
|
+
fs.mkdirSync(path.dirname(destination), { recursive: true })
|
|
64
|
+
git(['-C', root, 'worktree', 'move', canonical, destination])
|
|
65
|
+
if (current !== branch) {
|
|
66
|
+
git(['-C', root, 'branch', '-m', current, branch])
|
|
67
|
+
retargetUpstream(root, branch)
|
|
68
|
+
}
|
|
69
|
+
removeEmptyParents(source, path.dirname(root))
|
|
70
|
+
return worktrees.map((worktree) => (worktree === registered ? destination : worktree))
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function retargetUpstream(root, branch) {
|
|
75
|
+
const remote = spawnSync('git', ['-C', root, 'config', '--get', `branch.${branch}.remote`])
|
|
76
|
+
if (remote.error) throw remote.error
|
|
77
|
+
if (remote.status === 1) return
|
|
78
|
+
if (remote.status !== 0) {
|
|
79
|
+
throw new Error(remote.stderr?.toString().trim() || 'Could not inspect Git config')
|
|
80
|
+
}
|
|
81
|
+
git(['-C', root, 'config', `branch.${branch}.merge`, `refs/heads/${branch}`])
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function hasRef(root, ref) {
|
|
85
|
+
const result = spawnSync('git', ['-C', root, 'show-ref', '--verify', '--quiet', ref])
|
|
86
|
+
if (result.error) throw result.error
|
|
87
|
+
if (result.status === 0) return true
|
|
88
|
+
if (result.status === 1) return false
|
|
89
|
+
throw new Error(result.stderr?.toString().trim() || 'Could not inspect Git refs')
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function git(args) {
|
|
93
|
+
const result = spawnSync('git', args)
|
|
94
|
+
if (result.error) throw result.error
|
|
95
|
+
if (result.status !== 0) throw new Error(result.stderr?.toString().trim() || 'Git command failed')
|
|
96
|
+
return result.stdout?.toString().replace(/\r?\n$/, '')
|
|
97
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { isTTY } from 'bare-tty'
|
|
2
|
+
|
|
3
|
+
export default function reportError(err) {
|
|
4
|
+
const label = isTTY(2) ? '\x1b[31merror:\x1b[0m' : 'error:'
|
|
5
|
+
const message = err.message.replace(/^(?:error|fatal):\s*/i, '')
|
|
6
|
+
console.error(`${label} ${message}`)
|
|
7
|
+
if (err.hint) console.error(`hint: ${err.hint}`)
|
|
8
|
+
Bare.exitCode = 1
|
|
9
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import os from 'bare-os'
|
|
2
|
+
import path from 'bare-path'
|
|
3
|
+
import repositoryPath from './repository-path.js'
|
|
4
|
+
|
|
5
|
+
export default function repositoryName(worktree) {
|
|
6
|
+
const workspace = path.join(os.homedir(), 'workspace')
|
|
7
|
+
const repository = path.relative(workspace, worktree).split(path.sep).join('/')
|
|
8
|
+
try {
|
|
9
|
+
repositoryPath(repository)
|
|
10
|
+
} catch {
|
|
11
|
+
throw new Error(`Registered path is outside the owner/repo layout: ${worktree}`)
|
|
12
|
+
}
|
|
13
|
+
return repository
|
|
14
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import os from 'bare-os'
|
|
2
|
+
import path from 'bare-path'
|
|
3
|
+
|
|
4
|
+
export default function repositoryPath(repository) {
|
|
5
|
+
if (
|
|
6
|
+
!/^[a-z0-9][a-z0-9-]*\/[a-z0-9_.-]+(?:@[a-z0-9_][a-z0-9_.-]*(?:\/[a-z0-9_][a-z0-9_.-]*)*)?$/i.test(
|
|
7
|
+
repository
|
|
8
|
+
)
|
|
9
|
+
) {
|
|
10
|
+
throw new Error('Expected owner/repo or owner/repo@branch')
|
|
11
|
+
}
|
|
12
|
+
const [owner, ...parts] = repository.split('/')
|
|
13
|
+
const name = parts.join('/')
|
|
14
|
+
const base = name.split('@')[0]
|
|
15
|
+
if (base === '.' || base === '..') throw new Error('Invalid repository name')
|
|
16
|
+
|
|
17
|
+
return path.join(os.homedir(), 'workspace', owner, name)
|
|
18
|
+
}
|