microsoft / microsoft/vscode

`git.worktreeIncludeFiles` copies tracked files into a new worktree

Open
#329,147 2 comments 0 reactions 1 assignee Claimed by @lszomoru View on GitHub
Dominant language
TypeScript
Stars
193k
Forks
42.4k
PR merge metrics
PR metrics pending

Description

Does this issue occur when all extensions are disabled?: No

- VS Code Version: 1.131.0 (stable), commit `e4c7e7b1d6d060162f4aa7f8225271b67ce1df75`
- OS Version: macOS 26.5.2 (arm64)
- Git Version: 2.55.0
- Extension: built-in `vscode.git` (reproduces with all other extensions disabled)
- Source verified against `main` @ [`d2ba34a`](https://github.com/microsoft/vscode/blob/d2ba34a77c3d205108364ba72e68736d048858d8/extensions/git/src/repository.ts) — the code paths below are unchanged upstream.

## Summary

`git.worktreeIncludeFiles` is documented as:

> Configure [glob patterns](https://aka.ms/vscode-glob-patterns) for files and folders that are included when creating a new worktree. **Only files and folders that match the patterns and are listed in `.gitignore` will be copied** to the newly created worktree.

In practice, any pattern that matches a gitignored file in a subdirectory causes the **entire top-level directory** containing that file to be copied recursively from the parent worktree into the new one — tracked, non-ignored files included. The copy runs *after* `git worktree add`, so it silently overwrites the freshly checked-out branch content with the parent worktree's version.

Net effect: a brand-new worktree whose working tree does not match its own `HEAD`. `git status` reports dozens or hundreds of phantom modifications, and the branch's committed changes are missing from disk.

## Steps to Reproduce:

```bash
mkdir repro && cd repro && git init
printf '.env\n' > .gitignore
mkdir -p src/module && echo 'main content' > src/module/tracked.txt
git add -A && git commit -m init

echo 'SECRET=1' > src/module/.env # gitignored, nested

git checkout -b feature
echo 'FEATURE content' > src/module/tracked.txt
git commit -am feature
git checkout main
```

Then, in VS Code:

1. Set:
```json
"git.detectWorktrees": true,
"git.worktreeIncludeFiles": ["**/.env"]
```
2. Open the `repro` folder (on `main`).
3. Run **Git: Create Worktree...** and select the `feature` branch.
4. Open the new worktree and run `git status`.

### Expected

Only `src/module/.env` is copied. `git status` in the new worktree is clean and `src/module/tracked.txt` contains `FEATURE content` — the checked-out branch's version.

### Actual

`src/module/tracked.txt` is reported as modified and contains `main content` — the parent worktree's version. The whole `src/` directory was copied over the checkout. `tracked.txt` is tracked and not in `.gitignore`, so per the documented contract it should never have been copied.

## Root Cause

[`Repository._getWorktreeIncludePaths()`](https://github.com/microsoft/vscode/blob/d2ba34a77c3d205108364ba72e68736d048858d8/extensions/git/src/repository.ts#L2015-L2089) and [`Repository._copyWorktreeIncludeFiles()`](https://github.com/microsoft/vscode/blob/d2ba34a77c3d205108364ba72e68736d048858d8/extensions/git/src/repository.ts#L2091-L2107) in `extensions/git/src/repository.ts`.

The gitignore filter itself is correct. Patterns are resolved twice and subtracted, leaving only ignored files (L2026-L2042):

```ts
// Files that are git ignored = all files - non-ignored files
const gitIgnoredFiles = new Set(allFiles.map(uri => uri.fsPath));
for (const uri of nonIgnoredFiles) {
gitIgnoredFiles.delete(uri.fsPath);
}
```

The contract is then broken by the upward traversal, which inserts **ancestor directories** into the same set that is later copied (L2060-L2073):

```ts
// Add the folder paths for git ignored files, walking
// up only to the nearest file pattern base directory.
const gitIgnoredPaths = new Set(gitIgnoredFiles);

for (const filePath of gitIgnoredFiles) {
let dir = path.dirname(filePath);
while (dir !== this.root && !gitIgnoredPaths.has(dir)) {
gitIgnoredPaths.add(dir); // <-- adds a NON-ignored directory
if (filePatternBases.has(dir)) {
break; // <-- checked only AFTER the add
}
dir = path.dirname(dir);
}
}
```

The set is named `gitIgnoredPaths`, but directories that are not gitignored are inserted into it. Everything surviving the topmost-path collapse (L2075-L2086) is then handed to a recursive copy (L2107):

```ts
await cp(sourceFile, targetFile, { force: true, recursive: true, verbatimSymlinks: true });
```

A recursive copy of `src/` copies everything inside it, ignored or not. So a set that was correctly narrowed to gitignored files is widened back out to directories full of tracked files.

Two details make this unavoidable rather than an edge case:

1. **`**/…` patterns always produce `this.root` as their base.** `filePatternBases` is the fixed prefix before the first wildcard segment (L2044-L2058); for `**/.env` the first segment is already a wildcard, so `fixedSegments` is empty and the base is `path.join(this.root)` — the repository root. `filePatternBases.has(dir)` can therefore never fire, and the walk runs until `dir === this.root`, having already added the match's top-level directory.
2. **The base is checked after the add.** Even when a pattern's base *is* the containing directory, that directory is inserted before `break` — so it lands in the copy set regardless.

Because VS Code globs match against the whole relative path, `**/` is the only way to match a nested file. That means the only expressible way to include a nested gitignored file is also the shape that triggers the bug:

| pattern | `filePatternBases` entry | walk terminates at | resulting copy entry |
|---|---|---|---|
| `.env` (file at repo root) | `/.env` | walk never runs (`dirname` is `this.root`) | the file only ✅ |
| `node_modules/**` | `/node_modules` | `node_modules` — a base *and* an ancestor dir | `node_modules/` ✅ |
| `**/.env` | `` | nothing | top-level dir, e.g. `src/` ❌ |
| `src/module/.env` (fully literal) | the file itself | nothing (base is not an ancestor *directory*) | top-level dir, e.g. `src/` ❌ |

The last row matters for triage: "just write the exact path" is not a workaround.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.