git-worktree-organize 1.0.9 → 1.0.10

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.
@@ -1,75 +0,0 @@
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
- }
@@ -1,124 +0,0 @@
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
- })
@@ -1,25 +0,0 @@
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
- })
@@ -1,164 +0,0 @@
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 DELETED
@@ -1,13 +0,0 @@
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 DELETED
@@ -1,13 +0,0 @@
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
- })