Skip to content

Commit a6f0143

Browse files
authored
Merge pull request #128 from userlerueda/a-proposal-for-worktreeinclude
feat(worktrees): support .worktreeinclude for copying gitignored files
2 parents 91f8eae + 3856e14 commit a6f0143

5 files changed

Lines changed: 319 additions & 2 deletions

File tree

app/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
"chalk": "^2.3.0",
2929
"classnames": "^2.2.5",
3030
"codemirror": "^5.65.17",
31+
"ignore": "^7.0.5",
3132
"codemirror-mode-elixir": "^1.1.2",
3233
"codemirror-mode-luau": "^1.0.2",
3334
"codemirror-mode-zig": "^1.0.7",
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import * as Fs from 'fs'
2+
import * as Path from 'path'
3+
import { readFile, copyFile, mkdir } from 'fs/promises'
4+
import ignore from 'ignore'
5+
import type { Repository } from '../../models/repository'
6+
import { git } from './core'
7+
import { addWorktree, getMainWorktreePath } from './worktree'
8+
9+
const WorktreeIncludeFile = '.worktreeinclude'
10+
11+
/**
12+
* Reads the patterns from the `.worktreeinclude` file at the root of the
13+
* given repository path.
14+
*
15+
* The file uses `.gitignore` syntax. Blank lines and lines starting with `#`
16+
* are ignored.
17+
*
18+
* Returns an empty array if the file does not exist.
19+
*/
20+
export async function readWorktreeIncludePatterns(
21+
repositoryPath: string
22+
): Promise<ReadonlyArray<string>> {
23+
const filePath = Path.join(repositoryPath, WorktreeIncludeFile)
24+
25+
let contents: string
26+
try {
27+
contents = await readFile(filePath, 'utf8')
28+
} catch {
29+
return []
30+
}
31+
32+
return contents
33+
.split('\n')
34+
.map(line => line.trim())
35+
.filter(line => line.length > 0 && !line.startsWith('#'))
36+
}
37+
38+
/**
39+
* Returns the list of gitignored files in `repositoryPath` that match any of
40+
* the given patterns.
41+
*
42+
* Only files that are both gitignored **and** matched by a `.worktreeinclude`
43+
* pattern are returned — tracked files are never included.
44+
*/
45+
export async function getIgnoredFilesMatchingPatterns(
46+
repository: Repository,
47+
patterns: ReadonlyArray<string>
48+
): Promise<ReadonlyArray<string>> {
49+
if (patterns.length === 0) {
50+
return []
51+
}
52+
53+
const result = await git(
54+
['ls-files', '--others', '--ignored', '--exclude-standard', '-z'],
55+
repository.path,
56+
'getIgnoredFiles'
57+
)
58+
59+
// Files are NUL-separated; filter out empty entries from the split
60+
const ignoredFiles = result.stdout.split('\0').filter(f => f.length > 0)
61+
62+
const ig = ignore().add(patterns)
63+
return ignoredFiles.filter(f => ig.ignores(f))
64+
}
65+
66+
/**
67+
* Copies each file in `files` (relative paths) from `sourcePath` to
68+
* `destinationPath`, preserving the directory structure.
69+
*
70+
* Files that cannot be copied (e.g. because they no longer exist at the
71+
* source) are skipped silently — a failure to copy a single file never
72+
* prevents the others from being copied.
73+
*/
74+
export async function copyWorktreeIncludeFiles(
75+
sourcePath: string,
76+
destinationPath: string,
77+
files: ReadonlyArray<string>
78+
): Promise<void> {
79+
for (const file of files) {
80+
const src = Path.join(sourcePath, file)
81+
const dest = Path.join(destinationPath, file)
82+
83+
// Guard against path traversal: the resolved destination must be
84+
// inside the worktree directory.
85+
const resolvedDest = Path.resolve(dest)
86+
const resolvedWorktreeRoot = Path.resolve(destinationPath)
87+
if (!resolvedDest.startsWith(resolvedWorktreeRoot + Path.sep)) {
88+
continue
89+
}
90+
91+
try {
92+
// eslint-disable-next-line no-sync
93+
if (!Fs.existsSync(src)) {
94+
continue
95+
}
96+
97+
await mkdir(Path.dirname(dest), { recursive: true })
98+
await copyFile(src, dest)
99+
} catch (e) {
100+
log.warn(
101+
`[worktree-include] Failed to copy '${file}' to worktree`,
102+
e instanceof Error ? e : undefined
103+
)
104+
}
105+
}
106+
}
107+
108+
/**
109+
* Creates a new git worktree and then copies any gitignored files listed in
110+
* the `.worktreeinclude` file from the main worktree into the newly created
111+
* worktree.
112+
*
113+
* The copy step is best-effort: failures are logged but do not prevent the
114+
* worktree from being used.
115+
*
116+
* @param repository The repository to create the worktree in.
117+
* @param path The absolute path where the new worktree should be created.
118+
* @param options Options forwarded to `addWorktree`.
119+
*/
120+
export async function addWorktreeWithIncludes(
121+
repository: Repository,
122+
path: string,
123+
options: Parameters<typeof addWorktree>[2] = {}
124+
): Promise<void> {
125+
await addWorktree(repository, path, options)
126+
127+
try {
128+
const mainPath = (await getMainWorktreePath(repository)) ?? repository.path
129+
const patterns = await readWorktreeIncludePatterns(mainPath)
130+
131+
if (patterns.length === 0) {
132+
return
133+
}
134+
135+
const files = await getIgnoredFilesMatchingPatterns(repository, patterns)
136+
137+
if (files.length > 0) {
138+
await copyWorktreeIncludeFiles(mainPath, path, files)
139+
}
140+
} catch (e) {
141+
log.warn(
142+
'[worktree-include] Failed to process .worktreeinclude; worktree was still created',
143+
e instanceof Error ? e : undefined
144+
)
145+
}
146+
}

app/src/ui/worktrees/add-worktree-dialog.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { Button } from '../lib/button'
1010
import { Row } from '../lib/row'
1111
import { OkCancelButtonGroup } from '../dialog/ok-cancel-button-group'
1212
import { showOpenDialog } from '../main-process-proxy'
13-
import { addWorktree } from '../../lib/git/worktree'
13+
import { addWorktreeWithIncludes } from '../../lib/git/worktree-include'
1414

1515
interface IAddWorktreeDialogProps {
1616
readonly repository: Repository
@@ -72,7 +72,7 @@ export class AddWorktreeDialog extends React.Component<
7272
const worktreePath = Path.join(path, branchName)
7373

7474
try {
75-
await addWorktree(this.props.repository, worktreePath, {
75+
await addWorktreeWithIncludes(this.props.repository, worktreePath, {
7676
createBranch: branchName.length > 0 ? branchName : undefined,
7777
})
7878
} catch (e) {
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import assert from 'node:assert'
2+
import { describe, it } from 'node:test'
3+
import { mkdir, writeFile, readFile } from 'fs/promises'
4+
import { existsSync } from 'fs'
5+
import * as Path from 'path'
6+
import { exec } from 'dugite'
7+
8+
import {
9+
readWorktreeIncludePatterns,
10+
getIgnoredFilesMatchingPatterns,
11+
copyWorktreeIncludeFiles,
12+
} from '../../../src/lib/git/worktree-include'
13+
import { createTempDirectory } from '../../helpers/temp'
14+
import { setupEmptyRepository } from '../../helpers/repositories'
15+
16+
describe('git/worktree-include', () => {
17+
describe('readWorktreeIncludePatterns', () => {
18+
it('returns empty array when file does not exist', async t => {
19+
const dir = await createTempDirectory(t)
20+
const patterns = await readWorktreeIncludePatterns(dir)
21+
assert.deepStrictEqual(patterns, [])
22+
})
23+
24+
it('parses patterns from file', async t => {
25+
const dir = await createTempDirectory(t)
26+
await writeFile(
27+
Path.join(dir, '.worktreeinclude'),
28+
'.env\n.env.local\nconfig/secrets.json\n'
29+
)
30+
const patterns = await readWorktreeIncludePatterns(dir)
31+
assert.deepStrictEqual(patterns, [
32+
'.env',
33+
'.env.local',
34+
'config/secrets.json',
35+
])
36+
})
37+
38+
it('skips blank lines and comments', async t => {
39+
const dir = await createTempDirectory(t)
40+
await writeFile(
41+
Path.join(dir, '.worktreeinclude'),
42+
'# This is a comment\n\n.env\n\n# Another comment\n.env.local\n'
43+
)
44+
const patterns = await readWorktreeIncludePatterns(dir)
45+
assert.deepStrictEqual(patterns, ['.env', '.env.local'])
46+
})
47+
48+
it('returns empty array for a file with only comments and blanks', async t => {
49+
const dir = await createTempDirectory(t)
50+
await writeFile(Path.join(dir, '.worktreeinclude'), '# comment\n\n \n')
51+
const patterns = await readWorktreeIncludePatterns(dir)
52+
assert.deepStrictEqual(patterns, [])
53+
})
54+
})
55+
56+
describe('getIgnoredFilesMatchingPatterns', () => {
57+
it('returns empty array when patterns is empty', async t => {
58+
const repo = await setupEmptyRepository(t)
59+
const files = await getIgnoredFilesMatchingPatterns(repo, [])
60+
assert.deepStrictEqual(files, [])
61+
})
62+
63+
it('returns gitignored files matching the patterns', async t => {
64+
const repo = await setupEmptyRepository(t)
65+
66+
await exec(['config', 'user.email', 'test@example.com'], repo.path)
67+
await exec(['config', 'user.name', 'Test User'], repo.path)
68+
69+
await writeFile(Path.join(repo.path, '.gitignore'), '.env\n')
70+
await exec(['add', '.gitignore'], repo.path)
71+
await exec(['commit', '-m', 'add gitignore'], repo.path)
72+
73+
await writeFile(Path.join(repo.path, '.env'), 'SECRET=value\n')
74+
await writeFile(Path.join(repo.path, 'readme.txt'), 'hello\n')
75+
await exec(['add', 'readme.txt'], repo.path)
76+
77+
const files = await getIgnoredFilesMatchingPatterns(repo, ['.env'])
78+
assert.deepStrictEqual(files, ['.env'])
79+
})
80+
81+
it('does not return tracked files even if pattern matches', async t => {
82+
const repo = await setupEmptyRepository(t)
83+
await exec(['config', 'user.email', 'test@example.com'], repo.path)
84+
await exec(['config', 'user.name', 'Test User'], repo.path)
85+
86+
await writeFile(Path.join(repo.path, '.gitignore'), '')
87+
await writeFile(Path.join(repo.path, 'tracked.txt'), 'content\n')
88+
await exec(['add', 'tracked.txt', '.gitignore'], repo.path)
89+
await exec(['commit', '-m', 'initial'], repo.path)
90+
91+
const files = await getIgnoredFilesMatchingPatterns(repo, ['tracked.txt'])
92+
assert.deepStrictEqual(files, [])
93+
})
94+
95+
it('does not return gitignored files that do not match the pattern', async t => {
96+
const repo = await setupEmptyRepository(t)
97+
await exec(['config', 'user.email', 'test@example.com'], repo.path)
98+
await exec(['config', 'user.name', 'Test User'], repo.path)
99+
100+
await writeFile(
101+
Path.join(repo.path, '.gitignore'),
102+
'.env\nsecrets.json\n'
103+
)
104+
await exec(['add', '.gitignore'], repo.path)
105+
await exec(['commit', '-m', 'gitignore'], repo.path)
106+
107+
await writeFile(Path.join(repo.path, '.env'), 'SECRET=1\n')
108+
await writeFile(Path.join(repo.path, 'secrets.json'), '{}')
109+
110+
const files = await getIgnoredFilesMatchingPatterns(repo, ['.env'])
111+
assert.deepStrictEqual(files, ['.env'])
112+
})
113+
})
114+
115+
describe('copyWorktreeIncludeFiles', () => {
116+
it('copies files preserving directory structure', async t => {
117+
const source = await createTempDirectory(t)
118+
const dest = await createTempDirectory(t)
119+
120+
await mkdir(Path.join(source, 'config'), { recursive: true })
121+
await writeFile(Path.join(source, '.env'), 'SECRET=1\n')
122+
await writeFile(Path.join(source, 'config', 'secrets.json'), '{}')
123+
124+
await copyWorktreeIncludeFiles(source, dest, [
125+
'.env',
126+
'config/secrets.json',
127+
])
128+
129+
const envContent = await readFile(Path.join(dest, '.env'), 'utf8')
130+
assert.strictEqual(envContent, 'SECRET=1\n')
131+
132+
const secretsContent = await readFile(
133+
Path.join(dest, 'config', 'secrets.json'),
134+
'utf8'
135+
)
136+
assert.strictEqual(secretsContent, '{}')
137+
})
138+
139+
it('skips files that do not exist at the source', async t => {
140+
const source = await createTempDirectory(t)
141+
const dest = await createTempDirectory(t)
142+
143+
await writeFile(Path.join(source, '.env'), 'SECRET=1\n')
144+
145+
await copyWorktreeIncludeFiles(source, dest, ['.env', 'missing.txt'])
146+
147+
assert.ok(existsSync(Path.join(dest, '.env')))
148+
assert.ok(!existsSync(Path.join(dest, 'missing.txt')))
149+
})
150+
151+
it('does not copy files with path traversal patterns', async t => {
152+
const source = await createTempDirectory(t)
153+
const dest = await createTempDirectory(t)
154+
155+
await writeFile(Path.join(source, '.env'), 'SECRET=1\n')
156+
157+
await copyWorktreeIncludeFiles(source, dest, ['../../../etc/passwd'])
158+
159+
const destContents = await import('fs/promises').then(fs =>
160+
fs.readdir(dest)
161+
)
162+
assert.strictEqual(destContents.length, 0)
163+
})
164+
})
165+
})

app/yarn.lock

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -900,6 +900,11 @@ ieee754@^1.1.13:
900900
resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352"
901901
integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==
902902

903+
ignore@^7.0.5:
904+
version "7.0.5"
905+
resolved "https://registry.yarnpkg.com/ignore/-/ignore-7.0.5.tgz#4cb5f6cd7d4c7ab0365738c7aea888baa6d7efd9"
906+
integrity sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==
907+
903908
immediate@~3.0.5:
904909
version "3.0.6"
905910
resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b"

0 commit comments

Comments
 (0)