git-worktree-organize 1.0.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/.bare/HEAD +1 -0
- package/.bare/config +6 -0
- package/.bare/description +1 -0
- package/.bare/hooks/applypatch-msg.sample +15 -0
- package/.bare/hooks/commit-msg.sample +24 -0
- package/.bare/hooks/fsmonitor-watchman.sample +174 -0
- package/.bare/hooks/post-update.sample +8 -0
- package/.bare/hooks/pre-applypatch.sample +14 -0
- package/.bare/hooks/pre-commit.sample +49 -0
- package/.bare/hooks/pre-merge-commit.sample +13 -0
- package/.bare/hooks/pre-push.sample +53 -0
- package/.bare/hooks/pre-rebase.sample +169 -0
- package/.bare/hooks/pre-receive.sample +24 -0
- package/.bare/hooks/prepare-commit-msg.sample +42 -0
- package/.bare/hooks/push-to-checkout.sample +78 -0
- package/.bare/hooks/sendemail-validate.sample +77 -0
- package/.bare/hooks/update.sample +128 -0
- package/.bare/info/exclude +6 -0
- package/.bare/objects/pack/pack-1a869640f0628b133e36287958cd040e132be773.idx +0 -0
- package/.bare/objects/pack/pack-1a869640f0628b133e36287958cd040e132be773.pack +0 -0
- package/.bare/objects/pack/pack-1a869640f0628b133e36287958cd040e132be773.rev +0 -0
- package/.bare/packed-refs +2 -0
- package/.beads/README.md +81 -0
- package/.beads/backup/backup_state.json +13 -0
- package/.beads/backup/comments.jsonl +0 -0
- package/.beads/backup/config.jsonl +11 -0
- package/.beads/backup/dependencies.jsonl +10 -0
- package/.beads/backup/events.jsonl +31 -0
- package/.beads/backup/issues.jsonl +9 -0
- package/.beads/backup/labels.jsonl +0 -0
- package/.beads/config.yaml +55 -0
- package/.beads/hooks/post-checkout +9 -0
- package/.beads/hooks/post-merge +9 -0
- package/.beads/hooks/pre-commit +9 -0
- package/.beads/hooks/pre-push +9 -0
- package/.beads/hooks/prepare-commit-msg +9 -0
- package/.beads/interactions.jsonl +0 -0
- package/.beads/metadata.json +9 -0
- package/.claude/settings.local.json +8 -0
- package/AGENTS.md +150 -0
- package/README.md +84 -0
- package/bun.lock +205 -0
- package/package.json +21 -0
- package/src/cli.ts +143 -0
- package/src/detect.ts +95 -0
- package/src/fs.ts +22 -0
- package/src/git.ts +40 -0
- package/src/migrate.ts +162 -0
- package/src/worktrees.ts +54 -0
- package/test/__bun-shim__.ts +78 -0
- package/test/detect.test.ts +99 -0
- package/test/helpers/repo.ts +75 -0
- package/test/migrate.test.ts +124 -0
- package/test/security.test.ts +25 -0
- package/test/worktrees.test.ts +164 -0
- package/tsconfig.json +13 -0
- package/vitest.config.ts +13 -0
package/src/detect.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { existsSync, statSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join, resolve, isAbsolute } from 'node:path'
|
|
3
|
+
import { execSync } from 'node:child_process'
|
|
4
|
+
|
|
5
|
+
export type RepoConfig =
|
|
6
|
+
| { type: 'standard'; gitdir: string; mainWorktree: string }
|
|
7
|
+
| { type: 'bare-root'; gitdir: string }
|
|
8
|
+
| { type: 'bare-hub'; gitdir: string }
|
|
9
|
+
| { type: 'bare-dotgit'; gitdir: string }
|
|
10
|
+
| { type: 'bare-external'; gitdir: string }
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Read core.bare from a git config file at `gitdir/config`.
|
|
14
|
+
* Returns true if set to "true", false if set to "false" or not set.
|
|
15
|
+
*/
|
|
16
|
+
function readCoreBare(gitdir: string): boolean {
|
|
17
|
+
try {
|
|
18
|
+
const result = execSync(`git --git-dir=${gitdir} config --get core.bare`, {
|
|
19
|
+
encoding: 'utf8',
|
|
20
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
21
|
+
}).trim()
|
|
22
|
+
return result === 'true'
|
|
23
|
+
} catch {
|
|
24
|
+
// exit code 1 means key not found — treat as false
|
|
25
|
+
return false
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Detect the type of git repository at `repoPath`.
|
|
31
|
+
* Throws if the path is not a recognized git repo or is a linked worktree.
|
|
32
|
+
*/
|
|
33
|
+
export async function detect(repoPath: string): Promise<RepoConfig> {
|
|
34
|
+
const gitEntryPath = join(repoPath, '.git')
|
|
35
|
+
const gitEntryExists = existsSync(gitEntryPath)
|
|
36
|
+
|
|
37
|
+
if (gitEntryExists) {
|
|
38
|
+
const stat = statSync(gitEntryPath)
|
|
39
|
+
|
|
40
|
+
if (stat.isDirectory()) {
|
|
41
|
+
// .git is a directory — standard or bare-dotgit
|
|
42
|
+
const isbare = readCoreBare(gitEntryPath)
|
|
43
|
+
if (isbare) {
|
|
44
|
+
return { type: 'bare-dotgit', gitdir: gitEntryPath }
|
|
45
|
+
} else {
|
|
46
|
+
return { type: 'standard', gitdir: gitEntryPath, mainWorktree: repoPath }
|
|
47
|
+
}
|
|
48
|
+
} else if (stat.isFile()) {
|
|
49
|
+
// .git is a file — parse gitdir
|
|
50
|
+
const contents = readFileSync(gitEntryPath, 'utf8')
|
|
51
|
+
const firstLine = contents.split('\n')[0].trim()
|
|
52
|
+
const match = firstLine.match(/^gitdir:\s*(.+)$/)
|
|
53
|
+
if (!match) {
|
|
54
|
+
throw new Error(`not a git repository: ${repoPath}`)
|
|
55
|
+
}
|
|
56
|
+
const rawGitdir = match[1].trim()
|
|
57
|
+
const absGitdir = isAbsolute(rawGitdir)
|
|
58
|
+
? rawGitdir
|
|
59
|
+
: resolve(repoPath, rawGitdir)
|
|
60
|
+
|
|
61
|
+
// Linked worktree: resolved path contains /worktrees/
|
|
62
|
+
if (absGitdir.includes('/worktrees/')) {
|
|
63
|
+
throw new Error('is a linked worktree, not a repo root')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Validate the resolved gitdir looks like a git object store
|
|
67
|
+
if (
|
|
68
|
+
!existsSync(join(absGitdir, 'HEAD')) ||
|
|
69
|
+
!existsSync(join(absGitdir, 'objects')) ||
|
|
70
|
+
!existsSync(join(absGitdir, 'refs'))
|
|
71
|
+
) {
|
|
72
|
+
throw new Error(`gitdir does not appear to be a git repository: ${absGitdir}`)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// bare-hub: resolved path ends with .bare
|
|
76
|
+
if (absGitdir.endsWith('.bare')) {
|
|
77
|
+
return { type: 'bare-hub', gitdir: absGitdir }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// bare-external: points elsewhere
|
|
81
|
+
return { type: 'bare-external', gitdir: absGitdir }
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// No .git entry — check for bare-root (HEAD + refs/ + objects/ at root)
|
|
86
|
+
const headExists = existsSync(join(repoPath, 'HEAD'))
|
|
87
|
+
const refsExists = existsSync(join(repoPath, 'refs'))
|
|
88
|
+
const objectsExists = existsSync(join(repoPath, 'objects'))
|
|
89
|
+
|
|
90
|
+
if (headExists && refsExists && objectsExists) {
|
|
91
|
+
return { type: 'bare-root', gitdir: repoPath }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
throw new Error(`not a git repository: ${repoPath}`)
|
|
95
|
+
}
|
package/src/fs.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { statSync, renameSync } from 'node:fs'
|
|
2
|
+
import { $ } from 'bun'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Move `src` to `dest`.
|
|
6
|
+
* Uses rename() on the same filesystem; falls back to copy+delete across filesystems.
|
|
7
|
+
*/
|
|
8
|
+
export async function move(src: string, dest: string): Promise<void> {
|
|
9
|
+
if (samefs(src, dest)) {
|
|
10
|
+
renameSync(src, dest)
|
|
11
|
+
} else {
|
|
12
|
+
await $`cp -a ${src} ${dest}`.quiet()
|
|
13
|
+
await $`rm -rf ${src}`.quiet()
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Returns true if `a` and `b` are on the same filesystem (same device number).
|
|
19
|
+
*/
|
|
20
|
+
export function samefs(a: string, b: string): boolean {
|
|
21
|
+
return statSync(a).dev === statSync(b).dev
|
|
22
|
+
}
|
package/src/git.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { $ } from 'bun'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Run a git command and return stdout as a string.
|
|
5
|
+
* Throws if the command exits non-zero.
|
|
6
|
+
*/
|
|
7
|
+
export async function git(args: string[], options?: { cwd?: string; env?: Record<string, string> }): Promise<string> {
|
|
8
|
+
const proc = $`git ${args}`.quiet()
|
|
9
|
+
if (options?.cwd) proc.cwd(options.cwd)
|
|
10
|
+
if (options?.env) proc.env({ ...process.env, ...options.env })
|
|
11
|
+
const result = await proc
|
|
12
|
+
return result.stdout.toString()
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Read a git config value. Returns null if the key is not set.
|
|
17
|
+
*/
|
|
18
|
+
export async function gitConfig(key: string, options?: { gitdir?: string; cwd?: string }): Promise<string | null> {
|
|
19
|
+
const env: Record<string, string> = {}
|
|
20
|
+
if (options?.gitdir) env['GIT_DIR'] = options.gitdir
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
const result = await git(['config', '--get', key], { cwd: options?.cwd, env })
|
|
24
|
+
return result.trimEnd()
|
|
25
|
+
} catch (err: any) {
|
|
26
|
+
// git config --get exits with code 1 when the key is not set
|
|
27
|
+
if (err?.exitCode === 1) return null
|
|
28
|
+
throw err
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Set a git config value.
|
|
34
|
+
*/
|
|
35
|
+
export async function setGitConfig(key: string, value: string, options?: { gitdir?: string; cwd?: string }): Promise<void> {
|
|
36
|
+
const env: Record<string, string> = {}
|
|
37
|
+
if (options?.gitdir) env['GIT_DIR'] = options.gitdir
|
|
38
|
+
|
|
39
|
+
await git(['config', key, value], { cwd: options?.cwd, env })
|
|
40
|
+
}
|
package/src/migrate.ts
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync, readFileSync, existsSync, renameSync, statSync } from 'node:fs'
|
|
2
|
+
import { join, dirname, basename, resolve } from 'node:path'
|
|
3
|
+
import { $ } from 'bun'
|
|
4
|
+
import type { RepoConfig } from './detect.ts'
|
|
5
|
+
import { listWorktrees } from './worktrees.ts'
|
|
6
|
+
import { setGitConfig } from './git.ts'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Move src to dest. Uses rename if on same filesystem, cp+rm otherwise.
|
|
10
|
+
* Unlike fs.ts move(), this handles the case where dest does not yet exist
|
|
11
|
+
* by statting the parent of dest for the filesystem check.
|
|
12
|
+
*/
|
|
13
|
+
async function moveDir(src: string, dest: string): Promise<void> {
|
|
14
|
+
const destForStat = existsSync(dest) ? dest : dirname(dest)
|
|
15
|
+
if (statSync(src).dev === statSync(destForStat).dev) {
|
|
16
|
+
renameSync(src, dest)
|
|
17
|
+
} else {
|
|
18
|
+
await $`cp -a ${src} ${dest}`.quiet()
|
|
19
|
+
await $`rm -rf ${src}`.quiet()
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface MigrateOptions {
|
|
24
|
+
source: string
|
|
25
|
+
dest: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Sanitize a branch name for use as a directory name (replace / with -).
|
|
30
|
+
*/
|
|
31
|
+
export function sanitizeBranch(branch: string): string {
|
|
32
|
+
return branch.replace(/\//g, '-')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Orchestrate the full migration of a git repo into the bare-hub layout.
|
|
37
|
+
* Returns the path to the created hub directory.
|
|
38
|
+
*/
|
|
39
|
+
export async function migrate(config: RepoConfig, options: MigrateOptions): Promise<string> {
|
|
40
|
+
const source = resolve(options.source)
|
|
41
|
+
const dest = options.dest
|
|
42
|
+
? resolve(options.dest)
|
|
43
|
+
: join(dirname(source), basename(source) + '-bare')
|
|
44
|
+
|
|
45
|
+
// Check dest/.bare doesn't already exist
|
|
46
|
+
const destBare = join(dest, '.bare')
|
|
47
|
+
if (existsSync(destBare)) {
|
|
48
|
+
throw new Error(`'${destBare}' already exists`)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Step 1: Read worktrees, filter out bare entries
|
|
52
|
+
const allWorktrees = await listWorktrees(source)
|
|
53
|
+
const worktrees = allWorktrees.filter(wt => !wt.isBare)
|
|
54
|
+
|
|
55
|
+
// Step 2: Collision check on sanitized names
|
|
56
|
+
const seen = new Map<string, string>()
|
|
57
|
+
for (const wt of worktrees) {
|
|
58
|
+
const branch = wt.branch ?? `detached-${wt.head.slice(0, 8)}`
|
|
59
|
+
const safe = sanitizeBranch(branch)
|
|
60
|
+
if (seen.has(safe)) {
|
|
61
|
+
throw new Error(`branch name collision: '${seen.get(safe)}' and '${branch}' both map to '${safe}'`)
|
|
62
|
+
}
|
|
63
|
+
seen.set(safe, branch)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Step 3: mkdir -p dest/.bare
|
|
67
|
+
mkdirSync(destBare, { recursive: true })
|
|
68
|
+
|
|
69
|
+
// Step 4: Copy git database: cp -a <source_gitdir>/. dest/.bare/
|
|
70
|
+
await $`cp -a ${config.gitdir + '/.'} ${destBare + '/'}`.quiet()
|
|
71
|
+
|
|
72
|
+
// Step 5: Set core.bare = true
|
|
73
|
+
await setGitConfig('core.bare', 'true', { gitdir: destBare })
|
|
74
|
+
|
|
75
|
+
// Step 6: Set remote.origin.fetch
|
|
76
|
+
await setGitConfig('remote.origin.fetch', '+refs/heads/*:refs/remotes/origin/*', { gitdir: destBare })
|
|
77
|
+
|
|
78
|
+
// Step 7: Write dest/.git file
|
|
79
|
+
writeFileSync(join(dest, '.git'), 'gitdir: ./.bare\n')
|
|
80
|
+
|
|
81
|
+
// Step 8: If standard, handle main worktree
|
|
82
|
+
if (config.type === 'standard') {
|
|
83
|
+
const mainBranch = worktrees[0].branch!
|
|
84
|
+
const mainSafe = sanitizeBranch(mainBranch)
|
|
85
|
+
const mainDest = join(dest, mainSafe)
|
|
86
|
+
|
|
87
|
+
// Read HEAD content from dest/.bare/HEAD
|
|
88
|
+
const mainHeadContent = readFileSync(join(destBare, 'HEAD'), 'utf8')
|
|
89
|
+
|
|
90
|
+
// Remove source/.git directory
|
|
91
|
+
await $`rm -rf ${join(source, '.git')}`.quiet()
|
|
92
|
+
|
|
93
|
+
// Move source dir → mainDest
|
|
94
|
+
await moveDir(source, mainDest)
|
|
95
|
+
|
|
96
|
+
// Create dest/.bare/worktrees/mainSafe/ dir
|
|
97
|
+
const mainAdminDir = join(destBare, 'worktrees', mainSafe)
|
|
98
|
+
mkdirSync(mainAdminDir, { recursive: true })
|
|
99
|
+
|
|
100
|
+
// Write gitdir, commondir, HEAD
|
|
101
|
+
writeFileSync(join(mainAdminDir, 'gitdir'), mainDest + '/.git\n')
|
|
102
|
+
writeFileSync(join(mainAdminDir, 'commondir'), '../../\n')
|
|
103
|
+
const headToWrite = mainHeadContent.endsWith('\n') ? mainHeadContent : mainHeadContent + '\n'
|
|
104
|
+
writeFileSync(join(mainAdminDir, 'HEAD'), headToWrite)
|
|
105
|
+
|
|
106
|
+
// Move index if it exists
|
|
107
|
+
const bareIndex = join(destBare, 'index')
|
|
108
|
+
if (existsSync(bareIndex)) {
|
|
109
|
+
renameSync(bareIndex, join(mainAdminDir, 'index'))
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Write mainDest/.git
|
|
113
|
+
writeFileSync(join(mainDest, '.git'), `gitdir: ${mainAdminDir}\n`)
|
|
114
|
+
|
|
115
|
+
// Process linked worktrees starting at index 1
|
|
116
|
+
for (let i = 1; i < worktrees.length; i++) {
|
|
117
|
+
await processLinkedWorktree(worktrees[i], dest, destBare)
|
|
118
|
+
}
|
|
119
|
+
} else {
|
|
120
|
+
// Step 9: Not standard — process all linked worktrees starting at index 0
|
|
121
|
+
for (const wt of worktrees) {
|
|
122
|
+
await processLinkedWorktree(wt, dest, destBare)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return dest
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function processLinkedWorktree(
|
|
130
|
+
wt: { path: string; head: string; branch: string | null; isBare: boolean },
|
|
131
|
+
dest: string,
|
|
132
|
+
destBare: string,
|
|
133
|
+
): Promise<void> {
|
|
134
|
+
const wtSrc = wt.path
|
|
135
|
+
const wtBranch = wt.branch ?? `detached-${wt.head.slice(0, 8)}`
|
|
136
|
+
const wtSafe = sanitizeBranch(wtBranch)
|
|
137
|
+
const wtDest = join(dest, wtSafe)
|
|
138
|
+
|
|
139
|
+
// Move wtSrc → wtDest
|
|
140
|
+
await moveDir(wtSrc, wtDest)
|
|
141
|
+
|
|
142
|
+
// Read wtDest/.git file, parse gitdir: <oldPath>
|
|
143
|
+
const gitFileContent = readFileSync(join(wtDest, '.git'), 'utf8')
|
|
144
|
+
const match = gitFileContent.match(/^gitdir:\s*(.+)/m)
|
|
145
|
+
if (!match) {
|
|
146
|
+
console.warn(`Could not parse .git file in ${wtDest}`)
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
const oldPath = match[1].trim()
|
|
150
|
+
const adminName = basename(oldPath)
|
|
151
|
+
const newAdmin = join(destBare, 'worktrees', adminName)
|
|
152
|
+
|
|
153
|
+
// Write wtDest/.git pointing to new admin dir
|
|
154
|
+
writeFileSync(join(wtDest, '.git'), `gitdir: ${newAdmin}\n`)
|
|
155
|
+
|
|
156
|
+
// Update admin dir's gitdir if it exists
|
|
157
|
+
if (existsSync(newAdmin)) {
|
|
158
|
+
writeFileSync(join(newAdmin, 'gitdir'), wtDest + '/.git\n')
|
|
159
|
+
} else {
|
|
160
|
+
console.warn(`Admin dir ${newAdmin} does not exist for worktree ${wtDest}`)
|
|
161
|
+
}
|
|
162
|
+
}
|
package/src/worktrees.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { $ } from 'bun'
|
|
2
|
+
|
|
3
|
+
export interface Worktree {
|
|
4
|
+
path: string
|
|
5
|
+
head: string
|
|
6
|
+
branch: string | null // null = detached HEAD
|
|
7
|
+
isBare: boolean
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Parse the output of `git worktree list --porcelain` into Worktree objects.
|
|
12
|
+
*/
|
|
13
|
+
export function parsePorcelain(output: string): Worktree[] {
|
|
14
|
+
const worktrees: Worktree[] = []
|
|
15
|
+
const blocks = output.trim().split(/\n\n+/)
|
|
16
|
+
|
|
17
|
+
for (const block of blocks) {
|
|
18
|
+
if (!block.trim()) continue
|
|
19
|
+
|
|
20
|
+
const lines = block.trim().split('\n')
|
|
21
|
+
let path = ''
|
|
22
|
+
let head = ''
|
|
23
|
+
let branch: string | null = null
|
|
24
|
+
let isBare = false
|
|
25
|
+
|
|
26
|
+
for (const line of lines) {
|
|
27
|
+
if (line.startsWith('worktree ')) {
|
|
28
|
+
path = line.slice('worktree '.length)
|
|
29
|
+
} else if (line.startsWith('HEAD ')) {
|
|
30
|
+
head = line.slice('HEAD '.length)
|
|
31
|
+
} else if (line.startsWith('branch ')) {
|
|
32
|
+
const ref = line.slice('branch '.length)
|
|
33
|
+
branch = ref.startsWith('refs/heads/') ? ref.slice('refs/heads/'.length) : ref
|
|
34
|
+
} else if (line === 'detached') {
|
|
35
|
+
branch = null
|
|
36
|
+
} else if (line === 'bare') {
|
|
37
|
+
isBare = true
|
|
38
|
+
branch = null
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
worktrees.push({ path, head, branch, isBare })
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return worktrees
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* List all worktrees for the repo at `repoPath`.
|
|
50
|
+
*/
|
|
51
|
+
export async function listWorktrees(repoPath: string): Promise<Worktree[]> {
|
|
52
|
+
const result = await $`git -C ${repoPath} worktree list --porcelain`.text()
|
|
53
|
+
return parsePorcelain(result)
|
|
54
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal shim for `import { $ } from 'bun'` when running under Vitest (Node.js).
|
|
3
|
+
* Implements the tagged-template shell API that the tests use.
|
|
4
|
+
*/
|
|
5
|
+
import { spawnSync } from 'node:child_process'
|
|
6
|
+
|
|
7
|
+
class ShellOutput {
|
|
8
|
+
private _stdout: string
|
|
9
|
+
private _stderr: string
|
|
10
|
+
private _exitCode: number
|
|
11
|
+
|
|
12
|
+
constructor(stdout: string, stderr: string, exitCode: number) {
|
|
13
|
+
this._stdout = stdout
|
|
14
|
+
this._stderr = stderr
|
|
15
|
+
this._exitCode = exitCode
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
text() { return this._stdout }
|
|
19
|
+
get stdout() { return Buffer.from(this._stdout) }
|
|
20
|
+
get stderr() { return Buffer.from(this._stderr) }
|
|
21
|
+
get exitCode() { return this._exitCode }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
class ShellPromise extends Promise<ShellOutput> {
|
|
25
|
+
private _cmd: string[]
|
|
26
|
+
|
|
27
|
+
constructor(cmd: string[]) {
|
|
28
|
+
let resolve_: (v: ShellOutput) => void
|
|
29
|
+
let reject_: (e: unknown) => void
|
|
30
|
+
super((res, rej) => { resolve_ = res; reject_ = rej })
|
|
31
|
+
this._cmd = cmd
|
|
32
|
+
|
|
33
|
+
// Run synchronously but resolve asynchronously so it's awaitable
|
|
34
|
+
Promise.resolve().then(() => {
|
|
35
|
+
try {
|
|
36
|
+
const result = spawnSync(cmd[0], cmd.slice(1), {
|
|
37
|
+
encoding: 'utf8',
|
|
38
|
+
stdio: ['inherit', 'pipe', 'pipe'],
|
|
39
|
+
})
|
|
40
|
+
const exitCode = result.status ?? 1
|
|
41
|
+
const out = new ShellOutput(result.stdout ?? '', result.stderr ?? '', exitCode)
|
|
42
|
+
if (exitCode !== 0) {
|
|
43
|
+
const err = new Error(`Command failed: ${cmd.join(' ')}\n${result.stderr ?? ''}`)
|
|
44
|
+
;(err as any).exitCode = exitCode
|
|
45
|
+
reject_!(err)
|
|
46
|
+
} else {
|
|
47
|
+
resolve_!(out)
|
|
48
|
+
}
|
|
49
|
+
} catch (e) {
|
|
50
|
+
reject_!(e)
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
quiet(): ShellPromise {
|
|
56
|
+
// Already quiet (stderr captured, not printed)
|
|
57
|
+
return this
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function buildCmd(strings: TemplateStringsArray, ...values: unknown[]): string[] {
|
|
62
|
+
// Combine template parts and interpolated values into a flat array of tokens
|
|
63
|
+
const parts: string[] = []
|
|
64
|
+
strings.forEach((str, i) => {
|
|
65
|
+
// Split the static part by whitespace
|
|
66
|
+
parts.push(...str.trim().split(/\s+/).filter(Boolean))
|
|
67
|
+
if (i < values.length) {
|
|
68
|
+
const val = String(values[i]).trim()
|
|
69
|
+
if (val) parts.push(val)
|
|
70
|
+
}
|
|
71
|
+
})
|
|
72
|
+
return parts
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function $(strings: TemplateStringsArray, ...values: unknown[]): ShellPromise {
|
|
76
|
+
const cmd = buildCmd(strings, ...values)
|
|
77
|
+
return new ShellPromise(cmd)
|
|
78
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { $ } from 'bun'
|
|
5
|
+
import { describe, it, expect } from 'vitest'
|
|
6
|
+
import { detect } from '../src/detect.ts'
|
|
7
|
+
|
|
8
|
+
function tempDir() {
|
|
9
|
+
return mkdtempSync(join(tmpdir(), 'gwt-detect-'))
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async function makeCommit(dir: string) {
|
|
13
|
+
await $`git -C ${dir} config user.email "test@test.com"`.quiet()
|
|
14
|
+
await $`git -C ${dir} config user.name "Test"`.quiet()
|
|
15
|
+
await $`git -C ${dir} commit --allow-empty -m "init"`.quiet()
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('detect', () => {
|
|
19
|
+
it('standard: git init + commit → type standard', async () => {
|
|
20
|
+
const dir = tempDir()
|
|
21
|
+
await $`git -C ${dir} init`.quiet()
|
|
22
|
+
await makeCommit(dir)
|
|
23
|
+
|
|
24
|
+
const result = await detect(dir)
|
|
25
|
+
expect(result).toEqual({
|
|
26
|
+
type: 'standard',
|
|
27
|
+
gitdir: join(dir, '.git'),
|
|
28
|
+
mainWorktree: dir,
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('bare-root: git init --bare → type bare-root', async () => {
|
|
33
|
+
const dir = tempDir()
|
|
34
|
+
await $`git -C ${dir} init --bare`.quiet()
|
|
35
|
+
|
|
36
|
+
const result = await detect(dir)
|
|
37
|
+
expect(result).toEqual({
|
|
38
|
+
type: 'bare-root',
|
|
39
|
+
gitdir: dir,
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('bare-dotgit: git init + core.bare=true → type bare-dotgit', async () => {
|
|
44
|
+
const dir = tempDir()
|
|
45
|
+
await $`git -C ${dir} init`.quiet()
|
|
46
|
+
// Set core.bare = true in .git/config
|
|
47
|
+
await $`git -C ${dir} config core.bare true`.quiet()
|
|
48
|
+
|
|
49
|
+
const result = await detect(dir)
|
|
50
|
+
expect(result).toEqual({
|
|
51
|
+
type: 'bare-dotgit',
|
|
52
|
+
gitdir: join(dir, '.git'),
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('bare-hub: .git file pointing to .bare dir → type bare-hub', async () => {
|
|
57
|
+
const dir = tempDir()
|
|
58
|
+
const bareDir = join(dir, '.bare')
|
|
59
|
+
mkdirSync(bareDir)
|
|
60
|
+
await $`git -C ${bareDir} init --bare`.quiet()
|
|
61
|
+
writeFileSync(join(dir, '.git'), 'gitdir: ./.bare\n')
|
|
62
|
+
|
|
63
|
+
const result = await detect(dir)
|
|
64
|
+
expect(result).toEqual({
|
|
65
|
+
type: 'bare-hub',
|
|
66
|
+
gitdir: bareDir,
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('bare-external: .git file pointing to external gitdir → type bare-external', async () => {
|
|
71
|
+
const dir = tempDir()
|
|
72
|
+
const extDir = join(dir, 'extgit')
|
|
73
|
+
mkdirSync(extDir)
|
|
74
|
+
await $`git -C ${extDir} init --bare`.quiet()
|
|
75
|
+
writeFileSync(join(dir, '.git'), 'gitdir: ./extgit\n')
|
|
76
|
+
|
|
77
|
+
const result = await detect(dir)
|
|
78
|
+
expect(result).toEqual({
|
|
79
|
+
type: 'bare-external',
|
|
80
|
+
gitdir: extDir,
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('reject linked worktree: detect on worktree dir throws', async () => {
|
|
85
|
+
const mainDir = tempDir()
|
|
86
|
+
const wtDir = join(tempDir(), 'wt')
|
|
87
|
+
await $`git -C ${mainDir} init`.quiet()
|
|
88
|
+
await makeCommit(mainDir)
|
|
89
|
+
await $`git -C ${mainDir} worktree add ${wtDir}`.quiet()
|
|
90
|
+
|
|
91
|
+
await expect(detect(wtDir)).rejects.toThrow('linked worktree')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('reject not a git repo: plain dir throws', async () => {
|
|
95
|
+
const dir = tempDir()
|
|
96
|
+
|
|
97
|
+
await expect(detect(dir)).rejects.toThrow('not a git repository')
|
|
98
|
+
})
|
|
99
|
+
})
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, statSync, existsSync } from 'node:fs'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { $ } from 'bun'
|
|
5
|
+
|
|
6
|
+
/** Create a temp directory and return its path. */
|
|
7
|
+
export function makeTempDir(): string {
|
|
8
|
+
return mkdtempSync(join(tmpdir(), 'git-worktree-organize-'))
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Create a standard git repo (non-bare, .git/ dir) with one commit.
|
|
13
|
+
* Adds extra worktrees for each branch name supplied in `branches`.
|
|
14
|
+
*/
|
|
15
|
+
export async function makeStandardRepo(dir: string, branches: string[] = []): Promise<void> {
|
|
16
|
+
await $`git init ${dir}`.quiet()
|
|
17
|
+
await $`git -C ${dir} config user.email "test@test.com"`.quiet()
|
|
18
|
+
await $`git -C ${dir} config user.name "Test"`.quiet()
|
|
19
|
+
await $`git -C ${dir} commit --allow-empty -m "init"`.quiet()
|
|
20
|
+
|
|
21
|
+
for (const branch of branches) {
|
|
22
|
+
const wtDir = join(dir + '-' + branch.replace(/\//g, '-'))
|
|
23
|
+
await $`git -C ${dir} worktree add -b ${branch} ${wtDir}`.quiet()
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Create a bare repo at `dir` (clone --bare equivalent).
|
|
29
|
+
*/
|
|
30
|
+
export async function makeBareRootRepo(dir: string): Promise<void> {
|
|
31
|
+
await $`git init --bare ${dir}`.quiet()
|
|
32
|
+
await $`git -C ${dir} config user.email "test@test.com"`.quiet()
|
|
33
|
+
await $`git -C ${dir} config user.name "Test"`.quiet()
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Create a bare-hub repo (already in hub layout: .bare/ + .git file).
|
|
38
|
+
*/
|
|
39
|
+
export async function makeBareHubRepo(dir: string): Promise<void> {
|
|
40
|
+
const bareDir = join(dir, '.bare')
|
|
41
|
+
mkdirSync(bareDir, { recursive: true })
|
|
42
|
+
await $`git init --bare ${bareDir}`.quiet()
|
|
43
|
+
await $`git -C ${bareDir} config user.email "test@test.com"`.quiet()
|
|
44
|
+
await $`git -C ${bareDir} config user.name "Test"`.quiet()
|
|
45
|
+
writeFileSync(join(dir, '.git'), 'gitdir: ./.bare\n')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Assert the hub structure at `dir` is valid.
|
|
50
|
+
*/
|
|
51
|
+
export async function assertHubStructure(dir: string): Promise<void> {
|
|
52
|
+
const bareDir = join(dir, '.bare')
|
|
53
|
+
if (!existsSync(bareDir) || !statSync(bareDir).isDirectory()) {
|
|
54
|
+
throw new Error(`assertHubStructure: ${bareDir} does not exist or is not a directory`)
|
|
55
|
+
}
|
|
56
|
+
const gitFile = join(dir, '.git')
|
|
57
|
+
if (!existsSync(gitFile) || !statSync(gitFile).isFile()) {
|
|
58
|
+
throw new Error(`assertHubStructure: ${gitFile} does not exist or is not a file`)
|
|
59
|
+
}
|
|
60
|
+
const gitFileContent = readFileSync(gitFile, 'utf8') // eslint-disable-line @typescript-eslint/no-use-before-define
|
|
61
|
+
if (!gitFileContent.includes('gitdir: ./.bare')) {
|
|
62
|
+
throw new Error(`assertHubStructure: ${gitFile} does not contain 'gitdir: ./.bare'`)
|
|
63
|
+
}
|
|
64
|
+
// Assert git worktree list succeeds
|
|
65
|
+
await $`git -C ${dir} worktree list`.quiet()
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Assert that a worktree for `branch` is functional (git status succeeds).
|
|
70
|
+
*/
|
|
71
|
+
export async function assertWorktreeWorks(dir: string, branch: string): Promise<void> {
|
|
72
|
+
const safeBranch = branch.replace(/\//g, '-')
|
|
73
|
+
const wtPath = join(dir, safeBranch)
|
|
74
|
+
await $`git -C ${wtPath} status`.quiet()
|
|
75
|
+
}
|