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
@@ -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
+ }
@@ -0,0 +1,13 @@
1
+ import { defineConfig } from 'vitest/config'
2
+ import { resolve } from 'node:path'
3
+
4
+ export default defineConfig({
5
+ resolve: {
6
+ alias: {
7
+ bun: resolve(__dirname, 'test/__bun-shim__.ts'),
8
+ },
9
+ },
10
+ test: {
11
+ globals: false,
12
+ },
13
+ })