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.
Files changed (57) hide show
  1. package/.bare/HEAD +1 -0
  2. package/.bare/config +6 -0
  3. package/.bare/description +1 -0
  4. package/.bare/hooks/applypatch-msg.sample +15 -0
  5. package/.bare/hooks/commit-msg.sample +24 -0
  6. package/.bare/hooks/fsmonitor-watchman.sample +174 -0
  7. package/.bare/hooks/post-update.sample +8 -0
  8. package/.bare/hooks/pre-applypatch.sample +14 -0
  9. package/.bare/hooks/pre-commit.sample +49 -0
  10. package/.bare/hooks/pre-merge-commit.sample +13 -0
  11. package/.bare/hooks/pre-push.sample +53 -0
  12. package/.bare/hooks/pre-rebase.sample +169 -0
  13. package/.bare/hooks/pre-receive.sample +24 -0
  14. package/.bare/hooks/prepare-commit-msg.sample +42 -0
  15. package/.bare/hooks/push-to-checkout.sample +78 -0
  16. package/.bare/hooks/sendemail-validate.sample +77 -0
  17. package/.bare/hooks/update.sample +128 -0
  18. package/.bare/info/exclude +6 -0
  19. package/.bare/objects/pack/pack-1a869640f0628b133e36287958cd040e132be773.idx +0 -0
  20. package/.bare/objects/pack/pack-1a869640f0628b133e36287958cd040e132be773.pack +0 -0
  21. package/.bare/objects/pack/pack-1a869640f0628b133e36287958cd040e132be773.rev +0 -0
  22. package/.bare/packed-refs +2 -0
  23. package/.beads/README.md +81 -0
  24. package/.beads/backup/backup_state.json +13 -0
  25. package/.beads/backup/comments.jsonl +0 -0
  26. package/.beads/backup/config.jsonl +11 -0
  27. package/.beads/backup/dependencies.jsonl +10 -0
  28. package/.beads/backup/events.jsonl +31 -0
  29. package/.beads/backup/issues.jsonl +9 -0
  30. package/.beads/backup/labels.jsonl +0 -0
  31. package/.beads/config.yaml +55 -0
  32. package/.beads/hooks/post-checkout +9 -0
  33. package/.beads/hooks/post-merge +9 -0
  34. package/.beads/hooks/pre-commit +9 -0
  35. package/.beads/hooks/pre-push +9 -0
  36. package/.beads/hooks/prepare-commit-msg +9 -0
  37. package/.beads/interactions.jsonl +0 -0
  38. package/.beads/metadata.json +9 -0
  39. package/.claude/settings.local.json +8 -0
  40. package/AGENTS.md +150 -0
  41. package/README.md +84 -0
  42. package/bun.lock +205 -0
  43. package/package.json +21 -0
  44. package/src/cli.ts +143 -0
  45. package/src/detect.ts +95 -0
  46. package/src/fs.ts +22 -0
  47. package/src/git.ts +40 -0
  48. package/src/migrate.ts +162 -0
  49. package/src/worktrees.ts +54 -0
  50. package/test/__bun-shim__.ts +78 -0
  51. package/test/detect.test.ts +99 -0
  52. package/test/helpers/repo.ts +75 -0
  53. package/test/migrate.test.ts +124 -0
  54. package/test/security.test.ts +25 -0
  55. package/test/worktrees.test.ts +164 -0
  56. package/tsconfig.json +13 -0
  57. 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
+ }
@@ -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
+ }