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
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { join, dirname, basename } from 'node:path'
|
|
3
|
+
import { $ } from 'bun'
|
|
4
|
+
import { describe, it, expect } from 'vitest'
|
|
5
|
+
import { migrate } from '../src/migrate.ts'
|
|
6
|
+
import { detect } from '../src/detect.ts'
|
|
7
|
+
import {
|
|
8
|
+
makeStandardRepo,
|
|
9
|
+
makeBareRootRepo,
|
|
10
|
+
makeBareHubRepo,
|
|
11
|
+
assertHubStructure,
|
|
12
|
+
assertWorktreeWorks,
|
|
13
|
+
makeTempDir,
|
|
14
|
+
} from './helpers/repo.ts'
|
|
15
|
+
|
|
16
|
+
describe('migrate', () => {
|
|
17
|
+
it('standard repo with extra worktree → hub layout', async () => {
|
|
18
|
+
const src = makeTempDir()
|
|
19
|
+
await makeStandardRepo(src, ['feature'])
|
|
20
|
+
const config = await detect(src)
|
|
21
|
+
const dest = src + '-hub'
|
|
22
|
+
|
|
23
|
+
await migrate(config, { source: src, dest })
|
|
24
|
+
|
|
25
|
+
await assertHubStructure(dest)
|
|
26
|
+
await assertWorktreeWorks(dest, 'main')
|
|
27
|
+
await assertWorktreeWorks(dest, 'feature')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('standard repo with no extra worktrees → hub with just main', async () => {
|
|
31
|
+
const src = makeTempDir()
|
|
32
|
+
await makeStandardRepo(src)
|
|
33
|
+
const config = await detect(src)
|
|
34
|
+
const dest = src + '-hub'
|
|
35
|
+
|
|
36
|
+
await migrate(config, { source: src, dest })
|
|
37
|
+
|
|
38
|
+
await assertHubStructure(dest)
|
|
39
|
+
await assertWorktreeWorks(dest, 'main')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('bare-root repo → hub layout', async () => {
|
|
43
|
+
const src = makeTempDir()
|
|
44
|
+
await makeBareRootRepo(src)
|
|
45
|
+
const config = await detect(src)
|
|
46
|
+
expect(config.type).toBe('bare-root')
|
|
47
|
+
const dest = src + '-hub'
|
|
48
|
+
|
|
49
|
+
await migrate(config, { source: src, dest })
|
|
50
|
+
|
|
51
|
+
await assertHubStructure(dest)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('bare-hub repo → new location', async () => {
|
|
55
|
+
const src = makeTempDir()
|
|
56
|
+
await makeBareHubRepo(src)
|
|
57
|
+
const config = await detect(src)
|
|
58
|
+
expect(config.type).toBe('bare-hub')
|
|
59
|
+
const dest2 = src + '-hub2'
|
|
60
|
+
|
|
61
|
+
await migrate(config, { source: src, dest: dest2 })
|
|
62
|
+
|
|
63
|
+
await assertHubStructure(dest2)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('branch with / → sanitized to - in directory name', async () => {
|
|
67
|
+
const src = makeTempDir()
|
|
68
|
+
await makeStandardRepo(src, ['feature/my-feature'])
|
|
69
|
+
const config = await detect(src)
|
|
70
|
+
const dest = src + '-hub'
|
|
71
|
+
|
|
72
|
+
await migrate(config, { source: src, dest })
|
|
73
|
+
|
|
74
|
+
await assertHubStructure(dest)
|
|
75
|
+
await assertWorktreeWorks(dest, 'main')
|
|
76
|
+
await assertWorktreeWorks(dest, 'feature/my-feature')
|
|
77
|
+
// The directory should be named feature-my-feature
|
|
78
|
+
expect(existsSync(join(dest, 'feature-my-feature'))).toBe(true)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('branch name collision → throws with collision message', async () => {
|
|
82
|
+
const src = makeTempDir()
|
|
83
|
+
// Create a standard repo with two branches that sanitize to the same name:
|
|
84
|
+
// 'a/b' → 'a-b' and 'a-b' → 'a-b'
|
|
85
|
+
// We use different worktree directory names to avoid the filesystem conflict.
|
|
86
|
+
await $`git init ${src}`.quiet()
|
|
87
|
+
await $`git -C ${src} config user.email "test@test.com"`.quiet()
|
|
88
|
+
await $`git -C ${src} config user.name "Test"`.quiet()
|
|
89
|
+
await $`git -C ${src} commit --allow-empty -m "init"`.quiet()
|
|
90
|
+
// Create first worktree with branch 'a/b' in dir 'wt1'
|
|
91
|
+
const wt1 = join(src + '-wt1')
|
|
92
|
+
await $`git -C ${src} worktree add -b ${'a/b'} ${wt1}`.quiet()
|
|
93
|
+
// Create second worktree with branch 'a-b' in dir 'wt2'
|
|
94
|
+
const wt2 = join(src + '-wt2')
|
|
95
|
+
await $`git -C ${src} worktree add -b ${'a-b'} ${wt2}`.quiet()
|
|
96
|
+
|
|
97
|
+
const config = await detect(src)
|
|
98
|
+
const dest = src + '-hub'
|
|
99
|
+
|
|
100
|
+
await expect(migrate(config, { source: src, dest })).rejects.toThrow('collision')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('default dest derivation when dest is empty string', async () => {
|
|
104
|
+
const src = makeTempDir()
|
|
105
|
+
await makeStandardRepo(src)
|
|
106
|
+
const config = await detect(src)
|
|
107
|
+
|
|
108
|
+
const result = await migrate(config, { source: src, dest: '' })
|
|
109
|
+
|
|
110
|
+
const expectedDest = join(dirname(src), basename(src) + '-bare')
|
|
111
|
+
expect(result).toBe(expectedDest)
|
|
112
|
+
await assertHubStructure(expectedDest)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('throws if dest/.bare already exists', async () => {
|
|
116
|
+
const src = makeTempDir()
|
|
117
|
+
await makeStandardRepo(src)
|
|
118
|
+
const config = await detect(src)
|
|
119
|
+
const dest = src + '-hub'
|
|
120
|
+
await makeBareHubRepo(dest) // creates dest/.bare already
|
|
121
|
+
|
|
122
|
+
await expect(migrate(config, { source: src, dest })).rejects.toThrow('already exists')
|
|
123
|
+
})
|
|
124
|
+
})
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { mkdtempSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { detect } from '../src/detect.ts'
|
|
6
|
+
|
|
7
|
+
describe('security', () => {
|
|
8
|
+
it('detect() rejects a .git file pointing to a non-git directory (path traversal guard)', async () => {
|
|
9
|
+
// Simulates a malicious repo whose .git file points at an arbitrary
|
|
10
|
+
// directory (e.g. ~/.ssh) rather than a real git object store.
|
|
11
|
+
// A secure detect() must throw rather than return that path as config.gitdir,
|
|
12
|
+
// preventing migrate() from cp -a'ing the sensitive directory into dest/.bare/.
|
|
13
|
+
|
|
14
|
+
// --- Step 1: Create the "sensitive" directory (plain dir, not a git repo) ---
|
|
15
|
+
const sensitiveDir = mkdtempSync(join(tmpdir(), 'git-wto-sensitive-'))
|
|
16
|
+
writeFileSync(join(sensitiveDir, 'secret.txt'), 'sensitive data')
|
|
17
|
+
|
|
18
|
+
// --- Step 2: Create a malicious repo with .git pointing at the sensitive dir ---
|
|
19
|
+
const maliciousRepo = mkdtempSync(join(tmpdir(), 'git-wto-malicious-'))
|
|
20
|
+
writeFileSync(join(maliciousRepo, '.git'), `gitdir: ${sensitiveDir}\n`)
|
|
21
|
+
|
|
22
|
+
// --- Step 3: detect() must throw — the gitdir is not a git object store ---
|
|
23
|
+
await expect(detect(maliciousRepo)).rejects.toThrow('gitdir does not appear to be a git repository')
|
|
24
|
+
})
|
|
25
|
+
})
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { mkdtempSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { $ } from 'bun'
|
|
6
|
+
import { parsePorcelain, listWorktrees } from '../src/worktrees'
|
|
7
|
+
|
|
8
|
+
describe('parsePorcelain', () => {
|
|
9
|
+
it('parses a single normal worktree with branch main', () => {
|
|
10
|
+
const input = `worktree /path/to/main
|
|
11
|
+
HEAD abc123def456abc123def456abc123def456abc123
|
|
12
|
+
branch refs/heads/main
|
|
13
|
+
`
|
|
14
|
+
const result = parsePorcelain(input)
|
|
15
|
+
expect(result).toHaveLength(1)
|
|
16
|
+
expect(result[0]).toEqual({
|
|
17
|
+
path: '/path/to/main',
|
|
18
|
+
head: 'abc123def456abc123def456abc123def456abc123',
|
|
19
|
+
branch: 'main',
|
|
20
|
+
isBare: false,
|
|
21
|
+
})
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('parses multiple worktrees (main + feature branch)', () => {
|
|
25
|
+
const input = `worktree /path/to/main
|
|
26
|
+
HEAD abc123def456abc123def456abc123def456abc123
|
|
27
|
+
branch refs/heads/main
|
|
28
|
+
|
|
29
|
+
worktree /path/to/feature
|
|
30
|
+
HEAD def456abc123def456abc123def456abc123def456
|
|
31
|
+
branch refs/heads/feature
|
|
32
|
+
`
|
|
33
|
+
const result = parsePorcelain(input)
|
|
34
|
+
expect(result).toHaveLength(2)
|
|
35
|
+
expect(result[0]).toEqual({
|
|
36
|
+
path: '/path/to/main',
|
|
37
|
+
head: 'abc123def456abc123def456abc123def456abc123',
|
|
38
|
+
branch: 'main',
|
|
39
|
+
isBare: false,
|
|
40
|
+
})
|
|
41
|
+
expect(result[1]).toEqual({
|
|
42
|
+
path: '/path/to/feature',
|
|
43
|
+
head: 'def456abc123def456abc123def456abc123def456',
|
|
44
|
+
branch: 'feature',
|
|
45
|
+
isBare: false,
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('parses a worktree with detached HEAD (branch = null, isBare = false)', () => {
|
|
50
|
+
const input = `worktree /path/to/detached
|
|
51
|
+
HEAD ghi789abc123ghi789abc123ghi789abc123ghi789
|
|
52
|
+
detached
|
|
53
|
+
`
|
|
54
|
+
const result = parsePorcelain(input)
|
|
55
|
+
expect(result).toHaveLength(1)
|
|
56
|
+
expect(result[0]).toEqual({
|
|
57
|
+
path: '/path/to/detached',
|
|
58
|
+
head: 'ghi789abc123ghi789abc123ghi789abc123ghi789',
|
|
59
|
+
branch: null,
|
|
60
|
+
isBare: false,
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('parses a bare worktree (isBare = true, branch = null)', () => {
|
|
65
|
+
const input = `worktree /path/to/bare
|
|
66
|
+
HEAD 0000000000000000000000000000000000000000
|
|
67
|
+
bare
|
|
68
|
+
`
|
|
69
|
+
const result = parsePorcelain(input)
|
|
70
|
+
expect(result).toHaveLength(1)
|
|
71
|
+
expect(result[0]).toEqual({
|
|
72
|
+
path: '/path/to/bare',
|
|
73
|
+
head: '0000000000000000000000000000000000000000',
|
|
74
|
+
branch: null,
|
|
75
|
+
isBare: true,
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('parses a branch with / in name (feature/my-feature)', () => {
|
|
80
|
+
const input = `worktree /path/to/feature
|
|
81
|
+
HEAD abc123def456abc123def456abc123def456abc123
|
|
82
|
+
branch refs/heads/feature/my-feature
|
|
83
|
+
`
|
|
84
|
+
const result = parsePorcelain(input)
|
|
85
|
+
expect(result).toHaveLength(1)
|
|
86
|
+
expect(result[0]).toEqual({
|
|
87
|
+
path: '/path/to/feature',
|
|
88
|
+
head: 'abc123def456abc123def456abc123def456abc123',
|
|
89
|
+
branch: 'feature/my-feature',
|
|
90
|
+
isBare: false,
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('parses mixed: bare + linked + detached', () => {
|
|
95
|
+
const input = `worktree /path/to/bare
|
|
96
|
+
HEAD 0000000000000000000000000000000000000000
|
|
97
|
+
bare
|
|
98
|
+
|
|
99
|
+
worktree /path/to/linked
|
|
100
|
+
HEAD abc123def456abc123def456abc123def456abc123
|
|
101
|
+
branch refs/heads/main
|
|
102
|
+
|
|
103
|
+
worktree /path/to/detached
|
|
104
|
+
HEAD def456abc123def456abc123def456abc123def456
|
|
105
|
+
detached
|
|
106
|
+
`
|
|
107
|
+
const result = parsePorcelain(input)
|
|
108
|
+
expect(result).toHaveLength(3)
|
|
109
|
+
expect(result[0]).toEqual({
|
|
110
|
+
path: '/path/to/bare',
|
|
111
|
+
head: '0000000000000000000000000000000000000000',
|
|
112
|
+
branch: null,
|
|
113
|
+
isBare: true,
|
|
114
|
+
})
|
|
115
|
+
expect(result[1]).toEqual({
|
|
116
|
+
path: '/path/to/linked',
|
|
117
|
+
head: 'abc123def456abc123def456abc123def456abc123',
|
|
118
|
+
branch: 'main',
|
|
119
|
+
isBare: false,
|
|
120
|
+
})
|
|
121
|
+
expect(result[2]).toEqual({
|
|
122
|
+
path: '/path/to/detached',
|
|
123
|
+
head: 'def456abc123def456abc123def456abc123def456',
|
|
124
|
+
branch: null,
|
|
125
|
+
isBare: false,
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
describe('listWorktrees', () => {
|
|
131
|
+
it('lists worktrees from a real git repo', async () => {
|
|
132
|
+
const tmpDir = mkdtempSync(join(tmpdir(), 'git-worktree-test-'))
|
|
133
|
+
const repoDir = join(tmpDir, 'repo')
|
|
134
|
+
const linkedDir = join(tmpDir, 'linked')
|
|
135
|
+
|
|
136
|
+
// Initialize a git repo with an initial commit
|
|
137
|
+
await $`git init ${repoDir}`.quiet()
|
|
138
|
+
await $`git -C ${repoDir} config user.email test@example.com`.quiet()
|
|
139
|
+
await $`git -C ${repoDir} config user.name Test`.quiet()
|
|
140
|
+
await $`touch ${join(repoDir, 'README')}`.quiet()
|
|
141
|
+
await $`git -C ${repoDir} add README`.quiet()
|
|
142
|
+
await $`git -C ${repoDir} commit -m init`.quiet()
|
|
143
|
+
|
|
144
|
+
// Add a linked worktree on a new branch
|
|
145
|
+
await $`git -C ${repoDir} worktree add -b feature ${linkedDir}`.quiet()
|
|
146
|
+
|
|
147
|
+
const worktrees = await listWorktrees(repoDir)
|
|
148
|
+
|
|
149
|
+
expect(worktrees.length).toBe(2)
|
|
150
|
+
|
|
151
|
+
const main = worktrees.find(w => w.path === repoDir)
|
|
152
|
+
expect(main).toBeDefined()
|
|
153
|
+
// branch name depends on system git default (master or main)
|
|
154
|
+
expect(typeof main!.branch).toBe('string')
|
|
155
|
+
expect(main!.isBare).toBe(false)
|
|
156
|
+
expect(main!.head).toMatch(/^[0-9a-f]{40}$/)
|
|
157
|
+
|
|
158
|
+
const linked = worktrees.find(w => w.path === linkedDir)
|
|
159
|
+
expect(linked).toBeDefined()
|
|
160
|
+
expect(linked!.branch).toBe('feature')
|
|
161
|
+
expect(linked!.isBare).toBe(false)
|
|
162
|
+
expect(linked!.head).toMatch(/^[0-9a-f]{40}$/)
|
|
163
|
+
})
|
|
164
|
+
})
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ESNext",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"noEmit": true,
|
|
8
|
+
"allowImportingTsExtensions": true,
|
|
9
|
+
"types": ["bun-types"],
|
|
10
|
+
"skipLibCheck": true
|
|
11
|
+
},
|
|
12
|
+
"include": ["src/**/*", "test/**/*"]
|
|
13
|
+
}
|
package/vitest.config.ts
ADDED