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/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,6 @@
1
+ import { readRegistry } from './registry.js'
2
+ import repositoryName from './repository-name.js'
3
+
4
+ export default function list() {
5
+ return readRegistry().map(repositoryName).sort()
6
+ }
@@ -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
+ }
@@ -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
+ }